Detailed changes
@@ -1,59 +0,0 @@
-name: Bug Report (AI)
-description: Zed Agent Panel Bugs
-type: "Bug"
-labels: ["ai"]
-title: "AI: <a short description of the AI Related bug>"
-body:
- - type: textarea
- attributes:
- label: Summary
- description: Describe the bug with a one line summary, and provide detailed reproduction steps
- value: |
- <!-- Please insert a one line summary of the issue below -->
- SUMMARY_SENTENCE_HERE
-
- ### Description
- <!-- Describe with sufficient detail to reproduce from a clean Zed install. -->
- 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
-
- - type: textarea
- attributes:
- label: If applicable, attach your `Zed.log` file to this issue.
- description: |
- From the command palette, run `zed: open log` to see the last 1000 lines.
- Or run `zed: reveal log in file manager` to reveal the log file itself.
- value: |
- <details><summary>Zed.log</summary>
-
- <!-- Paste your log inside the code block. -->
- ```log
-
- ```
-
- </details>
- validations:
- required: false
@@ -1,53 +0,0 @@
-name: Bug Report (Debugger)
-description: Zed Debugger-Related Bugs
-type: "Bug"
-labels: ["debugger"]
-title: "Debugger: <a short description of the Debugger bug>"
-body:
- - type: textarea
- attributes:
- label: Summary
- description: Describe the bug with a one line summary, and provide detailed reproduction steps
- value: |
- <!-- Please insert a one line summary of the issue below -->
- SUMMARY_SENTENCE_HERE
-
- ### Description
- <!-- Describe with sufficient detail to reproduce from a clean Zed install. -->
- 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
-
- - type: textarea
- attributes:
- label: If applicable, attach your `Zed.log` file to this issue.
- description: |
- From the command palette, run `zed: open log` to see the last 1000 lines.
- Or run `zed: reveal log in file manager` to reveal the log file itself.
- value: |
- <details><summary>Zed.log</summary>
-
- <!-- Paste your log inside the code block. -->
- ```log
-
- ```
-
- </details>
- validations:
- required: false
@@ -1,53 +0,0 @@
-name: Bug Report (Git)
-description: Zed Git Related Bugs
-type: "Bug"
-labels: ["git"]
-title: "Git: <a short description of the Git bug>"
-body:
- - type: textarea
- attributes:
- label: Summary
- description: Describe the bug with a one-line summary, and provide detailed reproduction steps
- value: |
- <!-- Please insert a one-line summary of the issue below -->
- SUMMARY_SENTENCE_HERE
-
- ### Description
- <!-- Describe with sufficient detail to reproduce from a clean Zed install. -->
- 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
-
- - type: textarea
- attributes:
- label: If applicable, attach your `Zed.log` file to this issue.
- description: |
- From the command palette, run `zed: open log` to see the last 1000 lines.
- Or run `zed: reveal log in file manager` to reveal the log file itself.
- value: |
- <details><summary>Zed.log</summary>
-
- <!-- Paste your log inside the code block. -->
- ```log
-
- ```
-
- </details>
- validations:
- required: false
@@ -1,53 +0,0 @@
-name: Bug Report (Windows)
-description: Zed Windows Related Bugs
-type: "Bug"
-labels: ["windows"]
-title: "Windows: <a short description of the Windows bug>"
-body:
- - type: textarea
- attributes:
- label: Summary
- description: Describe the bug with a one-line summary, and provide detailed reproduction steps
- value: |
- <!-- Please insert a one-line summary of the issue below -->
- SUMMARY_SENTENCE_HERE
-
- ### Description
- <!-- Describe with sufficient detail to reproduce from a clean Zed install. -->
- 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
-
- - type: textarea
- attributes:
- label: If applicable, attach your `Zed.log` file to this issue.
- description: |
- From the command palette, run `zed: open log` to see the last 1000 lines.
- Or run `zed: reveal log in file manager` to reveal the log file itself.
- value: |
- <details><summary>Zed.log</summary>
-
- <!-- Paste your log inside the code block. -->
- ```log
-
- ```
-
- </details>
- validations:
- required: false
@@ -1,67 +1,53 @@
-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: textarea
+ - type: markdown
attributes:
- label: Summary
- description: Provide a one sentence summary and detailed reproduction steps
value: |
- <!-- Begin your issue with a one sentence summary -->
- SUMMARY_SENTENCE_HERE
-
- ### Description
- <!-- Describe with sufficient detail to reproduce from a clean Zed install.
- - Any code must be sufficient to reproduce (include context!)
- - Include code as text, not just as a screenshot.
- - Issues with insufficient detail may be summarily closed.
- -->
-
- DESCRIPTION_HERE
-
- Steps to reproduce:
- 1.
- 2.
- 3.
- 4.
+ 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.
- **Expected Behavior**:
- **Actual Behavior**:
-
- <!-- Before Submitting, did you:
- 1. Include settings.json, keymap.json, .editorconfig if relevant?
- 2. Check your Zed.log for relevant errors? (please include!)
- 3. Click Preview to ensure everything looks right?
- 4. Hide videos, large images and logs in ``` inside collapsible blocks:
-
- <details><summary>click to expand</summary>
-
- ```json
-
- ```
- </details>
- -->
+ 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: 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: <screenshot with an arrow> 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: If applicable, attach your `Zed.log` file to this issue.
+ label: Attach Zed log file
description: |
- From the command palette, run `zed: open log` to see the last 1000 lines.
- Or run `zed: reveal log in file manager` to reveal the log file itself.
+ 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: |
<details><summary>Zed.log</summary>
@@ -73,3 +59,57 @@ body:
</details>
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: |
+ <details><summary>settings.json</summary>
+
+ <!-- Paste your settings inside the code block. -->
+ ```json
+
+ ```
+
+ </details>
+ 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: |
+ <details><summary>keymap.json</summary>
+
+ <!-- Paste your keymap file inside the code block. -->
+ ```json
+
+ ```
+
+ </details>
+ 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
@@ -1,42 +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: |
- <!-- Begin your issue with a one sentence summary -->
- SUMMARY_SENTENCE_HERE
-
- ### Description
- <!-- Include all steps necessary to reproduce from a clean Zed installation. Be verbose -->
- Steps to trigger the problem:
- 1.
- 2.
- 3.
-
- Actual Behavior:
- Expected Behavior:
-
+ 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: |
+ 1. Start Zed
+ 2. Perform an action
+ 3. Zed crashes
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: Zed version and system specs
+ description: |
+ 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: If applicable, attach your `Zed.log` file to this issue.
+ label: Attach Zed log file
description: |
- From the command palette, run `zed: open log` to see the last 1000 lines.
- Or run `zed: reveal log in file manager` to reveal the log file itself.
+ 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: |
<details><summary>Zed.log</summary>
@@ -1,19 +0,0 @@
-name: Other [Staff Only]
-description: Zed Staff Only
-body:
- - type: textarea
- attributes:
- label: Summary
- value: |
- <!-- Please insert a one line summary of the issue below -->
- 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
@@ -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.
@@ -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
@@ -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
@@ -5,13 +5,27 @@ 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'
+ 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 }}
+ 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
@@ -21,13 +35,13 @@ jobs:
post_to_discord:
needs:
- rebuild_releases_page
- 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:
- id: get-release-url
name: after_release::post_to_discord::get_release_url
run: |
- if [ "${{ github.event.release.prerelease }}" == "true" ]; then
+ if [ "${{ github.event.release.prerelease || inputs.prerelease }}" == "true" ]; then
URL="https://zed.dev/releases/preview"
else
URL="https://zed.dev/releases/stable"
@@ -40,9 +54,9 @@ jobs:
uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757
with:
stringToTruncate: |
- 📣 Zed [${{ github.event.release.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released!
+ 📣 Zed [${{ github.event.release.tag_name || inputs.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released!
- ${{ github.event.release.body }}
+ ${{ github.event.release.body || inputs.body }}
maxLength: 2000
truncationSymbol: '...'
- name: after_release::post_to_discord::discord_webhook_action
@@ -56,22 +70,23 @@ jobs:
- id: set-package-name
name: after_release::publish_winget::set_package_name
run: |
- if [ "${{ github.event.release.prerelease }}" == "true" ]; then
- PACKAGE_NAME=ZedIndustries.Zed.Preview
- else
- PACKAGE_NAME=ZedIndustries.Zed
- fi
+ if ("${{ github.event.release.prerelease || inputs.prerelease }}" -eq "true") {
+ $PACKAGE_NAME = "ZedIndustries.Zed.Preview"
+ } else {
+ $PACKAGE_NAME = "ZedIndustries.Zed"
+ }
- echo "PACKAGE_NAME=$PACKAGE_NAME" >> "$GITHUB_OUTPUT"
- shell: bash -euxo pipefail {0}
+ 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'
+ if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- name: steps::checkout_repo
@@ -86,3 +101,19 @@ jobs:
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 }}
@@ -0,0 +1,128 @@
+# 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
+ run_clippy:
+ description: run_clippy
+ type: boolean
+ default: 'true'
+jobs:
+ run_autofix:
+ runs-on: namespace-profile-16x32-ubuntu-2204
+ steps:
+ - name: steps::checkout_repo
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ with:
+ clean: false
+ - name: autofix_pr::run_autofix::checkout_pr
+ run: gh pr checkout ${{ inputs.pr_number }}
+ shell: bash -euxo pipefail {0}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_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
+ if: ${{ inputs.run_clippy }}
+ run: cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged
+ shell: bash -euxo pipefail {0}
+ - id: create-patch
+ name: autofix_pr::run_autofix::create_patch
+ run: |
+ if git diff --quiet; then
+ echo "No changes to commit"
+ echo "has_changes=false" >> "$GITHUB_OUTPUT"
+ else
+ git diff > autofix.patch
+ echo "has_changes=true" >> "$GITHUB_OUTPUT"
+ fi
+ shell: bash -euxo pipefail {0}
+ - name: upload artifact autofix-patch
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
+ with:
+ name: autofix-patch
+ path: autofix.patch
+ if-no-files-found: ignore
+ retention-days: '1'
+ - name: steps::cleanup_cargo_config
+ if: always()
+ run: |
+ rm -rf ./../.cargo
+ shell: bash -euxo pipefail {0}
+ outputs:
+ has_changes: ${{ steps.create-patch.outputs.has_changes }}
+ commit_changes:
+ needs:
+ - run_autofix
+ if: needs.run_autofix.outputs.has_changes == 'true'
+ runs-on: namespace-profile-2x4-ubuntu-2404
+ steps:
+ - id: get-app-token
+ name: steps::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::commit_changes::checkout_pr
+ run: gh pr checkout ${{ inputs.pr_number }}
+ shell: bash -euxo pipefail {0}
+ env:
+ GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
+ - name: autofix_pr::download_patch_artifact
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53
+ with:
+ name: autofix-patch
+ - name: autofix_pr::commit_changes::apply_patch
+ run: git apply autofix.patch
+ shell: bash -euxo pipefail {0}
+ - name: autofix_pr::commit_changes::commit_and_push
+ run: |
+ git commit -am "Autofix"
+ git push
+ 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 }}
+concurrency:
+ group: ${{ github.workflow }}-${{ inputs.pr_number }}
+ cancel-in-progress: true
@@ -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"
@@ -1,6 +1,7 @@
# 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:
@@ -16,6 +17,10 @@ on:
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
@@ -25,7 +30,7 @@ jobs:
with:
clean: false
- id: get-app-token
- name: cherry_pick::run_cherry_pick::authenticate_as_zippy
+ name: steps::authenticate_as_zippy
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
with:
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
@@ -13,13 +13,73 @@ 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
+ davidbarsky
+ 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') {
@@ -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"
@@ -39,8 +39,7 @@ jobs:
run: ./script/download-wasi-sdk
shell: bash -euxo pipefail {0}
- name: compare_perf::run_perf::install_hyperfine
- run: cargo install hyperfine
- shell: bash -euxo pipefail {0}
+ 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}
@@ -12,7 +12,7 @@ on:
- 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:
- name: steps::checkout_repo
@@ -43,9 +43,7 @@ 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}
@@ -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
@@ -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 }}
@@ -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
@@ -10,7 +10,7 @@ on:
- v*
jobs:
run_tests_mac:
- if: github.repository_owner == 'zed-industries'
+ if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
runs-on: self-mini-macos
steps:
- name: steps::checkout_repo
@@ -29,14 +29,11 @@ jobs:
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- - name: steps::cargo_install_nextest
- run: cargo install cargo-nextest --locked
- shell: bash -euxo pipefail {0}
- name: steps::clear_target_dir_if_large
run: ./script/clear-target-dir-if-larger-than 300
shell: bash -euxo pipefail {0}
- name: steps::cargo_nextest
- run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final
+ run: cargo nextest run --workspace --no-fail-fast
shell: bash -euxo pipefail {0}
- name: steps::cleanup_cargo_config
if: always()
@@ -45,7 +42,7 @@ jobs:
shell: bash -euxo pipefail {0}
timeout-minutes: 60
run_tests_linux:
- if: github.repository_owner == 'zed-industries'
+ if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
runs-on: namespace-profile-16x32-ubuntu-2204
steps:
- name: steps::checkout_repo
@@ -77,14 +74,19 @@ jobs:
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- - name: steps::cargo_install_nextest
- run: cargo install cargo-nextest --locked
+ - name: steps::trigger_autofix
+ if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
+ run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=true
shell: bash -euxo pipefail {0}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - 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 --failure-output immediate-final
+ run: cargo nextest run --workspace --no-fail-fast
shell: bash -euxo pipefail {0}
- name: steps::cleanup_cargo_config
if: always()
@@ -93,7 +95,7 @@ jobs:
shell: bash -euxo pipefail {0}
timeout-minutes: 60
run_tests_windows:
- if: github.repository_owner == 'zed-industries'
+ if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
runs-on: self-32vcpu-windows-2022
steps:
- name: steps::checkout_repo
@@ -112,14 +114,11 @@ jobs:
- name: steps::clippy
run: ./script/clippy.ps1
shell: pwsh
- - name: steps::cargo_install_nextest
- run: cargo install cargo-nextest --locked
- shell: pwsh
- name: steps::clear_target_dir_if_large
run: ./script/clear-target-dir-if-larger-than.ps1 250
shell: pwsh
- name: steps::cargo_nextest
- run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final
+ run: cargo nextest run --workspace --no-fail-fast
shell: pwsh
- name: steps::cleanup_cargo_config
if: always()
@@ -128,7 +127,7 @@ jobs:
shell: pwsh
timeout-minutes: 60
check_scripts:
- 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:
- name: steps::checkout_repo
@@ -157,7 +156,7 @@ jobs:
shell: bash -euxo pipefail {0}
timeout-minutes: 60
create_draft_release:
- 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:
- name: steps::checkout_repo
@@ -479,11 +478,31 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
+ - id: get-app-token
+ name: steps::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: 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 }}
+ GITHUB_TOKEN: ${{ steps.get-app-token.outputs.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
@@ -12,7 +12,7 @@ on:
- cron: 0 7 * * *
jobs:
check_style:
- if: github.repository_owner == 'zed-industries'
+ if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
runs-on: self-mini-macos
steps:
- name: steps::checkout_repo
@@ -28,7 +28,7 @@ jobs:
shell: bash -euxo pipefail {0}
timeout-minutes: 60
run_tests_windows:
- if: github.repository_owner == 'zed-industries'
+ if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
runs-on: self-32vcpu-windows-2022
steps:
- name: steps::checkout_repo
@@ -47,14 +47,11 @@ jobs:
- name: steps::clippy
run: ./script/clippy.ps1
shell: pwsh
- - name: steps::cargo_install_nextest
- run: cargo install cargo-nextest --locked
- shell: pwsh
- name: steps::clear_target_dir_if_large
run: ./script/clear-target-dir-if-larger-than.ps1 250
shell: pwsh
- name: steps::cargo_nextest
- run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final
+ run: cargo nextest run --workspace --no-fail-fast
shell: pwsh
- name: steps::cleanup_cargo_config
if: always()
@@ -364,7 +361,7 @@ jobs:
needs:
- check_style
- run_tests_windows
- if: github.repository_owner == 'zed-industries'
+ 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 }}
@@ -395,7 +392,7 @@ jobs:
needs:
- check_style
- run_tests_windows
- if: github.repository_owner == 'zed-industries'
+ 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 }}
@@ -437,7 +434,7 @@ jobs:
- bundle_mac_x86_64
- bundle_windows_aarch64
- bundle_windows_x86_64
- if: github.repository_owner == 'zed-industries'
+ if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
runs-on: namespace-profile-4x8-ubuntu-2204
steps:
- name: steps::checkout_repo
@@ -493,3 +490,21 @@ jobs:
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 }}
@@ -6,6 +6,9 @@ env:
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 }}
@@ -48,6 +51,11 @@ jobs:
- 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: |
@@ -13,7 +13,7 @@ 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'))
+ (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
@@ -56,7 +56,7 @@ jobs:
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'))
+ (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))
runs-on: namespace-profile-32x64-ubuntu-2004
env:
CARGO_INCREMENTAL: 0
@@ -99,7 +99,7 @@ jobs:
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'))
+ (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))
runs-on: self-mini-macos
env:
CARGO_INCREMENTAL: 0
@@ -145,7 +145,7 @@ jobs:
bundle_mac_x86_64:
if: |-
(github.event.action == 'labeled' && github.event.label.name == 'run-bundling') ||
- (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))
+ (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))
runs-on: self-mini-macos
env:
CARGO_INCREMENTAL: 0
@@ -191,7 +191,7 @@ jobs:
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'))
+ (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))
runs-on: self-32vcpu-windows-2022
env:
CARGO_INCREMENTAL: 0
@@ -229,7 +229,7 @@ jobs:
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'))
+ (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))
runs-on: self-32vcpu-windows-2022
env:
CARGO_INCREMENTAL: 0
@@ -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
@@ -15,7 +15,7 @@ on:
- v[0-9]+.[0-9]+.x
jobs:
orchestrate:
- 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:
- name: steps::checkout_repo
@@ -47,7 +47,7 @@ jobs:
}
check_pattern "run_action_checks" '^\.github/(workflows/|actions/|actionlint.yml)|tooling/xtask|script/' -qP
- check_pattern "run_docs" '^docs/' -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
@@ -59,7 +59,7 @@ jobs:
run_nix: ${{ steps.filter.outputs.run_nix }}
run_tests: ${{ steps.filter.outputs.run_tests }}
check_style:
- if: github.repository_owner == 'zed-industries'
+ if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
runs-on: namespace-profile-4x8-ubuntu-2204
steps:
- name: steps::checkout_repo
@@ -77,6 +77,15 @@ jobs:
- name: ./script/prettier
run: ./script/prettier
shell: bash -euxo pipefail {0}
+ - name: steps::cargo_fmt
+ run: cargo fmt --all -- --check
+ shell: bash -euxo pipefail {0}
+ - name: steps::trigger_autofix
+ if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
+ run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=false
+ shell: bash -euxo pipefail {0}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: ./script/check-todos
run: ./script/check-todos
shell: bash -euxo pipefail {0}
@@ -84,12 +93,9 @@ jobs:
run: ./script/check-keymaps
shell: bash -euxo pipefail {0}
- name: run_tests::check_style::check_for_typos
- uses: crate-ci/typos@80c8a4945eec0f6d464eaf9e65ed98ef085283d1
+ 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:
@@ -113,14 +119,11 @@ jobs:
- name: steps::clippy
run: ./script/clippy.ps1
shell: pwsh
- - name: steps::cargo_install_nextest
- run: cargo install cargo-nextest --locked
- shell: pwsh
- name: steps::clear_target_dir_if_large
run: ./script/clear-target-dir-if-larger-than.ps1 250
shell: pwsh
- name: steps::cargo_nextest
- run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final
+ run: cargo nextest run --workspace --no-fail-fast
shell: pwsh
- name: steps::cleanup_cargo_config
if: always()
@@ -163,14 +166,19 @@ jobs:
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- - name: steps::cargo_install_nextest
- run: cargo install cargo-nextest --locked
+ - name: steps::trigger_autofix
+ if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
+ run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=true
shell: bash -euxo pipefail {0}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - 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 --failure-output immediate-final
+ run: cargo nextest run --workspace --no-fail-fast
shell: bash -euxo pipefail {0}
- name: steps::cleanup_cargo_config
if: always()
@@ -200,14 +208,11 @@ jobs:
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- - name: steps::cargo_install_nextest
- run: cargo install cargo-nextest --locked
- shell: bash -euxo pipefail {0}
- name: steps::clear_target_dir_if_large
run: ./script/clear-target-dir-if-larger-than 300
shell: bash -euxo pipefail {0}
- name: steps::cargo_nextest
- run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final
+ run: cargo nextest run --workspace --no-fail-fast
shell: bash -euxo pipefail {0}
- name: steps::cleanup_cargo_config
if: always()
@@ -500,7 +505,12 @@ jobs:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
- runs-on: self-mini-macos
+ 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
@@ -524,6 +534,7 @@ jobs:
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:
@@ -545,7 +556,7 @@ jobs:
- check_scripts
- build_nix_linux_x86_64
- build_nix_mac_aarch64
- if: github.repository_owner == 'zed-industries' && always()
+ 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
@@ -1,17 +1,26 @@
-# Generated from xtask::workflows::run_agent_evals
+# Generated from xtask::workflows::run_unit_evals
# Rebuild with `cargo xtask workflows`.
-name: run_agent_evals
+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:
- schedule:
- - cron: 47 1 * * 2
- workflow_dispatch: {}
+ workflow_dispatch:
+ inputs:
+ model_name:
+ description: model_name
+ required: true
+ type: string
+ commit_sha:
+ description: commit_sha
+ required: true
+ type: string
jobs:
- unit_evals:
+ run_unit_evals:
runs-on: namespace-profile-16x32-ubuntu-2204
steps:
- name: steps::checkout_repo
@@ -37,8 +46,7 @@ jobs:
run: ./script/download-wasi-sdk
shell: bash -euxo pipefail {0}
- name: steps::cargo_install_nextest
- run: cargo install cargo-nextest --locked
- shell: bash -euxo pipefail {0}
+ 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}
@@ -47,20 +55,15 @@ jobs:
shell: bash -euxo pipefail {0}
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- - name: run_agent_evals::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 }}"
+ 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.ref_name == 'main' && github.sha || 'anysha' }}
+ group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.run_id }}
cancel-in-progress: true
@@ -8,6 +8,7 @@
.DS_Store
.blob_store
.build
+.claude/settings.local.json
.envrc
.flatpak-builder
.idea
@@ -39,3 +40,6 @@ xcuserdata/
# Don't commit any secrets to the repo.
.env
.env.secret.toml
+
+# `nix build` output
+/result
@@ -141,6 +141,9 @@ Uladzislau Kaminski <i@uladkaminski.com>
Uladzislau Kaminski <i@uladkaminski.com> <uladzislau_kaminski@epam.com>
Vitaly Slobodin <vitaliy.slobodin@gmail.com>
Vitaly Slobodin <vitaliy.slobodin@gmail.com> <vitaly_slobodin@fastmail.com>
+Yara <davidsk@zed.dev>
+Yara <git@davidsk.dev>
+Yara <git@yara.blue>
Will Bradley <williambbradley@gmail.com>
Will Bradley <williambbradley@gmail.com> <will@zed.dev>
WindSoilder <WindSoilder@outlook.com>
@@ -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.
@@ -15,15 +15,17 @@ with the community to improve the product in ways we haven't thought of (or had
In particular we love PRs that are:
-- Fixes to existing bugs and issues.
-- Small enhancements to existing features, particularly to make them work for more people.
+- Fixing or extending the docs.
+- Fixing bugs.
+- Small enhancements to existing features to make them work for more people (making things work on more platforms/modes/whatever).
- Small extra features, like keybindings or actions you miss from other editors or extensions.
-- Work towards shipping larger features on our roadmap.
+- Part of a Community Program like [Let's Git Together](https://github.com/zed-industries/zed/issues/41541).
If you're looking for concrete ideas:
-- Our [top-ranking issues](https://github.com/zed-industries/zed/issues/5393) based on votes by the community.
-- Our [public roadmap](https://zed.dev/roadmap) contains a rough outline of our near-term priorities for Zed.
+- [Curated board of issues](https://github.com/orgs/zed-industries/projects/69) suitable for everyone from first-time contributors to seasoned community champions.
+- [Triaged bugs with confirmed steps to reproduce](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3Astate%3Areproducible).
+- [Area labels](https://github.com/zed-industries/zed/labels?q=area%3A*) to browse bugs in a specific part of the product you care about (after clicking on an area label, add type:Bug to the search).
## Sending changes
@@ -37,9 +39,17 @@ like, sorry).
Although we will take a look, we tend to only merge about half the PRs that are
submitted. If you'd like your PR to have the best chance of being merged:
-- Include a clear description of what you're solving, and why it's important to you.
-- Include tests.
-- If it changes the UI, attach screenshots or screen recordings.
+- Make sure the change is **desired**: we're always happy to accept bugfixes,
+ but features should be confirmed with us first if you aim to avoid wasted
+ effort. If there isn't already a GitHub issue for your feature with staff
+ confirmation that we want it, start with a GitHub discussion rather than a PR.
+- Include a clear description of **what you're solving**, and why it's important.
+- Include **tests**.
+- If it changes the UI, attach **screenshots** or screen recordings.
+- Make the PR about **one thing only**, e.g. if it's a bugfix, don't add two
+ features and a refactoring on top of that.
+- Keep AI assistance under your judgement and responsibility: it's unlikely
+ we'll merge a vibe-coded PR that the author doesn't understand.
The internal advice for reviewers is as follows:
@@ -50,10 +60,9 @@ The internal advice for reviewers is as follows:
If you need more feedback from us: the best way is to be responsive to
Github comments, or to offer up time to pair with us.
-If you are making a larger change, or need advice on how to finish the change
-you're making, please open the PR early. We would love to help you get
-things right, and it's often easier to see how to solve a problem before the
-diff gets too big.
+If you need help deciding how to fix a bug, or finish implementing a feature
+that we've agreed we want, please open a PR early so we can discuss how to make
+the change with code in hand.
## Things we will (probably) not merge
@@ -61,11 +70,11 @@ Although there are few hard and fast rules, typically we don't merge:
- Anything that can be provided by an extension. For example a new language, or theme. For adding themes or support for a new language to Zed, check out our [docs on developing extensions](https://zed.dev/docs/extensions/developing-extensions).
- New file icons. Zed's default icon theme consists of icons that are hand-designed to fit together in a cohesive manner, please don't submit PRs with off-the-shelf SVGs.
+- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit.
- Giant refactorings.
- Non-trivial changes with no tests.
- Stylistic code changes that do not alter any app logic. Reducing allocations, removing `.unwrap()`s, fixing typos is great; making code "more readable" — maybe not so much.
-- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit.
-- Anything that seems completely AI generated.
+- Anything that seems AI-generated without understanding the output.
## Bird's-eye view of Zed
@@ -37,6 +37,7 @@ dependencies = [
"terminal",
"ui",
"url",
+ "urlencoding",
"util",
"uuid",
"watch",
@@ -103,12 +104,22 @@ dependencies = [
"project",
"proto",
"release_channel",
+ "semver",
"smallvec",
"ui",
"util",
"workspace",
]
+[[package]]
+name = "addr2line"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
+dependencies = [
+ "gimli 0.31.1",
+]
+
[[package]]
name = "addr2line"
version = "0.25.1"
@@ -158,6 +169,7 @@ dependencies = [
"derive_more 0.99.20",
"editor",
"env_logger 0.11.8",
+ "eval_utils",
"fs",
"futures 0.3.31",
"git",
@@ -183,7 +195,7 @@ dependencies = [
"regex",
"reqwest_client",
"rust-embed",
- "schemars 1.0.4",
+ "schemars",
"serde",
"serde_json",
"settings",
@@ -209,14 +221,14 @@ dependencies = [
"worktree",
"zed_env_vars",
"zlog",
- "zstd 0.11.2+zstd.1.5.2",
+ "zstd",
]
[[package]]
name = "agent-client-protocol"
-version = "0.7.0"
+version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "525705e39c11cd73f7bc784e3681a9386aa30c8d0630808d3dc2237eb4f9cb1b"
+checksum = "c2ffe7d502c1e451aafc5aff655000f84d09c9af681354ac0012527009b1af13"
dependencies = [
"agent-client-protocol-schema",
"anyhow",
@@ -225,22 +237,22 @@ dependencies = [
"derive_more 2.0.1",
"futures 0.3.31",
"log",
- "parking_lot",
"serde",
"serde_json",
]
[[package]]
name = "agent-client-protocol-schema"
-version = "0.6.2"
+version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ecf16c18fea41282d6bbadd1549a06be6836bddb1893f44a6235f340fa24e2af"
+checksum = "8af81cc2d5c3f9c04f73db452efd058333735ba9d51c2cf7ef33c9fee038e7e6"
dependencies = [
"anyhow",
"derive_more 2.0.1",
- "schemars 1.0.4",
+ "schemars",
"serde",
"serde_json",
+ "strum 0.27.2",
]
[[package]]
@@ -289,6 +301,7 @@ dependencies = [
name = "agent_settings"
version = "0.1.0"
dependencies = [
+ "agent-client-protocol",
"anyhow",
"cloud_llm_client",
"collections",
@@ -298,7 +311,7 @@ dependencies = [
"language_model",
"paths",
"project",
- "schemars 1.0.4",
+ "schemars",
"serde",
"serde_json",
"serde_json_lenient",
@@ -322,10 +335,12 @@ dependencies = [
"assistant_slash_command",
"assistant_slash_commands",
"assistant_text_thread",
+ "async-fs",
"audio",
"buffer_diff",
"chrono",
"client",
+ "clock",
"cloud_llm_client",
"collections",
"command_palette_hooks",
@@ -333,6 +348,7 @@ dependencies = [
"context_server",
"db",
"editor",
+ "eval_utils",
"extension",
"extension_host",
"feature_flags",
@@ -341,8 +357,10 @@ dependencies = [
"futures 0.3.31",
"fuzzy",
"gpui",
+ "gpui_tokio",
"html_to_markdown",
"http_client",
+ "image",
"indoc",
"itertools 0.14.0",
"jsonschema",
@@ -366,12 +384,13 @@ dependencies = [
"prompt_store",
"proto",
"rand 0.9.2",
- "ref-cast",
"release_channel",
+ "reqwest_client",
"rope",
"rules_library",
- "schemars 1.0.4",
+ "schemars",
"search",
+ "semver",
"serde",
"serde_json",
"serde_json_lenient",
@@ -380,7 +399,6 @@ dependencies = [
"streaming_diff",
"task",
"telemetry",
- "telemetry_events",
"terminal",
"terminal_view",
"text",
@@ -392,13 +410,44 @@ dependencies = [
"ui_input",
"unindent",
"url",
- "urlencoding",
"util",
+ "uuid",
"watch",
"workspace",
"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"
@@ -625,7 +674,7 @@ dependencies = [
"chrono",
"futures 0.3.31",
"http_client",
- "schemars 1.0.4",
+ "schemars",
"serde",
"serde_json",
"settings",
@@ -674,21 +723,6 @@ dependencies = [
"syn 2.0.106",
]
-[[package]]
-name = "argminmax"
-version = "0.6.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70f13d10a41ac8d2ec79ee34178d61e6f47a29c2edfe7ef1721c7383b0359e65"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "array-init-cursor"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed51fe0f224d1d4ea768be38c51f9f831dee9d05c163c11fba0b8c44387b1fc3"
-
[[package]]
name = "arraydeque"
version = "0.5.1"
@@ -842,7 +876,6 @@ dependencies = [
"fs",
"futures 0.3.31",
"fuzzy",
- "globset",
"gpui",
"html_to_markdown",
"http_client",
@@ -882,6 +915,7 @@ dependencies = [
"fuzzy",
"gpui",
"indoc",
+ "itertools 0.14.0",
"language",
"language_model",
"log",
@@ -900,7 +934,7 @@ dependencies = [
"settings",
"smallvec",
"smol",
- "telemetry_events",
+ "telemetry",
"text",
"ui",
"unindent",
@@ -1238,15 +1272,15 @@ dependencies = [
[[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.1",
"pin-project",
- "thiserror 1.0.69",
+ "thiserror 2.0.17",
]
[[package]]
@@ -1271,15 +1305,6 @@ dependencies = [
"num-traits",
]
-[[package]]
-name = "atoi_simd"
-version = "0.16.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2a49e05797ca52e312a0c658938b7d00693ef037799ef7187678f212d7684cf"
-dependencies = [
- "debug_unsafe",
-]
-
[[package]]
name = "atomic"
version = "0.5.3"
@@ -1341,6 +1366,7 @@ dependencies = [
"parking_lot",
"paths",
"release_channel",
+ "semver",
"serde",
"serde_json",
"settings",
@@ -1376,6 +1402,7 @@ dependencies = [
"http_client",
"markdown_preview",
"release_channel",
+ "semver",
"serde",
"serde_json",
"smol",
@@ -1414,9 +1441,9 @@ dependencies = [
[[package]]
name = "aws-config"
-version = "1.8.8"
+version = "1.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37cf2b6af2a95a20e266782b4f76f1a5e12bf412a9db2de9c1e9123b9d8c0ad8"
+checksum = "1856b1b48b65f71a4dd940b1c0931f9a7b646d4a924b9828ffefc1454714668a"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1461,6 +1488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879b6c89592deb404ba4dc0ae6b58ffd1795c78991cbb5b8bc441c48a070440d"
dependencies = [
"aws-lc-sys",
+ "untrusted 0.7.1",
"zeroize",
]
@@ -1479,9 +1507,9 @@ dependencies = [
[[package]]
name = "aws-runtime"
-version = "1.5.12"
+version = "1.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa006bb32360ed90ac51203feafb9d02e3d21046e1fd3a450a404b90ea73e5d"
+checksum = "9f2402da1a5e16868ba98725e5d73f26b8116eaa892e56f2cd0bf5eec7985f70"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -1504,9 +1532,9 @@ dependencies = [
[[package]]
name = "aws-sdk-bedrockruntime"
-version = "1.109.0"
+version = "1.112.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fbfdfd941dcb253c17bf70baddbf1e5b22f19e29d313d2e049bad4b1dadb2011"
+checksum = "c06c037e6823696d752702ec2bad758d3cf95d1b92b712c8ac7e93824b5e2391"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1586,9 +1614,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
-version = "1.86.0"
+version = "1.88.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4a0abbfab841446cce6e87af853a3ba2cc1bc9afcd3f3550dd556c43d434c86d"
+checksum = "d05b276777560aa9a196dbba2e3aada4d8006d3d7eeb3ba7fe0c317227d933c4"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1608,9 +1636,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ssooidc"
-version = "1.88.0"
+version = "1.90.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a68d675582afea0e94d38b6ca9c5aaae4ca14f1d36faa6edb19b42e687e70d7"
+checksum = "f9be14d6d9cd761fac3fd234a0f47f7ed6c0df62d83c0eeb7012750e4732879b"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1630,9 +1658,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
-version = "1.88.0"
+version = "1.90.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d30990923f4f675523c51eb1c0dec9b752fb267b36a61e83cbc219c9d86da715"
+checksum = "98a862d704c817d865c8740b62d8bbeb5adcb30965e93b471df8a5bcefa20a80"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1653,9 +1681,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
-version = "1.3.5"
+version = "1.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bffc03068fbb9c8dd5ce1c6fb240678a5cffb86fb2b7b1985c999c4b83c8df68"
+checksum = "c35452ec3f001e1f2f6db107b6373f1f48f05ec63ba2c5c9fa91f07dad32af11"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
@@ -1712,9 +1740,9 @@ dependencies = [
[[package]]
name = "aws-smithy-eventstream"
-version = "0.60.12"
+version = "0.60.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9656b85088f8d9dc7ad40f9a6c7228e1e8447cdf4b046c87e152e0805dea02fa"
+checksum = "e29a304f8319781a39808847efb39561351b1bb76e933da7aa90232673638658"
dependencies = [
"aws-smithy-types",
"bytes 1.10.1",
@@ -1723,9 +1751,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
-version = "0.62.4"
+version = "0.62.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3feafd437c763db26aa04e0cc7591185d0961e64c61885bece0fb9d50ceac671"
+checksum = "445d5d720c99eed0b4aa674ed00d835d9b1427dd73e04adaf2f94c6b2d6f9fca"
dependencies = [
"aws-smithy-eventstream",
"aws-smithy-runtime-api",
@@ -1733,6 +1761,7 @@ dependencies = [
"bytes 1.10.1",
"bytes-utils",
"futures-core",
+ "futures-util",
"http 0.2.12",
"http 1.3.1",
"http-body 0.4.6",
@@ -1744,9 +1773,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
-version = "1.1.3"
+version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1053b5e587e6fa40ce5a79ea27957b04ba660baa02b28b7436f64850152234f1"
+checksum = "623254723e8dfd535f566ee7b2381645f8981da086b5c4aa26c0c41582bb1d2c"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -1774,9 +1803,9 @@ dependencies = [
[[package]]
name = "aws-smithy-json"
-version = "0.61.6"
+version = "0.61.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cff418fc8ec5cadf8173b10125f05c2e7e1d46771406187b2c878557d4503390"
+checksum = "2db31f727935fc63c6eeae8b37b438847639ec330a9161ece694efba257e0c54"
dependencies = [
"aws-smithy-types",
]
@@ -1802,9 +1831,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
-version = "1.9.3"
+version = "1.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40ab99739082da5347660c556689256438defae3bcefd66c52b095905730e404"
+checksum = "0bbe9d018d646b96c7be063dd07987849862b0e6d07c778aad7d93d1be6c1ef0"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -1826,9 +1855,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
-version = "1.9.1"
+version = "1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3683c5b152d2ad753607179ed71988e8cfd52964443b4f74fd8e552d0bbfeb46"
+checksum = "ec7204f9fd94749a7c53b26da1b961b4ac36bf070ef1e0b94bb09f79d4f6c193"
dependencies = [
"aws-smithy-async",
"aws-smithy-types",
@@ -1843,9 +1872,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
-version = "1.3.3"
+version = "1.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f5b3a7486f6690ba25952cabf1e7d75e34d69eaff5081904a47bc79074d6457"
+checksum = "25f535879a207fce0db74b679cfc3e91a3159c8144d717d55f5832aea9eef46e"
dependencies = [
"base64-simd",
"bytes 1.10.1",
@@ -1869,18 +1898,18 @@ dependencies = [
[[package]]
name = "aws-smithy-xml"
-version = "0.60.11"
+version = "0.60.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9c34127e8c624bc2999f3b657e749c1393bedc9cd97b92a804db8ced4d2e163"
+checksum = "eab77cdd036b11056d2a30a7af7b775789fb024bf216acc13884c6c97752ae56"
dependencies = [
"xmlparser",
]
[[package]]
name = "aws-types"
-version = "1.3.9"
+version = "1.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2fd329bf0e901ff3f60425691410c69094dc2a1f34b331f37bfc4e9ac1565a1"
+checksum = "d79fb68e3d7fe5d4833ea34dc87d2e97d26d3086cb3da660bb6b1f76d98680b6"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
@@ -1979,7 +2008,7 @@ version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
- "addr2line",
+ "addr2line 0.25.1",
"cfg-if",
"libc",
"miniz_oxide",
@@ -2030,7 +2059,7 @@ dependencies = [
"aws-sdk-bedrockruntime",
"aws-smithy-types",
"futures 0.3.31",
- "schemars 1.0.4",
+ "schemars",
"serde",
"serde_json",
"strum 0.27.2",
@@ -2060,26 +2089,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "bincode"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
-dependencies = [
- "bincode_derive",
- "serde",
- "unty",
-]
-
-[[package]]
-name = "bincode_derive"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
-dependencies = [
- "virtue",
-]
-
[[package]]
name = "bindgen"
version = "0.71.1"
@@ -2120,30 +2129,15 @@ dependencies = [
"syn 2.0.106",
]
-[[package]]
-name = "bit-set"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
-dependencies = [
- "bit-vec 0.6.3",
-]
-
[[package]]
name = "bit-set"
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"
@@ -2247,19 +2241,6 @@ dependencies = [
"profiling",
]
-[[package]]
-name = "blake3"
-version = "1.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0"
-dependencies = [
- "arrayref",
- "arrayvec",
- "cc",
- "cfg-if",
- "constant_time_eq 0.3.1",
-]
-
[[package]]
name = "block"
version = "0.1.6"
@@ -2322,9 +2303,9 @@ dependencies = [
[[package]]
name = "borrow-or-share"
-version = "0.2.2"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32"
+checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "borsh"
@@ -2349,12 +2330,6 @@ dependencies = [
"syn 2.0.106",
]
-[[package]]
-name = "boxcar"
-version = "0.2.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e"
-
[[package]]
name = "breadcrumbs"
version = "0.1.0"
@@ -2417,6 +2392,7 @@ dependencies = [
"rand 0.9.2",
"rope",
"serde_json",
+ "settings",
"sum_tree",
"text",
"unindent",
@@ -2520,9 +2496,6 @@ name = "bytes"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
-dependencies = [
- "serde",
-]
[[package]]
name = "bytes-utils"
@@ -2575,7 +2548,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201"
dependencies = [
- "darling 0.20.11",
+ "darling",
"proc-macro2",
"quote",
"syn 2.0.106",
@@ -2614,26 +2587,24 @@ dependencies = [
[[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.4",
- "log",
"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",
]
@@ -2811,15 +2782,6 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
-[[package]]
-name = "castaway"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
-dependencies = [
- "rustversion",
-]
-
[[package]]
name = "cbc"
version = "0.1.2"
@@ -2836,7 +2798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff"
dependencies = [
"heck 0.4.1",
- "indexmap 2.11.4",
+ "indexmap",
"log",
"proc-macro2",
"quote",
@@ -2849,9 +2811,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.41"
+version = "1.2.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7"
+checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2927,12 +2889,24 @@ dependencies = [
"postage",
"release_channel",
"rpc",
+ "semver",
"settings",
"text",
"time",
"util",
]
+[[package]]
+name = "chardetng"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea"
+dependencies = [
+ "cfg-if",
+ "encoding_rs",
+ "memchr",
+]
+
[[package]]
name = "chrono"
version = "0.4.42"
@@ -2947,16 +2921,6 @@ dependencies = [
"windows-link 0.2.1",
]
-[[package]]
-name = "chrono-tz"
-version = "0.10.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
-dependencies = [
- "chrono",
- "phf 0.12.1",
-]
-
[[package]]
name = "chunked_transfer"
version = "1.5.0"
@@ -3087,6 +3051,7 @@ dependencies = [
"rayon",
"release_channel",
"serde",
+ "serde_json",
"tempfile",
"util",
"windows 0.61.3",
@@ -3124,6 +3089,7 @@ dependencies = [
"release_channel",
"rpc",
"rustls-pki-types",
+ "semver",
"serde",
"serde_json",
"serde_urlencoded",
@@ -3197,25 +3163,11 @@ dependencies = [
"uuid",
]
-[[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",
- "schemars 1.0.4",
- "serde",
- "strum 0.27.2",
-]
-
[[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",
]
@@ -3316,8 +3268,8 @@ name = "codestral"
version = "0.1.0"
dependencies = [
"anyhow",
- "edit_prediction",
"edit_prediction_context",
+ "edit_prediction_types",
"futures 0.3.31",
"gpui",
"http_client",
@@ -3407,7 +3359,6 @@ dependencies = [
"scrypt",
"sea-orm",
"sea-orm-macros",
- "semantic_version",
"semver",
"serde",
"serde_json",
@@ -3482,7 +3433,7 @@ dependencies = [
name = "collections"
version = "0.1.0"
dependencies = [
- "indexmap 2.11.4",
+ "indexmap",
"rustc-hash 2.1.1",
]
@@ -3508,17 +3459,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "comfy-table"
-version = "7.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b"
-dependencies = [
- "crossterm",
- "unicode-segmentation",
- "unicode-width",
-]
-
[[package]]
name = "command-fds"
version = "0.3.2"
@@ -3572,21 +3512,6 @@ dependencies = [
"workspace",
]
-[[package]]
-name = "compact_str"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
-dependencies = [
- "castaway",
- "cfg-if",
- "itoa",
- "rustversion",
- "ryu",
- "serde",
- "static_assertions",
-]
-
[[package]]
name = "component"
version = "0.1.0"
@@ -3667,16 +3592,30 @@ dependencies = [
]
[[package]]
-name = "constant_time_eq"
-version = "0.1.5"
+name = "const_format"
+version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
+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.3.1"
+version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
+checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
[[package]]
name = "context_server"
@@ -3687,16 +3626,19 @@ dependencies = [
"collections",
"futures 0.3.31",
"gpui",
+ "http_client",
"log",
"net",
"parking_lot",
"postage",
- "schemars 1.0.4",
+ "schemars",
"serde",
"serde_json",
"settings",
+ "slotmap",
"smol",
"tempfile",
+ "terminal",
"url",
"util",
]
@@ -3729,7 +3671,7 @@ dependencies = [
"command_palette_hooks",
"ctor",
"dirs 4.0.0",
- "edit_prediction",
+ "edit_prediction_types",
"editor",
"fs",
"futures 0.3.31",
@@ -3754,6 +3696,7 @@ dependencies = [
"task",
"theme",
"ui",
+ "url",
"util",
"workspace",
"zlog",
@@ -4004,20 +3947,38 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cranelift-assembler-x64"
+version = "0.120.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5023e06632d8f351c2891793ccccfe4aef957954904392434038745fb6f1f68"
+dependencies = [
+ "cranelift-assembler-x64-meta",
+]
+
+[[package]]
+name = "cranelift-assembler-x64-meta"
+version = "0.120.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1c4012b4c8c1f6eb05c0a0a540e3e1ee992631af51aa2bbb3e712903ce4fd65"
+dependencies = [
+ "cranelift-srcgen",
+]
+
[[package]]
name = "cranelift-bforest"
-version = "0.116.1"
+version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4"
+checksum = "4d6d883b4942ef3a7104096b8bc6f2d1a41393f159ac8de12aed27b25d67f895"
dependencies = [
"cranelift-entity",
]
[[package]]
name = "cranelift-bitset"
-version = "0.116.1"
+version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c6e3969a7ce267259ce244b7867c5d3bc9e65b0a87e81039588dfdeaede9f34"
+checksum = "db7b2ee9eec6ca8a716d900d5264d678fb2c290c58c46c8da7f94ee268175d17"
dependencies = [
"serde",
"serde_derive",
@@ -9,6 +9,7 @@ members = [
"crates/agent_servers",
"crates/agent_settings",
"crates/agent_ui",
+ "crates/agent_ui_v2",
"crates/ai_onboarding",
"crates/anthropic",
"crates/askpass",
@@ -32,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",
@@ -54,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",
@@ -110,6 +111,7 @@ members = [
"crates/menu",
"crates/migrator",
"crates/mistral",
+ "crates/miniprofiler_ui",
"crates/multi_buffer",
"crates/nc",
"crates/net",
@@ -126,6 +128,7 @@ members = [
"crates/picker",
"crates/prettier",
"crates/project",
+ "crates/project_benchmarks",
"crates/project_panel",
"crates/project_symbols",
"crates/prompt_store",
@@ -145,7 +148,6 @@ members = [
"crates/rules_library",
"crates/schema_generator",
"crates/search",
- "crates/semantic_version",
"crates/session",
"crates/settings",
"crates/settings_json",
@@ -190,6 +192,7 @@ members = [
"crates/vercel",
"crates/vim",
"crates/vim_mode_setting",
+ "crates/which_key",
"crates/watch",
"crates/web_search",
"crates/web_search_providers",
@@ -199,11 +202,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
@@ -240,9 +244,9 @@ action_log = { path = "crates/action_log" }
agent = { path = "crates/agent" }
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" }
@@ -252,7 +256,6 @@ assistant_slash_command = { path = "crates/assistant_slash_command" }
assistant_slash_commands = { path = "crates/assistant_slash_commands" }
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" }
@@ -266,8 +269,6 @@ 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", version = "0.1.0" }
command_palette = { path = "crates/command_palette" }
@@ -288,6 +289,7 @@ deepseek = { path = "crates/deepseek" }
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" }
@@ -311,10 +313,9 @@ 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" }
@@ -341,6 +342,7 @@ 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" }
@@ -355,8 +357,6 @@ panel = { path = "crates/panel" }
paths = { path = "crates/paths" }
perf = { path = "tooling/perf" }
picker = { path = "crates/picker" }
-plugin = { path = "crates/plugin" }
-plugin_macros = { path = "crates/plugin_macros" }
prettier = { path = "crates/prettier" }
settings_profile_selector = { path = "crates/settings_profile_selector" }
project = { path = "crates/project" }
@@ -367,18 +367,15 @@ proto = { path = "crates/proto" }
recent_projects = { path = "crates/recent_projects" }
refineable = { path = "crates/refineable" }
release_channel = { path = "crates/release_channel" }
-scheduler = { path = "crates/scheduler" }
remote = { path = "crates/remote" }
remote_server = { path = "crates/remote_server" }
repl = { path = "crates/repl" }
reqwest_client = { path = "crates/reqwest_client" }
-rich_text = { path = "crates/rich_text" }
rodio = { 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" }
session = { path = "crates/session" }
settings = { path = "crates/settings" }
settings_json = { path = "crates/settings_json" }
@@ -390,7 +387,6 @@ 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" }
supermaven = { path = "crates/supermaven" }
@@ -407,7 +403,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" }
@@ -421,6 +416,7 @@ util_macros = { path = "crates/util_macros" }
vercel = { path = "crates/vercel" }
vim = { path = "crates/vim" }
vim_mode_setting = { path = "crates/vim_mode_setting" }
+which_key = { path = "crates/which_key" }
watch = { path = "crates/watch" }
web_search = { path = "crates/web_search" }
@@ -431,16 +427,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.7.0", 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"
@@ -458,16 +456,16 @@ async-tar = "0.5.1"
async-task = "4.7"
async-trait = "0.1"
async-tungstenite = "0.31.0"
-async_zip = { version = "0.0.17", features = ["deflate", "deflate64"] }
-aws-config = { version = "1.6.1", features = ["behavior-version-latest"] }
-aws-credential-types = { version = "1.2.2", features = [
+async_zip = { version = "0.0.18", features = ["deflate", "deflate64"] }
+aws-config = { version = "1.8.10", features = ["behavior-version-latest"] }
+aws-credential-types = { version = "1.2.8", features = [
"hardcoded-credentials",
] }
-aws-sdk-bedrockruntime = { version = "1.80.0", features = [
+aws-sdk-bedrockruntime = { version = "1.112.0", features = [
"behavior-version-latest",
] }
-aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] }
-aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] }
+aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] }
+aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] }
backtrace = "0.3"
base64 = "0.22"
bincode = "1.2.1"
@@ -475,14 +473,16 @@ 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"
cfg-if = "1.0.3"
+chardetng = "0.1"
chrono = { version = "0.4", features = ["serde"] }
ciborium = "0.2"
circular-buffer = "1.0"
-clap = { version = "4.4", features = ["derive"] }
+clap = { version = "4.4", features = ["derive", "wrap_help"] }
cocoa = "=0.26.0"
cocoa-foundation = "=0.2.0"
convert_case = "0.8.0"
@@ -502,17 +502,16 @@ dotenvy = "0.15.0"
ec4rs = "1.1"
emojis = "0.6.1"
env_logger = "0.11"
+encoding_rs = "0.8"
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 = "3eaa84abca0778eb54272f45a312cb24f9a0b435" }
+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,10 +528,10 @@ 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"
@@ -545,10 +544,9 @@ 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.1", default-features = false, features = [
"NSArray",
@@ -583,14 +581,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 = "e97b9508befa0062929da65a01054d25c4be861c" }
-pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" }
-pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" }
-pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" }
-pet-pixi = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" }
-pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" }
-pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" }
-pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" }
+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 +600,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 +612,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 +622,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,13 +632,12 @@ 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"
@@ -656,7 +651,7 @@ 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",
@@ -668,11 +663,12 @@ time = { version = "0.3", features = [
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 = { version = "0.26", features = ["wasm"] }
+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"
@@ -694,6 +690,7 @@ tree-sitter-ruby = "0.23"
tree-sitter-rust = "0.24"
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"
@@ -704,7 +701,7 @@ uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] }
walkdir = "2.5"
wasm-encoder = "0.221"
wasmparser = "0.221"
-wasmtime = { version = "29", default-features = false, features = [
+wasmtime = { version = "33", default-features = false, features = [
"async",
"demangle",
"runtime",
@@ -713,14 +710,15 @@ wasmtime = { version = "29", default-features = false, features = [
"incremental-cache",
"parallel-compilation",
] }
-wasmtime-wasi = "29"
+wasmtime-wasi = "33"
+wax = "0.6"
which = "6.0.0"
windows-core = "0.61"
-wit-component = "0.221"
yawc = "0.2.5"
zeroize = "1.8"
zstd = "0.11"
+
[workspace.dependencies.windows]
version = "0.61"
features = [
@@ -776,6 +774,7 @@ features = [
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" }
+calloop = { git = "https://github.com/zed-industries/calloop" }
[profile.dev]
split-debuginfo = "unpacked"
@@ -789,14 +788,20 @@ 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 }
@@ -805,12 +810,11 @@ breadcrumbs = { 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 }
@@ -825,12 +829,9 @@ project_symbols = { codegen-units = 1 }
refineable = { codegen-units = 1 }
release_channel = { codegen-units = 1 }
reqwest_client = { codegen-units = 1 }
-rich_text = { codegen-units = 1 }
-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 }
@@ -860,8 +861,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"
@@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.2
-FROM rust:1.90-bookworm as builder
+FROM rust:1.92-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"]
@@ -9,7 +9,7 @@ Welcome to Zed, a high-performance, multiplayer code editor from the creators of
### Installation
-On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/download) or [install Zed via your local package manager](https://zed.dev/docs/linux#installing-via-a-package-manager).
+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:
@@ -28,7 +28,7 @@ ai
= @rtfeldman
audio
- = @dvdsk
+ = @yara-blue
crashes
= @p1n3appl3
@@ -43,7 +43,9 @@ design
= @danilo-leal
docs
+ = @miguelraz
= @probably-neb
+ = @yeskunall
extension
= @kubkon
@@ -51,6 +53,10 @@ extension
git
= @cole-miller
= @danilo-leal
+ = @yara-blue
+ = @kubkon
+ = @Anthony-Eid
+ = @cameron1024
gpui
= @Anthony-Eid
@@ -70,7 +76,7 @@ languages
linux
= @cole-miller
- = @dvdsk
+ = @yara-blue
= @p1n3appl3
= @probably-neb
= @smitbarmase
@@ -86,7 +92,7 @@ multi_buffer
= @SomeoneToIgnore
pickers
- = @dvdsk
+ = @yara-blue
= @p1n3appl3
= @SomeoneToIgnore
@@ -98,6 +104,12 @@ settings_ui
= @danilo-leal
= @probably-neb
+sum_tree
+ = @Veykril
+
+support
+ = @miguelraz
+
tasks
= @SomeoneToIgnore
= @Veykril
@@ -106,6 +118,9 @@ terminal
= @kubkon
= @Veykril
+text
+ = @Veykril
+
vim
= @ConradIrwin
= @dinocosta
@@ -115,3 +130,4 @@ vim
windows
= @localcc
= @reflectronic
+ = @Veykril
@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.00156 10.3996C9.32705 10.3996 10.4016 9.32509 10.4016 7.99961C10.4016 6.67413 9.32705 5.59961 8.00156 5.59961C6.67608 5.59961 5.60156 6.67413 5.60156 7.99961C5.60156 9.32509 6.67608 10.3996 8.00156 10.3996Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.4 5.6V8.6C10.4 9.07739 10.5896 9.53523 10.9272 9.8728C11.2648 10.2104 11.7226 10.4 12.2 10.4C12.6774 10.4 13.1352 10.2104 13.4728 9.8728C13.8104 9.53523 14 9.07739 14 8.6V8C14 6.64839 13.5436 5.33636 12.7048 4.27651C11.8661 3.21665 10.694 2.47105 9.37852 2.16051C8.06306 1.84997 6.68129 1.99269 5.45707 2.56554C4.23285 3.13838 3.23791 4.1078 2.63344 5.31672C2.02898 6.52565 1.85041 7.90325 2.12667 9.22633C2.40292 10.5494 3.11782 11.7405 4.15552 12.6065C5.19323 13.4726 6.49295 13.9629 7.84411 13.998C9.19527 14.0331 10.5187 13.611 11.6 12.8" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.3996 5.59852C13.3994 5.3881 13.3439 5.18144 13.2386 4.99926C13.1333 4.81709 12.9819 4.66581 12.7997 4.56059L8.59996 2.16076C8.41755 2.05544 8.21063 2 8 2C7.78937 2 7.58246 2.05544 7.40004 2.16076L3.20033 4.56059C3.0181 4.66581 2.86674 4.81709 2.76144 4.99926C2.65613 5.18144 2.60059 5.3881 2.60037 5.59852V10.3982C2.60059 10.6086 2.65613 10.8153 2.76144 10.9975C2.86674 11.1796 3.0181 11.3309 3.20033 11.4361L7.40004 13.836C7.58246 13.9413 7.78937 13.9967 8 13.9967C8.21063 13.9967 8.41755 13.9413 8.59996 13.836L12.7997 11.4361C12.9819 11.3309 13.1333 11.1796 13.2386 10.9975C13.3439 10.8153 13.3994 10.6086 13.3996 10.3982V5.59852Z" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.78033 4.99857L7.99998 7.99836L13.2196 4.99857" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 13.9979V7.99829" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M14 11.333A6 6 0 0 0 4 6.867l-1 .9"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333" d="M2 4.667v4h4"/><path fill="#000" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 12a.667.667 0 1 0 0-1.333A.667.667 0 0 0 8 12Z"/></svg>
@@ -1 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M3.333 10 8 14.667 12.667 10M8 5.333v9.334"/><path fill="#000" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 2.667a.667.667 0 1 0 0-1.334.667.667 0 0 0 0 1.334Z"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2 13H5" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11 13H14" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.5 8.5L8 12M8 12L4.5 8.5M8 12L8 3" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M3.333 6 8 1.333 12.667 6M8 10.667V1.333"/><path fill="#000" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 13.333a.667.667 0 1 1 0 1.334.667.667 0 0 1 0-1.334Z"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.5 6.5L8 3M8 3L11.5 6.5M8 3V12" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2 13H5" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11 13H14" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M2 11.333a6 6 0 0 1 10-4.466l1 .9"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333" d="M14 4.667v4h-4"/><path fill="#000" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M8 12a.667.667 0 1 1 0-1.333A.667.667 0 0 1 8 12Z"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2 11.333C2.00118 10.1752 2.33729 9.04258 2.96777 8.07159C3.59826 7.10059 4.49621 6.33274 5.55331 5.86064C6.61041 5.38853 7.78152 5.23235 8.9254 5.41091C10.0693 5.58947 11.1371 6.09516 12 6.86698L13 7.76698" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M14 4.66699V8.66699H10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7 13H10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,10 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_1_2)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M4.58747 12.9359C4.35741 12.778 4.17558 12.625 4.17558 12.625L10.092 2.37749C10.092 2.37749 10.3355 2.46782 10.5367 2.56426C10.7903 2.6858 11.0003 2.80429 11.0003 2.80429C13.8681 4.46005 14.8523 8.13267 13.1965 11.0005C11.5407 13.8684 7.8681 14.8525 5.00023 13.1967C5.00023 13.1967 4.79936 13.0812 4.58747 12.9359ZM10.5003 3.67032L5.50023 12.3307C7.89013 13.7105 10.9506 12.8904 12.3305 10.5006C13.7102 8.1106 12.8902 5.05015 10.5003 3.67032ZM3.07664 11.4314C2.87558 11.1403 2.804 11.0006 2.804 11.0006C1.77036 9.20524 1.69456 6.92215 2.80404 5.00046C3.91353 3.07877 5.92859 2.00291 8.0003 2.00036C8.0003 2.00036 8.28 1.99964 8.51289 2.02194C8.86375 2.05556 9.09702 2.10083 9.09702 2.10083L3.43905 11.9007C3.43905 11.9007 3.30482 11.7618 3.07664 11.4314ZM7.40178 3.03702C5.89399 3.22027 4.48727 4.08506 3.67008 5.50052C2.85288 6.9159 2.80733 8.56653 3.40252 9.96401L7.40178 3.03702Z" fill="black" stroke="black" stroke-width="0.1"/>
+</g>
+<defs>
+<clipPath id="clip0_1_2">
+<rect width="16" height="16" fill="white"/>
+</clipPath>
+</defs>
+</svg>
@@ -0,0 +1,8 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4 2V10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12 6C12.5304 6 13.0391 5.78929 13.4142 5.41421C13.7893 5.03914 14 4.53043 14 4C14 3.46957 13.7893 2.96086 13.4142 2.58579C13.0391 2.21071 12.5304 2 12 2C11.4696 2 10.9609 2.21071 10.5858 2.58579C10.2107 2.96086 10 3.46957 10 4C10 4.53043 10.2107 5.03914 10.5858 5.41421C10.9609 5.78929 11.4696 6 12 6Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 14C4.53043 14 5.03914 13.7893 5.41421 13.4142C5.78929 13.0391 6 12.5304 6 12C6 11.4696 5.78929 10.9609 5.41421 10.5858C5.03914 10.2107 4.53043 10 4 10C3.46957 10 2.96086 10.2107 2.58579 10.5858C2.21071 10.9609 2 11.4696 2 12C2 12.5304 2.21071 13.0391 2.58579 13.4142C2.96086 13.7893 3.46957 14 4 14Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10 4C8.4087 4 6.88258 4.63214 5.75736 5.75736C4.63214 6.88258 4 8.4087 4 10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12 10V14" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M14 12H10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,11 @@
+<svg width="28" height="28" viewBox="0 0 28 28" fill="none" id="svg1378540956_510">
+<g clip-path="url(#svg1378540956_510_clip0_1_1506)" transform="translate(4, 4) scale(0.857)">
+<path d="M17.0547 0.372066H8.52652L-0.00165176 8.90024V17.4284H8.52652V8.90024H17.0547V0.372066Z" fill="#1A1C20"></path>
+<path d="M10.1992 27.6279H18.7274L27.2556 19.0998V10.5716H18.7274V19.0998H10.1992V27.6279Z" fill="#1A1C20"></path>
+</g>
+<defs>
+<clipPath id="svg1378540956_510_clip0_1_1506">
+<rect width="27.2559" height="27.2559" fill="white" transform="translate(0 0.37207)"></rect>
+</clipPath>
+</defs>
+</svg>
@@ -0,0 +1,32 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_3348_16)">
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.2224 1.32129L5.2036 4.41875C5.15145 4.57727 5.06282 4.72134 4.94481 4.83934C4.82681 4.95735 4.68274 5.04598 4.52422 5.09813L1.42676 6.11693L4.52422 7.13574C4.68274 7.18788 4.82681 7.27652 4.94481 7.39453C5.06282 7.51253 5.15145 7.6566 5.2036 7.81512L6.2224 10.9126L7.24121 7.81512C7.29335 7.6566 7.38199 7.51253 7.5 7.39453C7.618 7.27652 7.76207 7.18788 7.9206 7.13574L11.018 6.11693L7.9206 5.09813C7.76207 5.04598 7.618 4.95735 7.5 4.83934C7.38199 4.72134 7.29335 4.57727 7.24121 4.41875L6.2224 1.32129Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.76681 13.9373C9.76681 13.6048 9.95997 13.3083 10.5126 12.7917L11.8872 11.4978C12.3545 11.0575 12.5612 10.77 12.5612 10.4735C12.5612 10.1411 12.3185 9.91643 11.9681 9.91643C11.6986 9.91643 11.5054 10.0242 11.2673 10.3208C10.9933 10.6622 10.7956 10.779 10.4946 10.779C10.0633 10.779 9.75781 10.4915 9.75781 10.0916C9.75781 9.21559 10.8136 8.44287 12.067 8.44287C13.3743 8.44287 14.3492 9.22907 14.3492 10.2848C14.3492 10.9452 13.9988 11.5742 13.2845 12.2077L12.2242 13.1511V13.223H13.7292C14.2503 13.223 14.5738 13.5015 14.5738 13.9552C14.5738 14.4089 14.2593 14.6785 13.7292 14.6785H10.5979C10.1037 14.6785 9.76681 14.3775 9.76681 13.9373Z" fill="black"/>
+<path d="M12.8994 1.32129V4.00482M11.5576 2.66302H14.2412" stroke="black" stroke-opacity="0.75" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -25,7 +25,8 @@
"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 }],
@@ -41,18 +42,18 @@
"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-.": "project_panel::ToggleHideHidden"
- }
+ "ctrl-alt-shift-s": "workspace::ToggleWorktreeSecurity",
+ },
},
{
"context": "Picker || menu",
"bindings": {
"up": "menu::SelectPrevious",
- "down": "menu::SelectNext"
- }
+ "down": "menu::SelectNext",
+ },
},
{
"context": "Editor",
@@ -63,7 +64,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 }],
@@ -125,8 +125,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",
@@ -145,44 +145,44 @@
"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",
@@ -190,8 +190,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",
@@ -200,8 +200,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",
@@ -209,8 +209,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",
@@ -226,8 +226,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",
@@ -240,50 +240,49 @@
"ctrl-alt-l": "agent::OpenRulesLibrary",
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-alt-/": "agent::ToggleModelSelector",
- "ctrl-shift-a": "agent::ToggleContextPicker",
"ctrl-shift-j": "agent::ToggleNavigationMenu",
"ctrl-alt-i": "agent::ToggleOptionsMenu",
"ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl->": "agent::AddSelectionToThread",
- "ctrl-alt-e": "agent::RemoveAllContext",
"ctrl-shift-e": "project_panel::ToggleFocus",
"ctrl-shift-enter": "agent::ContinueThread",
"super-ctrl-b": "agent::ToggleBurnMode",
"alt-enter": "agent::ContinueWithBurnMode",
"ctrl-y": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
- "ctrl-alt-z": "agent::RejectOnce"
- }
+ "ctrl-alt-z": "agent::RejectOnce",
+ "alt-tab": "agent::CycleFavoriteModels",
+ },
},
{
"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 && text_thread",
"bindings": {
"ctrl-n": "agent::NewTextThread",
- "ctrl-alt-t": "agent::NewThread"
- }
+ "ctrl-alt-t": "agent::NewThread",
+ },
},
{
"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",
@@ -293,8 +292,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",
@@ -304,41 +303,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",
@@ -347,8 +335,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",
@@ -358,14 +346,15 @@
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
- "shift-tab": "agent::CycleModeSelector"
- }
+ "shift-tab": "agent::CycleModeSelector",
+ "alt-tab": "agent::CycleFavoriteModels",
+ },
},
{
"context": "ThreadHistory",
"bindings": {
- "backspace": "agent::RemoveSelectedThread"
- }
+ "backspace": "agent::RemoveSelectedThread",
+ },
},
{
"context": "RulesLibrary",
@@ -373,8 +362,8 @@
"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",
@@ -387,22 +376,22 @@
"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",
@@ -413,22 +402,22 @@
"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",
@@ -436,8 +425,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",
@@ -486,8 +475,8 @@
"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
{
@@ -514,6 +503,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",
@@ -550,31 +540,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",
@@ -630,8 +620,8 @@
"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",
@@ -668,28 +658,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
{
@@ -707,8 +697,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
{
@@ -717,37 +707,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"
- }
+ "tab": "editor::NextSnippetTabstop",
+ },
},
{
"context": "Editor && in_snippet && has_previous_tabstop && !showing_completions",
"use_key_equivalents": true,
"bindings": {
- "shift-tab": "editor::PreviousSnippetTabstop"
- }
+ "shift-tab": "editor::PreviousSnippetTabstop",
+ },
},
// Bindings for accepting edit predictions
//
@@ -759,22 +749,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)",
@@ -784,29 +776,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",
@@ -818,16 +810,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",
@@ -835,14 +828,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",
@@ -859,13 +852,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",
@@ -897,20 +891,22 @@
"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",
"bindings": {
- "up": "menu::SelectPrevious",
- "down": "menu::SelectNext",
+ "left": "git_panel::CollapseSelectedEntry",
+ "right": "git_panel::ExpandSelectedEntry",
+ "up": "git_panel::PreviousEntry",
+ "down": "git_panel::NextEntry",
"enter": "menu::Confirm",
"alt-y": "git::StageFile",
"alt-shift-y": "git::UnstageFile",
@@ -925,15 +921,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",
@@ -942,8 +938,8 @@
"enter": "editor::Newline",
"ctrl-enter": "git::Commit",
"ctrl-shift-enter": "git::Amend",
- "alt-l": "git::GenerateCommitMessage"
- }
+ "alt-l": "git::GenerateCommitMessage",
+ },
},
{
"context": "GitPanel",
@@ -959,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",
@@ -968,14 +964,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",
@@ -987,16 +983,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",
@@ -1008,8 +1004,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",
@@ -1017,35 +1013,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-enter": "collab_panel::OpenSelectedChannelNotes"
- }
+ "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",
@@ -1054,29 +1050,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)",
@@ -1085,8 +1081,8 @@
"ctrl-j": "pane::SplitDown",
"ctrl-k": "pane::SplitUp",
"ctrl-h": "pane::SplitLeft",
- "ctrl-l": "pane::SplitRight"
- }
+ "ctrl-l": "pane::SplitRight",
+ },
},
{
"context": "TabSwitcher",
@@ -1094,15 +1090,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-v": "stash_picker::ShowStashItem"
- }
+ "ctrl-shift-v": "stash_picker::ShowStashItem",
+ },
},
{
"context": "Terminal",
@@ -1147,65 +1143,69 @@
"ctrl-shift-r": "terminal::RerunTask",
"ctrl-alt-r": "terminal::RerunTask",
"alt-t": "terminal::RerunTask",
- "ctrl-shift-5": "pane::SplitRight"
- }
+ "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",
@@ -47,11 +47,12 @@
"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",
- "cmd-alt-.": "project_panel::ToggleHideHidden"
- }
+ "ctrl-cmd-c": "editor::DisplayCursorNames",
+ "ctrl-cmd-s": "workspace::ToggleWorktreeSecurity",
+ },
},
{
"context": "Editor",
@@ -148,8 +149,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",
@@ -167,8 +168,8 @@
"cmd->": "agent::AddSelectionToThread",
"cmd-<": "assistant::InsertIntoEditor",
"cmd-alt-e": "editor::SelectEnclosingSymbol",
- "alt-enter": "editor::OpenSelectionsInMultibuffer"
- }
+ "alt-enter": "editor::OpenSelectionsInMultibuffer",
+ },
},
{
"context": "Editor && multibuffer",
@@ -177,23 +178,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",
@@ -201,23 +202,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",
@@ -226,8 +227,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",
@@ -236,8 +237,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",
@@ -247,8 +248,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",
@@ -264,8 +265,9 @@
"cmd-k c": "assistant::CopyCode",
"cmd-g": "search::SelectNextMatch",
"cmd-shift-g": "search::SelectPreviousMatch",
- "cmd-k l": "agent::OpenRulesLibrary"
- }
+ "cmd-k l": "agent::OpenRulesLibrary",
+ "alt-tab": "agent::CycleFavoriteModels",
+ },
},
{
"context": "AgentPanel",
@@ -279,50 +281,49 @@
"cmd-alt-p": "agent::ManageProfiles",
"cmd-i": "agent::ToggleProfileSelector",
"cmd-alt-/": "agent::ToggleModelSelector",
- "cmd-shift-a": "agent::ToggleContextPicker",
"cmd-shift-j": "agent::ToggleNavigationMenu",
"cmd-alt-m": "agent::ToggleOptionsMenu",
"cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
"cmd->": "agent::AddSelectionToThread",
- "cmd-alt-e": "agent::RemoveAllContext",
"cmd-shift-e": "project_panel::ToggleFocus",
"cmd-ctrl-b": "agent::ToggleBurnMode",
"cmd-shift-enter": "agent::ContinueThread",
"alt-enter": "agent::ContinueWithBurnMode",
"cmd-y": "agent::AllowOnce",
"cmd-alt-y": "agent::AllowAlways",
- "cmd-alt-z": "agent::RejectOnce"
- }
+ "cmd-alt-z": "agent::RejectOnce",
+ "alt-tab": "agent::CycleFavoriteModels",
+ },
},
{
"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 && text_thread",
"use_key_equivalents": true,
"bindings": {
"cmd-n": "agent::NewTextThread",
- "cmd-alt-t": "agent::NewThread"
- }
+ "cmd-alt-n": "agent::NewExternalAgentThread",
+ },
},
{
"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",
@@ -333,8 +334,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",
@@ -345,8 +346,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",
@@ -354,8 +355,8 @@
"bindings": {
"escape": "menu::Cancel",
"enter": "menu::Confirm",
- "alt-enter": "editor::Newline"
- }
+ "alt-enter": "editor::Newline",
+ },
},
{
"context": "AgentFeedbackMessageEditor > Editor",
@@ -363,32 +364,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",
@@ -398,8 +387,9 @@
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
- "shift-tab": "agent::CycleModeSelector"
- }
+ "shift-tab": "agent::CycleModeSelector",
+ "alt-tab": "agent::CycleFavoriteModels",
+ },
},
{
"context": "AcpThread > Editor && use_modifier_to_send",
@@ -409,20 +399,21 @@
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
- "shift-tab": "agent::CycleModeSelector"
- }
+ "shift-tab": "agent::CycleModeSelector",
+ "alt-tab": "agent::CycleFavoriteModels",
+ },
},
{
"context": "ThreadHistory",
"bindings": {
- "ctrl--": "pane::GoBack"
- }
+ "ctrl--": "pane::GoBack",
+ },
},
{
"context": "ThreadHistory > Editor",
"bindings": {
- "shift-backspace": "agent::RemoveSelectedThread"
- }
+ "shift-backspace": "agent::RemoveSelectedThread",
+ },
},
{
"context": "RulesLibrary",
@@ -430,8 +421,8 @@
"bindings": {
"cmd-n": "rules_library::NewRule",
"cmd-shift-s": "rules_library::ToggleDefaultRule",
- "cmd-w": "workspace::CloseWindow"
- }
+ "cmd-w": "workspace::CloseWindow",
+ },
},
{
"context": "BufferSearchBar",
@@ -445,24 +436,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",
@@ -474,24 +465,24 @@
"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",
@@ -502,8 +493,8 @@
"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",
@@ -533,8 +524,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
{
@@ -605,24 +596,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",
@@ -631,8 +621,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",
@@ -650,8 +640,8 @@
"ctrl-0": "pane::ActivateLastItem",
"ctrl--": "pane::GoBack",
"ctrl-_": "pane::GoForward",
- "cmd-shift-f": "pane::DeploySearch"
- }
+ "cmd-shift-f": "pane::DeploySearch",
+ },
},
{
"context": "Workspace",
@@ -699,8 +689,8 @@
"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",
@@ -722,8 +712,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",
@@ -734,26 +724,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
{
@@ -774,8 +765,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
{
@@ -785,16 +776,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",
@@ -802,45 +793,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"
- }
+ "tab": "editor::NextSnippetTabstop",
+ },
},
{
"context": "Editor && in_snippet && has_previous_tabstop && !showing_completions",
"use_key_equivalents": true,
"bindings": {
- "shift-tab": "editor::PreviousSnippetTabstop"
- }
+ "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)",
@@ -851,15 +844,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
{
@@ -869,8 +862,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",
@@ -883,19 +876,20 @@
"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",
+ "alt-tab": "agent::CycleFavoriteModels",
"ctrl-[": "agent::CyclePreviousInlineAssist",
- "ctrl-]": "agent::CycleNextInlineAssist"
- }
+ "ctrl-]": "agent::CycleNextInlineAssist",
+ "cmd-shift-enter": "inline_assistant::ThumbsUpResult",
+ "cmd-shift-backspace": "inline_assistant::ThumbsDownResult",
+ },
},
{
"context": "Prompt",
@@ -904,15 +898,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",
@@ -928,14 +922,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",
@@ -958,15 +953,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",
@@ -979,17 +974,19 @@
"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",
"use_key_equivalents": true,
"bindings": {
- "up": "menu::SelectPrevious",
- "down": "menu::SelectNext",
- "cmd-up": "menu::SelectFirst",
- "cmd-down": "menu::SelectLast",
+ "up": "git_panel::PreviousEntry",
+ "down": "git_panel::NextEntry",
+ "cmd-up": "git_panel::FirstEntry",
+ "cmd-down": "git_panel::LastEntry",
+ "left": "git_panel::CollapseSelectedEntry",
+ "right": "git_panel::ExpandSelectedEntry",
"enter": "menu::Confirm",
"cmd-alt-y": "git::ToggleStaged",
"space": "git::ToggleStaged",
@@ -1003,15 +1000,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",
@@ -1020,8 +1017,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",
@@ -1034,8 +1031,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",
@@ -1052,8 +1049,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",
@@ -1063,16 +1060,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",
@@ -1080,16 +1077,16 @@
"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",
@@ -1097,22 +1094,22 @@
"bindings": {
"alt-up": "collab_panel::MoveChannelUp",
"alt-down": "collab_panel::MoveChannelDown",
- "alt-enter": "collab_panel::OpenSelectedChannelNotes"
- }
+ "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",
@@ -1123,30 +1120,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)",
@@ -1156,8 +1153,8 @@
"cmd-j": "pane::SplitDown",
"cmd-k": "pane::SplitUp",
"cmd-h": "pane::SplitLeft",
- "cmd-l": "pane::SplitRight"
- }
+ "cmd-l": "pane::SplitRight",
+ },
},
{
"context": "TabSwitcher",
@@ -1166,16 +1163,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-v": "stash_picker::ShowStashItem"
- }
+ "ctrl-shift-v": "stash_picker::ShowStashItem",
+ },
},
{
"context": "Terminal",
@@ -1230,35 +1227,35 @@
"ctrl-alt-left": "pane::SplitLeft",
"ctrl-alt-right": "pane::SplitRight",
"cmd-d": "pane::SplitRight",
- "cmd-alt-r": "terminal::RerunTask"
- }
+ "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",
@@ -1266,52 +1263,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",
@@ -24,7 +24,8 @@
"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 }],
@@ -36,22 +37,22 @@
"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",
- "ctrl-alt-.": "project_panel::ToggleHideHidden"
- }
+ "ctrl-shift-alt-c": "editor::DisplayCursorNames",
+ "ctrl-shift-alt-s": "workspace::ToggleWorktreeSecurity",
+ },
},
{
"context": "Picker || menu",
"use_key_equivalents": true,
"bindings": {
"up": "menu::SelectPrevious",
- "down": "menu::SelectNext"
- }
+ "down": "menu::SelectNext",
+ },
},
{
"context": "Editor",
@@ -63,7 +64,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 }],
@@ -118,10 +118,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",
@@ -140,23 +140,23 @@
"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",
@@ -164,23 +164,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",
@@ -188,8 +188,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",
@@ -199,8 +199,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",
@@ -209,14 +209,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",
@@ -226,8 +226,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",
@@ -241,51 +241,50 @@
"shift-alt-p": "agent::ManageProfiles",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-alt-/": "agent::ToggleModelSelector",
- "ctrl-shift-a": "agent::ToggleContextPicker",
- "ctrl-shift-j": "agent::ToggleNavigationMenu",
- "ctrl-alt-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-.": "agent::AddSelectionToThread",
- "shift-alt-e": "agent::RemoveAllContext",
"ctrl-shift-e": "project_panel::ToggleFocus",
"ctrl-shift-enter": "agent::ContinueThread",
"super-ctrl-b": "agent::ToggleBurnMode",
"alt-enter": "agent::ContinueWithBurnMode",
- "ctrl-y": "agent::AllowOnce",
+ "shift-alt-a": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
- "ctrl-alt-z": "agent::RejectOnce"
- }
+ "shift-alt-z": "agent::RejectOnce",
+ "alt-tab": "agent::CycleFavoriteModels",
+ },
},
{
"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 && text_thread",
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "agent::NewTextThread",
- "ctrl-alt-t": "agent::NewThread"
- }
+ "ctrl-alt-t": "agent::NewThread",
+ },
},
{
"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",
@@ -296,8 +295,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",
@@ -308,8 +307,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",
@@ -317,8 +316,8 @@
"bindings": {
"escape": "menu::Cancel",
"enter": "menu::Confirm",
- "alt-enter": "editor::Newline"
- }
+ "alt-enter": "editor::Newline",
+ },
},
{
"context": "AgentFeedbackMessageEditor > Editor",
@@ -326,26 +325,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",
@@ -355,8 +342,9 @@
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
- "shift-tab": "agent::CycleModeSelector"
- }
+ "shift-tab": "agent::CycleModeSelector",
+ "alt-tab": "agent::CycleFavoriteModels",
+ },
},
{
"context": "AcpThread > Editor && use_modifier_to_send",
@@ -366,15 +354,16 @@
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
- "shift-tab": "agent::CycleModeSelector"
- }
+ "shift-tab": "agent::CycleModeSelector",
+ "alt-tab": "agent::CycleFavoriteModels",
+ },
},
{
"context": "ThreadHistory",
"use_key_equivalents": true,
"bindings": {
- "backspace": "agent::RemoveSelectedThread"
- }
+ "backspace": "agent::RemoveSelectedThread",
+ },
},
{
"context": "RulesLibrary",
@@ -382,8 +371,8 @@
"bindings": {
"ctrl-n": "rules_library::NewRule",
"ctrl-shift-s": "rules_library::ToggleDefaultRule",
- "ctrl-w": "workspace::CloseWindow"
- }
+ "ctrl-w": "workspace::CloseWindow",
+ },
},
{
"context": "BufferSearchBar",
@@ -396,24 +385,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",
@@ -422,24 +411,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",
@@ -447,8 +436,8 @@
"bindings": {
"escape": "project_search::ToggleFocus",
"ctrl-shift-h": "search::ToggleReplace",
- "alt-r": "search::ToggleRegex" // vscode
- }
+ "alt-r": "search::ToggleRegex", // vscode
+ },
},
{
"context": "Pane",
@@ -479,8 +468,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",
@@ -493,8 +484,8 @@
"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
{
@@ -503,8 +494,8 @@
"bindings": {
"ctrl-[": "editor::Outdent",
"ctrl-]": "editor::Indent",
- "ctrl-shift-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above
- "ctrl-shift-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below
+ "ctrl-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",
@@ -515,24 +506,21 @@
"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",
@@ -556,34 +544,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",
@@ -631,8 +618,8 @@
"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",
@@ -667,22 +654,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",
@@ -690,8 +677,8 @@
"bindings": {
"f10": "menu::Cancel",
"left": "app_menu::ActivateMenuLeft",
- "right": "app_menu::ActivateMenuRight"
- }
+ "right": "app_menu::ActivateMenuRight",
+ },
},
// Bindings from Sublime Text
{
@@ -708,8 +695,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
{
@@ -719,16 +706,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",
@@ -736,22 +723,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"
- }
+ "tab": "editor::NextSnippetTabstop",
+ },
},
{
"context": "Editor && in_snippet && has_previous_tabstop && !showing_completions",
"use_key_equivalents": true,
"bindings": {
- "shift-tab": "editor::PreviousSnippetTabstop"
- }
+ "shift-tab": "editor::PreviousSnippetTabstop",
+ },
},
// Bindings for accepting edit predictions
//
@@ -764,8 +751,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",
@@ -773,15 +761,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)",
@@ -792,16 +781,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
{
@@ -809,15 +798,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",
@@ -830,8 +819,8 @@
"ctrl-f8": "editor::GoToHunk",
"ctrl-shift-f8": "editor::GoToPreviousHunk",
"ctrl-enter": "assistant::InlineAssist",
- "ctrl-shift-;": "editor::ToggleInlayHints"
- }
+ "ctrl-shift-;": "editor::ToggleInlayHints",
+ },
},
{
"context": "PromptEditor",
@@ -839,8 +828,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",
@@ -849,15 +839,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",
@@ -872,14 +862,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",
@@ -903,22 +894,24 @@
"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",
"use_key_equivalents": true,
"bindings": {
- "up": "menu::SelectPrevious",
- "down": "menu::SelectNext",
+ "up": "git_panel::PreviousEntry",
+ "down": "git_panel::NextEntry",
+ "left": "git_panel::CollapseSelectedEntry",
+ "right": "git_panel::ExpandSelectedEntry",
"enter": "menu::Confirm",
"alt-y": "git::StageFile",
"shift-alt-y": "git::UnstageFile",
@@ -932,15 +925,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",
@@ -950,8 +943,8 @@
"enter": "editor::Newline",
"ctrl-enter": "git::Commit",
"ctrl-shift-enter": "git::Amend",
- "alt-l": "git::GenerateCommitMessage"
- }
+ "alt-l": "git::GenerateCommitMessage",
+ },
},
{
"context": "GitPanel",
@@ -968,8 +961,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",
@@ -978,15 +971,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",
@@ -999,8 +992,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",
@@ -1008,8 +1001,8 @@
"bindings": {
"ctrl-t": "debugger::ToggleThreadPicker",
"ctrl-i": "debugger::ToggleSessionPicker",
- "shift-alt-escape": "debugger::ToggleExpandItem"
- }
+ "shift-alt-escape": "debugger::ToggleExpandItem",
+ },
},
{
"context": "VariableList",
@@ -1022,8 +1015,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",
@@ -1032,16 +1025,16 @@
"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",
@@ -1049,22 +1042,22 @@
"bindings": {
"alt-up": "collab_panel::MoveChannelUp",
"alt-down": "collab_panel::MoveChannelDown",
- "alt-enter": "collab_panel::OpenSelectedChannelNotes"
- }
+ "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",
@@ -1074,22 +1067,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)",
@@ -1097,8 +1090,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)",
@@ -1108,8 +1101,8 @@
"ctrl-j": "pane::SplitDown",
"ctrl-k": "pane::SplitUp",
"ctrl-h": "pane::SplitLeft",
- "ctrl-l": "pane::SplitRight"
- }
+ "ctrl-l": "pane::SplitRight",
+ },
},
{
"context": "TabSwitcher",
@@ -1118,16 +1111,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-v": "stash_picker::ShowStashItem"
- }
+ "ctrl-shift-v": "stash_picker::ShowStashItem",
+ },
},
{
"context": "Terminal",
@@ -10,12 +10,12 @@
"context": "Workspace",
"bindings": {
// "shift shift": "file_finder::Toggle"
- }
+ },
},
{
"context": "Editor && vim_mode == insert",
"bindings": {
// "j k": "vim::NormalBefore"
- }
- }
+ },
+ },
]
@@ -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",
@@ -32,8 +32,8 @@
"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
+ },
+ },
]
@@ -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",
@@ -20,18 +20,18 @@
"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,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": {
"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::AcceptNextWordEditPrediction",
+ "ctrl-down": "editor::AcceptNextLineEditPrediction",
+ },
},
{
"context": "Terminal",
"use_key_equivalents": true,
"bindings": {
- "ctrl-k": "assistant::InlineAssist"
- }
- }
+ "ctrl-k": "assistant::InlineAssist",
+ },
+ },
]
@@ -5,8 +5,8 @@
[
{
"bindings": {
- "ctrl-g": "menu::Cancel"
- }
+ "ctrl-g": "menu::Cancel",
+ },
},
{
// Workaround to avoid falling back to default bindings.
@@ -18,8 +18,8 @@
"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
- }
+ "ctrl-n": null, // currently activates `workspace::NewFile` when the cursor is on the last character of the buffer
+ },
},
{
"context": "Editor",
@@ -82,8 +82,8 @@
"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
@@ -119,22 +119,22 @@
"alt->": "editor::SelectToEnd",
"ctrl-home": "editor::SelectToBeginning",
"ctrl-end": "editor::SelectToEnd",
- "ctrl-g": "editor::Cancel"
- }
+ "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)
@@ -164,8 +164,8 @@
"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 native emacs from the Zed terminal.
@@ -185,22 +185,22 @@
"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",
+ },
+ },
]
@@ -1,18 +1,20 @@
[
{
"bindings": {
- "ctrl-alt-s": "zed::OpenSettingsFile",
+ "ctrl-alt-s": "zed::OpenSettings",
"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,8 +93,8 @@
"alt-c": "search::ToggleCaseSensitive",
"alt-e": "search::ToggleSelection",
"alt-x": "search::ToggleRegex",
- "alt-w": "search::ToggleWholeWord"
- }
+ "alt-w": "search::ToggleWholeWord",
+ },
},
{
"context": "Workspace",
@@ -94,9 +102,13 @@
"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",
+ },
+ },
]
@@ -22,8 +22,8 @@
"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",
@@ -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",
+ },
+ },
]
@@ -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",
@@ -33,8 +33,8 @@
"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",
+ },
+ },
]
@@ -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",
@@ -20,19 +20,19 @@
"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",
+ },
+ },
]
@@ -6,8 +6,8 @@
{
"context": "!GitPanel",
"bindings": {
- "ctrl-g": "menu::Cancel"
- }
+ "ctrl-g": "menu::Cancel",
+ },
},
{
// Workaround to avoid falling back to default bindings.
@@ -15,8 +15,8 @@
// 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
- }
+ "ctrl-g": null, // currently activates `go_to_line::Toggle` when there is nothing to cancel
+ },
},
{
"context": "Editor",
@@ -79,8 +79,8 @@
"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
@@ -116,22 +116,22 @@
"alt->": "editor::SelectToEnd",
"ctrl-home": "editor::SelectToBeginning",
"ctrl-end": "editor::SelectToEnd",
- "ctrl-g": "editor::Cancel"
- }
+ "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)
@@ -161,8 +161,8 @@
"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 native emacs from the Zed terminal.
@@ -182,22 +182,22 @@
"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",
+ },
+ },
]
@@ -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,8 +95,8 @@
"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",
@@ -96,9 +104,13 @@
"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",
+ },
+ },
]
@@ -22,8 +22,8 @@
"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",
@@ -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",
+ },
+ },
]
@@ -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": {},
+ },
]
@@ -27,7 +27,7 @@
"backspace": "editor::Backspace",
"delete": "editor::Delete",
"left": "editor::MoveLeft",
- "right": "editor::MoveRight"
- }
- }
+ "right": "editor::MoveRight",
+ },
+ },
]
@@ -180,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",
@@ -224,8 +223,8 @@
"] 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,22 +409,31 @@
"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"
- }
+ "escape": "vim::SwitchToHelixNormalMode",
+ },
},
{
"context": "(vim_mode == helix_normal || vim_mode == helix_select) && !menu",
@@ -445,9 +453,9 @@
"shift-r": "editor::Paste",
"`": "vim::ConvertToLowerCase",
"alt-`": "vim::ConvertToUpperCase",
- "insert": "vim::InsertBefore",
+ "insert": "vim::InsertBefore", // not a helix default
"shift-u": "editor::Redo",
- "ctrl-r": "vim::Redo",
+ "ctrl-r": "vim::Redo", // not a helix default
"y": "vim::HelixYank",
"p": "vim::HelixPaste",
"shift-p": ["vim::HelixPaste", { "before": true }],
@@ -476,31 +484,40 @@
"alt-p": "editor::SelectPreviousSyntaxNode",
"alt-n": "editor::SelectNextSyntaxNode",
+ // Search
+ "n": "vim::HelixSelectNext",
+ "shift-n": "vim::HelixSelectPrevious",
+
// Goto mode
"g e": "vim::EndOfDocument",
"g h": "vim::StartOfLine",
"g l": "vim::EndOfLine",
- "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s"
+ "g s": "vim::FirstNonWhitespace",
"g t": "vim::WindowTop",
"g c": "vim::WindowMiddle",
"g b": "vim::WindowBottom",
- "g r": "editor::FindAllReferences", // zed specific
+ "g r": "editor::FindAllReferences",
"g n": "pane::ActivateNextItem",
- "shift-l": "pane::ActivateNextItem",
+ "shift-l": "pane::ActivateNextItem", // not a helix default
"g p": "pane::ActivatePreviousItem",
- "shift-h": "pane::ActivatePreviousItem",
- "g .": "vim::HelixGotoLastModification", // go to last modification
+ "shift-h": "pane::ActivatePreviousItem", // not a helix default
+ "g .": "vim::HelixGotoLastModification",
+ "g o": "editor::ToggleSelectedDiffHunks", // Zed specific
+ "g shift-o": "git::ToggleStaged", // Zed specific
+ "g shift-r": "git::Restore", // Zed specific
+ "g u": "git::StageAndNext", // Zed specific
+ "g shift-u": "git::UnstageAndNext", // Zed specific
// Window mode
+ "space w v": "pane::SplitDown",
+ "space w s": "pane::SplitRight",
"space w h": "workspace::ActivatePaneLeft",
- "space w l": "workspace::ActivatePaneRight",
- "space w k": "workspace::ActivatePaneUp",
"space w j": "workspace::ActivatePaneDown",
+ "space w k": "workspace::ActivatePaneUp",
+ "space w l": "workspace::ActivatePaneRight",
"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 r": "pane::SplitRight", // not a helix default
+ "space w d": "pane::SplitDown", // not a helix default
// Space mode
"space f": "file_finder::Toggle",
@@ -514,6 +531,7 @@
"space c": "editor::ToggleComments",
"space p": "editor::Paste",
"space y": "editor::Copy",
+ "space /": "pane::DeploySearch",
// Other
":": "command_palette::Toggle",
@@ -521,24 +539,22 @@
"]": ["vim::PushHelixNext", { "around": true }],
"[": ["vim::PushHelixPrevious", { "around": true }],
"g q": "vim::PushRewrap",
- "g w": "vim::PushRewrap"
- // "tab": "pane::ActivateNextItem",
- // "shift-tab": "pane::ActivatePrevItem",
- }
+ "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",
@@ -554,8 +570,8 @@
"backspace": "vim::UndoReplace",
"tab": "vim::Tab",
"enter": "vim::Enter",
- "insert": "vim::InsertBefore"
- }
+ "insert": "vim::InsertBefore",
+ },
},
{
"context": "vim_mode == waiting",
@@ -567,14 +583,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",
@@ -582,8 +598,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",
@@ -620,14 +636,14 @@
"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",
@@ -644,8 +660,8 @@
"x": "editor::SelectSmallerSyntaxNode",
"d": "editor::GoToDiagnostic",
"c": "editor::GoToHunk",
- "space": "vim::InsertEmptyLineBelow"
- }
+ "space": "vim::InsertEmptyLineBelow",
+ },
},
{
"context": "vim_operator == helix_previous",
@@ -662,8 +678,8 @@
"x": "editor::SelectLargerSyntaxNode",
"d": "editor::GoToPreviousDiagnostic",
"c": "editor::GoToPreviousHunk",
- "space": "vim::InsertEmptyLineAbove"
- }
+ "space": "vim::InsertEmptyLineAbove",
+ },
},
{
"context": "vim_operator == c",
@@ -671,8 +687,8 @@
"c": "vim::CurrentLine",
"x": "vim::Exchange",
"d": "editor::Rename", // zed specific
- "s": ["vim::PushChangeSurrounds", {}]
- }
+ "s": ["vim::PushChangeSurrounds", {}],
+ },
},
{
"context": "vim_operator == d",
@@ -684,36 +700,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",
@@ -721,66 +737,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",
@@ -822,15 +838,15 @@
"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 && !menu || !Editor && !Terminal",
@@ -853,6 +869,8 @@
"ctrl-w shift-right": "workspace::SwapPaneRight",
"ctrl-w shift-up": "workspace::SwapPaneUp",
"ctrl-w shift-down": "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",
@@ -889,15 +907,19 @@
"ctrl-w ctrl-n": "workspace::NewFileSplitHorizontal",
"ctrl-w n": "workspace::NewFileSplitHorizontal",
"g t": "vim::GoToTab",
- "g shift-t": "vim::GoToPreviousTab"
- }
+ "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
@@ -947,17 +969,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",
@@ -972,8 +1022,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",
@@ -984,8 +1034,8 @@
"#": null,
"*": null,
"n": null,
- "shift-n": null
- }
+ "shift-n": null,
+ },
},
{
"context": "Picker > Editor",
@@ -994,29 +1044,29 @@
"ctrl-u": "editor::DeleteToBeginningOfLine",
"ctrl-w": "editor::DeleteToPreviousWordStart",
"ctrl-p": "menu::SelectPrevious",
- "ctrl-n": "menu::SelectNext"
- }
+ "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",
@@ -1024,8 +1074,8 @@
// 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",
@@ -1035,7 +1085,16 @@
"k": "settings_editor::FocusPreviousNavEntry",
"j": "settings_editor::FocusNextNavEntry",
"g g": "settings_editor::FocusFirstNavEntry",
- "shift-g": "settings_editor::FocusLastNavEntry"
- }
- }
+ "shift-g": "settings_editor::FocusLastNavEntry",
+ },
+ },
+ {
+ "context": "MarkdownPreview",
+ "bindings": {
+ "ctrl-u": "markdown::ScrollPageUp",
+ "ctrl-d": "markdown::ScrollPageDown",
+ "ctrl-y": "markdown::ScrollUp",
+ "ctrl-e": "markdown::ScrollDown",
+ },
+ },
]
@@ -0,0 +1,40 @@
+{{#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 <rewrite_this></rewrite_this> tags.
+
+<document>
+{{{document_content}}}
+</document>
+
+{{#if is_truncated}}
+The context around the relevant section has been truncated (possibly in the middle of a line) for brevity.
+{{/if}}
+
+And here's the section to rewrite based on that prompt again for reference:
+
+<rewrite_this>
+{{{rewrite_section}}}
+</rewrite_this>
+
+{{#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}}
+<diagnostic_error>
+ <line_number>{{line_number}}</line_number>
+ <error_message>{{error_message}}</error_message>
+ <code_content>{{code_content}}</code_content>
+</diagnostic_error>
+{{/each}}
+{{/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.
@@ -12,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.
@@ -29,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
@@ -69,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,
@@ -87,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
@@ -103,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.
//
@@ -175,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,
@@ -255,6 +265,12 @@
// 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:
//
@@ -280,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.
//
@@ -384,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": {
@@ -404,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,
@@ -420,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.
@@ -456,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": {
@@ -495,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": {
@@ -544,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,
@@ -567,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.
@@ -588,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",
@@ -607,7 +625,7 @@
"fast_scroll_sensitivity": 4.0,
"sticky_scroll": {
// Whether to stick scopes to the top of the editor.
- "enabled": false
+ "enabled": false,
},
"relative_line_numbers": "disabled",
// If 'search_wrap' is disabled, search result do not wrap around the end of the file.
@@ -625,7 +643,7 @@
// 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
+ "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:
@@ -668,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".
@@ -717,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:
@@ -740,16 +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,
// Whether to hide the hidden entries in the project panel.
"hide_hidden": false,
- // Whether to automatically open files when pasting them in the project panel.
- "open_file_on_paste": true
+ // 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
@@ -782,7 +817,7 @@
// "always"
// 2. Never show indent guides:
// "never"
- "show": "always"
+ "show": "always",
},
// Scrollbar-related settings
"scrollbar": {
@@ -799,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.
@@ -811,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.
@@ -837,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.
@@ -856,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'.
@@ -867,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.
@@ -878,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
@@ -929,12 +972,14 @@
"now": true,
"find_path": true,
"read_file": true,
+ "restore_file_from_disk": true,
+ "save_file": true,
"open": true,
"grep": true,
"terminal": true,
"thinking": true,
- "web_search": true
- }
+ "web_search": true,
+ },
},
"ask": {
"name": "Ask",
@@ -951,14 +996,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
@@ -987,7 +1032,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,
@@ -1022,7 +1067,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": {
@@ -1061,19 +1106,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": {
@@ -1117,7 +1171,7 @@
// * "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"
+ "include_ignored": "smart",
},
// Whether or not to remove any trailing whitespace from lines of a buffer
// before saving it.
@@ -1176,12 +1230,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.
//
@@ -1215,7 +1276,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": {
@@ -1233,8 +1294,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`.
@@ -1248,7 +1309,7 @@
"**/.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
@@ -1283,14 +1344,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:
@@ -1299,7 +1360,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": [
@@ -1314,6 +1378,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.
@@ -1331,7 +1397,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:
@@ -1349,15 +1415,16 @@
"copilot": {
"enterprise_uri": null,
"proxy": null,
- "proxy_no_verify": null
+ "proxy_no_verify": null,
},
"codestral": {
- "model": null,
- "max_tokens": null
+ "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
+ "enabled_in_text_threads": true,
},
// Settings specific to journaling
"journal": {
@@ -1367,7 +1434,7 @@
// May take 2 values:
// 1. hour12
// 2. hour24
- "hour_format": "hour12"
+ "hour_format": "hour12",
},
// Status bar-related settings.
"status_bar": {
@@ -1378,7 +1445,7 @@
// Whether to show the cursor position button in the status bar.
"cursor_position_button": true,
// Whether to show active line endings button in the status bar.
- "line_endings_button": false
+ "line_endings_button": false,
},
// Settings specific to the terminal
"terminal": {
@@ -1405,7 +1472,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
@@ -1499,8 +1566,8 @@
// Preferred Conda manager to use when activating Conda environments.
// Values: "auto", "conda", "mamba", "micromamba"
// Default: "auto"
- "conda_manager": "auto"
- }
+ "conda_manager": "auto",
+ },
},
"toolbar": {
// Whether to display the terminal title in its toolbar's breadcrumbs.
@@ -1508,7 +1575,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": {
@@ -1525,7 +1592,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.
@@ -1543,6 +1610,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.
@@ -1557,7 +1626,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) (?<link>(?<path>.+?))(:$|$)",
+ // "\\s+(Compiling|Checking|Documenting) [^(]+\\((?<link>(?<path>.+))\\)"
+ // ],
+ //
+ // 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 \"(?<path>[^\"]+)\", line (?<line>[0-9]+)",
+ // Common path syntax with optional line, column, description, trailing punctuation, or
+ // surrounding symbols or quotes
+ [
+ "(?x)",
+ "(?<path>",
+ " (",
+ " # 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.
@@ -1573,7 +1690,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
@@ -1588,9 +1705,14 @@
// }
//
"file_types": {
- "JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json", "tsconfig*.json"],
+ "JSONC": [
+ "**/.zed/*.json",
+ "**/.vscode/**/*.json",
+ "**/{zed,Zed}/{settings,keymap,tasks,debug}.json",
+ "tsconfig*.json",
+ ],
"Markdown": [".rules", ".cursorrules", ".windsurfrules", ".clinerules"],
- "Shell Script": [".env.*"]
+ "Shell Script": [".env.*"],
},
// Settings for which version of Node.js and NPM to use when installing
// language servers and Copilot.
@@ -1606,14 +1728,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.
//
@@ -1621,7 +1743,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": {
@@ -1672,7 +1794,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": {
@@ -1680,113 +1802,116 @@
"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", "..."]
+ "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", "..."]
+ "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",
@@ -1794,136 +1919,145 @@
"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",
- "soft_wrap": "editor_width"
+ "soft_wrap": "editor_width",
+ "completions": {
+ "words": "disabled",
+ },
+ },
+ "Proto": {
+ "language_servers": ["buf", "!protols", "!protobuf-language-server", "..."],
},
"Python": {
"code_actions_on_format": {
- "source.organizeImports.ruff": true
+ "source.organizeImports.ruff": true,
},
"formatter": {
"language_server": {
- "name": "ruff"
- }
+ "name": "ruff",
+ },
},
"debuggers": ["Debugpy"],
- "language_servers": ["basedpyright", "ruff", "!ty", "!pyrefly", "!pyright", "!pylsp", "..."]
+ "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",
"language_servers": ["!slang", "..."],
- "use_on_type_format": false
+ "use_on_type_format": false,
},
"Vue.js": {
"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", "..."]
+ "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.
@@ -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",
+ },
]
@@ -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": {},
}
@@ -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": []
- }
+ },
]
@@ -12,6 +12,6 @@
"theme": {
"mode": "system",
"light": "One Light",
- "dark": "One Dark"
- }
+ "dark": "One Dark",
+ },
}
@@ -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,
@@ -46,6 +46,7 @@
"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,
@@ -70,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",
@@ -452,6 +453,7 @@
"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,
@@ -476,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",
@@ -858,6 +860,7 @@
"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,
@@ -882,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",
@@ -1264,6 +1267,7 @@
"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,
@@ -1291,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",
@@ -1670,6 +1674,7 @@
"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,
@@ -1697,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",
@@ -2076,6 +2081,7 @@
"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,
@@ -2103,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",
@@ -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",
@@ -14,6 +14,7 @@ disallowed-methods = [
{ 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" },
@@ -46,6 +46,7 @@ url.workspace = true
util.workspace = true
uuid.workspace = true
watch.workspace = true
+urlencoding.workspace = true
[dev-dependencies]
env_logger.workspace = true
@@ -43,6 +43,7 @@ pub struct UserMessage {
pub content: ContentBlock,
pub chunks: Vec<acp::ContentBlock>,
pub checkpoint: Option<Checkpoint>,
+ pub indented: bool,
}
#[derive(Debug)]
@@ -73,6 +74,7 @@ impl UserMessage {
#[derive(Debug, PartialEq)]
pub struct AssistantMessage {
pub chunks: Vec<AssistantMessageChunk>,
+ pub indented: bool,
}
impl AssistantMessage {
@@ -123,6 +125,14 @@ pub enum AgentThreadEntry {
}
impl AgentThreadEntry {
+ pub fn is_indented(&self) -> bool {
+ match self {
+ Self::UserMessage(message) => message.indented,
+ Self::AssistantMessage(message) => message.indented,
+ Self::ToolCall(_) => false,
+ }
+ }
+
pub fn to_markdown(&self, cx: &App) -> String {
match self {
Self::UserMessage(message) => message.to_markdown(cx),
@@ -201,17 +211,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,
@@ -241,6 +253,7 @@ impl ToolCall {
locations,
raw_input,
raw_output,
+ ..
} = fields;
if let Some(kind) = kind {
@@ -262,21 +275,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(), path_style, 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);
}
@@ -347,13 +368,13 @@ 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()?;
@@ -425,6 +446,7 @@ impl From<acp::ToolCallStatus> for ToolCallStatus {
acp::ToolCallStatus::InProgress => Self::InProgress,
acp::ToolCallStatus::Completed => Self::Completed,
acp::ToolCallStatus::Failed => Self::Failed,
+ _ => Self::Pending,
}
}
}
@@ -537,7 +559,7 @@ impl ContentBlock {
..
}) => 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(),
}
}
@@ -591,15 +613,17 @@ impl ToolCallContent {
path_style: PathStyle,
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
cx: &mut App,
- ) -> Result<Self> {
+ ) -> Result<Option<Self>> {
match content {
- acp::ToolCallContent::Content { content } => Ok(Self::ContentBlock(ContentBlock::new(
- content,
- &language_registry,
- path_style,
- cx,
- ))),
- acp::ToolCallContent::Diff { diff } => Ok(Self::Diff(cx.new(|cx| {
+ 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,
@@ -607,12 +631,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),
}
}
@@ -623,9 +648,9 @@ impl ToolCallContent {
path_style: PathStyle,
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
cx: &mut App,
- ) -> Result<()> {
+ ) -> Result<bool> {
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,
@@ -635,10 +660,14 @@ impl ToolCallContent {
_ => true,
};
- if needs_update {
- *self = Self::from_acp(new, language_registry, path_style, 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 {
@@ -660,7 +689,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,
}
@@ -732,6 +761,7 @@ impl Plan {
acp::PlanEntryStatus::Completed => {
stats.completed += 1;
}
+ _ => {}
}
}
@@ -1154,6 +1184,7 @@ impl AcpThread {
current_mode_id,
..
}) => cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id)),
+ _ => {}
}
Ok(())
}
@@ -1163,6 +1194,16 @@ impl AcpThread {
message_id: Option<UserMessageId>,
chunk: acp::ContentBlock,
cx: &mut Context<Self>,
+ ) {
+ self.push_user_content_block_with_indent(message_id, chunk, false, cx)
+ }
+
+ pub fn push_user_content_block_with_indent(
+ &mut self,
+ message_id: Option<UserMessageId>,
+ chunk: acp::ContentBlock,
+ indented: bool,
+ cx: &mut Context<Self>,
) {
let language_registry = self.project.read(cx).languages().clone();
let path_style = self.project.read(cx).path_style(cx);
@@ -1173,8 +1214,10 @@ impl AcpThread {
id,
content,
chunks,
+ indented: existing_indented,
..
}) = last_entry
+ && *existing_indented == indented
{
*id = message_id.or(id.take());
content.append(chunk.clone(), &language_registry, path_style, cx);
@@ -1189,6 +1232,7 @@ impl AcpThread {
content,
chunks: vec![chunk],
checkpoint: None,
+ indented,
}),
cx,
);
@@ -1200,12 +1244,26 @@ impl AcpThread {
chunk: acp::ContentBlock,
is_thought: bool,
cx: &mut Context<Self>,
+ ) {
+ self.push_assistant_content_block_with_indent(chunk, is_thought, false, cx)
+ }
+
+ pub fn push_assistant_content_block_with_indent(
+ &mut self,
+ chunk: acp::ContentBlock,
+ is_thought: bool,
+ indented: bool,
+ cx: &mut Context<Self>,
) {
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
+ && let AgentThreadEntry::AssistantMessage(AssistantMessage {
+ chunks,
+ indented: existing_indented,
+ }) = last_entry
+ && *existing_indented == indented
{
let idx = entries_len - 1;
cx.emit(AcpThreadEvent::EntryUpdated(idx));
@@ -1234,6 +1292,7 @@ impl AcpThread {
self.push_entry(
AgentThreadEntry::AssistantMessage(AssistantMessage {
chunks: vec![chunk],
+ indented,
}),
cx,
);
@@ -1287,11 +1346,7 @@ 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,
@@ -1315,7 +1370,7 @@ impl AcpThread {
let location_updated = update.fields.locations.is_some();
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) => {
@@ -1353,9 +1408,9 @@ impl AcpThread {
) -> Result<(), acp::Error> {
let language_registry = self.project.read(cx).languages().clone();
let path_style = self.project.read(cx).path_style(cx);
- let id = update.id.clone();
+ let id = update.tool_call_id.clone();
- let agent = self.connection().telemetry_id();
+ 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) {
@@ -1363,7 +1418,12 @@ impl AcpThread {
} else {
"failed"
};
- telemetry::event!("Agent Tool Call Completed", agent, session, status);
+ telemetry::event!(
+ "Agent Tool Call Completed",
+ agent_telemetry_id,
+ session,
+ status
+ );
}
if let Some(ix) = self.index_for_tool_call(&id) {
@@ -1518,16 +1578,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());
}
@@ -1543,7 +1603,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,
}
}
@@ -1570,6 +1632,7 @@ impl AcpThread {
acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways => {
ToolCallStatus::InProgress
}
+ _ => ToolCallStatus::InProgress,
};
let curr_status = mem::replace(&mut call.status, new_status);
@@ -1648,14 +1711,7 @@ impl AcpThread {
message: &str,
cx: &mut Context<Self>,
) -> 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(
@@ -1669,11 +1725,7 @@ impl AcpThread {
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() {
@@ -1690,6 +1742,7 @@ impl AcpThread {
content: block,
chunks: message,
checkpoint: None,
+ indented: false,
}),
cx,
);
@@ -1765,7 +1818,7 @@ impl AcpThread {
result,
Ok(Ok(acp::PromptResponse {
stop_reason: acp::StopReason::Cancelled,
- meta: None,
+ ..
}))
);
@@ -1781,7 +1834,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() {
@@ -1866,10 +1919,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
@@ -1894,9 +1951,25 @@ impl AcpThread {
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<acp::TerminalId> = 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(Some(telemetry), cx)
@@ -1997,7 +2070,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?;
@@ -2030,7 +2103,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
@@ -2100,7 +2173,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,
);
@@ -2182,7 +2255,7 @@ impl AcpThread {
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| {
@@ -2392,7 +2465,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| {
@@ -2454,7 +2527,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| {
@@ -2472,11 +2545,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,
);
@@ -2533,15 +2602,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| {
@@ -2557,15 +2618,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| {
@@ -2580,26 +2633,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,
);
});
@@ -2627,27 +2668,22 @@ mod tests {
thread.update(&mut cx, |thread, cx| {
thread
.handle_session_update(
- acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk {
- content: "Thinking ".into(),
- meta: None,
- }),
+ acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
+ "Thinking ".into(),
+ )),
cx,
)
.unwrap();
thread
.handle_session_update(
- acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk {
- content: "hard!".into(),
- meta: None,
- }),
+ 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()
},
@@ -2715,10 +2751,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()
},
@@ -2940,7 +2973,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]
@@ -2949,7 +2982,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();
@@ -2959,26 +2992,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()
}
@@ -3020,14 +3044,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,
)
})
@@ -3059,33 +3079,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()
}
@@ -3138,18 +3146,14 @@ mod tests {
thread.update(&mut cx, |thread, cx| {
thread
.handle_session_update(
- acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk {
- content: content.text.to_uppercase().into(),
- meta: None,
- }),
+ 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()
}
@@ -3305,34 +3309,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()
@@ -3360,16 +3352,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();
@@ -3415,21 +3398,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()
}
}
}));
@@ -3486,10 +3459,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 {
@@ -3498,18 +3468,14 @@ mod tests {
thread.update(&mut cx, |thread, cx| {
thread
.handle_session_update(
- acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk {
- content: content.text.to_uppercase().into(),
- meta: None,
- }),
+ 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()
}
@@ -3634,8 +3600,8 @@ mod tests {
}
impl AgentConnection for FakeAgentConnection {
- fn telemetry_id(&self) -> &'static str {
- "fake"
+ fn telemetry_id(&self) -> SharedString {
+ "fake".into()
}
fn auth_methods(&self) -> &[acp::AuthMethod] {
@@ -3648,13 +3614,12 @@ mod tests {
_cwd: &Path,
cx: &mut App,
) -> Task<gpui::Result<Entity<AcpThread>>> {
- let session_id = acp::SessionId(
+ let session_id = acp::SessionId::new(
rand::rng()
.sample_iter(&distr::Alphanumeric)
.take(7)
.map(char::from)
- .collect::<String>()
- .into(),
+ .collect::<String>(),
);
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let thread = cx.new(|cx| {
@@ -3664,12 +3629,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,
)
});
@@ -3698,10 +3663,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)))
}
}
@@ -3756,17 +3718,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,
);
@@ -3803,4 +3761,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)"
+ );
+ }
}
@@ -20,7 +20,7 @@ impl UserMessageId {
}
pub trait AgentConnection {
- fn telemetry_id(&self) -> &'static str;
+ fn telemetry_id(&self) -> SharedString;
fn new_thread(
self: Rc<Self>,
@@ -197,6 +197,17 @@ pub trait AgentModelSelector: 'static {
fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
None
}
+
+ /// Returns whether the model picker should render a footer.
+ fn should_render_footer(&self) -> bool {
+ false
+ }
+
+ /// Whether this selector supports the favorites feature.
+ /// Only the native agent uses the model ID format that maps to settings.
+ fn supports_favorites(&self) -> bool {
+ false
+ }
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -234,6 +245,10 @@ impl AgentModelList {
AgentModelList::Grouped(groups) => groups.is_empty(),
}
}
+
+ pub fn is_flat(&self) -> bool {
+ matches!(self, AgentModelList::Flat(_))
+ }
}
#[cfg(feature = "test-support")]
@@ -317,8 +332,8 @@ mod test_support {
}
impl AgentConnection for StubAgentConnection {
- fn telemetry_id(&self) -> &'static str {
- "stub"
+ fn telemetry_id(&self) -> SharedString {
+ "stub".into()
}
fn auth_methods(&self) -> &[acp::AuthMethod] {
@@ -331,7 +346,7 @@ mod test_support {
_cwd: &Path,
cx: &mut gpui::App,
) -> Task<gpui::Result<Entity<AcpThread>>> {
- 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(
@@ -340,12 +355,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,
)
});
@@ -384,10 +399,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(..) {
@@ -395,7 +407,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 {
@@ -424,10 +436,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))
})
}
}
@@ -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::<Vec<_>>()
+ 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::<Vec<_>>()
};
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 {
@@ -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::<Vec<_>>();
ranges.extend(
@@ -4,12 +4,14 @@ use file_icons::FileIcons;
use prompt_store::{PromptId, UserPromptId};
use serde::{Deserialize, Serialize};
use std::{
+ borrow::Cow,
fmt,
ops::RangeInclusive,
path::{Path, PathBuf},
};
use ui::{App, IconName, SharedString};
use url::Url;
+use urlencoding::decode;
use util::paths::PathStyle;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
@@ -74,11 +76,13 @@ impl MentionUri {
let path = url.path();
match url.scheme() {
"file" => {
- let path = if path_style.is_windows() {
+ let normalized = if path_style.is_windows() {
path.trim_start_matches("/")
} else {
path
};
+ let decoded = decode(normalized).unwrap_or(Cow::Borrowed(normalized));
+ let path = decoded.as_ref();
if let Some(fragment) = url.fragment() {
let line_range = parse_line_range(fragment)?;
@@ -108,7 +112,7 @@ impl MentionUri {
if let Some(thread_id) = path.strip_prefix("/agent/thread/") {
let name = single_query_param(&url, "name")?.context("Missing thread name")?;
Ok(Self::Thread {
- id: acp::SessionId(thread_id.into()),
+ id: acp::SessionId::new(thread_id),
name,
})
} else if let Some(path) = path.strip_prefix("/agent/text-thread/") {
@@ -406,6 +410,19 @@ mod tests {
assert_eq!(parsed.to_uri().to_string(), selection_uri);
}
+ #[test]
+ fn test_parse_file_uri_with_non_ascii() {
+ let file_uri = uri!("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt");
+ let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
+ match &parsed {
+ MentionUri::File { abs_path } => {
+ assert_eq!(abs_path, Path::new(path!("/path/to/日本語.txt")));
+ }
+ _ => panic!("Expected File variant"),
+ }
+ assert_eq!(parsed.to_uri().to_string(), file_uri);
+ }
+
#[test]
fn test_parse_untitled_selection_uri() {
let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10");
@@ -75,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(),
}
@@ -103,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)
}
}
@@ -195,8 +187,10 @@ pub async fn create_terminal_entity(
Default::default()
};
- // Disables paging for `git` and hopefully other commands
+ // 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
@@ -371,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()
@@ -528,7 +528,7 @@ impl Render for AcpTools {
.with_sizing_behavior(gpui::ListSizingBehavior::Auto)
.size_full(),
)
- .vertical_scrollbar_for(connection.list_state.clone(), window, cx)
+ .vertical_scrollbar_for(&connection.list_state, window, cx)
.into_any()
}
}
@@ -409,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);
@@ -732,12 +734,10 @@ impl ActionLog {
cx: &mut Context<Self>,
) -> Task<()> {
let futures = self.changed_buffers(cx).into_keys().map(|buffer| {
- let reject = self.reject_edits_in_ranges(
- buffer,
- vec![Anchor::MIN..Anchor::MAX],
- telemetry.clone(),
- 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();
@@ -777,7 +777,7 @@ impl ActionLog {
#[derive(Clone)]
pub struct ActionLogTelemetry {
- pub agent_telemetry_id: &'static str,
+ pub agent_telemetry_id: SharedString,
pub session_id: Arc<str>,
}
@@ -2010,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, None, 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![]);
@@ -2031,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], None, cx)
+ log.reject_edits_in_ranges(
+ buffer.clone(),
+ vec![Anchor::min_max_range_for_buffer(
+ buffer.read(cx).remote_id(),
+ )],
+ None,
+ cx,
+ )
})
.await
.unwrap();
@@ -23,6 +23,7 @@ gpui.workspace = true
language.workspace = true
project.workspace = true
proto.workspace = true
+semver.workspace = true
smallvec.workspace = true
ui.workspace = true
util.workspace = true
@@ -925,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");
@@ -83,6 +83,7 @@ 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"] }
@@ -5,12 +5,12 @@ mod legacy_thread;
mod native_agent_server;
pub mod outline;
mod templates;
-mod thread;
-mod tools;
-
#[cfg(test)]
mod tests;
+mod thread;
+mod tools;
+use context_server::ContextServerId;
pub use db::*;
pub use history_store::*;
pub use native_agent_server::NativeAgentServer;
@@ -18,11 +18,11 @@ pub use templates::*;
pub use thread::*;
pub use tools::*;
-use acp_thread::{AcpThread, AgentModelSelector};
+use acp_thread::{AcpThread, AgentModelSelector, UserMessageId};
use agent_client_protocol as acp;
use anyhow::{Context as _, Result, anyhow};
use chrono::{DateTime, Utc};
-use collections::{HashSet, IndexMap};
+use collections::{HashMap, HashSet, IndexMap};
use fs::Fs;
use futures::channel::{mpsc, oneshot};
use futures::future::Shared;
@@ -33,12 +33,12 @@ use gpui::{
use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry};
use project::{Project, ProjectItem, ProjectPath, Worktree};
use prompt_store::{
- ProjectContext, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext,
+ 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;
@@ -51,18 +51,6 @@ pub struct ProjectSnapshot {
pub timestamp: DateTime<Utc>,
}
-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,
}
@@ -133,9 +121,7 @@ impl LanguageModels {
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);
- }
+ provider_models.push(model_info);
models.insert(model_id, model);
}
if !provider_models.is_empty() {
@@ -172,7 +158,7 @@ impl LanguageModels {
}
fn model_id(model: &Arc<dyn LanguageModel>) -> acp::ModelId {
- acp::ModelId(format!("{}/{}", model.provider_id().0, model.id().0).into())
+ acp::ModelId::new(format!("{}/{}", model.provider_id().0, model.id().0))
}
fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> {
@@ -265,12 +251,24 @@ impl NativeAgent {
.await;
cx.new(|cx| {
+ let context_server_store = project.read(cx).context_server_store();
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
+
let mut subscriptions = vec![
cx.subscribe(&project, Self::handle_project_event),
cx.subscribe(
&LanguageModelRegistry::global(cx),
Self::handle_models_updated_event,
),
+ cx.subscribe(
+ &context_server_store,
+ Self::handle_context_server_store_updated,
+ ),
+ cx.subscribe(
+ &context_server_registry,
+ Self::handle_context_server_registry_event,
+ ),
];
if let Some(prompt_store) = prompt_store.as_ref() {
subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event))
@@ -279,16 +277,14 @@ impl NativeAgent {
let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
watch::channel(());
Self {
- sessions: HashMap::new(),
+ sessions: HashMap::default(),
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)
- }),
+ context_server_registry,
templates,
models: LanguageModels::new(cx),
project,
@@ -357,6 +353,9 @@ impl NativeAgent {
pending_save: Task::ready(()),
},
);
+
+ self.update_available_commands(cx);
+
acp_thread
}
@@ -427,10 +426,7 @@ impl NativeAgent {
.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,
- },
+ uuid: prompt_metadata.id.user_id()?,
title: prompt_metadata.title.map(|title| title.to_string()),
contents,
}),
@@ -624,6 +620,99 @@ impl NativeAgent {
}
}
+ fn handle_context_server_store_updated(
+ &mut self,
+ _store: Entity<project::context_server_store::ContextServerStore>,
+ _event: &project::context_server_store::Event,
+ cx: &mut Context<Self>,
+ ) {
+ self.update_available_commands(cx);
+ }
+
+ fn handle_context_server_registry_event(
+ &mut self,
+ _registry: Entity<ContextServerRegistry>,
+ event: &ContextServerRegistryEvent,
+ cx: &mut Context<Self>,
+ ) {
+ match event {
+ ContextServerRegistryEvent::ToolsChanged => {}
+ ContextServerRegistryEvent::PromptsChanged => {
+ self.update_available_commands(cx);
+ }
+ }
+ }
+
+ fn update_available_commands(&self, cx: &mut Context<Self>) {
+ let available_commands = self.build_available_commands(cx);
+ for session in self.sessions.values() {
+ if let Some(acp_thread) = session.acp_thread.upgrade() {
+ acp_thread.update(cx, |thread, cx| {
+ thread
+ .handle_session_update(
+ acp::SessionUpdate::AvailableCommandsUpdate(
+ acp::AvailableCommandsUpdate::new(available_commands.clone()),
+ ),
+ cx,
+ )
+ .log_err();
+ });
+ }
+ }
+ }
+
+ fn build_available_commands(&self, cx: &App) -> Vec<acp::AvailableCommand> {
+ let registry = self.context_server_registry.read(cx);
+
+ let mut prompt_name_counts: HashMap<&str, usize> = HashMap::default();
+ for context_server_prompt in registry.prompts() {
+ *prompt_name_counts
+ .entry(context_server_prompt.prompt.name.as_str())
+ .or_insert(0) += 1;
+ }
+
+ registry
+ .prompts()
+ .flat_map(|context_server_prompt| {
+ let prompt = &context_server_prompt.prompt;
+
+ let should_prefix = prompt_name_counts
+ .get(prompt.name.as_str())
+ .copied()
+ .unwrap_or(0)
+ > 1;
+
+ let name = if should_prefix {
+ format!("{}.{}", context_server_prompt.server_id, prompt.name)
+ } else {
+ prompt.name.clone()
+ };
+
+ let mut command = acp::AvailableCommand::new(
+ name,
+ prompt.description.clone().unwrap_or_default(),
+ );
+
+ match prompt.arguments.as_deref() {
+ Some([arg]) => {
+ let hint = format!("<{}>", arg.name);
+
+ command = command.input(acp::AvailableCommandInput::Unstructured(
+ acp::UnstructuredCommandInput::new(hint),
+ ));
+ }
+ Some([]) | None => {}
+ Some(_) => {
+ // skip >1 argument commands since we don't support them yet
+ return None;
+ }
+ }
+
+ Some(command)
+ })
+ .collect()
+ }
+
pub fn load_thread(
&mut self,
id: acp::SessionId,
@@ -722,6 +811,102 @@ impl NativeAgent {
history.update(cx, |history, cx| history.reload(cx)).ok();
});
}
+
+ fn send_mcp_prompt(
+ &self,
+ message_id: UserMessageId,
+ session_id: agent_client_protocol::SessionId,
+ prompt_name: String,
+ server_id: ContextServerId,
+ arguments: HashMap<String, String>,
+ original_content: Vec<acp::ContentBlock>,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<acp::PromptResponse>> {
+ let server_store = self.context_server_registry.read(cx).server_store().clone();
+ let path_style = self.project.read(cx).path_style(cx);
+
+ cx.spawn(async move |this, cx| {
+ let prompt =
+ crate::get_prompt(&server_store, &server_id, &prompt_name, arguments, cx).await?;
+
+ let (acp_thread, thread) = this.update(cx, |this, _cx| {
+ let session = this
+ .sessions
+ .get(&session_id)
+ .context("Failed to get session")?;
+ anyhow::Ok((session.acp_thread.clone(), session.thread.clone()))
+ })??;
+
+ let mut last_is_user = true;
+
+ thread.update(cx, |thread, cx| {
+ thread.push_acp_user_block(
+ message_id,
+ original_content.into_iter().skip(1),
+ path_style,
+ cx,
+ );
+ })?;
+
+ for message in prompt.messages {
+ let context_server::types::PromptMessage { role, content } = message;
+ let block = mcp_message_content_to_acp_content_block(content);
+
+ match role {
+ context_server::types::Role::User => {
+ let id = acp_thread::UserMessageId::new();
+
+ acp_thread.update(cx, |acp_thread, cx| {
+ acp_thread.push_user_content_block_with_indent(
+ Some(id.clone()),
+ block.clone(),
+ true,
+ cx,
+ );
+ anyhow::Ok(())
+ })??;
+
+ thread.update(cx, |thread, cx| {
+ thread.push_acp_user_block(id, [block], path_style, cx);
+ anyhow::Ok(())
+ })??;
+ }
+ context_server::types::Role::Assistant => {
+ acp_thread.update(cx, |acp_thread, cx| {
+ acp_thread.push_assistant_content_block_with_indent(
+ block.clone(),
+ false,
+ true,
+ cx,
+ );
+ anyhow::Ok(())
+ })??;
+
+ thread.update(cx, |thread, cx| {
+ thread.push_acp_agent_block(block, cx);
+ anyhow::Ok(())
+ })??;
+ }
+ }
+
+ last_is_user = role == context_server::types::Role::User;
+ }
+
+ let response_stream = thread.update(cx, |thread, cx| {
+ if last_is_user {
+ thread.send_existing(cx)
+ } else {
+ // Resume if MCP prompt did not end with a user message
+ thread.resume(cx)
+ }
+ })??;
+
+ cx.update(|cx| {
+ NativeAgentConnection::handle_thread_events(response_stream, acp_thread, cx)
+ })?
+ .await
+ })
+ }
}
/// Wrapper struct that implements the AgentConnection trait
@@ -791,28 +976,12 @@ impl NativeAgentConnection {
}
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,
- )
+ thread.push_assistant_content_block(text.into(), 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,
- )
+ thread.push_assistant_content_block(text.into(), true, cx)
})?;
}
ThreadEvent::ToolCallAuthorization(ToolCallAuthorization {
@@ -826,8 +995,9 @@ impl NativeAgentConnection {
)
})??;
cx.background_spawn(async move {
- if let acp::RequestPermissionOutcome::Selected { option_id } =
- outcome_task.await
+ if let acp::RequestPermissionOutcome::Selected(
+ acp::SelectedPermissionOutcome { option_id, .. },
+ ) = outcome_task.await
{
response
.send(option_id)
@@ -854,10 +1024,7 @@ impl NativeAgentConnection {
}
ThreadEvent::Stop(stop_reason) => {
log::debug!("Assistant message complete: {:?}", stop_reason);
- return Ok(acp::PromptResponse {
- stop_reason,
- meta: None,
- });
+ return Ok(acp::PromptResponse::new(stop_reason));
}
}
}
@@ -869,14 +1036,44 @@ impl NativeAgentConnection {
}
log::debug!("Response stream completed");
- anyhow::Ok(acp::PromptResponse {
- stop_reason: acp::StopReason::EndTurn,
- meta: None,
- })
+ anyhow::Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
})
}
}
+struct Command<'a> {
+ prompt_name: &'a str,
+ arg_value: &'a str,
+ explicit_server_id: Option<&'a str>,
+}
+
+impl<'a> Command<'a> {
+ fn parse(prompt: &'a [acp::ContentBlock]) -> Option<Self> {
+ let acp::ContentBlock::Text(text_content) = prompt.first()? else {
+ return None;
+ };
+ let text = text_content.text.trim();
+ let command = text.strip_prefix('/')?;
+ let (command, arg_value) = command
+ .split_once(char::is_whitespace)
+ .unwrap_or((command, ""));
+
+ if let Some((server_id, prompt_name)) = command.split_once('.') {
+ Some(Self {
+ prompt_name,
+ arg_value,
+ explicit_server_id: Some(server_id),
+ })
+ } else {
+ Some(Self {
+ prompt_name: command,
+ arg_value,
+ explicit_server_id: None,
+ })
+ }
+ }
+}
+
struct NativeAgentModelSelector {
session_id: acp::SessionId,
connection: NativeAgentConnection,
@@ -963,11 +1160,19 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
fn watch(&self, cx: &mut App) -> Option<watch::Receiver<()>> {
Some(self.connection.0.read(cx).models.watch())
}
+
+ fn should_render_footer(&self) -> bool {
+ true
+ }
+
+ fn supports_favorites(&self) -> bool {
+ true
+ }
}
impl acp_thread::AgentConnection for NativeAgentConnection {
- fn telemetry_id(&self) -> &'static str {
- "zed"
+ fn telemetry_id(&self) -> SharedString {
+ "zed".into()
}
fn new_thread(
@@ -1038,6 +1243,47 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
let session_id = params.session_id.clone();
log::info!("Received prompt request for session: {}", session_id);
log::debug!("Prompt blocks count: {}", params.prompt.len());
+
+ if let Some(parsed_command) = Command::parse(¶ms.prompt) {
+ let registry = self.0.read(cx).context_server_registry.read(cx);
+
+ let explicit_server_id = parsed_command
+ .explicit_server_id
+ .map(|server_id| ContextServerId(server_id.into()));
+
+ if let Some(prompt) =
+ registry.find_prompt(explicit_server_id.as_ref(), parsed_command.prompt_name)
+ {
+ let arguments = if !parsed_command.arg_value.is_empty()
+ && let Some(arg_name) = prompt
+ .prompt
+ .arguments
+ .as_ref()
+ .and_then(|args| args.first())
+ .map(|arg| arg.name.clone())
+ {
+ HashMap::from_iter([(arg_name, parsed_command.arg_value.to_string())])
+ } else {
+ Default::default()
+ };
+
+ let prompt_name = prompt.prompt.name.clone();
+ let server_id = prompt.server_id.clone();
+
+ return self.0.update(cx, |agent, cx| {
+ agent.send_mcp_prompt(
+ id,
+ session_id.clone(),
+ prompt_name,
+ server_id,
+ arguments,
+ params.prompt,
+ cx,
+ )
+ });
+ };
+ };
+
let path_style = self.0.read(cx).project.read(cx).path_style(cx);
self.run_turn(session_id, cx, move |thread, cx| {
@@ -1238,6 +1484,15 @@ impl TerminalHandle for AcpTerminalHandle {
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)]
@@ -1372,7 +1627,7 @@ mod internal_tests {
IndexMap::from_iter([(
AgentModelGroupName("Fake".into()),
vec![AgentModelInfo {
- id: acp::ModelId("fake/fake".into()),
+ id: acp::ModelId::new("fake/fake"),
name: "Fake".into(),
description: None,
icon: Some(ui::IconName::ZedAssistant),
@@ -1433,7 +1688,7 @@ mod internal_tests {
// Select a model
let selector = connection.model_selector(&session_id).unwrap();
- let model_id = acp::ModelId("fake/fake".into());
+ let model_id = acp::ModelId::new("fake/fake");
cx.update(|cx| selector.select_model(model_id.clone(), cx))
.await
.unwrap();
@@ -1519,20 +1774,14 @@ mod internal_tests {
thread.send(
vec![
"What does ".into(),
- acp::ContentBlock::ResourceLink(acp::ResourceLink {
- name: "b.md".into(),
- uri: MentionUri::File {
+ acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
+ "b.md",
+ 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,
@@ -1631,3 +1880,35 @@ mod internal_tests {
});
}
}
+
+fn mcp_message_content_to_acp_content_block(
+ content: context_server::types::MessageContent,
+) -> acp::ContentBlock {
+ match content {
+ context_server::types::MessageContent::Text {
+ text,
+ annotations: _,
+ } => text.into(),
+ context_server::types::MessageContent::Image {
+ data,
+ mime_type,
+ annotations: _,
+ } => acp::ContentBlock::Image(acp::ImageContent::new(data, mime_type)),
+ context_server::types::MessageContent::Audio {
+ data,
+ mime_type,
+ annotations: _,
+ } => acp::ContentBlock::Audio(acp::AudioContent::new(data, mime_type)),
+ context_server::types::MessageContent::Resource {
+ resource,
+ annotations: _,
+ } => {
+ let mut link =
+ acp::ResourceLink::new(resource.uri.to_string(), resource.uri.to_string());
+ if let Some(mime_type) = resource.mime_type {
+ link = link.mime_type(mime_type);
+ }
+ acp::ContentBlock::ResourceLink(link)
+ }
+ }
+}
@@ -150,6 +150,7 @@ impl DbThread {
.unwrap_or_default(),
input: tool_use.input,
is_input_complete: true,
+ thought_signature: None,
},
));
}
@@ -181,6 +182,7 @@ impl DbThread {
crate::Message::Agent(AgentMessage {
content,
tool_results,
+ reasoning_details: None,
})
}
language_model::Role::System => {
@@ -364,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),
});
@@ -422,4 +424,20 @@ impl ThreadsDatabase {
Ok(())
})
}
+
+ pub fn delete_threads(&self) -> Task<Result<()>> {
+ let connection = self.connection.clone();
+
+ self.executor.spawn(async move {
+ let connection = connection.lock();
+
+ let mut delete = connection.exec_bound::<()>(indoc! {"
+ DELETE FROM threads
+ "})?;
+
+ delete(())?;
+
+ Ok(())
+ })
+ }
}
@@ -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())
+ ),
})
);
}
@@ -15,12 +15,14 @@ const SEPARATOR_MARKER: &str = "=======";
const REPLACE_MARKER: &str = ">>>>>>> REPLACE";
const SONNET_PARAMETER_INVOKE_1: &str = "</parameter>\n</invoke>";
const SONNET_PARAMETER_INVOKE_2: &str = "</parameter></invoke>";
-const END_TAGS: [&str; 5] = [
+const SONNET_PARAMETER_INVOKE_3: &str = "</parameter>";
+const END_TAGS: [&str; 6] = [
OLD_TEXT_END_TAG,
NEW_TEXT_END_TAG,
EDITS_END_TAG,
- SONNET_PARAMETER_INVOKE_1, // Remove this after switching to streaming tool call
+ SONNET_PARAMETER_INVOKE_1, // Remove these after switching to streaming tool call
SONNET_PARAMETER_INVOKE_2,
+ SONNET_PARAMETER_INVOKE_3,
];
#[derive(Debug)]
@@ -567,21 +569,29 @@ mod tests {
parse_random_chunks(
indoc! {"
<old_text>some text</old_text><new_text>updated text</parameter></invoke>
+ <old_text>more text</old_text><new_text>upd</parameter></new_text>
"},
&mut parser,
&mut rng
),
- vec![Edit {
- old_text: "some text".to_string(),
- new_text: "updated text".to_string(),
- line_hint: None,
- },]
+ 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: 2,
- mismatched_tags: 1
+ tags: 4,
+ mismatched_tags: 2
}
);
}
@@ -4,7 +4,7 @@ use crate::{
};
use Role::*;
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};
@@ -20,16 +20,62 @@ 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<EvalOutput<EditEvalMetadata>>,
+}
+
+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<Self::Metadata>) {
+ 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 = "unit-eval"), ignore)]
fn eval_extract_handle_command_output() {
@@ -55,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,
@@ -102,9 +145,9 @@ 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]
@@ -122,18 +165,16 @@ fn eval_delete_run_git_blame() {
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,
@@ -166,8 +207,8 @@ fn eval_delete_run_git_blame() {
],
Some(input_file_content.into()),
EvalAssertion::assert_eq(output_file_content),
- ),
- );
+ ))
+ });
}
#[test]
@@ -185,18 +226,16 @@ fn eval_translate_doc_comments() {
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,
@@ -229,8 +268,8 @@ fn eval_translate_doc_comments() {
],
Some(input_file_content.into()),
EvalAssertion::judge_diff("Doc comments were translated to Italian"),
- ),
- );
+ ))
+ });
}
#[test]
@@ -249,33 +288,31 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() {
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.
-
- 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
- "})],
+ 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}}
+
+ 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,
@@ -352,11 +389,11 @@ 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]
@@ -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,9 +474,9 @@ fn eval_disable_cursor_blinking() {
),
],
Some(input_file_content.into()),
- EvalAssertion::assert_diff_any(possible_diffs),
- ),
- );
+ EvalAssertion::assert_diff_any(possible_diffs.clone()),
+ ))
+ });
}
#[test]
@@ -467,20 +501,16 @@ fn eval_from_pixels_constructor() {
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,
@@ -545,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(
@@ -648,11 +678,11 @@ 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]
@@ -670,11 +700,9 @@ fn eval_zode() {
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(
@@ -733,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();
@@ -758,8 +786,8 @@ fn eval_zode() {
})
}
}),
- ),
- );
+ ))
+ });
}
#[test]
@@ -777,19 +805,17 @@ fn eval_add_overwrite_test() {
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,
@@ -809,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]
-
- 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.
- "},
+ 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)
+
+ 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(
@@ -963,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",
@@ -990,8 +1016,8 @@ fn eval_add_overwrite_test() {
EvalAssertion::judge_diff(
"A new test for overwritten files was created, without changing any previous test",
),
- ),
- );
+ ))
+ });
}
#[test]
@@ -1016,20 +1042,18 @@ fn eval_create_empty_file() {
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",
@@ -1051,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",
@@ -1065,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(
@@ -1081,6 +1105,7 @@ fn message(
role,
content: contents.into_iter().collect(),
cache: false,
+ reasoning_details: None,
}
}
@@ -1108,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,
})
}
@@ -1267,6 +1293,7 @@ impl EvalAssertion {
role: Role::User,
content: vec![prompt.into()],
cache: false,
+ reasoning_details: None,
}],
thinking_allowed: true,
..Default::default()
@@ -1309,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::<Vec<_>>();
- 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::<Vec<_>>();
- 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<Result<EvalOutput>>) {
+fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<EditEvalMetadata> {
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() {
@@ -1436,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<Project>,
@@ -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!
@@ -188,6 +188,15 @@ impl HistoryStore {
})
}
+ pub fn delete_threads(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
+ 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<Path>,
@@ -207,14 +216,10 @@ impl HistoryStore {
}
pub fn reload(&self, cx: &mut Context<Self>) {
- let database_future = ThreadsDatabase::connect(cx);
+ let database_connection = ThreadsDatabase::connect(cx);
cx.spawn(async move |this, cx| {
- let threads = database_future
- .await
- .map_err(|err| anyhow!(err))?
- .list_threads()
- .await?;
-
+ let database = database_connection.await;
+ let threads = database.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
@@ -335,7 +340,8 @@ impl HistoryStore {
fn load_recently_opened_entries(cx: &AsyncApp) -> Task<Result<VecDeque<HistoryEntryId>>> {
cx.background_spawn(async move {
if cfg!(any(feature = "test-support", test)) {
- anyhow::bail!("history store does not persist in tests");
+ log::warn!("history store does not persist in tests");
+ return Ok(VecDeque::new());
}
let json = KEY_VALUE_STORE
.read_kvp(RECENTLY_OPENED_THREADS_KEY)?
@@ -345,9 +351,9 @@ impl HistoryStore {
.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::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()),
),
@@ -21,10 +21,6 @@ impl NativeAgentServer {
}
impl AgentServer for NativeAgentServer {
- fn telemetry_id(&self) -> &'static str {
- "zed"
- }
-
fn name(&self) -> SharedString {
"Zed Agent".into()
}
@@ -44,14 +44,31 @@ pub async fn get_buffer_content_or_outline(
.collect::<Vec<_>>()
})?;
+ // 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::<String>();
+ 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} (file too large to show full content)\n\n{outline_text}",
- )
+ format!("# File outline for {path}\n\n{outline_text}",)
} else {
- format!("# File outline (file too large to show full content)\n\n{outline_text}",)
+ format!("# File outline\n\n{outline_text}",)
};
Ok(BufferContent {
text,
@@ -140,3 +157,62 @@ fn render_entries(
entries_rendered
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fs::FakeFs;
+ use gpui::TestAppContext;
+ use project::Project;
+ use settings::SettingsStore;
+
+ #[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);
+ });
+
+ let fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs, [], cx).await;
+
+ 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");
+
+ buffer.update(cx, |buffer, cx| buffer.set_text(content, cx));
+
+ 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"
+ );
+ }
+}
@@ -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
@@ -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<AtomicBool>,
+ wait_for_exit: Shared<Task<acp::TerminalExitStatus>>,
+ 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<acp::TerminalId> {
+ Ok(self.id.clone())
+ }
+
+ fn current_output(&self, _cx: &AsyncApp) -> Result<acp::TerminalOutputResponse> {
+ Ok(self.output.clone())
+ }
+
+ fn wait_for_exit(&self, _cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>> {
+ Ok(self.wait_for_exit.clone())
+ }
+
+ fn kill(&self, _cx: &AsyncApp) -> Result<()> {
+ self.killed.store(true, Ordering::SeqCst);
+ Ok(())
+ }
+}
+
+struct FakeThreadEnvironment {
+ handle: Rc<FakeTerminalHandle>,
+}
+
+impl crate::ThreadEnvironment for FakeThreadEnvironment {
+ fn create_terminal(
+ &self,
+ _command: String,
+ _cwd: Option<std::path::PathBuf>,
+ _output_byte_limit: Option<u64>,
+ _cx: &mut AsyncApp,
+ ) -> Task<Result<Rc<dyn crate::TerminalHandle>>> {
+ Task::ready(Ok(self.handle.clone() as Rc<dyn crate::TerminalHandle>))
+ }
+}
+
+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<Fuse<Task<Result<String>>>>> = 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;
@@ -215,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(
@@ -239,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,
}
]
);
@@ -274,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()));
@@ -294,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,
}
]
);
@@ -461,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(
@@ -470,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();
@@ -479,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();
@@ -496,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(),
@@ -520,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();
@@ -528,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();
@@ -537,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(),
@@ -554,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();
@@ -592,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();
@@ -621,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()));
@@ -641,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::<Vec<_>>().await.pop().unwrap();
assert!(
@@ -677,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,
}
]
);
@@ -731,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(),
@@ -741,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::<Vec<_>>().await.pop().unwrap();
assert!(
@@ -765,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,
}
]
);
@@ -1037,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();
@@ -1080,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(
@@ -1089,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();
@@ -1324,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;
}
_ => {}
@@ -1788,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(),
@@ -1795,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(
@@ -1818,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,
@@ -1826,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,
@@ -1837,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,
},
],
);
@@ -1961,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,
)
})
@@ -2000,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,
},
));
@@ -2012,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();
@@ -2020,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: Some(json!({ "tool_name": "thinking" })),
- }
+ 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.")
+ )
);
}
@@ -2214,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(),
@@ -2232,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,
@@ -2250,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,
},
]
);
@@ -2264,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,
}))
);
})
@@ -2512,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(),
@@ -2577,3 +2809,181 @@ fn setup_context_server(
cx.run_until_parked();
mcp_tool_calls_rx
}
+
+#[gpui::test]
+async fn test_tokens_before_message(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ // First message
+ let message_1_id = UserMessageId::new();
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(message_1_id.clone(), ["First message"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ // Before any response, tokens_before_message should return None for first message
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.tokens_before_message(&message_1_id),
+ None,
+ "First message should have no tokens before it"
+ );
+ });
+
+ // Complete first message with usage
+ fake_model.send_last_completion_stream_text_chunk("Response 1");
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
+ language_model::TokenUsage {
+ input_tokens: 100,
+ output_tokens: 50,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ // First message still has no tokens before it
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.tokens_before_message(&message_1_id),
+ None,
+ "First message should still have no tokens before it after response"
+ );
+ });
+
+ // Second message
+ let message_2_id = UserMessageId::new();
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(message_2_id.clone(), ["Second message"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ // Second message should have first message's input tokens before it
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.tokens_before_message(&message_2_id),
+ Some(100),
+ "Second message should have 100 tokens before it (from first request)"
+ );
+ });
+
+ // Complete second message
+ fake_model.send_last_completion_stream_text_chunk("Response 2");
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
+ language_model::TokenUsage {
+ input_tokens: 250, // Total for this request (includes previous context)
+ output_tokens: 75,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ // Third message
+ let message_3_id = UserMessageId::new();
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(message_3_id.clone(), ["Third message"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ // Third message should have second message's input tokens (250) before it
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.tokens_before_message(&message_3_id),
+ Some(250),
+ "Third message should have 250 tokens before it (from second request)"
+ );
+ // Second message should still have 100
+ assert_eq!(
+ thread.tokens_before_message(&message_2_id),
+ Some(100),
+ "Second message should still have 100 tokens before it"
+ );
+ // First message still has none
+ assert_eq!(
+ thread.tokens_before_message(&message_1_id),
+ None,
+ "First message should still have no tokens before it"
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_tokens_before_message_after_truncate(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ // Set up three messages with responses
+ let message_1_id = UserMessageId::new();
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(message_1_id.clone(), ["Message 1"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_text_chunk("Response 1");
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
+ language_model::TokenUsage {
+ input_tokens: 100,
+ output_tokens: 50,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ let message_2_id = UserMessageId::new();
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(message_2_id.clone(), ["Message 2"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ fake_model.send_last_completion_stream_text_chunk("Response 2");
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
+ language_model::TokenUsage {
+ input_tokens: 250,
+ output_tokens: 75,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ // Verify initial state
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(thread.tokens_before_message(&message_2_id), Some(100));
+ });
+
+ // Truncate at message 2 (removes message 2 and everything after)
+ thread
+ .update(cx, |thread, cx| thread.truncate(message_2_id.clone(), cx))
+ .unwrap();
+ cx.run_until_parked();
+
+ // After truncation, message_2_id no longer exists, so lookup should return None
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.tokens_before_message(&message_2_id),
+ None,
+ "After truncation, message 2 no longer exists"
+ );
+ // Message 1 still exists but has no tokens before it
+ assert_eq!(
+ thread.tokens_before_message(&message_1_id),
+ None,
+ "First message still has no tokens before it"
+ );
+ });
+}
@@ -2,7 +2,8 @@ use crate::{
ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread,
DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool,
- SystemPromptTemplate, Template, Templates, TerminalTool, ThinkingTool, WebSearchTool,
+ RestoreFileFromDiskTool, SaveFileTool, SystemPromptTemplate, Template, Templates, TerminalTool,
+ ThinkingTool, WebSearchTool,
};
use acp_thread::{MentionUri, UserMessageId};
use action_log::ActionLog;
@@ -15,7 +16,7 @@ use agent_settings::{
use anyhow::{Context as _, Result, anyhow};
use chrono::{DateTime, Utc};
use client::{ModelRequestUsage, RequestUsage, UserStore};
-use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, Plan, UsageLimit};
+use cloud_llm_client::{CompletionIntent, Plan, UsageLimit};
use collections::{HashMap, HashSet, IndexMap};
use fs::Fs;
use futures::stream;
@@ -107,12 +108,19 @@ impl Message {
pub fn to_request(&self) -> Vec<LanguageModelRequestMessage> {
match self {
- Message::User(message) => vec![message.to_request()],
+ Message::User(message) => {
+ if message.content.is_empty() {
+ vec![]
+ } else {
+ 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,
}],
}
}
@@ -177,6 +185,7 @@ impl UserMessage {
role: Role::User,
content: Vec::with_capacity(self.content.len()),
cache: false,
+ reasoning_details: None,
};
const OPEN_CONTEXT: &str = "<context>\n\
@@ -444,6 +453,7 @@ impl AgentMessage {
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 {
@@ -479,6 +489,7 @@ impl AgentMessage {
role: Role::User,
content: Vec::new(),
cache: false,
+ reasoning_details: None,
};
for tool_result in self.tool_results.values() {
@@ -508,6 +519,7 @@ impl AgentMessage {
pub struct AgentMessage {
pub content: Vec<AgentMessageContent>,
pub tool_results: IndexMap<LanguageModelToolUseId, LanguageModelToolResult>,
+ pub reasoning_details: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -525,6 +537,7 @@ pub trait TerminalHandle {
fn id(&self, cx: &AsyncApp) -> Result<acp::TerminalId>;
fn current_output(&self, cx: &AsyncApp) -> Result<acp::TerminalOutputResponse>;
fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>>;
+ fn kill(&self, cx: &AsyncApp) -> Result<()>;
}
pub trait ThreadEnvironment {
@@ -607,17 +620,16 @@ pub struct Thread {
pub(crate) prompt_capabilities_rx: watch::Receiver<acp::PromptCapabilities>,
pub(crate) project: Entity<Project>,
pub(crate) action_log: Entity<ActionLog>,
+ /// Tracks the last time files were read by the agent, to detect external modifications
+ pub(crate) file_read_times: HashMap<PathBuf, fs::MTime>,
}
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,
- }
+ acp::PromptCapabilities::new()
+ .image(image)
+ .embedded_context(true)
}
pub fn new(
@@ -633,7 +645,7 @@ impl Thread {
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()),
+ id: acp::SessionId::new(uuid::Uuid::new_v4().to_string()),
prompt_id: PromptId::new(),
updated_at: Utc::now(),
title: None,
@@ -665,6 +677,7 @@ impl Thread {
prompt_capabilities_rx,
project,
action_log,
+ file_read_times: HashMap::default(),
}
}
@@ -729,17 +742,11 @@ impl Thread {
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,
- })))
+ .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;
};
@@ -769,8 +776,8 @@ impl Thread {
stream.update_tool_call_fields(
&tool_use.id,
- acp::ToolCallUpdateFields {
- status: Some(
+ acp::ToolCallUpdateFields::new()
+ .status(
tool_result
.as_ref()
.map_or(acp::ToolCallStatus::Failed, |result| {
@@ -780,10 +787,8 @@ impl Thread {
acp::ToolCallStatus::Completed
}
}),
- ),
- raw_output: output,
- ..Default::default()
- },
+ )
+ .raw_output(output),
);
}
@@ -860,6 +865,7 @@ impl Thread {
updated_at: db_thread.updated_at,
prompt_capabilities_tx,
prompt_capabilities_rx,
+ file_read_times: HashMap::default(),
}
}
@@ -999,9 +1005,12 @@ impl Thread {
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(SaveFileTool::new(self.project.clone()));
+ self.add_tool(RestoreFileFromDiskTool::new(self.project.clone()));
self.add_tool(TerminalTool::new(self.project.clone(), environment));
self.add_tool(ThinkingTool);
self.add_tool(WebSearchTool);
@@ -1086,6 +1095,28 @@ impl Thread {
})
}
+ /// Get the total input token count as of the message before the given message.
+ ///
+ /// Returns `None` if:
+ /// - `target_id` is the first message (no previous message)
+ /// - The previous message hasn't received a response yet (no usage data)
+ /// - `target_id` is not found in the messages
+ pub fn tokens_before_message(&self, target_id: &UserMessageId) -> Option<u64> {
+ let mut previous_user_message_id: Option<&UserMessageId> = None;
+
+ for message in &self.messages {
+ if let Message::User(user_msg) = message {
+ if &user_msg.id == target_id {
+ let prev_id = previous_user_message_id?;
+ let usage = self.request_token_usage.get(prev_id)?;
+ return Some(usage.input_tokens);
+ }
+ previous_user_message_id = Some(&user_msg.id);
+ }
+ }
+ None
+ }
+
/// Look up the active profile and resolve its preferred model if one is configured.
fn resolve_profile_model(
profile_id: &AgentProfileId,
@@ -1138,20 +1169,64 @@ impl Thread {
where
T: Into<UserMessageContent>,
{
+ let content = content.into_iter().map(Into::into).collect::<Vec<_>>();
+ log::debug!("Thread::send content: {:?}", content);
+
+ self.messages
+ .push(Message::User(UserMessage { id, content }));
+ cx.notify();
+
+ self.send_existing(cx)
+ }
+
+ pub fn send_existing(
+ &mut self,
+ cx: &mut Context<Self>,
+ ) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
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::<Vec<_>>();
- log::debug!("Thread::send content: {:?}", content);
+ log::debug!("Total messages in thread: {}", self.messages.len());
+ self.run_turn(cx)
+ }
+ pub fn push_acp_user_block(
+ &mut self,
+ id: UserMessageId,
+ blocks: impl IntoIterator<Item = acp::ContentBlock>,
+ path_style: PathStyle,
+ cx: &mut Context<Self>,
+ ) {
+ let content = blocks
+ .into_iter()
+ .map(|block| UserMessageContent::from_content_block(block, path_style))
+ .collect::<Vec<_>>();
self.messages
.push(Message::User(UserMessage { id, content }));
cx.notify();
+ }
- log::debug!("Total messages in thread: {}", self.messages.len());
- self.run_turn(cx)
+ pub fn push_acp_agent_block(&mut self, block: acp::ContentBlock, cx: &mut Context<Self>) {
+ let text = match block {
+ acp::ContentBlock::Text(text_content) => text_content.text,
+ acp::ContentBlock::Image(_) => "[image]".to_string(),
+ acp::ContentBlock::Audio(_) => "[audio]".to_string(),
+ acp::ContentBlock::ResourceLink(resource_link) => resource_link.uri,
+ acp::ContentBlock::Resource(resource) => match resource.resource {
+ acp::EmbeddedResourceResource::TextResourceContents(resource) => resource.uri,
+ acp::EmbeddedResourceResource::BlobResourceContents(resource) => resource.uri,
+ _ => "[resource]".to_string(),
+ },
+ _ => "[unknown]".to_string(),
+ };
+
+ self.messages.push(Message::Agent(AgentMessage {
+ content: vec![AgentMessageContent::Text(text)],
+ ..Default::default()
+ }));
+ cx.notify();
}
#[cfg(feature = "eval")]
@@ -1264,15 +1339,13 @@ impl Thread {
event_stream.update_tool_call_fields(
&tool_result.tool_use_id,
- acp::ToolCallUpdateFields {
- status: Some(if tool_result.is_error {
+ acp::ToolCallUpdateFields::new()
+ .status(if tool_result.is_error {
acp::ToolCallStatus::Failed
} else {
acp::ToolCallStatus::Completed
- }),
- raw_output: tool_result.output.clone(),
- ..Default::default()
- },
+ })
+ .raw_output(tool_result.output.clone()),
);
this.update(cx, |this, _cx| {
this.pending_message()
@@ -1393,6 +1466,18 @@ impl Thread {
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));
}
@@ -1425,20 +1510,16 @@ impl Thread {
);
self.update_token_usage(usage, cx);
}
- StatusUpdate(CompletionRequestStatus::UsageUpdated { amount, limit }) => {
+ UsageUpdated { amount, limit } => {
self.update_model_request_usage(amount, limit, cx);
}
- StatusUpdate(
- CompletionRequestStatus::Started
- | CompletionRequestStatus::Queued { .. }
- | CompletionRequestStatus::Failed { .. },
- ) => {}
- StatusUpdate(CompletionRequestStatus::ToolUseLimitReached) => {
+ 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)
@@ -1542,12 +1623,10 @@ impl Thread {
} 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()
- },
+ acp::ToolCallUpdateFields::new()
+ .title(title.as_str())
+ .kind(kind)
+ .raw_input(tool_use.input.clone()),
);
}
@@ -1569,10 +1648,9 @@ impl Thread {
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()
- });
+ 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);
@@ -1647,6 +1725,10 @@ impl Thread {
self.pending_summary_generation.is_some()
}
+ pub fn is_generating_title(&self) -> bool {
+ self.pending_title_generation.is_some()
+ }
+
pub fn summary(&mut self, cx: &mut Context<Self>) -> Shared<Task<Option<SharedString>>> {
if let Some(summary) = self.summary.as_ref() {
return Task::ready(Some(summary.clone())).shared();
@@ -1672,6 +1754,7 @@ impl Thread {
role: Role::User,
content: vec![SUMMARIZE_THREAD_DETAILED_PROMPT.into()],
cache: false,
+ reasoning_details: None,
});
let task = cx
@@ -1682,9 +1765,7 @@ impl Thread {
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, limit, cx);
})
@@ -1715,7 +1796,7 @@ impl Thread {
task
}
- fn generate_title(&mut self, cx: &mut Context<Self>) {
+ pub fn generate_title(&mut self, cx: &mut Context<Self>) {
let Some(model) = self.summarization_model.clone() else {
return;
};
@@ -1738,6 +1819,7 @@ impl Thread {
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();
@@ -1748,9 +1830,7 @@ impl Thread {
let event = event?;
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, limit, cx);
})?;
@@ -1965,6 +2045,12 @@ impl Thread {
self.running_turn.as_ref()?.tools.get(name).cloned()
}
+ pub fn has_tool(&self, name: &str) -> bool {
+ self.running_turn
+ .as_ref()
+ .is_some_and(|turn| turn.tools.contains_key(name))
+ }
+
fn build_request_messages(
&self,
available_tools: Vec<SharedString>,
@@ -1987,6 +2073,7 @@ impl Thread {
role: Role::System,
content: vec![system_prompt.into()],
cache: false,
+ reasoning_details: None,
}];
for message in &self.messages {
messages.extend(message.to_request());
@@ -2364,19 +2451,13 @@ impl ThreadEventStream {
kind: acp::ToolKind,
input: serde_json::Value,
) -> acp::ToolCall {
- acp::ToolCall {
- meta: Some(serde_json::json!({
- "tool_name": tool_name
- })),
- id: acp::ToolCallId(id.to_string().into()),
- title,
- kind,
- status: acp::ToolCallStatus::Pending,
- content: vec![],
- locations: vec![],
- raw_input: Some(input),
- raw_output: None,
- }
+ 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(
@@ -2386,12 +2467,7 @@ impl ThreadEventStream {
) {
self.0
.unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
- acp::ToolCallUpdate {
- meta: None,
- id: acp::ToolCallId(tool_use_id.to_string().into()),
- fields,
- }
- .into(),
+ acp::ToolCallUpdate::new(tool_use_id.to_string(), fields).into(),
)))
.ok();
}
@@ -2454,7 +2530,7 @@ impl ToolCallEventStream {
.0
.unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
acp_thread::ToolCallUpdateDiff {
- id: acp::ToolCallId(self.tool_use_id.to_string().into()),
+ id: acp::ToolCallId::new(self.tool_use_id.to_string()),
diff,
}
.into(),
@@ -2472,33 +2548,26 @@ impl ToolCallEventStream {
.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()
- },
- },
+ tool_call: acp::ToolCallUpdate::new(
+ self.tool_use_id.to_string(),
+ acp::ToolCallUpdateFields::new().title(title.into()),
+ ),
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,
- },
+ 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,
},
@@ -2643,7 +2712,15 @@ impl UserMessageContent {
// 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())
+ }
}
}
}
@@ -2651,32 +2728,15 @@ impl UserMessageContent {
impl From<UserMessageContent> 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,
- })
+ 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()),
+ )),
+ ),
}
}
}
@@ -2684,7 +2744,6 @@ impl From<UserMessageContent> for acp::ContentBlock {
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()),
+ size: None,
}
}
@@ -12,6 +12,9 @@ mod move_path_tool;
mod now_tool;
mod open_tool;
mod read_file_tool;
+mod restore_file_from_disk_tool;
+mod save_file_tool;
+
mod terminal_tool;
mod thinking_tool;
mod web_search_tool;
@@ -33,6 +36,9 @@ pub use move_path_tool::*;
pub use now_tool::*;
pub use open_tool::*;
pub use read_file_tool::*;
+pub use restore_file_from_disk_tool::*;
+pub use save_file_tool::*;
+
pub use terminal_tool::*;
pub use thinking_tool::*;
pub use web_search_tool::*;
@@ -88,6 +94,8 @@ tools! {
NowTool,
OpenTool,
ReadFileTool,
+ RestoreFileFromDiskTool,
+ SaveFileTool,
TerminalTool,
ThinkingTool,
WebSearchTool,
@@ -2,12 +2,24 @@ use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream};
use agent_client_protocol::ToolKind;
use anyhow::{Result, anyhow, bail};
use collections::{BTreeMap, HashMap};
-use context_server::ContextServerId;
-use gpui::{App, Context, Entity, SharedString, Task};
+use context_server::{ContextServerId, client::NotificationSubscription};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task};
use project::context_server_store::{ContextServerStatus, ContextServerStore};
use std::sync::Arc;
use util::ResultExt;
+pub struct ContextServerPrompt {
+ pub server_id: ContextServerId,
+ pub prompt: context_server::types::Prompt,
+}
+
+pub enum ContextServerRegistryEvent {
+ ToolsChanged,
+ PromptsChanged,
+}
+
+impl EventEmitter<ContextServerRegistryEvent> for ContextServerRegistry {}
+
pub struct ContextServerRegistry {
server_store: Entity<ContextServerStore>,
registered_servers: HashMap<ContextServerId, RegisteredContextServer>,
@@ -16,7 +28,10 @@ pub struct ContextServerRegistry {
struct RegisteredContextServer {
tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
+ prompts: BTreeMap<SharedString, ContextServerPrompt>,
load_tools: Task<Result<()>>,
+ load_prompts: Task<Result<()>>,
+ _tools_updated_subscription: Option<NotificationSubscription>,
}
impl ContextServerRegistry {
@@ -28,6 +43,7 @@ impl ContextServerRegistry {
};
for server in server_store.read(cx).running_servers() {
this.reload_tools_for_server(server.id(), cx);
+ this.reload_prompts_for_server(server.id(), cx);
}
this
}
@@ -56,6 +72,88 @@ impl ContextServerRegistry {
.map(|(id, server)| (id, &server.tools))
}
+ pub fn prompts(&self) -> impl Iterator<Item = &ContextServerPrompt> {
+ self.registered_servers
+ .values()
+ .flat_map(|server| server.prompts.values())
+ }
+
+ pub fn find_prompt(
+ &self,
+ server_id: Option<&ContextServerId>,
+ name: &str,
+ ) -> Option<&ContextServerPrompt> {
+ if let Some(server_id) = server_id {
+ self.registered_servers
+ .get(server_id)
+ .and_then(|server| server.prompts.get(name))
+ } else {
+ self.registered_servers
+ .values()
+ .find_map(|server| server.prompts.get(name))
+ }
+ }
+
+ pub fn server_store(&self) -> &Entity<ContextServerStore> {
+ &self.server_store
+ }
+
+ fn get_or_register_server(
+ &mut self,
+ server_id: &ContextServerId,
+ cx: &mut Context<Self>,
+ ) -> &mut RegisteredContextServer {
+ self.registered_servers
+ .entry(server_id.clone())
+ .or_insert_with(|| Self::init_registered_server(server_id, &self.server_store, cx))
+ }
+
+ fn init_registered_server(
+ server_id: &ContextServerId,
+ server_store: &Entity<ContextServerStore>,
+ cx: &mut Context<Self>,
+ ) -> RegisteredContextServer {
+ let tools_updated_subscription = server_store
+ .read(cx)
+ .get_running_server(server_id)
+ .and_then(|server| {
+ let client = server.client()?;
+
+ if !client.capable(context_server::protocol::ServerCapability::Tools) {
+ return None;
+ }
+
+ let server_id = server.id();
+ let this = cx.entity().downgrade();
+
+ Some(client.on_notification(
+ "notifications/tools/list_changed",
+ Box::new(move |_params, cx: AsyncApp| {
+ let server_id = server_id.clone();
+ let this = this.clone();
+ cx.spawn(async move |cx| {
+ this.update(cx, |this, cx| {
+ log::info!(
+ "Received tools/list_changed notification for server {}",
+ server_id
+ );
+ this.reload_tools_for_server(server_id, cx);
+ })
+ })
+ .detach();
+ }),
+ ))
+ });
+
+ RegisteredContextServer {
+ tools: BTreeMap::default(),
+ prompts: BTreeMap::default(),
+ load_tools: Task::ready(Ok(())),
+ load_prompts: Task::ready(Ok(())),
+ _tools_updated_subscription: tools_updated_subscription,
+ }
+ }
+
fn reload_tools_for_server(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else {
return;
@@ -63,17 +161,12 @@ impl ContextServerRegistry {
let Some(client) = server.client() else {
return;
};
+
if !client.capable(context_server::protocol::ServerCapability::Tools) {
return;
}
- let registered_server =
- self.registered_servers
- .entry(server_id.clone())
- .or_insert(RegisteredContextServer {
- tools: BTreeMap::default(),
- load_tools: Task::ready(Ok(())),
- });
+ let registered_server = self.get_or_register_server(&server_id, cx);
registered_server.load_tools = cx.spawn(async move |this, cx| {
let response = client
.request::<context_server::types::requests::ListTools>(())
@@ -94,6 +187,49 @@ impl ContextServerRegistry {
));
registered_server.tools.insert(tool.name(), tool);
}
+ cx.emit(ContextServerRegistryEvent::ToolsChanged);
+ cx.notify();
+ }
+ })
+ });
+ }
+
+ fn reload_prompts_for_server(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
+ let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else {
+ return;
+ };
+ let Some(client) = server.client() else {
+ return;
+ };
+ if !client.capable(context_server::protocol::ServerCapability::Prompts) {
+ return;
+ }
+
+ let registered_server = self.get_or_register_server(&server_id, cx);
+
+ registered_server.load_prompts = cx.spawn(async move |this, cx| {
+ let response = client
+ .request::<context_server::types::requests::PromptsList>(())
+ .await;
+
+ this.update(cx, |this, cx| {
+ let Some(registered_server) = this.registered_servers.get_mut(&server_id) else {
+ return;
+ };
+
+ registered_server.prompts.clear();
+ if let Some(response) = response.log_err() {
+ for prompt in response.prompts {
+ let name: SharedString = prompt.name.clone().into();
+ registered_server.prompts.insert(
+ name,
+ ContextServerPrompt {
+ server_id: server_id.clone(),
+ prompt,
+ },
+ );
+ }
+ cx.emit(ContextServerRegistryEvent::PromptsChanged);
cx.notify();
}
})
@@ -112,9 +248,17 @@ impl ContextServerRegistry {
ContextServerStatus::Starting => {}
ContextServerStatus::Running => {
self.reload_tools_for_server(server_id.clone(), cx);
+ self.reload_prompts_for_server(server_id.clone(), cx);
}
ContextServerStatus::Stopped | ContextServerStatus::Error(_) => {
- self.registered_servers.remove(server_id);
+ if let Some(registered_server) = self.registered_servers.remove(server_id) {
+ if !registered_server.tools.is_empty() {
+ cx.emit(ContextServerRegistryEvent::ToolsChanged);
+ }
+ if !registered_server.prompts.is_empty() {
+ cx.emit(ContextServerRegistryEvent::PromptsChanged);
+ }
+ }
cx.notify();
}
}
@@ -251,3 +395,39 @@ impl AnyAgentTool for ContextServerTool {
Ok(())
}
}
+
+pub fn get_prompt(
+ server_store: &Entity<ContextServerStore>,
+ server_id: &ContextServerId,
+ prompt_name: &str,
+ arguments: HashMap<String, String>,
+ cx: &mut AsyncApp,
+) -> Task<Result<context_server::types::PromptsGetResponse>> {
+ let server = match cx.update(|cx| server_store.read(cx).get_running_server(server_id)) {
+ Ok(server) => server,
+ Err(error) => return Task::ready(Err(error)),
+ };
+ let Some(server) = server else {
+ return Task::ready(Err(anyhow::anyhow!("Context server not found")));
+ };
+
+ let Some(protocol) = server.client() else {
+ return Task::ready(Err(anyhow::anyhow!("Context server not initialized")));
+ };
+
+ let prompt_name = prompt_name.to_string();
+
+ cx.background_spawn(async move {
+ let response = protocol
+ .request::<context_server::types::requests::PromptsGet>(
+ context_server::types::PromptsGetParams {
+ name: prompt_name,
+ arguments: (!arguments.is_empty()).then(|| arguments),
+ meta: None,
+ },
+ )
+ .await?;
+
+ Ok(response)
+ })
+}
@@ -273,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);
@@ -309,6 +304,59 @@ 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, has_save_tool, has_restore_tool) = 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();
+ let has_save = thread.has_tool("save_file");
+ let has_restore = thread.has_tool("restore_file_from_disk");
+ (last_read, current, dirty, has_save, has_restore)
+ })?;
+
+ // Check for unsaved changes first - these indicate modifications we don't know about
+ if is_dirty {
+ let message = match (has_save_tool, has_restore_tool) {
+ (true, true) => {
+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
+ If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
+ If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
+ }
+ (true, false) => {
+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
+ If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
+ If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
+ }
+ (false, true) => {
+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
+ If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
+ If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
+ }
+ (false, false) => {
+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
+ then ask them to save or revert the file manually and inform you when it's ok to proceed."
+ }
+ };
+ anyhow::bail!("{}", message);
+ }
+
+ // 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({
@@ -355,10 +403,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;
}
@@ -421,6 +466,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({
@@ -1748,10 +1804,438 @@ 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);
});
}
+
+ #[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(
+ "<old_text>original content</old_text><new_text>modified content</new_text>"
+ .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(
+ "<old_text>modified content</old_text><new_text>further modified content</new_text>".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("This file has unsaved changes."),
+ "Error should mention unsaved changes, got: {}",
+ error_msg
+ );
+ assert!(
+ error_msg.contains("keep or discard"),
+ "Error should ask whether to keep or discard changes, got: {}",
+ error_msg
+ );
+ // Since save_file and restore_file_from_disk tools aren't added to the thread,
+ // the error message should ask the user to manually save or revert
+ assert!(
+ error_msg.contains("save or revert the file manually"),
+ "Error should ask user to manually save or revert when tools aren't available, got: {}",
+ error_msg
+ );
+ }
}
@@ -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::<Vec<_>>(),
+ ),
+ );
Ok(FindPathToolOutput {
offset: input.offset,
@@ -177,7 +173,7 @@ fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Resu
let mut results = Vec::new();
for snapshot in snapshots {
for entry in snapshot.entries(false, 0) {
- if path_matcher.is_match(snapshot.root_name().join(&entry.path).as_std_path()) {
+ if path_matcher.is_match(&snapshot.root_name().join(&entry.path)) {
results.push(snapshot.absolutize(&entry.path));
}
}
@@ -32,8 +32,21 @@ pub struct GrepToolInput {
/// 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".
+ /// Supports standard glob patterns like "**/*.rs" or "frontend/src/**/*.ts".
/// If omitted, all files in the project will be searched.
+ ///
+ /// The glob pattern is matched against the full path including the project root directory.
+ ///
+ /// <example>
+ /// 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.
+ /// </example>
pub include_pattern: Option<String>,
/// 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
@@ -781,22 +792,6 @@ mod tests {
});
}
- 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);
@@ -1,7 +1,7 @@
use action_log::ActionLog;
use agent_client_protocol::{self as acp, ToolCallUpdateFields};
use anyhow::{Context as _, Result, anyhow};
-use gpui::{App, Entity, SharedString, Task};
+use gpui::{App, Entity, SharedString, Task, WeakEntity};
use indoc::formatdoc;
use language::Point;
use language_model::{LanguageModelImage, LanguageModelToolResultContent};
@@ -12,11 +12,14 @@ use settings::Settings;
use std::sync::Arc;
use util::markdown::MarkdownCodeBlock;
-use crate::{AgentTool, ToolCallEventStream, outline};
+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.
@@ -42,13 +45,19 @@ pub struct ReadFileToolInput {
}
pub struct ReadFileTool {
+ thread: WeakEntity<Thread>,
project: Entity<Project>,
action_log: Entity<ActionLog>,
}
impl ReadFileTool {
- pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
+ pub fn new(
+ thread: WeakEntity<Thread>,
+ project: Entity<Project>,
+ action_log: Entity<ActionLog>,
+ ) -> Self {
Self {
+ thread,
project,
action_log,
}
@@ -144,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| {
@@ -195,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
@@ -237,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 {
@@ -258,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,
);
@@ -268,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)),
+ ]));
}
})?;
@@ -285,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]
@@ -300,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
@@ -333,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 {
@@ -361,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 {
@@ -378,7 +434,7 @@ mod test {
let content = result.to_str().unwrap();
assert_eq!(
- content.lines().skip(4).take(6).collect::<Vec<_>>(),
+ content.lines().skip(7).take(6).collect::<Vec<_>>(),
vec![
"struct Test0 [L1-4]",
" a [L2]",
@@ -413,7 +469,7 @@ mod test {
pretty_assertions::assert_eq!(
content
.lines()
- .skip(4)
+ .skip(7)
.take(expected_content.len())
.collect::<Vec<_>>(),
expected_content
@@ -435,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 {
@@ -463,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
@@ -512,49 +594,6 @@ mod test {
});
}
- 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);
@@ -607,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
@@ -821,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
@@ -0,0 +1,352 @@
+use agent_client_protocol as acp;
+use anyhow::Result;
+use collections::FxHashSet;
+use gpui::{App, Entity, SharedString, Task};
+use language::Buffer;
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use crate::{AgentTool, ToolCallEventStream};
+
+/// Discards unsaved changes in open buffers by reloading file contents from disk.
+///
+/// Use this tool when:
+/// - You attempted to edit files but they have unsaved changes the user does not want to keep.
+/// - You want to reset files to the on-disk state before retrying an edit.
+///
+/// Only use this tool after asking the user for permission, because it will discard unsaved changes.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct RestoreFileFromDiskToolInput {
+ /// The paths of the files to restore from disk.
+ pub paths: Vec<PathBuf>,
+}
+
+pub struct RestoreFileFromDiskTool {
+ project: Entity<Project>,
+}
+
+impl RestoreFileFromDiskTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for RestoreFileFromDiskTool {
+ type Input = RestoreFileFromDiskToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "restore_file_from_disk"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Other
+ }
+
+ fn initial_title(
+ &self,
+ input: Result<Self::Input, serde_json::Value>,
+ _cx: &mut App,
+ ) -> SharedString {
+ match input {
+ Ok(input) if input.paths.len() == 1 => "Restore file from disk".into(),
+ Ok(input) => format!("Restore {} files from disk", input.paths.len()).into(),
+ Err(_) => "Restore files from disk".into(),
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<String>> {
+ let project = self.project.clone();
+ let input_paths = input.paths;
+
+ cx.spawn(async move |cx| {
+ let mut buffers_to_reload: FxHashSet<Entity<Buffer>> = FxHashSet::default();
+
+ let mut restored_paths: Vec<PathBuf> = Vec::new();
+ let mut clean_paths: Vec<PathBuf> = Vec::new();
+ let mut not_found_paths: Vec<PathBuf> = Vec::new();
+ let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
+ let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
+ let mut reload_errors: Vec<String> = Vec::new();
+
+ for path in input_paths {
+ let project_path =
+ project.read_with(cx, |project, cx| project.find_project_path(&path, cx));
+
+ let project_path = match project_path {
+ Ok(Some(project_path)) => project_path,
+ Ok(None) => {
+ not_found_paths.push(path);
+ continue;
+ }
+ Err(error) => {
+ open_errors.push((path, error.to_string()));
+ continue;
+ }
+ };
+
+ let open_buffer_task =
+ project.update(cx, |project, cx| project.open_buffer(project_path, cx));
+
+ let buffer = match open_buffer_task {
+ Ok(task) => match task.await {
+ Ok(buffer) => buffer,
+ Err(error) => {
+ open_errors.push((path, error.to_string()));
+ continue;
+ }
+ },
+ Err(error) => {
+ open_errors.push((path, error.to_string()));
+ continue;
+ }
+ };
+
+ let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) {
+ Ok(is_dirty) => is_dirty,
+ Err(error) => {
+ dirty_check_errors.push((path, error.to_string()));
+ continue;
+ }
+ };
+
+ if is_dirty {
+ buffers_to_reload.insert(buffer);
+ restored_paths.push(path);
+ } else {
+ clean_paths.push(path);
+ }
+ }
+
+ if !buffers_to_reload.is_empty() {
+ let reload_task = project.update(cx, |project, cx| {
+ project.reload_buffers(buffers_to_reload, true, cx)
+ });
+
+ match reload_task {
+ Ok(task) => {
+ if let Err(error) = task.await {
+ reload_errors.push(error.to_string());
+ }
+ }
+ Err(error) => {
+ reload_errors.push(error.to_string());
+ }
+ }
+ }
+
+ let mut lines: Vec<String> = Vec::new();
+
+ if !restored_paths.is_empty() {
+ lines.push(format!("Restored {} file(s).", restored_paths.len()));
+ }
+ if !clean_paths.is_empty() {
+ lines.push(format!("{} clean.", clean_paths.len()));
+ }
+
+ if !not_found_paths.is_empty() {
+ lines.push(format!("Not found ({}):", not_found_paths.len()));
+ for path in ¬_found_paths {
+ lines.push(format!("- {}", path.display()));
+ }
+ }
+ if !open_errors.is_empty() {
+ lines.push(format!("Open failed ({}):", open_errors.len()));
+ for (path, error) in &open_errors {
+ lines.push(format!("- {}: {}", path.display(), error));
+ }
+ }
+ if !dirty_check_errors.is_empty() {
+ lines.push(format!(
+ "Dirty check failed ({}):",
+ dirty_check_errors.len()
+ ));
+ for (path, error) in &dirty_check_errors {
+ lines.push(format!("- {}: {}", path.display(), error));
+ }
+ }
+ if !reload_errors.is_empty() {
+ lines.push(format!("Reload failed ({}):", reload_errors.len()));
+ for error in &reload_errors {
+ lines.push(format!("- {}", error));
+ }
+ }
+
+ if lines.is_empty() {
+ Ok("No paths provided.".to_string())
+ } else {
+ Ok(lines.join("\n"))
+ }
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fs::Fs;
+ use gpui::TestAppContext;
+ use language::LineEnding;
+ use project::FakeFs;
+ 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);
+ });
+ }
+
+ #[gpui::test]
+ async fn test_restore_file_from_disk_output_and_effects(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "dirty.txt": "on disk: dirty\n",
+ "clean.txt": "on disk: clean\n",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let tool = Arc::new(RestoreFileFromDiskTool::new(project.clone()));
+
+ // Make dirty.txt dirty in-memory by saving different content into the buffer without saving to disk.
+ let dirty_project_path = project.read_with(cx, |project, cx| {
+ project
+ .find_project_path("root/dirty.txt", cx)
+ .expect("dirty.txt should exist in project")
+ });
+
+ let dirty_buffer = project
+ .update(cx, |project, cx| {
+ project.open_buffer(dirty_project_path, cx)
+ })
+ .await
+ .unwrap();
+ dirty_buffer.update(cx, |buffer, cx| {
+ buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
+ });
+ assert!(
+ dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "dirty.txt buffer should be dirty before restore"
+ );
+
+ // Ensure clean.txt is opened but remains clean.
+ let clean_project_path = project.read_with(cx, |project, cx| {
+ project
+ .find_project_path("root/clean.txt", cx)
+ .expect("clean.txt should exist in project")
+ });
+
+ let clean_buffer = project
+ .update(cx, |project, cx| {
+ project.open_buffer(clean_project_path, cx)
+ })
+ .await
+ .unwrap();
+ assert!(
+ !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "clean.txt buffer should start clean"
+ );
+
+ let output = cx
+ .update(|cx| {
+ tool.clone().run(
+ RestoreFileFromDiskToolInput {
+ paths: vec![
+ PathBuf::from("root/dirty.txt"),
+ PathBuf::from("root/clean.txt"),
+ ],
+ },
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ // Output should mention restored + clean.
+ assert!(
+ output.contains("Restored 1 file(s)."),
+ "expected restored count line, got:\n{output}"
+ );
+ assert!(
+ output.contains("1 clean."),
+ "expected clean count line, got:\n{output}"
+ );
+
+ // Effect: dirty buffer should be restored back to disk content and become clean.
+ let dirty_text = dirty_buffer.read_with(cx, |buffer, _| buffer.text());
+ assert_eq!(
+ dirty_text, "on disk: dirty\n",
+ "dirty.txt buffer should be restored to disk contents"
+ );
+ assert!(
+ !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "dirty.txt buffer should not be dirty after restore"
+ );
+
+ // Disk contents should be unchanged (restore-from-disk should not write).
+ let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
+ assert_eq!(disk_dirty, "on disk: dirty\n");
+
+ // Sanity: clean buffer should remain clean and unchanged.
+ let clean_text = clean_buffer.read_with(cx, |buffer, _| buffer.text());
+ assert_eq!(clean_text, "on disk: clean\n");
+ assert!(
+ !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "clean.txt buffer should remain clean"
+ );
+
+ // Test empty paths case.
+ let output = cx
+ .update(|cx| {
+ tool.clone().run(
+ RestoreFileFromDiskToolInput { paths: vec![] },
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ assert_eq!(output, "No paths provided.");
+
+ // Test not-found path case (path outside the project root).
+ let output = cx
+ .update(|cx| {
+ tool.clone().run(
+ RestoreFileFromDiskToolInput {
+ paths: vec![PathBuf::from("nonexistent/path.txt")],
+ },
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ assert!(
+ output.contains("Not found (1):"),
+ "expected not-found header line, got:\n{output}"
+ );
+ assert!(
+ output.contains("- nonexistent/path.txt"),
+ "expected not-found path bullet, got:\n{output}"
+ );
+
+ let _ = LineEnding::Unix; // keep import used if the buffer edit API changes
+ }
+}
@@ -0,0 +1,351 @@
+use agent_client_protocol as acp;
+use anyhow::Result;
+use collections::FxHashSet;
+use gpui::{App, Entity, SharedString, Task};
+use language::Buffer;
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use crate::{AgentTool, ToolCallEventStream};
+
+/// Saves files that have unsaved changes.
+///
+/// Use this tool when you need to edit files but they have unsaved changes that must be saved first.
+/// Only use this tool after asking the user for permission to save their unsaved changes.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct SaveFileToolInput {
+ /// The paths of the files to save.
+ pub paths: Vec<PathBuf>,
+}
+
+pub struct SaveFileTool {
+ project: Entity<Project>,
+}
+
+impl SaveFileTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for SaveFileTool {
+ type Input = SaveFileToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "save_file"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Other
+ }
+
+ fn initial_title(
+ &self,
+ input: Result<Self::Input, serde_json::Value>,
+ _cx: &mut App,
+ ) -> SharedString {
+ match input {
+ Ok(input) if input.paths.len() == 1 => "Save file".into(),
+ Ok(input) => format!("Save {} files", input.paths.len()).into(),
+ Err(_) => "Save files".into(),
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<String>> {
+ let project = self.project.clone();
+ let input_paths = input.paths;
+
+ cx.spawn(async move |cx| {
+ let mut buffers_to_save: FxHashSet<Entity<Buffer>> = FxHashSet::default();
+
+ let mut saved_paths: Vec<PathBuf> = Vec::new();
+ let mut clean_paths: Vec<PathBuf> = Vec::new();
+ let mut not_found_paths: Vec<PathBuf> = Vec::new();
+ let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
+ let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
+ let mut save_errors: Vec<(String, String)> = Vec::new();
+
+ for path in input_paths {
+ let project_path =
+ project.read_with(cx, |project, cx| project.find_project_path(&path, cx));
+
+ let project_path = match project_path {
+ Ok(Some(project_path)) => project_path,
+ Ok(None) => {
+ not_found_paths.push(path);
+ continue;
+ }
+ Err(error) => {
+ open_errors.push((path, error.to_string()));
+ continue;
+ }
+ };
+
+ let open_buffer_task =
+ project.update(cx, |project, cx| project.open_buffer(project_path, cx));
+
+ let buffer = match open_buffer_task {
+ Ok(task) => match task.await {
+ Ok(buffer) => buffer,
+ Err(error) => {
+ open_errors.push((path, error.to_string()));
+ continue;
+ }
+ },
+ Err(error) => {
+ open_errors.push((path, error.to_string()));
+ continue;
+ }
+ };
+
+ let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) {
+ Ok(is_dirty) => is_dirty,
+ Err(error) => {
+ dirty_check_errors.push((path, error.to_string()));
+ continue;
+ }
+ };
+
+ if is_dirty {
+ buffers_to_save.insert(buffer);
+ saved_paths.push(path);
+ } else {
+ clean_paths.push(path);
+ }
+ }
+
+ // Save each buffer individually since there's no batch save API.
+ for buffer in buffers_to_save {
+ let path_for_buffer = match buffer.read_with(cx, |buffer, _| {
+ buffer
+ .file()
+ .map(|file| file.path().to_rel_path_buf())
+ .map(|path| path.as_rel_path().as_unix_str().to_owned())
+ }) {
+ Ok(path) => path.unwrap_or_else(|| "<unknown>".to_string()),
+ Err(error) => {
+ save_errors.push(("<unknown>".to_string(), error.to_string()));
+ continue;
+ }
+ };
+
+ let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx));
+
+ match save_task {
+ Ok(task) => {
+ if let Err(error) = task.await {
+ save_errors.push((path_for_buffer, error.to_string()));
+ }
+ }
+ Err(error) => {
+ save_errors.push((path_for_buffer, error.to_string()));
+ }
+ }
+ }
+
+ let mut lines: Vec<String> = Vec::new();
+
+ if !saved_paths.is_empty() {
+ lines.push(format!("Saved {} file(s).", saved_paths.len()));
+ }
+ if !clean_paths.is_empty() {
+ lines.push(format!("{} clean.", clean_paths.len()));
+ }
+
+ if !not_found_paths.is_empty() {
+ lines.push(format!("Not found ({}):", not_found_paths.len()));
+ for path in ¬_found_paths {
+ lines.push(format!("- {}", path.display()));
+ }
+ }
+ if !open_errors.is_empty() {
+ lines.push(format!("Open failed ({}):", open_errors.len()));
+ for (path, error) in &open_errors {
+ lines.push(format!("- {}: {}", path.display(), error));
+ }
+ }
+ if !dirty_check_errors.is_empty() {
+ lines.push(format!(
+ "Dirty check failed ({}):",
+ dirty_check_errors.len()
+ ));
+ for (path, error) in &dirty_check_errors {
+ lines.push(format!("- {}: {}", path.display(), error));
+ }
+ }
+ if !save_errors.is_empty() {
+ lines.push(format!("Save failed ({}):", save_errors.len()));
+ for (path, error) in &save_errors {
+ lines.push(format!("- {}: {}", path, error));
+ }
+ }
+
+ if lines.is_empty() {
+ Ok("No paths provided.".to_string())
+ } else {
+ Ok(lines.join("\n"))
+ }
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fs::Fs;
+ use gpui::TestAppContext;
+ use project::FakeFs;
+ 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);
+ });
+ }
+
+ #[gpui::test]
+ async fn test_save_file_output_and_effects(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "dirty.txt": "on disk: dirty\n",
+ "clean.txt": "on disk: clean\n",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let tool = Arc::new(SaveFileTool::new(project.clone()));
+
+ // Make dirty.txt dirty in-memory.
+ let dirty_project_path = project.read_with(cx, |project, cx| {
+ project
+ .find_project_path("root/dirty.txt", cx)
+ .expect("dirty.txt should exist in project")
+ });
+
+ let dirty_buffer = project
+ .update(cx, |project, cx| {
+ project.open_buffer(dirty_project_path, cx)
+ })
+ .await
+ .unwrap();
+ dirty_buffer.update(cx, |buffer, cx| {
+ buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
+ });
+ assert!(
+ dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "dirty.txt buffer should be dirty before save"
+ );
+
+ // Ensure clean.txt is opened but remains clean.
+ let clean_project_path = project.read_with(cx, |project, cx| {
+ project
+ .find_project_path("root/clean.txt", cx)
+ .expect("clean.txt should exist in project")
+ });
+
+ let clean_buffer = project
+ .update(cx, |project, cx| {
+ project.open_buffer(clean_project_path, cx)
+ })
+ .await
+ .unwrap();
+ assert!(
+ !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "clean.txt buffer should start clean"
+ );
+
+ let output = cx
+ .update(|cx| {
+ tool.clone().run(
+ SaveFileToolInput {
+ paths: vec![
+ PathBuf::from("root/dirty.txt"),
+ PathBuf::from("root/clean.txt"),
+ ],
+ },
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ // Output should mention saved + clean.
+ assert!(
+ output.contains("Saved 1 file(s)."),
+ "expected saved count line, got:\n{output}"
+ );
+ assert!(
+ output.contains("1 clean."),
+ "expected clean count line, got:\n{output}"
+ );
+
+ // Effect: dirty buffer should now be clean and disk should have new content.
+ assert!(
+ !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "dirty.txt buffer should not be dirty after save"
+ );
+
+ let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
+ assert_eq!(
+ disk_dirty, "in memory: dirty\n",
+ "dirty.txt disk content should be updated"
+ );
+
+ // Sanity: clean buffer should remain clean and disk unchanged.
+ let disk_clean = fs.load(path!("/root/clean.txt").as_ref()).await.unwrap();
+ assert_eq!(disk_clean, "on disk: clean\n");
+
+ // Test empty paths case.
+ let output = cx
+ .update(|cx| {
+ tool.clone().run(
+ SaveFileToolInput { paths: vec![] },
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ assert_eq!(output, "No paths provided.");
+
+ // Test not-found path case.
+ let output = cx
+ .update(|cx| {
+ tool.clone().run(
+ SaveFileToolInput {
+ paths: vec![PathBuf::from("nonexistent/path.txt")],
+ },
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ assert!(
+ output.contains("Not found (1):"),
+ "expected not-found header line, got:\n{output}"
+ );
+ assert!(
+ output.contains("- nonexistent/path.txt"),
+ "expected not-found path bullet, got:\n{output}"
+ );
+ }
+}
@@ -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<u64>,
}
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))
@@ -43,10 +43,8 @@ impl AgentTool for ThinkingTool {
event_stream: ToolCallEventStream,
_cx: &mut App,
) -> Task<Result<String>> {
- 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()))
}
}
@@ -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::<Vec<_>>(),
+ ),
+ );
}
@@ -9,6 +9,8 @@ use futures::io::BufReader;
use project::Project;
use project::agent_server_store::AgentServerCommand;
use serde::Deserialize;
+use settings::Settings as _;
+use task::ShellBuilder;
use util::ResultExt as _;
use std::path::PathBuf;
@@ -21,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")]
@@ -29,12 +31,13 @@ pub struct UnsupportedVersion;
pub struct AcpConnection {
server_name: SharedString,
- telemetry_id: &'static str,
+ telemetry_id: SharedString,
connection: Rc<acp::ClientSideConnection>,
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
auth_methods: Vec<acp::AuthMethod>,
agent_capabilities: acp::AgentCapabilities,
default_mode: Option<acp::SessionModeId>,
+ default_model: Option<acp::ModelId>,
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).
@@ -53,19 +56,19 @@ pub struct AcpSession {
pub async fn connect(
server_name: SharedString,
- telemetry_id: &'static str,
command: AgentServerCommand,
root_dir: &Path,
default_mode: Option<acp::SessionModeId>,
+ default_model: Option<acp::ModelId>,
is_remote: bool,
cx: &mut AsyncApp,
) -> Result<Rc<dyn AgentConnection>> {
let conn = AcpConnection::stdio(
server_name,
- telemetry_id,
command.clone(),
root_dir,
default_mode,
+ default_model,
is_remote,
cx,
)
@@ -73,21 +76,23 @@ 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(
server_name: SharedString,
- telemetry_id: &'static str,
command: AgentServerCommand,
root_dir: &Path,
default_mode: Option<acp::SessionModeId>,
+ default_model: Option<acp::ModelId>,
is_remote: bool,
cx: &mut AsyncApp,
) -> Result<Self> {
- 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())
@@ -170,34 +175,38 @@ 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: Some(serde_json::json!({
- // Experimental: Allow for rendering terminal output from the agents
- "terminal_output": true,
- "terminal-auth": true,
- })),
- },
- client_info: Some(acp::Implementation {
- name: "zed".to_owned(),
- title: release_channel.map(|c| c.to_owned()),
- version,
- }),
- 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(),
@@ -207,6 +216,7 @@ impl AcpConnection {
sessions,
agent_capabilities: response.agent_capabilities,
default_mode,
+ default_model,
_io_task: io_task,
_wait_task: wait_task,
_stderr_task: stderr_task,
@@ -231,8 +241,8 @@ impl Drop for AcpConnection {
}
impl AgentConnection for AcpConnection {
- fn telemetry_id(&self) -> &'static str {
- self.telemetry_id
+ fn telemetry_id(&self) -> SharedString {
+ self.telemetry_id.clone()
}
fn new_thread(
@@ -245,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() {
@@ -253,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 {
@@ -281,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);
}
@@ -312,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() {
@@ -346,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::<Vec<_>>()
+ .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| {
@@ -381,12 +446,8 @@ impl AgentConnection for AcpConnection {
fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>> {
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(())
})
}
@@ -413,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)
}
@@ -440,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))
}
@@ -460,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();
@@ -544,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() {
@@ -607,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() {
@@ -673,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(
@@ -708,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(
@@ -746,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));
@@ -762,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,
@@ -787,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,
);
});
@@ -806,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,
@@ -857,7 +898,7 @@ impl acp::Client for ClientDelegate {
// 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))
}
}
@@ -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<agent_client_protocol::SessionModeId> {
None
}
@@ -68,6 +67,18 @@ pub trait AgentServer: Send {
) {
}
+ fn default_model(&self, _cx: &mut App) -> Option<agent_client_protocol::ModelId> {
+ None
+ }
+
+ fn set_default_model(
+ &self,
+ _model_id: Option<agent_client_protocol::ModelId>,
+ _fs: Arc<dyn Fs>,
+ _cx: &mut App,
+ ) {
+ }
+
fn connect(
&self,
root_dir: Option<&Path>,
@@ -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<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
@@ -55,6 +51,27 @@ impl AgentServer for ClaudeCode {
});
}
+ fn default_model(&self, cx: &mut App) -> Option<acp::ModelId> {
+ let settings = cx.read_global(|settings: &SettingsStore, _| {
+ settings.get::<AllAgentServersSettings>(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<acp::ModelId>, fs: Arc<dyn Fs>, 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>,
@@ -62,12 +79,12 @@ impl AgentServer for ClaudeCode {
cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
let name = self.name();
- let telemetry_id = self.telemetry_id();
let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned());
let is_remote = delegate.project.read(cx).is_via_remote_server();
let store = delegate.store.downgrade();
let extra_env = load_proxy_env(cx);
let default_mode = self.default_mode(cx);
+ let default_model = self.default_model(cx);
cx.spawn(async move |cx| {
let (command, root_dir, login) = store
@@ -86,10 +103,10 @@ impl AgentServer for ClaudeCode {
.await?;
let connection = crate::acp::connect(
name,
- telemetry_id,
command,
root_dir.as_ref(),
default_mode,
+ default_model,
is_remote,
cx,
)
@@ -23,10 +23,6 @@ pub(crate) mod tests {
}
impl AgentServer for Codex {
- fn telemetry_id(&self) -> &'static str {
- "codex"
- }
-
fn name(&self) -> SharedString {
"Codex".into()
}
@@ -42,7 +38,7 @@ impl AgentServer for Codex {
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<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
@@ -56,6 +52,27 @@ impl AgentServer for Codex {
});
}
+ fn default_model(&self, cx: &mut App) -> Option<acp::ModelId> {
+ let settings = cx.read_global(|settings: &SettingsStore, _| {
+ settings.get::<AllAgentServersSettings>(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<acp::ModelId>, fs: Arc<dyn Fs>, 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>,
@@ -63,12 +80,12 @@ impl AgentServer for Codex {
cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
let name = self.name();
- let telemetry_id = self.telemetry_id();
let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned());
let is_remote = delegate.project.read(cx).is_via_remote_server();
let store = delegate.store.downgrade();
let extra_env = load_proxy_env(cx);
let default_mode = self.default_mode(cx);
+ let default_model = self.default_model(cx);
cx.spawn(async move |cx| {
let (command, root_dir, login) = store
@@ -88,10 +105,10 @@ impl AgentServer for Codex {
let connection = crate::acp::connect(
name,
- telemetry_id,
command,
root_dir.as_ref(),
default_mode,
+ default_model,
is_remote,
cx,
)
@@ -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<acp::SessionModeId>, fs: Arc<dyn Fs>, 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_mode, .. }
+ | settings::CustomAgentServerSettings::Extension { default_mode, .. } => {
+ *default_mode = mode_id.map(|m| m.to_string());
+ }
+ }
+ });
+ }
+
+ fn default_model(&self, cx: &mut App) -> Option<acp::ModelId> {
+ let settings = cx.read_global(|settings: &SettingsStore, _| {
settings
+ .get::<AllAgentServersSettings>(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<acp::ModelId>, fs: Arc<dyn Fs>, cx: &mut App) {
+ let name = self.name();
+ update_settings_file(fs, cx, move |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_model, .. }
+ | settings::CustomAgentServerSettings::Extension { default_model, .. } => {
+ *default_model = model_id.map(|m| m.to_string());
+ }
+ }
});
}
@@ -67,13 +108,12 @@ impl crate::AgentServer for CustomAgentServer {
cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
let name = self.name();
- let telemetry_id = self.telemetry_id();
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| {
@@ -93,10 +133,10 @@ impl crate::AgentServer for CustomAgentServer {
.await?;
let connection = crate::acp::connect(
name,
- telemetry_id,
command,
root_dir.as_ref(),
default_mode,
+ default_model,
is_remote,
cx,
)
@@ -82,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,
)
@@ -429,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;
@@ -476,6 +459,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
env: None,
ignore_system_version: None,
default_mode: None,
+ default_model: None,
}),
gemini: Some(crate::gemini::tests::local_command().into()),
codex: Some(BuiltinAgentServerSettings {
@@ -484,6 +468,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
env: None,
ignore_system_version: None,
default_mode: None,
+ default_model: None,
}),
custom: collections::HashMap::default(),
},
@@ -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()
}
@@ -31,12 +27,12 @@ impl AgentServer for Gemini {
cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
let name = self.name();
- let telemetry_id = self.telemetry_id();
let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned());
let is_remote = delegate.project.read(cx).is_via_remote_server();
let store = delegate.store.downgrade();
let mut extra_env = load_proxy_env(cx);
let default_mode = self.default_mode(cx);
+ let default_model = self.default_model(cx);
cx.spawn(async move |cx| {
extra_env.insert("SURFACE".to_owned(), "zed".to_owned());
@@ -65,10 +61,10 @@ impl AgentServer for Gemini {
let connection = crate::acp::connect(
name,
- telemetry_id,
command,
root_dir.as_ref(),
default_mode,
+ default_model,
is_remote,
cx,
)
@@ -12,6 +12,7 @@ workspace = true
path = "src/agent_settings.rs"
[dependencies]
+agent-client-protocol.workspace = true
anyhow.workspace = true
cloud_llm_client.workspace = true
collections.workspace = true
@@ -2,14 +2,15 @@ mod agent_profile;
use std::sync::Arc;
-use collections::IndexMap;
+use agent_client_protocol::ModelId;
+use collections::{HashSet, IndexMap};
use gpui::{App, Pixels, px};
use language_model::LanguageModel;
use project::DisableAiSettings;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{
- DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection,
+ DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection,
NotifyWhenAgentWaiting, RegisterSetting, Settings,
};
@@ -24,13 +25,16 @@ 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<LanguageModelSelection>,
pub inline_assistant_model: Option<LanguageModelSelection>,
+ pub inline_assistant_use_streaming_tools: bool,
pub commit_message_model: Option<LanguageModelSelection>,
pub thread_summary_model: Option<LanguageModelSelection>,
pub inline_alternatives: Vec<LanguageModelSelection>,
+ pub favorite_models: Vec<LanguageModelSelection>,
pub default_profile: AgentProfileId,
pub default_view: DefaultAgentView,
pub profiles: IndexMap<AgentProfileId, AgentProfileSettings>,
@@ -94,6 +98,13 @@ impl AgentSettings {
pub fn set_message_editor_max_lines(&self) -> usize {
self.message_editor_min_lines * 2
}
+
+ pub fn favorite_model_ids(&self) -> HashSet<ModelId> {
+ self.favorite_models
+ .iter()
+ .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model)))
+ .collect()
+ }
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
@@ -151,13 +162,18 @@ impl Settings for AgentSettings {
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(),
+ favorite_models: agent.favorite_models,
default_profile: AgentProfileId(agent.default_profile.unwrap()),
default_view: agent.default_view.unwrap(),
profiles: agent
@@ -13,7 +13,8 @@ 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", "agent/test-support"]
+unit-eval = []
[dependencies]
acp_thread.workspace = true
@@ -39,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
@@ -47,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
@@ -69,7 +72,7 @@ postage.workspace = true
project.workspace = true
prompt_store.workspace = true
proto.workspace = true
-ref-cast.workspace = true
+rand.workspace = true
release_channel.workspace = true
rope.workspace = true
rules_library.workspace = true
@@ -83,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
@@ -93,19 +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.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"] }
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"] }
@@ -113,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
@@ -1,4 +1,3 @@
-mod completion_provider;
mod entry_view_state;
mod message_editor;
mod mode_selector;
@@ -22,7 +22,7 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
pub struct EntryViewState {
workspace: WeakEntity<Workspace>,
- project: Entity<Project>,
+ project: WeakEntity<Project>,
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
entries: Vec<Entry>,
@@ -34,7 +34,7 @@ pub struct EntryViewState {
impl EntryViewState {
pub fn new(
workspace: WeakEntity<Workspace>,
- project: Entity<Project>,
+ project: WeakEntity<Project>,
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
@@ -328,7 +328,7 @@ impl Entry {
fn create_terminal(
workspace: WeakEntity<Workspace>,
- project: Entity<Project>,
+ project: WeakEntity<Project>,
terminal: Entity<acp_thread::Terminal>,
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,
);
@@ -405,7 +405,7 @@ mod tests {
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
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;
@@ -432,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| {
@@ -471,7 +458,7 @@ mod tests {
let view_state = cx.new(|_cx| {
EntryViewState::new(
workspace.downgrade(),
- project.clone(),
+ project.downgrade(),
history_store,
None,
Default::default(),
@@ -539,7 +526,7 @@ mod tests {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
theme::init(theme::LoadThemes::JustBase, cx);
- release_channel::init(SemanticVersion::default(), cx);
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
});
}
}
@@ -1,66 +1,45 @@
use crate::{
ChatWithFollow,
- acp::completion_provider::{ContextPickerCompletionProvider, SlashCommandCompletion},
- context_picker::{ContextPickerAction, fetch_context_picker::fetch_url_content},
+ 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 agent::{HistoryStore, outline};
+use acp_thread::MentionUri;
+use agent::HistoryStore;
use agent_client_protocol as acp;
-use agent_servers::{AgentServer, AgentServerDelegate};
use anyhow::{Result, anyhow};
-use assistant_slash_commands::codeblock_fence_for_path;
-use collections::{HashMap, HashSet};
+use collections::HashSet;
use editor::{
- Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
- EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, Inlay,
- MultiBuffer, ToOffset,
- actions::Paste,
- display_map::{Crease, CreaseId, FoldId},
+ 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::{Shared, join_all},
-};
+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, InlayId, 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::{CollaboratorId, 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<MentionSet>,
editor: Entity<Editor>,
- project: Entity<Project>,
workspace: WeakEntity<Workspace>,
- history_store: Entity<HistoryStore>,
- prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
agent_name: SharedString,
@@ -80,10 +59,45 @@ impl EventEmitter<MessageEditorEvent> for MessageEditor {}
const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
+impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
+ fn supports_images(&self, cx: &App) -> bool {
+ self.read(cx).prompt_capabilities.borrow().image
+ }
+
+ fn supported_modes(&self, cx: &App) -> Vec<PromptContextType> {
+ 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<crate::completion_provider::AvailableCommand> {
+ 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<Workspace>,
- project: Entity<Project>,
+ project: WeakEntity<Project>,
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
@@ -101,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));
@@ -117,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,
@@ -128,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)
@@ -146,9 +165,13 @@ impl MessageEditor {
if let EditorEvent::Edited { .. } = event
&& !editor.read(cx).read_only(cx)
{
- let snapshot = editor.update(cx, |editor, 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::<Vec<_>>();
let has_new_hint = !new_hints.is_empty();
@@ -162,11 +185,7 @@ impl MessageEditor {
cx,
);
has_hint = has_new_hint;
-
- editor.snapshot(window, cx)
});
- this.mention_set.remove_invalid(snapshot);
-
cx.notify();
}
}
@@ -174,11 +193,8 @@ impl MessageEditor {
Self {
editor,
- project,
mention_set,
workspace,
- history_store,
- prompt_store,
prompt_capabilities,
available_commands,
agent_name,
@@ -187,13 +203,12 @@ impl MessageEditor {
}
}
- fn command_hint(&self, buffer: &Entity<MultiBuffer>, cx: &App) -> Option<Inlay> {
+ fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option<Inlay> {
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;
@@ -204,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, ' ');
@@ -236,6 +256,9 @@ impl MessageEditor {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ let Some(workspace) = self.workspace.upgrade() else {
+ return;
+ };
let uri = MentionUri::Thread {
id: thread.id.clone(),
name: thread.title.to_string(),
@@ -254,7 +277,22 @@ impl MessageEditor {
.text_anchor
});
- self.confirm_mention_completion(thread.title, start, content_len, uri, window, cx)
+ 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();
}
@@ -263,397 +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<MentionUri> {
- 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<Self>,
- ) -> Task<()> {
- let snapshot = self
- .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 + 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::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, 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.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<Self>,
- ) -> Task<Result<Mention>> {
- 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],
- })
- })
- }
-
- fn confirm_mention_for_fetch(
- &mut self,
- url: url::Url,
- cx: &mut Context<Self>,
- ) -> Task<Result<Mention>> {
- 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<u32>,
- cx: &mut Context<Self>,
- ) -> Task<Result<Mention>> {
- 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<Self>,
- ) -> Task<Result<Mention>> {
- 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(),
- })
- })
+ .context_menu()
+ .borrow()
+ .as_ref()
+ .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible())
}
- pub fn confirm_mention_for_selection(
- &mut self,
- source_range: Range<text::Anchor>,
- selections: Vec<(Entity<Buffer>, Range<text::Anchor>, Range<usize>)>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- let snapshot = self.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| 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::<String>();
- 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(),
- ),
- );
- }
-
- // 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.
- let editor = self.editor.clone();
- 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<Self>,
- ) -> Task<Result<Mention>> {
- let server = Rc::new(agent::NativeAgentServer::new(
- self.project.read(cx).fs().clone(),
- self.history_store.clone(),
- ));
- let delegate = AgentServerDelegate::new(
- self.project.read(cx).agent_server_store().clone(),
- self.project.clone(),
- None,
- None,
- );
- let connection = server.connect(None, delegate, cx);
- cx.spawn(async move |_, cx| {
- let (agent, _) = connection.await?;
- let agent = agent.downcast::<agent::NativeAgentConnection>().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<Self>,
- ) -> Task<Result<Mention>> {
- 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(),
- })
- })
+ #[cfg(test)]
+ pub fn mention_set(&self) -> &Entity<MentionSet> {
+ &self.mention_set
}
fn validate_slash_commands(
@@ -705,7 +368,7 @@ impl MessageEditor {
let contents = self
.mention_set
- .contents(full_mention_content, self.project.clone(), cx);
+ .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;
@@ -728,8 +391,8 @@ impl MessageEditor {
};
let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot());
- if crease_range.start > ix {
- 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 {
@@ -739,34 +402,27 @@ impl MessageEditor {
} => {
all_tracked_buffers.extend(tracked_buffers.iter().cloned());
if supports_embedded_context {
- 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,
- },
+ acp::ContentBlock::Resource(acp::EmbeddedResource::new(
+ acp::EmbeddedResourceResource::TextResourceContents(
+ acp::TextResourceContents::new(
+ content.clone(),
+ uri.to_uri().to_string(),
),
- meta: None,
- })
+ ),
+ ))
} else {
- 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,
- })
+ 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 => {
@@ -776,28 +432,14 @@ 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::Link => 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() {
@@ -817,10 +459,12 @@ 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::<Vec<_>>()
+ }),
cx,
)
});
@@ -836,6 +480,45 @@ impl MessageEditor {
cx.emit(MessageEditorEvent::Send)
}
+ pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ 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::<text::Point>(&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>) {
self.send(cx);
}
@@ -860,111 +543,150 @@ impl MessageEditor {
}
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
- 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::<Vec<_>>()
- })
- .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)
- });
+ .and_then(|item| item.entries().first().cloned())
+ .and_then(|entry| match entry {
+ ClipboardEntry::String(text) => {
+ text.metadata_json::<Vec<editor::ClipboardSelection>>()
+ }
+ _ => None,
+ });
- 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 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())
});
- 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())
- }
+
+ 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;
}
- })
- .shared();
+ }
+
+ 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);
- self.mention_set
- .mentions
- .insert(crease_id, (MentionUri::PastedImage, task.clone()));
+ 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();
- 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);
+ self.mention_set.update(cx, |mention_set, _cx| {
+ mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
});
- this.mention_set.mentions.remove(&crease_id);
- })
- .ok();
+ }
}
- })
- .detach();
+ 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();
}
}
@@ -11,7 +11,7 @@ use ui::{
PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*,
};
-use crate::{CycleModeSelector, ToggleProfileSelector};
+use crate::{CycleModeSelector, ToggleProfileSelector, ui::HoldForDefault};
pub struct ModeSelector {
connection: Rc<dyn AgentSessionModes>,
@@ -56,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<Self>) {
let task = self.connection.set_mode(mode, cx);
self.setting_mode = true;
@@ -104,36 +108,11 @@ impl ModeSelector {
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()
}
})
@@ -182,7 +161,7 @@ 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
@@ -243,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()
})
}
}
@@ -1,27 +1,39 @@
use std::{cmp::Reverse, rc::Rc, sync::Arc};
use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector};
+use agent_client_protocol::ModelId;
+use agent_servers::AgentServer;
+use agent_settings::AgentSettings;
use anyhow::Result;
-use collections::IndexMap;
+use collections::{HashSet, IndexMap};
+use fs::Fs;
use futures::FutureExt;
use fuzzy::{StringMatchCandidate, match_strings};
-use gpui::{AsyncWindowContext, BackgroundExecutor, DismissEvent, Task, WeakEntity};
+use gpui::{
+ Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Task, WeakEntity,
+};
+use itertools::Itertools;
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
-use ui::{
- DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, ListItem,
- ListItemSpacing, prelude::*,
-};
+use settings::Settings;
+use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, prelude::*};
use util::ResultExt;
+use zed_actions::agent::OpenSettings;
+
+use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
pub type AcpModelSelector = Picker<AcpModelPickerDelegate>;
pub fn acp_model_selector(
selector: Rc<dyn AgentModelSelector>,
+ agent_server: Rc<dyn AgentServer>,
+ fs: Arc<dyn Fs>,
+ focus_handle: FocusHandle,
window: &mut Window,
cx: &mut Context<AcpModelSelector>,
) -> 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.))
@@ -30,22 +42,28 @@ pub fn acp_model_selector(
enum AcpModelPickerEntry {
Separator(SharedString),
- Model(AgentModelInfo),
+ Model(AgentModelInfo, bool),
}
pub struct AcpModelPickerDelegate {
selector: Rc<dyn AgentModelSelector>,
+ agent_server: Rc<dyn AgentServer>,
+ fs: Arc<dyn Fs>,
filtered_entries: Vec<AcpModelPickerEntry>,
models: Option<AgentModelList>,
selected_index: usize,
- selected_description: Option<(usize, SharedString)>,
+ selected_description: Option<(usize, SharedString, bool)>,
selected_model: Option<AgentModelInfo>,
_refresh_models_task: Task<()>,
+ focus_handle: FocusHandle,
}
impl AcpModelPickerDelegate {
fn new(
selector: Rc<dyn AgentModelSelector>,
+ agent_server: Rc<dyn AgentServer>,
+ fs: Arc<dyn Fs>,
+ focus_handle: FocusHandle,
window: &mut Window,
cx: &mut Context<AcpModelSelector>,
) -> Self {
@@ -86,18 +104,82 @@ 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,
}
}
pub fn active_model(&self) -> Option<&AgentModelInfo> {
self.selected_model.as_ref()
}
+
+ pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+ if !self.selector.supports_favorites() {
+ return;
+ }
+
+ let favorites = AgentSettings::get_global(cx).favorite_model_ids();
+
+ if favorites.is_empty() {
+ return;
+ }
+
+ let Some(models) = self.models.clone() else {
+ return;
+ };
+
+ let all_models: Vec<AgentModelInfo> = match models {
+ AgentModelList::Flat(list) => list,
+ AgentModelList::Grouped(index_map) => index_map
+ .into_values()
+ .flatten()
+ .collect::<Vec<AgentModelInfo>>(),
+ };
+
+ let favorite_models = all_models
+ .iter()
+ .filter(|model| favorites.contains(&model.id))
+ .unique_by(|model| &model.id)
+ .cloned()
+ .collect::<Vec<_>>();
+
+ let current_id = self.selected_model.as_ref().map(|m| m.id.clone());
+
+ let current_index_in_favorites = current_id
+ .as_ref()
+ .and_then(|id| favorite_models.iter().position(|m| &m.id == id))
+ .unwrap_or(usize::MAX);
+
+ let next_index = if current_index_in_favorites == usize::MAX {
+ 0
+ } else {
+ (current_index_in_favorites + 1) % favorite_models.len()
+ };
+
+ let next_model = favorite_models[next_index].clone();
+
+ self.selector
+ .select_model(next_model.id.clone(), cx)
+ .detach_and_log_err(cx);
+
+ self.selected_model = Some(next_model);
+
+ // Keep the picker selection aligned with the newly-selected model
+ if let Some(new_index) = self.filtered_entries.iter().position(|entry| {
+ matches!(entry, AcpModelPickerEntry::Model(model_info, _) if self.selected_model.as_ref().is_some_and(|selected| model_info.id == selected.id))
+ }) {
+ self.set_selected_index(new_index, window, cx);
+ } else {
+ cx.notify();
+ }
+ }
}
impl PickerDelegate for AcpModelPickerDelegate {
@@ -123,7 +205,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
_cx: &mut Context<Picker<Self>>,
) -> bool {
match self.filtered_entries.get(ix) {
- Some(AcpModelPickerEntry::Model(_)) => true,
+ Some(AcpModelPickerEntry::Model(_, _)) => true,
Some(AcpModelPickerEntry::Separator(_)) | None => false,
}
}
@@ -138,6 +220,12 @@ impl PickerDelegate for AcpModelPickerDelegate {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
+ let favorites = if self.selector.supports_favorites() {
+ Arc::new(AgentSettings::get_global(cx).favorite_model_ids())
+ } else {
+ Default::default()
+ };
+
cx.spawn_in(window, async move |this, cx| {
let filtered_models = match this
.read_with(cx, |this, cx| {
@@ -154,7 +242,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
this.update_in(cx, |this, window, cx| {
this.delegate.filtered_entries =
- info_list_to_picker_entries(filtered_models).collect();
+ info_list_to_picker_entries(filtered_models, favorites);
// Finds the currently selected model in the list
let new_index = this
.delegate
@@ -162,7 +250,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
.as_ref()
.and_then(|selected| {
this.delegate.filtered_entries.iter().position(|entry| {
- if let AcpModelPickerEntry::Model(model_info) = entry {
+ if let AcpModelPickerEntry::Model(model_info, _) = entry {
model_info.id == selected.id
} else {
false
@@ -178,9 +266,24 @@ impl PickerDelegate for AcpModelPickerDelegate {
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
- if let Some(AcpModelPickerEntry::Model(model_info)) =
+ 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);
@@ -206,73 +309,56 @@ impl PickerDelegate for AcpModelPickerDelegate {
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
match self.filtered_entries.get(ix)? {
- AcpModelPickerEntry::Separator(title) => Some(
- div()
- .px_2()
- .pb_1()
- .when(ix > 1, |this| {
- this.mt_1()
- .pt_2()
- .border_t_1()
- .border_color(cx.theme().colors().border_variant)
- })
- .child(
- Label::new(title)
- .size(LabelSize::XSmall)
- .color(Color::Muted),
- )
- .into_any_element(),
- ),
- AcpModelPickerEntry::Model(model_info) => {
+ AcpModelPickerEntry::Separator(title) => {
+ Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
+ }
+ AcpModelPickerEntry::Model(model_info, is_favorite) => {
let is_selected = Some(model_info) == self.selected_model.as_ref();
-
- let model_icon_color = if is_selected {
- Color::Accent
- } else {
- Color::Muted
+ let default_model = self.agent_server.default_model(cx);
+ let is_default = default_model.as_ref() == Some(&model_info.id);
+
+ let supports_favorites = self.selector.supports_favorites();
+
+ let is_favorite = *is_favorite;
+ let handle_action_click = {
+ let model_id = model_info.id.clone();
+ let fs = self.fs.clone();
+
+ move |cx: &App| {
+ crate::favorite_models::toggle_model_id_in_settings(
+ model_id.clone(),
+ !is_favorite,
+ fs.clone(),
+ cx,
+ );
+ }
};
Some(
div()
.id(("model-picker-menu-child", ix))
.when_some(model_info.description.clone(), |this, description| {
- this
- .on_hover(cx.listener(move |menu, hovered, _, cx| {
- if *hovered {
- menu.delegate.selected_description = Some((ix, description.clone()));
- } else if matches!(menu.delegate.selected_description, Some((id, _)) if id == ix) {
- menu.delegate.selected_description = None;
- }
- cx.notify();
- }))
+ this.on_hover(cx.listener(move |menu, hovered, _, cx| {
+ if *hovered {
+ 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();
+ }))
})
.child(
- ListItem::new(ix)
- .inset(true)
- .spacing(ListItemSpacing::Sparse)
- .toggle_state(selected)
- .start_slot::<Icon>(model_info.icon.map(|icon| {
- Icon::new(icon)
- .color(model_icon_color)
- .size(IconSize::Small)
- }))
- .child(
- h_flex()
- .w_full()
- .pl_0p5()
- .gap_1p5()
- .w(px(240.))
- .child(Label::new(model_info.name.clone()).truncate()),
- )
- .end_slot(div().pr_3().when(is_selected, |this| {
- this.child(
- Icon::new(IconName::Check)
- .color(Color::Accent)
- .size(IconSize::Small),
- )
- })),
+ ModelSelectorListItem::new(ix, model_info.name.clone())
+ .when_some(model_info.icon, |this, icon| this.icon(icon))
+ .is_selected(is_selected)
+ .is_focused(selected)
+ .when(supports_favorites, |this| {
+ this.is_favorite(is_favorite)
+ .on_toggle_favorite(handle_action_click)
+ }),
)
- .into_any_element()
+ .into_any_element(),
)
}
}
@@ -283,31 +369,88 @@ impl PickerDelegate for AcpModelPickerDelegate {
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Option<ui::DocumentationAside> {
- self.selected_description.as_ref().map(|(_, description)| {
- let description = description.clone();
- DocumentationAside::new(
- DocumentationSide::Left,
- DocumentationEdge::Top,
- Rc::new(move |_| Label::new(description.clone()).into_any_element()),
- )
- })
+ 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<Picker<Self>>,
+ ) -> Option<AnyElement> {
+ let focus_handle = self.focus_handle.clone();
+
+ if !self.selector.should_render_footer() {
+ return None;
+ }
+
+ Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
}
}
fn info_list_to_picker_entries(
model_list: AgentModelList,
-) -> impl Iterator<Item = AcpModelPickerEntry> {
+ favorites: Arc<HashSet<ModelId>>,
+) -> Vec<AcpModelPickerEntry> {
+ let mut entries = Vec::new();
+
+ let all_models: Vec<_> = match &model_list {
+ AgentModelList::Flat(list) => list.iter().collect(),
+ AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(),
+ };
+
+ let favorite_models: Vec<_> = all_models
+ .iter()
+ .filter(|m| favorites.contains(&m.id))
+ .unique_by(|m| &m.id)
+ .collect();
+
+ let has_favorites = !favorite_models.is_empty();
+ if has_favorites {
+ entries.push(AcpModelPickerEntry::Separator("Favorite".into()));
+ for model in favorite_models {
+ entries.push(AcpModelPickerEntry::Model((*model).clone(), true));
+ }
+ }
+
match model_list {
AgentModelList::Flat(list) => {
- itertools::Either::Left(list.into_iter().map(AcpModelPickerEntry::Model))
+ if has_favorites {
+ entries.push(AcpModelPickerEntry::Separator("All".into()));
+ }
+ for model in list {
+ let is_favorite = favorites.contains(&model.id);
+ entries.push(AcpModelPickerEntry::Model(model, is_favorite));
+ }
}
AgentModelList::Grouped(index_map) => {
- itertools::Either::Right(index_map.into_iter().flat_map(|(group_name, models)| {
- std::iter::once(AcpModelPickerEntry::Separator(group_name.0))
- .chain(models.into_iter().map(AcpModelPickerEntry::Model))
- }))
+ for (group_name, models) in index_map {
+ entries.push(AcpModelPickerEntry::Separator(group_name.0));
+ for model in models {
+ let is_favorite = favorites.contains(&model.id);
+ entries.push(AcpModelPickerEntry::Model(model, is_favorite));
+ }
+ }
}
}
+
+ entries
}
async fn fuzzy_search(
@@ -323,9 +466,7 @@ async fn fuzzy_search(
let candidates = model_list
.iter()
.enumerate()
- .map(|(ix, model)| {
- StringMatchCandidate::new(ix, &format!("{}/{}", model.id, model.name))
- })
+ .map(|(ix, model)| StringMatchCandidate::new(ix, model.name.as_ref()))
.collect::<Vec<_>>();
let mut matches = match_strings(
&candidates,
@@ -384,7 +525,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,
@@ -431,6 +572,170 @@ mod tests {
}
}
+ fn create_favorites(models: Vec<&str>) -> Arc<HashSet<ModelId>> {
+ Arc::new(
+ models
+ .into_iter()
+ .map(|m| ModelId::new(m.to_string()))
+ .collect(),
+ )
+ }
+
+ fn get_entry_model_ids(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
+ entries
+ .iter()
+ .filter_map(|entry| match entry {
+ AcpModelPickerEntry::Model(info, _) => Some(info.id.0.as_ref()),
+ _ => None,
+ })
+ .collect()
+ }
+
+ fn get_entry_labels(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
+ entries
+ .iter()
+ .map(|entry| match entry {
+ AcpModelPickerEntry::Model(info, _) => info.id.0.as_ref(),
+ AcpModelPickerEntry::Separator(s) => &s,
+ })
+ .collect()
+ }
+
+ #[gpui::test]
+ fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
+ let models = create_model_list(vec![
+ ("zed", vec!["zed/claude", "zed/gemini"]),
+ ("openai", vec!["openai/gpt-5"]),
+ ]);
+ let favorites = create_favorites(vec!["zed/gemini"]);
+
+ let entries = info_list_to_picker_entries(models, favorites);
+
+ assert!(matches!(
+ entries.first(),
+ Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite"
+ ));
+
+ let model_ids = get_entry_model_ids(&entries);
+ assert_eq!(model_ids[0], "zed/gemini");
+ }
+
+ #[gpui::test]
+ fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) {
+ let models = create_model_list(vec![("zed", vec!["zed/claude", "zed/gemini"])]);
+ let favorites = create_favorites(vec![]);
+
+ let entries = info_list_to_picker_entries(models, favorites);
+
+ assert!(matches!(
+ entries.first(),
+ Some(AcpModelPickerEntry::Separator(s)) if s == "zed"
+ ));
+ }
+
+ #[gpui::test]
+ fn test_models_have_correct_actions(_cx: &mut TestAppContext) {
+ let models = create_model_list(vec![
+ ("zed", vec!["zed/claude", "zed/gemini"]),
+ ("openai", vec!["openai/gpt-5"]),
+ ]);
+ let favorites = create_favorites(vec!["zed/claude"]);
+
+ let entries = info_list_to_picker_entries(models, favorites);
+
+ for entry in &entries {
+ if let AcpModelPickerEntry::Model(info, is_favorite) = entry {
+ if info.id.0.as_ref() == "zed/claude" {
+ assert!(is_favorite, "zed/claude should be a favorite");
+ } else {
+ assert!(!is_favorite, "{} should not be a favorite", info.id.0);
+ }
+ }
+ }
+ }
+
+ #[gpui::test]
+ fn test_favorites_appear_in_both_sections(_cx: &mut TestAppContext) {
+ let models = create_model_list(vec![
+ ("zed", vec!["zed/claude", "zed/gemini"]),
+ ("openai", vec!["openai/gpt-5", "openai/gpt-4"]),
+ ]);
+ let favorites = create_favorites(vec!["zed/gemini", "openai/gpt-5"]);
+
+ let entries = info_list_to_picker_entries(models, favorites);
+ let model_ids = get_entry_model_ids(&entries);
+
+ assert_eq!(model_ids[0], "zed/gemini");
+ assert_eq!(model_ids[1], "openai/gpt-5");
+
+ assert!(model_ids[2..].contains(&"zed/gemini"));
+ assert!(model_ids[2..].contains(&"openai/gpt-5"));
+ }
+
+ #[gpui::test]
+ fn test_favorites_are_not_duplicated_when_repeated_in_other_sections(_cx: &mut TestAppContext) {
+ let models = create_model_list(vec![
+ ("Recommended", vec!["zed/claude", "anthropic/claude"]),
+ ("Zed", vec!["zed/claude", "zed/gpt-5"]),
+ ("Antropic", vec!["anthropic/claude"]),
+ ("OpenAI", vec!["openai/gpt-5"]),
+ ]);
+
+ let favorites = create_favorites(vec!["zed/claude"]);
+
+ let entries = info_list_to_picker_entries(models, favorites);
+ let labels = get_entry_labels(&entries);
+
+ assert_eq!(
+ labels,
+ vec![
+ "Favorite",
+ "zed/claude",
+ "Recommended",
+ "zed/claude",
+ "anthropic/claude",
+ "Zed",
+ "zed/claude",
+ "zed/gpt-5",
+ "Antropic",
+ "anthropic/claude",
+ "OpenAI",
+ "openai/gpt-5"
+ ]
+ );
+ }
+
+ #[gpui::test]
+ fn test_flat_model_list_with_favorites(_cx: &mut TestAppContext) {
+ let models = AgentModelList::Flat(vec![
+ acp_thread::AgentModelInfo {
+ id: acp::ModelId::new("zed/claude".to_string()),
+ name: "Claude".into(),
+ description: None,
+ icon: None,
+ },
+ acp_thread::AgentModelInfo {
+ id: acp::ModelId::new("zed/gemini".to_string()),
+ name: "Gemini".into(),
+ description: None,
+ icon: None,
+ },
+ ]);
+ let favorites = create_favorites(vec!["zed/gemini"]);
+
+ let entries = info_list_to_picker_entries(models, favorites);
+
+ assert!(matches!(
+ entries.first(),
+ Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite"
+ ));
+
+ assert!(entries.iter().any(|e| matches!(
+ e,
+ AcpModelPickerEntry::Separator(s) if s == "All"
+ )));
+ }
+
#[gpui::test]
async fn test_fuzzy_match(cx: &mut TestAppContext) {
let models = create_model_list(vec![
@@ -1,14 +1,17 @@
use std::rc::Rc;
+use std::sync::Arc;
use acp_thread::{AgentModelInfo, AgentModelSelector};
+use agent_servers::AgentServer;
+use agent_settings::AgentSettings;
+use fs::Fs;
use gpui::{Entity, FocusHandle};
use picker::popover_menu::PickerPopoverMenu;
-use ui::{
- ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, TintColor, Tooltip, Window,
- prelude::*,
-};
+use settings::Settings as _;
+use ui::{ButtonLike, KeyBinding, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
use zed_actions::agent::ToggleModelSelector;
+use crate::CycleFavoriteModels;
use crate::acp::{AcpModelSelector, model_selector::acp_model_selector};
pub struct AcpModelSelectorPopover {
@@ -20,13 +23,25 @@ pub struct AcpModelSelectorPopover {
impl AcpModelSelectorPopover {
pub(crate) fn new(
selector: Rc<dyn AgentModelSelector>,
+ agent_server: Rc<dyn AgentServer>,
+ fs: Arc<dyn Fs>,
menu_handle: PopoverMenuHandle<AcpModelSelector>,
focus_handle: FocusHandle,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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,
}
@@ -39,6 +54,12 @@ impl AcpModelSelectorPopover {
pub fn active_model<'a>(&self, cx: &'a App) -> Option<&'a AgentModelInfo> {
self.selector.read(cx).delegate.active_model()
}
+
+ pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context<Self>) {
+ self.selector.update(cx, |selector, cx| {
+ selector.delegate.cycle_favorite_models(window, cx);
+ });
+ }
}
impl Render for AcpModelSelectorPopover {
@@ -59,6 +80,46 @@ impl Render for AcpModelSelectorPopover {
(Color::Muted, IconName::ChevronDown)
};
+ let tooltip = Tooltip::element({
+ move |_, cx| {
+ let focus_handle = focus_handle.clone();
+ let should_show_cycle_row = !AgentSettings::get_global(cx)
+ .favorite_model_ids()
+ .is_empty();
+
+ v_flex()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .justify_between()
+ .child(Label::new("Change Model"))
+ .child(KeyBinding::for_action_in(
+ &ToggleModelSelector,
+ &focus_handle,
+ cx,
+ )),
+ )
+ .when(should_show_cycle_row, |this| {
+ this.child(
+ h_flex()
+ .pt_1()
+ .gap_2()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
+ .justify_between()
+ .child(Label::new("Cycle Favorited Models"))
+ .child(KeyBinding::for_action_in(
+ &CycleFavoriteModels,
+ &focus_handle,
+ cx,
+ )),
+ )
+ })
+ .into_any()
+ }
+ });
+
PickerPopoverMenu::new(
self.selector.clone(),
ButtonLike::new("active-model")
@@ -73,9 +134,7 @@ impl Render for AcpModelSelectorPopover {
.ml_0p5(),
)
.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)),
- move |_window, cx| {
- Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
- },
+ tooltip,
gpui::Corner::BottomRight,
cx,
)
@@ -1,5 +1,5 @@
use crate::acp::AcpThreadView;
-use crate::{AgentPanel, RemoveSelectedThread};
+use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
use agent::{HistoryEntry, HistoryStore};
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
use editor::{Editor, EditorEvent};
@@ -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::*,
};
@@ -25,6 +25,7 @@ pub struct AcpThreadHistory {
search_query: SharedString,
visible_items: Vec<ListItemType>,
local_timezone: UtcOffset,
+ confirming_delete_history: bool,
_update_task: Task<()>,
_subscriptions: Vec<gpui::Subscription>,
}
@@ -98,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(()),
};
@@ -331,6 +333,24 @@ impl AcpThreadHistory {
task.detach_and_log_err(cx);
}
+ fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+ 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>) {
+ self.confirming_delete_history = true;
+ cx.notify();
+ }
+
+ fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+ self.confirming_delete_history = false;
+ cx.notify();
+ }
+
fn render_list_items(
&mut self,
range: Range<usize>,
@@ -426,9 +446,10 @@ impl AcpThreadHistory {
.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
@@ -447,6 +468,8 @@ impl Focusable for AcpThreadHistory {
impl Render for AcpThreadHistory {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let has_no_history = self.history_store.read(cx).is_empty(cx);
+
v_flex()
.key_context("ThreadHistory")
.size_full()
@@ -457,9 +480,12 @@ impl Render for AcpThreadHistory {
.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(px(41.)) // Match the toolbar perfectly
+ .h(Tab::container_height(cx))
.w_full()
.py_1()
.px_2()
@@ -481,7 +507,7 @@ impl Render for AcpThreadHistory {
.overflow_hidden()
.flex_grow();
- if self.history_store.read(cx).is_empty(cx) {
+ if has_no_history {
view.justify_center().items_center().child(
Label::new("You don't have any past threads yet.")
.size(LabelSize::Small)
@@ -502,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,
+ );
+ })),
+ ),
+ )
+ }),
+ )
+ })
}
}
@@ -51,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;
@@ -63,14 +63,11 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
use crate::agent_diff::AgentDiff;
use crate::profile_selector::{ProfileProvider, ProfileSelector};
-use crate::ui::{
- AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip,
- UsageCallout,
-};
+use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip, UsageCallout};
use crate::{
AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode,
- CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, OpenAgentDiff, OpenHistory, RejectAll,
- RejectOnce, ToggleBurnMode, ToggleProfileSelector,
+ CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread,
+ OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector,
};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
@@ -100,7 +97,7 @@ impl ThreadError {
{
Self::ModelRequestLimitReached(error.plan)
} else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
- && acp_error.code == acp::ErrorCode::AUTH_REQUIRED.code
+ && acp_error.code == acp::ErrorCode::AuthRequired
{
Self::AuthenticationRequired(acp_error.message.clone().into())
} else {
@@ -170,7 +167,7 @@ impl ThreadFeedbackState {
}
}
let session_id = thread.read(cx).session_id().clone();
- let agent = thread.read(cx).connection().telemetry_id();
+ 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",
@@ -180,7 +177,7 @@ impl ThreadFeedbackState {
let thread = task.await?;
telemetry::event!(
"Agent Thread Rated",
- agent = agent,
+ agent = agent_telemetry_id,
session_id = session_id,
rating = rating,
thread = thread
@@ -207,13 +204,13 @@ impl ThreadFeedbackState {
self.comments_editor.take();
let session_id = thread.read(cx).session_id().clone();
- let agent = thread.read(cx).connection().telemetry_id();
+ 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,
+ agent = agent_telemetry_id,
session_id = session_id,
comments = comments,
thread = thread
@@ -256,7 +253,7 @@ impl ThreadFeedbackState {
editor
});
- editor.read(cx).focus_handle(cx).focus(window);
+ editor.read(cx).focus_handle(cx).focus(window, cx);
editor
}
}
@@ -278,6 +275,7 @@ pub struct AcpThreadView {
notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
thread_retry_status: Option<RetryStatus>,
thread_error: Option<ThreadError>,
+ thread_error_markdown: Option<Entity<Markdown>>,
thread_feedback: ThreadFeedbackState,
list_state: ListState,
auth_task: Option<Task<()>>,
@@ -296,6 +294,7 @@ pub struct AcpThreadView {
_cancel_task: Option<Task<()>>,
_subscriptions: [Subscription; 5],
show_codex_windows_warning: bool,
+ in_flight_prompt: Option<Vec<acp::ContentBlock>>,
}
enum ThreadState {
@@ -331,6 +330,7 @@ impl AcpThreadView {
project: Entity<Project>,
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
+ track_load_event: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -342,7 +342,7 @@ impl AcpThreadView {
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(),
@@ -367,7 +367,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(),
@@ -389,8 +389,20 @@ impl AcpThreadView {
),
];
- let show_codex_windows_warning = crate::ExternalAgent::parse_built_in(agent.as_ref())
- == Some(crate::ExternalAgent::Codex);
+ cx.on_release(|this, cx| {
+ for window in this.notifications.drain(..) {
+ window
+ .update(cx, |_, window, _| {
+ window.remove_window();
+ })
+ .ok();
+ }
+ })
+ .detach();
+
+ let show_codex_windows_warning = cfg!(windows)
+ && project.read(cx).is_local()
+ && agent.clone().downcast::<agent_servers::Codex>().is_some();
Self {
agent: agent.clone(),
@@ -402,6 +414,7 @@ impl AcpThreadView {
resume_thread.clone(),
workspace.clone(),
project.clone(),
+ track_load_event,
window,
cx,
),
@@ -415,6 +428,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(),
@@ -435,6 +449,7 @@ impl AcpThreadView {
new_server_version_available: None,
resume_thread_metadata: resume_thread,
show_codex_windows_warning,
+ in_flight_prompt: None,
}
}
@@ -444,6 +459,7 @@ impl AcpThreadView {
self.resume_thread_metadata.clone(),
self.workspace.clone(),
self.project.clone(),
+ true,
window,
cx,
);
@@ -457,6 +473,7 @@ impl AcpThreadView {
resume_thread: Option<DbThreadMetadata>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
+ track_load_event: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> ThreadState {
@@ -515,6 +532,10 @@ 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::<agent::NativeAgentConnection>()
@@ -589,9 +610,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,
@@ -645,7 +670,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| {
@@ -658,6 +682,8 @@ impl AcpThreadView {
})
});
+ this.message_editor.focus_handle(cx).focus(window, cx);
+
cx.notify();
}
Err(err) => {
@@ -675,7 +701,7 @@ impl AcpThreadView {
this.new_server_version_available = Some(new_version.into());
cx.notify();
})
- .log_err();
+ .ok();
}
}
})
@@ -758,7 +784,7 @@ impl AcpThreadView {
_subscription: subscription,
};
if this.message_editor.focus_handle(cx).is_focused(window) {
- this.focus_handle.focus(window)
+ this.focus_handle.focus(window, cx)
}
cx.notify();
})
@@ -778,7 +804,7 @@ impl AcpThreadView {
ThreadState::LoadError(LoadError::Other(format!("{:#}", err).into()))
}
if self.message_editor.focus_handle(cx).is_focused(window) {
- self.focus_handle.focus(window)
+ self.focus_handle.focus(window, cx)
}
cx.notify();
}
@@ -798,6 +824,7 @@ impl AcpThreadView {
if should_retry {
self.thread_error = None;
+ self.thread_error_markdown = None;
self.reset(window, cx);
}
}
@@ -991,6 +1018,10 @@ impl AcpThreadView {
}
}
+ pub fn is_loading(&self) -> bool {
+ matches!(self.thread_state, ThreadState::Loading { .. })
+ }
+
fn resume_chat(&mut self, cx: &mut Context<Self>) {
self.thread_error.take();
let Some(thread) = self.thread() else {
@@ -1119,8 +1150,8 @@ impl AcpThreadView {
let Some(thread) = self.thread() else {
return;
};
- let agent_telemetry_id = self.agent.telemetry_id();
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
@@ -1132,6 +1163,7 @@ 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;
@@ -1147,6 +1179,7 @@ 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| {
@@ -1166,19 +1199,26 @@ impl AcpThreadView {
"Agent Message Sent",
agent = agent_telemetry_id,
session = session_id,
- model = model_id
+ model = model_id,
+ mode = mode_id
);
thread.send(contents, cx)
})?;
let res = send.await;
let turn_time_ms = turn_start_time.elapsed().as_millis();
- let status = if res.is_ok() { "success" } else { "failure" };
+ 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,
);
@@ -1230,7 +1270,7 @@ impl AcpThreadView {
}
})
};
- self.focus_handle(cx).focus(window);
+ self.focus_handle(cx).focus(window, cx);
cx.notify();
}
@@ -1255,16 +1295,38 @@ 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);
+ this.focus_handle(cx).focus(window, cx);
})?;
anyhow::Ok(())
})
- .detach();
+ .detach_and_log_err(cx);
}
fn open_edited_buffer(
@@ -1327,6 +1389,7 @@ impl AcpThreadView {
fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
self.thread_error = None;
+ self.thread_error_markdown = None;
cx.notify();
}
@@ -1402,7 +1465,7 @@ impl AcpThreadView {
self.thread_retry_status.take();
self.thread_state = ThreadState::LoadError(error.clone());
if self.message_editor.focus_handle(cx).is_focused(window) {
- self.focus_handle.focus(window)
+ self.focus_handle.focus(window, cx)
}
}
AcpThreadEvent::TitleUpdated => {
@@ -1430,18 +1493,8 @@ 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();
@@ -1476,6 +1529,7 @@ 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);
@@ -1543,19 +1597,18 @@ impl AcpThreadView {
);
cx.notify();
self.auth_task = Some(cx.spawn_in(window, {
- let agent = self.agent.clone();
async move |this, cx| {
let result = authenticate.await;
match &result {
Ok(_) => telemetry::event!(
"Authenticate Agent Succeeded",
- agent = agent.telemetry_id()
+ agent = agent_telemetry_id
),
Err(_) => {
telemetry::event!(
"Authenticate Agent Failed",
- agent = agent.telemetry_id(),
+ agent = agent_telemetry_id,
)
}
}
@@ -1639,6 +1692,7 @@ impl AcpThreadView {
None,
this.workspace.clone(),
this.project.clone(),
+ true,
window,
cx,
)
@@ -1694,43 +1748,38 @@ 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;
-
- match &result {
- Ok(_) => telemetry::event!(
- "Authenticate Agent Succeeded",
- agent = agent.telemetry_id()
- ),
- Err(_) => {
- telemetry::event!(
- "Authenticate Agent Failed",
- agent = agent.telemetry_id(),
- )
- }
+ 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.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.auth_task.take()
- })
- .ok();
- }
- }));
+ this.handle_thread_error(err, cx);
+ } else {
+ this.reset(window, cx);
+ }
+ this.auth_task.take()
+ })
+ .ok();
+ }
+ }));
}
fn spawn_external_agent_login(
@@ -1849,6 +1898,17 @@ impl AcpThreadView {
})
}
+ pub fn has_user_submitted_prompt(&self, cx: &App) -> bool {
+ self.thread().is_some_and(|thread| {
+ thread.read(cx).entries().iter().any(|entry| {
+ matches!(
+ entry,
+ AgentThreadEntry::UserMessage(user_message) if user_message.id.is_some()
+ )
+ })
+ })
+ }
+
fn authorize_tool_call(
&mut self,
tool_call_id: acp::ToolCallId,
@@ -1860,10 +1920,11 @@ 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 = self.agent.telemetry_id(),
+ agent = agent_telemetry_id,
session = thread.read(cx).session_id(),
option = option_kind
);
@@ -1901,6 +1962,16 @@ impl AcpThreadView {
window: &mut Window,
cx: &Context<Self>,
) -> AnyElement {
+ let is_indented = entry.is_indented();
+ let is_first_indented = is_indented
+ && self.thread().is_some_and(|thread| {
+ thread
+ .read(cx)
+ .entries()
+ .get(entry_ix.saturating_sub(1))
+ .is_none_or(|entry| !entry.is_indented())
+ });
+
let primary = match &entry {
AgentThreadEntry::UserMessage(message) => {
let Some(editor) = self
@@ -1933,7 +2004,9 @@ impl AcpThreadView {
v_flex()
.id(("user_message", entry_ix))
.map(|this| {
- if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
+ if is_first_indented {
+ this.pt_0p5()
+ } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
this.pt(rems_from_px(18.))
} else if rules_item.is_some() {
this.pt_3()
@@ -1979,6 +2052,9 @@ impl AcpThreadView {
.shadow_md()
.bg(cx.theme().colors().editor_background)
.border_1()
+ .when(is_indented, |this| {
+ this.py_2().px_2().shadow_sm()
+ })
.when(editing && !editor_focus, |this| this.border_dashed())
.border_color(cx.theme().colors().border)
.map(|this|{
@@ -2049,10 +2125,23 @@ impl AcpThreadView {
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.style(ButtonStyle::Transparent)
- .tooltip(move |_window, cx| {
- cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone()))
- .into()
- })
+ .tooltip(Tooltip::element({
+ move |_, _| {
+ v_flex()
+ .gap_1()
+ .child(Label::new("Unavailable Editing")).child(
+ div().max_w_64().child(
+ Label::new(format!(
+ "Editing previous messages is not available for {} yet.",
+ agent_name.clone()
+ ))
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .into_any_element()
+ }
+ }))
)
)
}
@@ -2060,7 +2149,10 @@ impl AcpThreadView {
)
.into_any()
}
- AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
+ AgentThreadEntry::AssistantMessage(AssistantMessage {
+ chunks,
+ indented: _,
+ }) => {
let is_last = entry_ix + 1 == total_entries;
let style = default_markdown_style(false, false, window, cx);
@@ -2094,6 +2186,7 @@ impl AcpThreadView {
v_flex()
.px_5()
.py_1p5()
+ .when(is_first_indented, |this| this.pt_0p5())
.when(is_last, |this| this.pb_4())
.w_full()
.text_ui(cx)
@@ -2103,19 +2196,48 @@ impl AcpThreadView {
AgentThreadEntry::ToolCall(tool_call) => {
let has_terminals = tool_call.terminals().next().is_some();
- div().w_full().map(|this| {
- if has_terminals {
- this.children(tool_call.terminals().map(|terminal| {
- self.render_terminal_tool_call(
- entry_ix, terminal, tool_call, window, cx,
- )
- }))
- } else {
- this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
- }
- })
+ div()
+ .w_full()
+ .map(|this| {
+ if has_terminals {
+ this.children(tool_call.terminals().map(|terminal| {
+ self.render_terminal_tool_call(
+ entry_ix, terminal, tool_call, window, cx,
+ )
+ }))
+ } else {
+ this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
+ }
+ })
+ .into_any()
}
- .into_any(),
+ };
+
+ let primary = if is_indented {
+ let line_top = if is_first_indented {
+ rems_from_px(-12.0)
+ } else {
+ rems_from_px(0.0)
+ };
+
+ div()
+ .relative()
+ .w_full()
+ .pl(rems_from_px(20.0))
+ .bg(cx.theme().colors().panel_background.opacity(0.2))
+ .child(
+ div()
+ .absolute()
+ .left(rems_from_px(18.0))
+ .top(line_top)
+ .bottom_0()
+ .w_px()
+ .bg(cx.theme().colors().border.opacity(0.6)),
+ )
+ .child(primary)
+ .into_any_element()
+ } else {
+ primary
};
let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry {
@@ -2516,7 +2638,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)
@@ -2768,7 +2890,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 {
@@ -2784,7 +2906,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)
}
};
@@ -2809,7 +2931,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(
@@ -3140,7 +3262,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()
@@ -3148,7 +3270,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()
})),
)
})
@@ -3465,7 +3595,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 {
@@ -3484,12 +3616,12 @@ impl AcpThreadView {
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,
)
@@ -3754,48 +3886,64 @@ impl AcpThreadView {
}))
}
- fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> 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<Self>,
+ ) -> 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(
@@ -3933,162 +4081,185 @@ impl AcpThreadView {
changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
pending_edits: bool,
cx: &Context<Self>,
- ) -> 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)
+ .size(LabelSize::XSmall)
+ .buffer_font(cx),
+ )
+ }
+ });
- 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_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)
- .ml_1p5()
- });
+ let full_path = path.display(path_style).to_string();
- 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()
- .id(("file-name-row", index))
- .relative()
- .pr_8()
- .w_full()
- .overflow_x_scroll()
+ 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()
- .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),
- ),
- )
- .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);
- })
- }),
+ .id(("file-name-row", index))
+ .relative()
+ .pr_8()
+ .w_full()
+ .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(move |_, cx| {
+ Tooltip::with_meta(
+ "Go to File",
+ None,
+ full_path.clone(),
+ cx,
+ )
+ })
+ .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),
+ ),
)
- .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
+ 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();
- let telemetry = telemetry.clone();
- move |_, _, cx| {
- action_log.update(cx, |action_log, cx| {
- action_log.keep_edits_in_range(
- buffer.clone(),
- Anchor::MIN..Anchor::MAX,
- Some(telemetry.clone()),
- 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<Self>) -> AnyElement {
@@ -1,5 +1,5 @@
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;
@@ -8,10 +8,11 @@ use std::{ops::Range, sync::Arc};
use agent::ContextServerRegistry;
use anyhow::Result;
+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 fs::Fs;
@@ -26,26 +27,27 @@ use language_model::{
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::{
- Button, ButtonStyle, Chip, CommonAnimationExt, ContextMenu, Disclosure, Divider, DividerColor,
- ElevationIndex, IconName, IconPosition, IconSize, Indicator, LabelSize, PopoverMenu, Switch,
- SwitchColor, 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 {
@@ -415,6 +417,7 @@ impl AgentConfiguration {
cx: &mut Context<Self>,
) -> impl IntoElement {
let providers = LanguageModelRegistry::read_global(cx).providers();
+
let popover_menu = PopoverMenu::new("add-provider-popover")
.trigger(
Button::new("add-provider", "Add Provider")
@@ -425,7 +428,6 @@ impl AgentConfiguration {
.icon_color(Color::Muted)
.label_size(LabelSize::Small),
)
- .anchor(gpui::Corner::TopRight)
.menu({
let workspace = self.workspace.clone();
move |window, cx| {
@@ -447,6 +449,11 @@ impl AgentConfiguration {
})
}))
}
+ })
+ .anchor(gpui::Corner::TopRight)
+ .offset(gpui::Point {
+ x: px(0.0),
+ y: px(2.0),
});
v_flex()
@@ -541,12 +548,13 @@ impl AgentConfiguration {
.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| {
@@ -564,6 +572,11 @@ impl AgentConfiguration {
})
}))
}
+ })
+ .anchor(gpui::Corner::TopRight)
+ .offset(gpui::Point {
+ x: px(0.0),
+ y: px(2.0),
});
v_flex()
@@ -639,7 +652,7 @@ impl AgentConfiguration {
let is_running = matches!(server_status, ContextServerStatus::Running);
let item_id = SharedString::from(context_server_id.0.clone());
// Servers without a configuration can only be provided by extensions.
- let provided_by_extension = server_configuration.is_none_or(|config| {
+ let provided_by_extension = server_configuration.as_ref().is_none_or(|config| {
matches!(
config.as_ref(),
ContextServerConfiguration::Extension { .. }
@@ -695,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)
@@ -718,14 +734,25 @@ 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 > 0, |this| this.entry("View Tools", None, {
let context_server_id = context_server_id.clone();
@@ -811,7 +838,7 @@ impl AgentConfiguration {
.min_w_0()
.child(
h_flex()
- .id(SharedString::from(format!("tooltip-{}", item_id)))
+ .id(format!("tooltip-{}", item_id))
.h_full()
.w_3()
.mr_2()
@@ -852,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();
@@ -943,35 +969,104 @@ impl AgentConfiguration {
.cloned()
.collect::<Vec<_>>();
- let user_defined_agents = user_defined_agents
+ let user_defined_agents: Vec<_> = user_defined_agents
.into_iter()
.map(|name| {
let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) {
AgentIcon::Path(icon_path)
} else {
- AgentIcon::Name(IconName::Ai)
+ AgentIcon::Name(IconName::Sparkle)
};
- self.render_agent_server(icon, name, true)
- .into_any_element()
+ let display_name = agent_server_store
+ .agent_display_name(&name)
+ .unwrap_or_else(|| name.0.clone());
+ (name, icon, display_name)
})
- .collect::<Vec<_>>();
+ .collect();
- let add_agens_button = 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)
- .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
+ 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,
+ )
+ }
})
- .detach_and_log_err(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()
@@ -982,7 +1077,7 @@ impl AgentConfiguration {
.child(self.render_section_title(
"External Agents",
"All agents connected through the Agent Client Protocol.",
- add_agens_button.into_any_element(),
+ add_agent_popover.into_any_element(),
))
.child(
v_flex()
@@ -992,27 +1087,39 @@ impl AgentConfiguration {
.child(self.render_agent_server(
AgentIcon::Name(IconName::AiClaude),
"Claude Code",
+ "Claude Code",
false,
+ cx,
))
.child(Divider::horizontal().color(DividerColor::BorderFaded))
.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 agent in user_defined_agents {
+ for (name, icon, display_name) in user_defined_agents {
parent = parent
.child(
Divider::horizontal().color(DividerColor::BorderFaded),
)
- .child(agent);
+ .child(self.render_agent_server(
+ icon,
+ name,
+ display_name,
+ true,
+ cx,
+ ));
}
parent
}),
@@ -1023,10 +1130,14 @@ impl AgentConfiguration {
fn render_agent_server(
&self,
icon: AgentIcon,
- name: impl Into<SharedString>,
+ id: impl Into<SharedString>,
+ display_name: impl Into<SharedString>,
external: bool,
+ cx: &mut Context<Self>,
) -> impl IntoElement {
- let name = name.into();
+ 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)
@@ -1036,31 +1147,59 @@ impl AgentConfiguration {
.color(Color::Muted),
};
- let tooltip_id = SharedString::new(format!("agent-source-{}", name));
- let tooltip_message = format!("The {} agent was installed from an extension.", name);
+ 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_1p5()
- .child(icon)
- .child(Label::new(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),
- ),
- )
- })
+ .gap_1()
+ .justify_between()
.child(
- Icon::new(IconName::Check)
- .color(Color::Success)
- .size(IconSize::Small),
+ 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))
}
}
@@ -1087,7 +1226,7 @@ impl Render for AgentConfiguration {
.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),
)
}
}
@@ -1221,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,
},
);
}
@@ -1240,7 +1380,15 @@ async fn open_new_agent_servers_entry_in_settings_editor(
.map(|(range, _)| range.clone())
.collect::<Vec<_>>();
- 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())
{
@@ -1253,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),
+ ]);
},
);
}
@@ -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::InputField;
use workspace::{ModalView, Workspace};
+fn single_line_input(
+ label: impl Into<SharedString>,
+ placeholder: impl Into<SharedString>,
+ text: Option<&str>,
+ tab_index: isize,
+ window: &mut Window,
+ cx: &mut App,
+) -> Entity<InputField> {
+ 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,
@@ -41,12 +67,14 @@ struct AddLlmProviderInput {
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) {
@@ -84,11 +113,14 @@ struct ModelInput {
}
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,
);
@@ -96,6 +128,7 @@ impl ModelInput {
"Max Completion Tokens",
"200000",
Some("200000"),
+ base_tab_index + 2,
window,
cx,
);
@@ -103,16 +136,26 @@ 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,
parallel_tool_calls,
prompt_cache_key,
} = ModelCapabilities::default();
+
Self {
name: model_name,
max_completion_tokens,
@@ -165,24 +208,6 @@ impl ModelInput {
}
}
-fn single_line_input(
- label: impl Into<SharedString>,
- placeholder: impl Into<SharedString>,
- text: Option<&str>,
- window: &mut Window,
- cx: &mut App,
-) -> Entity<InputField> {
- cx.new(|cx| {
- let input = InputField::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,
@@ -258,6 +283,7 @@ fn save_provider_to_settings(
pub struct AddLlmProviderModal {
provider: LlmCompatibleProvider,
input: AddLlmProviderInput,
+ scroll_handle: ScrollHandle,
focus_handle: FocusHandle,
last_error: Option<SharedString>,
}
@@ -278,6 +304,7 @@ impl AddLlmProviderModal {
provider,
last_error: None,
focus_handle: cx.focus_handle(),
+ scroll_handle: ScrollHandle::new(),
}
}
@@ -418,6 +445,19 @@ impl AddLlmProviderModal {
)
})
}
+
+ fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
+ window.focus_next(cx);
+ }
+
+ fn on_tab_prev(
+ &mut self,
+ _: &menu::SelectPrevious,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ window.focus_prev(cx);
+ }
}
impl EventEmitter<DismissEvent> for AddLlmProviderModal {}
@@ -431,17 +471,29 @@ impl Focusable for AddLlmProviderModal {
impl ModalView for AddLlmProviderModal {}
impl Render for AddLlmProviderModal {
- fn render(&mut self, _window: &mut ui::Window, cx: &mut ui::Context<Self>) -> impl IntoElement {
+ fn render(&mut self, window: &mut ui::Window, cx: &mut ui::Context<Self>) -> 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);
+ this.focus_handle(cx).focus(window, cx);
}))
.child(
Modal::new("configure-context-server", None)
@@ -462,17 +514,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(
@@ -642,7 +702,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);
@@ -678,7 +738,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);
@@ -703,7 +763,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);
@@ -767,7 +827,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);
@@ -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<String, String>,
+ },
Extension {
id: ContextServerId,
repository_url: Option<SharedString>,
@@ -46,9 +51,11 @@ enum ConfigurationTarget {
enum ConfigurationSource {
New {
editor: Entity<Editor>,
+ is_http: bool,
},
Existing {
editor: Entity<Editor>,
+ 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, String>)>,
+) -> String {
+ let (name, url, headers) = match existing {
+ Some((id, url, headers)) => {
+ let header = if headers.is_empty() {
+ r#"// "Authorization": "Bearer <token>"#.to_string()
+ } else {
+ let json = serde_json::to_string_pretty(&headers).unwrap();
+ let mut lines = json.split("\n").collect::<Vec<_>>();
+ if lines.len() > 1 {
+ lines.remove(0);
+ lines.pop();
+ }
+ lines
+ .into_iter()
+ .map(|line| format!(" {}", line))
+ .collect::<String>()
+ };
+ (id.0.to_string(), url, header)
+ }
+ None => (
+ "some-remote-server".to_string(),
+ "https://example.com/mcp".to_string(),
+ r#"// "Authorization": "Bearer <token>"#.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<String, String>)> {
+ #[derive(Deserialize)]
+ struct Temp {
+ url: String,
+ #[serde(default)]
+ headers: HashMap<String, String>,
+ }
+ let value: HashMap<String, Temp> = 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<WorktreeStore>,
@@ -252,6 +348,7 @@ pub struct ConfigureContextServerModal {
source: ConfigurationSource,
state: State,
original_server_id: Option<ContextServerId>,
+ 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();
@@ -598,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
},
@@ -693,20 +831,35 @@ impl Render for ConfigureContextServerModal {
}),
)
.capture_any_mouse_down(cx.listener(|this, _, window, cx| {
- this.focus_handle(cx).focus(window);
+ this.focus_handle(cx).focus(window, cx);
}))
.child(
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(cx)),
)
@@ -87,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()
@@ -138,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()
}
}
@@ -8,6 +8,7 @@ use editor::Editor;
use fs::Fs;
use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, prelude::*};
use language_model::{LanguageModel, LanguageModelRegistry};
+use settings::SettingsStore;
use settings::{
LanguageModelProviderSetting, LanguageModelSelection, Settings as _, update_settings_file,
};
@@ -94,6 +95,7 @@ pub struct ViewProfileMode {
configure_default_model: NavigableEntry,
configure_tools: NavigableEntry,
configure_mcps: NavigableEntry,
+ delete_profile: NavigableEntry,
cancel_item: NavigableEntry,
}
@@ -109,6 +111,7 @@ pub struct ManageProfilesModal {
active_model: Option<Arc<dyn LanguageModel>>,
focus_handle: FocusHandle,
mode: Mode,
+ _settings_subscription: Subscription,
}
impl ManageProfilesModal {
@@ -148,18 +151,29 @@ impl ManageProfilesModal {
) -> Self {
let focus_handle = cx.focus_handle();
+ // Keep this modal in sync with settings changes (including profile deletion).
+ let settings_subscription =
+ cx.observe_global_in::<SettingsStore>(window, |this, window, cx| {
+ if matches!(this.mode, Mode::ChooseProfile(_)) {
+ this.mode = Mode::choose_profile(window, cx);
+ this.focus_handle(cx).focus(window, cx);
+ cx.notify();
+ }
+ });
+
Self {
fs,
active_model,
context_server_registry,
focus_handle,
mode: Mode::choose_profile(window, cx),
+ _settings_subscription: settings_subscription,
}
}
fn choose_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.mode = Mode::choose_profile(window, cx);
- self.focus_handle(cx).focus(window);
+ self.focus_handle(cx).focus(window, cx);
}
fn new_profile(
@@ -177,7 +191,7 @@ impl ManageProfilesModal {
name_editor,
base_profile_id,
});
- self.focus_handle(cx).focus(window);
+ self.focus_handle(cx).focus(window, cx);
}
pub fn view_profile(
@@ -192,9 +206,10 @@ impl ManageProfilesModal {
configure_default_model: NavigableEntry::focusable(cx),
configure_tools: NavigableEntry::focusable(cx),
configure_mcps: NavigableEntry::focusable(cx),
+ delete_profile: NavigableEntry::focusable(cx),
cancel_item: NavigableEntry::focusable(cx),
});
- self.focus_handle(cx).focus(window);
+ self.focus_handle(cx).focus(window, cx);
}
fn configure_default_model(
@@ -207,7 +222,6 @@ impl ManageProfilesModal {
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(
@@ -235,24 +249,39 @@ impl ManageProfilesModal {
})
}
},
- 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();
+ {
+ let fs = fs.clone();
+ 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(),
- });
+ 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(),
+ });
+ }
}
- }
- });
+ });
+ }
+ },
+ {
+ let fs = fs.clone();
+ move |model, should_be_favorite, cx| {
+ crate::favorite_models::toggle_in_settings(
+ model,
+ should_be_favorite,
+ fs.clone(),
+ cx,
+ );
+ }
},
false, // Do not use popover styles for the model picker
+ self.focus_handle.clone(),
window,
cx,
)
@@ -271,7 +300,7 @@ impl ManageProfilesModal {
model_picker,
_subscription: dismiss_subscription,
};
- self.focus_handle(cx).focus(window);
+ self.focus_handle(cx).focus(window, cx);
}
fn configure_mcp_tools(
@@ -307,7 +336,7 @@ impl ManageProfilesModal {
tool_picker,
_subscription: dismiss_subscription,
};
- self.focus_handle(cx).focus(window);
+ self.focus_handle(cx).focus(window, cx);
}
fn configure_builtin_tools(
@@ -348,7 +377,7 @@ impl ManageProfilesModal {
tool_picker,
_subscription: dismiss_subscription,
};
- self.focus_handle(cx).focus(window);
+ self.focus_handle(cx).focus(window, cx);
}
fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -368,6 +397,42 @@ impl ManageProfilesModal {
}
}
+ fn delete_profile(
+ &mut self,
+ profile_id: AgentProfileId,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if builtin_profiles::is_builtin(&profile_id) {
+ self.view_profile(profile_id, window, cx);
+ return;
+ }
+
+ let fs = self.fs.clone();
+
+ update_settings_file(fs, cx, move |settings, _cx| {
+ let Some(agent_settings) = settings.agent.as_mut() else {
+ return;
+ };
+
+ let Some(profiles) = agent_settings.profiles.as_mut() else {
+ return;
+ };
+
+ profiles.shift_remove(profile_id.0.as_ref());
+
+ if agent_settings
+ .default_profile
+ .as_deref()
+ .is_some_and(|default_profile| default_profile == profile_id.0.as_ref())
+ {
+ agent_settings.default_profile = Some(AgentProfileId::default().0);
+ }
+ });
+
+ self.choose_profile(window, cx);
+ }
+
fn cancel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
match &self.mode {
Mode::ChooseProfile { .. } => {
@@ -421,7 +486,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();
@@ -430,7 +495,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)
@@ -755,6 +820,40 @@ impl ManageProfilesModal {
}),
),
)
+ .child(
+ div()
+ .id("delete-profile")
+ .track_focus(&mode.delete_profile.focus_handle)
+ .on_action({
+ let profile_id = mode.profile_id.clone();
+ cx.listener(move |this, _: &menu::Confirm, window, cx| {
+ this.delete_profile(profile_id.clone(), window, cx);
+ })
+ })
+ .child(
+ ListItem::new("delete-profile")
+ .toggle_state(
+ mode.delete_profile
+ .focus_handle
+ .contains_focused(window, cx),
+ )
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .start_slot(
+ Icon::new(IconName::Trash)
+ .size(IconSize::Small)
+ .color(Color::Error),
+ )
+ .child(Label::new("Delete Profile").color(Color::Error))
+ .disabled(builtin_profiles::is_builtin(&mode.profile_id))
+ .on_click({
+ let profile_id = mode.profile_id.clone();
+ cx.listener(move |this, _, window, cx| {
+ this.delete_profile(profile_id.clone(), window, cx);
+ })
+ }),
+ ),
+ )
.child(ListSeparator)
.child(
div()
@@ -804,6 +903,7 @@ impl ManageProfilesModal {
.entry(mode.configure_default_model)
.entry(mode.configure_tools)
.entry(mode.configure_mcps)
+ .entry(mode.delete_profile)
.entry(mode.cancel_item)
}
}
@@ -851,7 +951,7 @@ impl Render for ManageProfilesModal {
.on_action(cx.listener(|this, _: &menu::Cancel, window, cx| this.cancel(window, cx)))
.on_action(cx.listener(|this, _: &menu::Confirm, window, cx| this.confirm(window, cx)))
.capture_any_mouse_down(cx.listener(|this, _, window, cx| {
- this.focus_handle(cx).focus(window);
+ this.focus_handle(cx).focus(window, cx);
}))
.on_mouse_down_out(cx.listener(|_this, _, _, cx| cx.emit(DismissEvent)))
.child(match &self.mode {
@@ -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};
@@ -130,7 +130,12 @@ impl AgentDiffPane {
.action_log()
.read(cx)
.changed_buffers(cx);
- let mut paths_to_delete = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
+ let mut paths_to_delete = self
+ .multibuffer
+ .read(cx)
+ .paths()
+ .cloned()
+ .collect::<HashSet<_>>();
for (buffer, diff_handle) in changed_buffers {
if buffer.read(cx).file().is_none() {
@@ -145,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,
)
@@ -207,10 +212,10 @@ impl AgentDiffPane {
.focus_handle(cx)
.contains_focused(window, cx)
{
- self.focus_handle.focus(window);
+ self.focus_handle.focus(window, cx);
} else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
self.editor.update(cx, |editor, cx| {
- editor.focus_handle(cx).focus(window);
+ editor.focus_handle(cx).focus(window, cx);
});
}
}
@@ -493,7 +498,7 @@ impl Item for AgentDiffPane {
Some("Assistant Diff Opened")
}
- fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
@@ -580,11 +585,11 @@ impl Item for AgentDiffPane {
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
- ) -> Option<AnyView> {
+ ) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
- Some(self_handle.to_any())
+ Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
- Some(self.editor.to_any())
+ Some(self.editor.clone().into())
} else {
None
}
@@ -869,12 +874,12 @@ impl AgentDiffToolbar {
match active_item {
AgentDiffToolbarItem::Pane(agent_diff) => {
if let Some(agent_diff) = agent_diff.upgrade() {
- agent_diff.focus_handle(cx).focus(window);
+ agent_diff.focus_handle(cx).focus(window, cx);
}
}
AgentDiffToolbarItem::Editor { editor, .. } => {
if let Some(editor) = editor.upgrade() {
- editor.read(cx).focus_handle(cx).focus(window);
+ editor.read(cx).focus_handle(cx).focus(window, cx);
}
}
}
@@ -25,29 +25,45 @@ impl AgentModelSelector {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
+ let focus_handle_clone = focus_handle.clone();
+
Self {
selector: cx.new(move |cx| {
- let fs = fs.clone();
language_model_selector(
{
let model_context = model_usage_context.clone();
move |cx| model_context.configured_model(cx)
},
- move |model, cx| {
- let provider = model.provider_id().0.to_string();
- let model_id = model.id().0.to_string();
- match &model_usage_context {
- ModelUsageContext::InlineAssistant => {
- update_settings_file(fs.clone(), cx, move |settings, _cx| {
- settings
- .agent
- .get_or_insert_default()
- .set_inline_assistant_model(provider.clone(), model_id);
- });
+ {
+ let fs = fs.clone();
+ move |model, cx| {
+ let provider = model.provider_id().0.to_string();
+ let model_id = model.id().0.to_string();
+ match &model_usage_context {
+ ModelUsageContext::InlineAssistant => {
+ update_settings_file(fs.clone(), cx, move |settings, _cx| {
+ settings
+ .agent
+ .get_or_insert_default()
+ .set_inline_assistant_model(provider.clone(), model_id);
+ });
+ }
}
}
},
+ {
+ let fs = fs.clone();
+ move |model, should_be_favorite, cx| {
+ crate::favorite_models::toggle_in_settings(
+ model,
+ should_be_favorite,
+ fs.clone(),
+ cx,
+ );
+ }
+ },
true, // Use popover styles for picker
+ focus_handle_clone,
window,
cx,
)
@@ -60,6 +76,10 @@ impl AgentModelSelector {
pub fn toggle(&self, window: &mut Window, cx: &mut Context<Self>) {
self.menu_handle.toggle(window, cx);
}
+
+ pub fn active_model(&self, cx: &App) -> Option<language_model::ConfiguredModel> {
+ self.selector.read(cx).delegate.active_model(cx)
+ }
}
impl Render for AgentModelSelector {
@@ -95,7 +115,7 @@ impl Render for AgentModelSelector {
.child(
Icon::new(IconName::ChevronDown)
.color(color)
- .size(IconSize::XSmall),
+ .size(IconSize::Small),
),
move |_window, cx| {
Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
@@ -1,16 +1,12 @@
-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 agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore};
+use agent_servers::AgentServer;
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use project::{
ExternalAgentServerName,
- agent_server_store::{
- AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME,
- },
+ agent_server_store::{CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME},
};
use serde::{Deserialize, Serialize};
use settings::{
@@ -19,12 +15,12 @@ use settings::{
use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent};
+use crate::ManageProfiles;
use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
use crate::{
- AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, Follow, InlineAssistant,
- NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory,
- ResetTrialEndUpsell, ResetTrialUpsell, ToggleNavigationMenu, ToggleNewThreadMenu,
- ToggleOptionsMenu,
+ AddContextServer, AgentDiffPane, Follow, InlineAssistant, NewTextThread, NewThread,
+ OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
+ ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
acp::AcpThreadView,
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
slash_command::SlashCommandCompletionProvider,
@@ -35,10 +31,7 @@ use crate::{
ExpandMessageEditor,
acp::{AcpThreadHistory, ThreadHistoryEvent},
};
-use crate::{
- ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary, placeholder_command,
-};
-use crate::{ManageProfiles, context_store::ContextStore};
+use crate::{ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary};
use agent_settings::AgentSettings;
use ai_onboarding::AgentPanelOnboarding;
use anyhow::{Result, anyhow};
@@ -51,9 +44,9 @@ 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};
@@ -61,12 +54,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::{
@@ -248,7 +240,6 @@ pub enum AgentType {
Codex,
Custom {
name: SharedString,
- command: AgentServerCommand,
},
}
@@ -269,7 +260,7 @@ impl AgentType {
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),
}
}
}
@@ -280,7 +271,7 @@ impl From<ExternalAgent> 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,
}
}
@@ -297,7 +288,7 @@ impl ActiveView {
}
}
- pub fn native_agent(
+ fn native_agent(
fs: Arc<dyn Fs>,
prompt_store: Option<Entity<PromptStore>>,
history_store: Entity<agent::HistoryStore>,
@@ -315,6 +306,7 @@ impl ActiveView {
project,
history_store,
prompt_store,
+ false,
window,
cx,
)
@@ -436,7 +428,6 @@ pub struct AgentPanel {
text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
context_server_registry: Entity<ContextServerRegistry>,
- inline_assist_context_store: Entity<ContextStore>,
configuration: Option<Entity<AgentConfiguration>>,
configuration_subscription: Option<Subscription>,
active_view: ActiveView,
@@ -452,6 +443,7 @@ pub struct AgentPanel {
pending_serialization: Option<Task<Result<()>>>,
onboarding: Entity<AgentPanelOnboarding>,
selected_agent: AgentType,
+ show_trust_workspace_message: bool,
}
impl AgentPanel {
@@ -548,7 +540,6 @@ impl AgentPanel {
let client = workspace.client().clone();
let workspace = workspace.weak_handle();
- let inline_assist_context_store = cx.new(|_cx| ContextStore::new(project.downgrade()));
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
@@ -621,11 +612,14 @@ impl AgentPanel {
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| {
@@ -685,7 +679,6 @@ impl AgentPanel {
configuration: None,
configuration_subscription: None,
context_server_registry,
- inline_assist_context_store,
previous_view: None,
new_thread_menu_handle: PopoverMenuHandle::default(),
agent_panel_menu_handle: PopoverMenuHandle::default(),
@@ -701,6 +694,7 @@ impl AgentPanel {
history_store,
selected_agent: AgentType::default(),
loading: false,
+ show_trust_workspace_message: false,
};
// Initial sync of agent servers from extensions
@@ -726,10 +720,6 @@ impl AgentPanel {
&self.prompt_store
}
- pub(crate) fn inline_assist_context_store(&self) -> &Entity<ContextStore> {
- &self.inline_assist_context_store
- }
-
pub(crate) fn thread_store(&self) -> &Entity<HistoryStore> {
&self.history_store
}
@@ -828,10 +818,11 @@ impl AgentPanel {
window,
cx,
),
+ true,
window,
cx,
);
- text_thread_editor.focus_handle(cx).focus(window);
+ text_thread_editor.focus_handle(cx).focus(window, cx);
}
fn external_thread(
@@ -897,34 +888,21 @@ impl AgentPanel {
};
let server = ext_agent.server(fs, history);
+ this.update_in(cx, |agent_panel, window, cx| {
+ agent_panel._external_thread(
+ server,
+ resume_thread,
+ summarize_thread,
+ workspace,
+ project,
+ loading,
+ ext_agent,
+ window,
+ cx,
+ );
+ })?;
- if !loading {
- telemetry::event!("Agent Thread Started", agent = server.telemetry_id());
- }
-
- this.update_in(cx, |this, window, cx| {
- let selected_agent = ext_agent.into();
- if this.selected_agent != selected_agent {
- this.selected_agent = selected_agent;
- this.serialize(cx);
- }
-
- let thread_view = cx.new(|cx| {
- crate::acp::AcpThreadView::new(
- server,
- resume_thread,
- summarize_thread,
- workspace.clone(),
- project,
- this.history_store.clone(),
- this.prompt_store.clone(),
- window,
- cx,
- )
- });
-
- this.set_active_view(ActiveView::ExternalAgentThread { thread_view }, window, cx);
- })
+ anyhow::Ok(())
})
.detach_and_log_err(cx);
}
@@ -957,7 +935,7 @@ impl AgentPanel {
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);
+ view.focus_handle(cx).focus(window, cx);
});
}
}
@@ -965,10 +943,10 @@ impl AgentPanel {
fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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.set_active_view(ActiveView::History, window, cx);
+ self.set_active_view(ActiveView::History, true, window, cx);
}
cx.notify();
}
@@ -1024,6 +1002,7 @@ impl AgentPanel {
window,
cx,
),
+ true,
window,
cx,
);
@@ -1037,12 +1016,12 @@ impl AgentPanel {
match &self.active_view {
ActiveView::ExternalAgentThread { thread_view } => {
- thread_view.focus_handle(cx).focus(window);
+ thread_view.focus_handle(cx).focus(window, cx);
}
ActiveView::TextThread {
text_thread_editor, ..
} => {
- text_thread_editor.focus_handle(cx).focus(window);
+ text_thread_editor.focus_handle(cx).focus(window, cx);
}
ActiveView::History | ActiveView::Configuration => {}
}
@@ -1169,7 +1148,7 @@ impl AgentPanel {
let context_server_store = self.project.read(cx).context_server_store();
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,
@@ -1190,7 +1169,7 @@ impl AgentPanel {
Self::handle_agent_configuration_event,
));
- configuration.focus_handle(cx).focus(window);
+ configuration.focus_handle(cx).focus(window, cx);
}
}
@@ -1286,6 +1265,7 @@ impl AgentPanel {
fn set_active_view(
&mut self,
new_view: ActiveView,
+ focus: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1324,7 +1304,9 @@ impl AgentPanel {
self.active_view = new_view;
}
- self.focus_handle(cx).focus(window);
+ if focus {
+ self.focus_handle(cx).focus(window, cx);
+ }
}
fn populate_recently_opened_menu_section(
@@ -1459,8 +1441,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,
@@ -1483,6 +1465,47 @@ impl AgentPanel {
cx,
);
}
+
+ fn _external_thread(
+ &mut self,
+ server: Rc<dyn AgentServer>,
+ resume_thread: Option<DbThreadMetadata>,
+ summarize_thread: Option<DbThreadMetadata>,
+ workspace: WeakEntity<Workspace>,
+ project: Entity<Project>,
+ loading: bool,
+ ext_agent: ExternalAgent,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let selected_agent = AgentType::from(ext_agent);
+ if self.selected_agent != selected_agent {
+ self.selected_agent = selected_agent;
+ self.serialize(cx);
+ }
+
+ let thread_view = cx.new(|cx| {
+ crate::acp::AcpThreadView::new(
+ server,
+ resume_thread,
+ summarize_thread,
+ workspace.clone(),
+ project,
+ self.history_store.clone(),
+ self.prompt_store.clone(),
+ !loading,
+ window,
+ cx,
+ )
+ });
+
+ self.set_active_view(
+ ActiveView::ExternalAgentThread { thread_view },
+ !loading,
+ window,
+ cx,
+ );
+ }
}
impl Focusable for AgentPanel {
@@ -1597,14 +1620,19 @@ impl AgentPanel {
let content = match &self.active_view {
ActiveView::ExternalAgentThread { thread_view } => {
+ let is_generating_title = thread_view
+ .read(cx)
+ .as_native_thread(cx)
+ .map_or(false, |t| t.read(cx).is_generating_title());
+
if let Some(title_editor) = thread_view.read(cx).title_editor() {
- div()
+ let container = div()
.w_full()
.on_action({
let thread_view = thread_view.downgrade();
move |_: &menu::Confirm, window, cx| {
if let Some(thread_view) = thread_view.upgrade() {
- thread_view.focus_handle(cx).focus(window);
+ thread_view.focus_handle(cx).focus(window, cx);
}
}
})
@@ -1612,12 +1640,25 @@ impl AgentPanel {
let thread_view = thread_view.downgrade();
move |_: &editor::actions::Cancel, window, cx| {
if let Some(thread_view) = thread_view.upgrade() {
- thread_view.focus_handle(cx).focus(window);
+ thread_view.focus_handle(cx).focus(window, cx);
}
}
})
- .child(title_editor)
- .into_any_element()
+ .child(title_editor);
+
+ if is_generating_title {
+ container
+ .with_animation(
+ "generating_title",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.4, 0.8)),
+ |div, delta| div.opacity(delta),
+ )
+ .into_any_element()
+ } else {
+ container.into_any_element()
+ }
} else {
Label::new(thread_view.read(cx).title(cx))
.color(Color::Muted)
@@ -1647,6 +1688,13 @@ impl AgentPanel {
Label::new(LOADING_SUMMARY_PLACEHOLDER)
.truncate()
.color(Color::Muted)
+ .with_animation(
+ "generating_title",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.4, 0.8)),
+ |label, delta| label.alpha(delta),
+ )
.into_any_element()
}
}
@@ -1690,6 +1738,25 @@ impl AgentPanel {
.into_any()
}
+ fn handle_regenerate_thread_title(thread_view: Entity<AcpThreadView>, cx: &mut App) {
+ thread_view.update(cx, |thread_view, cx| {
+ if let Some(thread) = thread_view.as_native_thread(cx) {
+ thread.update(cx, |thread, cx| {
+ thread.generate_title(cx);
+ });
+ }
+ });
+ }
+
+ fn handle_regenerate_text_thread_title(
+ text_thread_editor: Entity<TextThreadEditor>,
+ cx: &mut App,
+ ) {
+ text_thread_editor.update(cx, |text_thread_editor, cx| {
+ text_thread_editor.regenerate_summary(cx);
+ });
+ }
+
fn render_panel_options_menu(
&self,
window: &mut Window,
@@ -1709,6 +1776,35 @@ impl AgentPanel {
let selected_agent = self.selected_agent.clone();
+ let text_thread_view = match &self.active_view {
+ ActiveView::TextThread {
+ text_thread_editor, ..
+ } => Some(text_thread_editor.clone()),
+ _ => None,
+ };
+ let text_thread_with_messages = match &self.active_view {
+ ActiveView::TextThread {
+ text_thread_editor, ..
+ } => text_thread_editor
+ .read(cx)
+ .text_thread()
+ .read(cx)
+ .messages(cx)
+ .any(|message| message.role == language_model::Role::Assistant),
+ _ => false,
+ };
+
+ let thread_view = match &self.active_view {
+ ActiveView::ExternalAgentThread { thread_view } => Some(thread_view.clone()),
+ _ => None,
+ };
+ let thread_with_messages = match &self.active_view {
+ ActiveView::ExternalAgentThread { thread_view } => {
+ thread_view.read(cx).has_user_submitted_prompt(cx)
+ }
+ _ => false,
+ };
+
PopoverMenu::new("agent-options-menu")
.trigger_with_tooltip(
IconButton::new("agent-options-menu", IconName::Ellipsis)
@@ -1731,6 +1827,7 @@ impl AgentPanel {
move |window, cx| {
Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
menu = menu.context(focus_handle.clone());
+
if let Some(usage) = usage {
menu = menu
.header_with_link("Prompt Usage", "Manage", account_url.clone())
@@ -1768,6 +1865,38 @@ impl AgentPanel {
.separator()
}
+ if thread_with_messages | text_thread_with_messages {
+ menu = menu.header("Current Thread");
+
+ if let Some(text_thread_view) = text_thread_view.as_ref() {
+ menu = menu
+ .entry("Regenerate Thread Title", None, {
+ let text_thread_view = text_thread_view.clone();
+ move |_, cx| {
+ Self::handle_regenerate_text_thread_title(
+ text_thread_view.clone(),
+ cx,
+ );
+ }
+ })
+ .separator();
+ }
+
+ if let Some(thread_view) = thread_view.as_ref() {
+ menu = menu
+ .entry("Regenerate Thread Title", None, {
+ let thread_view = thread_view.clone();
+ move |_, cx| {
+ Self::handle_regenerate_thread_title(
+ thread_view.clone(),
+ cx,
+ );
+ }
+ })
+ .separator();
+ }
+ }
+
menu = menu
.header("MCP Servers")
.action(
@@ -1857,14 +1986,17 @@ impl AgentPanel {
let agent_server_store = self.project.read(cx).agent_server_store().clone();
let focus_handle = self.focus_handle(cx);
- // Get custom icon path for selected agent before building menu (to avoid borrow issues)
- let selected_agent_custom_icon =
+ let (selected_agent_custom_icon, selected_agent_label) =
if let AgentType::Custom { name, .. } = &self.selected_agent {
- agent_server_store
- .read(cx)
- .agent_icon(&ExternalAgentServerName(name.clone()))
+ 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
+ (None, self.selected_agent.label())
};
let active_thread = match &self.active_view {
@@ -1892,6 +2024,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| {
@@ -1905,7 +2040,6 @@ impl AgentPanel {
let active_thread = active_thread.clone();
Some(ContextMenu::build(window, cx, |menu, _window, cx| {
menu.context(focus_handle.clone())
- .header("Zed Agent")
.when_some(active_thread, |this, active_thread| {
let thread = active_thread.read(cx);
@@ -1929,9 +2063,11 @@ impl AgentPanel {
}
})
.item(
- ContextMenuEntry::new("New Thread")
- .action(NewThread.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();
@@ -1955,10 +2091,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| {
@@ -1983,7 +2119,10 @@ impl AgentPanel {
.separator()
.header("External Agents")
.item(
- ContextMenuEntry::new("New Claude Code")
+ 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)
@@ -2009,7 +2148,10 @@ impl AgentPanel {
}),
)
.item(
- ContextMenuEntry::new("New Codex CLI")
+ 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)
@@ -2035,7 +2177,10 @@ impl AgentPanel {
}),
)
.item(
- ContextMenuEntry::new("New Gemini CLI")
+ 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)
@@ -2061,8 +2206,8 @@ impl AgentPanel {
}),
)
.map(|mut menu| {
- let agent_server_store_read = agent_server_store.read(cx);
- let agent_names = agent_server_store_read
+ let agent_server_store = agent_server_store.read(cx);
+ let agent_names = agent_server_store
.external_agents()
.filter(|name| {
name.0 != GEMINI_NAME
@@ -2071,27 +2216,34 @@ impl AgentPanel {
})
.cloned()
.collect::<Vec<_>>();
- let custom_settings = cx
- .global::<SettingsStore>()
- .get::<AllAgentServersSettings>(None)
- .custom
- .clone();
+
for agent_name in agent_names {
- let icon_path = agent_server_store_read.agent_icon(&agent_name);
- let mut entry =
- ContextMenuEntry::new(format!("New {}", agent_name));
+ 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::Terminal);
+ 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();
- let custom_settings = custom_settings.clone();
move |window, cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
@@ -2104,17 +2256,6 @@ impl AgentPanel {
name: agent_name
.clone()
.into(),
- command: custom_settings
- .get(&agent_name.0)
- .map(|settings| {
- settings
- .command
- .clone()
- })
- .unwrap_or(
- placeholder_command(
- ),
- ),
},
window,
cx,
@@ -2125,6 +2266,7 @@ impl AgentPanel {
}
}
});
+
menu = menu.item(entry);
}
@@ -2150,30 +2292,41 @@ impl AgentPanel {
}
});
- 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(selected_agent_custom_icon, |this, icon_path| {
- let label = selected_agent_label.clone();
- this.px(DynamicSpacing::Base02.rems(cx))
+ this.px_1()
.child(Icon::from_external_svg(icon_path).color(Color::Muted))
- .tooltip(move |_window, cx| {
- Tooltip::with_meta(label.clone(), None, "Selected Agent", cx)
- })
})
.when(!has_custom_icon, |this| {
this.when_some(self.selected_agent.icon(), |this, icon| {
- let label = selected_agent_label.clone();
- this.px(DynamicSpacing::Base02.rems(cx))
- .child(Icon::new(icon).color(Color::Muted))
- .tooltip(move |_window, cx| {
- Tooltip::with_meta(label.clone(), None, "Selected Agent", cx)
- })
+ this.px_1().child(Icon::new(icon).color(Color::Muted))
})
})
- .into_any_element();
+ .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")
@@ -2539,6 +2692,38 @@ impl AgentPanel {
}
}
+ fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
+ if !self.show_trust_workspace_message {
+ return None;
+ }
+
+ let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
+
+ Some(
+ Callout::new()
+ .icon(IconName::Warning)
+ .severity(Severity::Warning)
+ .border_position(ui::BorderPosition::Bottom)
+ .title("You're in Restricted Mode")
+ .description(description)
+ .actions_slot(
+ Button::new("open-trust-modal", "Configure Project Trust")
+ .label_size(LabelSize::Small)
+ .style(ButtonStyle::Outlined)
+ .on_click({
+ cx.listener(move |this, _, window, cx| {
+ this.workspace
+ .update(cx, |workspace, cx| {
+ workspace
+ .show_worktree_trust_security_modal(true, window, cx)
+ })
+ .log_err();
+ })
+ }),
+ ),
+ )
+ }
+
fn key_context(&self) -> KeyContext {
let mut key_context = KeyContext::new_with_defaults();
key_context.add("AgentPanel");
@@ -2591,6 +2776,7 @@ impl Render for AgentPanel {
}
}))
.child(self.render_toolbar(window, cx))
+ .children(self.render_workspace_trust_message(cx))
.children(self.render_onboarding(window, cx))
.map(|parent| match &self.active_view {
ActiveView::ExternalAgentThread { thread_view, .. } => parent
@@ -2662,27 +2848,24 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
cx: &mut Context<RulesLibrary>,
) {
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 Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
return;
};
- let prompt_store = None;
- let thread_store = None;
- let context_store = cx.new(|_| ContextStore::new(project.clone()));
+ 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,
+ None,
initial_prompt,
window,
cx,
- )
+ );
})
}
@@ -1,17 +1,17 @@
-mod acp;
+pub mod acp;
mod agent_configuration;
mod agent_diff;
mod agent_model_selector;
mod agent_panel;
mod buffer_codegen;
+mod completion_provider;
mod context;
-mod context_picker;
mod context_server_configuration;
-mod context_store;
-mod context_strip;
+mod favorite_models;
mod inline_assistant;
mod inline_prompt_editor;
mod language_model_selector;
+mod mention_set;
mod profile_selector;
mod slash_command;
mod slash_command_picker;
@@ -27,15 +27,17 @@ 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 +56,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 +68,12 @@ actions!(
ToggleProfileSelector,
/// Cycles through available session modes.
CycleModeSelector,
- /// Removes all added context from the current conversation.
- RemoveAllContext,
+ /// Cycles through favorited models in the ACP model selector.
+ CycleFavoriteModels,
/// 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 +94,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.
@@ -159,31 +157,10 @@ pub enum ExternalAgent {
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 {
- pub fn parse_built_in(server: &dyn agent_servers::AgentServer) -> Option<Self> {
- match server.telemetry_id() {
- "gemini-cli" => Some(Self::Gemini),
- "claude-code" => Some(Self::ClaudeCode),
- "codex" => Some(Self::Codex),
- "zed" => Some(Self::NativeAgent),
- _ => None,
- }
- }
-
pub fn server(
&self,
fs: Arc<dyn fs::Fs>,
@@ -194,9 +171,7 @@ impl ExternalAgent {
Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
Self::Codex => Rc::new(agent_servers::Codex),
Self::NativeAgent => Rc::new(agent::NativeAgentServer::new(fs, history)),
- Self::Custom { name, command: _ } => {
- Rc::new(agent_servers::CustomAgentServer::new(name.clone()))
- }
+ Self::Custom { name } => Rc::new(agent_servers::CustomAgentServer::new(name.clone())),
}
}
}
@@ -231,11 +206,6 @@ impl ModelUsageContext {
}
}
}
-
- pub fn language_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
- self.configured_model(cx)
- .map(|configured_model| configured_model.model)
- }
}
/// Initializes the `agent` crate.
@@ -247,7 +217,7 @@ pub fn init(
is_eval: bool,
cx: &mut App,
) {
- assistant_text_thread::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
@@ -260,13 +230,8 @@ pub fn init(
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)
})
@@ -282,56 +247,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::<AgentV2FeatureFlag>();
+ 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::<AcceptEditPrediction>(),
+ TypeId::of::<AcceptNextWordEditPrediction>(),
+ TypeId::of::<AcceptNextLineEditPrediction>(),
+ TypeId::of::<AcceptEditPrediction>(),
+ TypeId::of::<ShowEditPrediction>(),
+ TypeId::of::<NextEditPrediction>(),
+ TypeId::of::<PreviousEditPrediction>(),
+ TypeId::of::<ToggleEditPrediction>(),
+ ];
+
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::<AcceptEditPrediction>(),
- TypeId::of::<AcceptPartialEditPrediction>(),
- TypeId::of::<ShowEditPrediction>(),
- TypeId::of::<NextEditPrediction>(),
- TypeId::of::<PreviousEditPrediction>(),
- TypeId::of::<ToggleEditPrediction>(),
- ];
filter.hide_action_types(&edit_prediction_actions);
filter.hide_action_types(&[TypeId::of::<zed_actions::OpenZedPredictOnboarding>()]);
} 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");
- 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::<AcceptEditPrediction>(),
- TypeId::of::<AcceptPartialEditPrediction>(),
- TypeId::of::<ShowEditPrediction>(),
- TypeId::of::<NextEditPrediction>(),
- TypeId::of::<PreviousEditPrediction>(),
- TypeId::of::<ToggleEditPrediction>(),
- ];
- filter.show_action_types(edit_prediction_actions.iter());
+ 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_action_types(&[TypeId::of::<zed_actions::OpenZedPredictOnboarding>()]);
+ if !agent_v2_enabled {
+ filter.hide_action_types(&[TypeId::of::<zed_actions::agent::ToggleAgentPane>()]);
+ }
}
});
}
@@ -420,3 +422,140 @@ 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![],
+ favorite_models: 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::<SettingsStore, _>(|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::<SettingsStore, _>(|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"
+ );
+ });
+ }
+}
@@ -1,26 +1,34 @@
-use crate::{
- context::load_context, context_store::ContextStore, inline_prompt_editor::CodegenStatus,
-};
+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,
@@ -33,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 <rewrite_this> 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 <rewrite_this></rewrite_this> 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<Entity<CodegenAlternative>>,
@@ -43,23 +70,20 @@ pub struct BufferCodegen {
buffer: Entity<MultiBuffer>,
range: Range<Anchor>,
initial_transaction_id: Option<TransactionId>,
- context_store: Entity<ContextStore>,
- project: WeakEntity<Project>,
- prompt_store: Option<Entity<PromptStore>>,
- telemetry: Arc<Telemetry>,
builder: Arc<PromptBuilder>,
pub is_insertion: bool,
+ session_id: Uuid,
}
+pub const REWRITE_SECTION_TOOL_NAME: &str = "rewrite_section";
+pub const FAILURE_MESSAGE_TOOL_NAME: &str = "failure_message";
+
impl BufferCodegen {
pub fn new(
buffer: Entity<MultiBuffer>,
range: Range<Anchor>,
initial_transaction_id: Option<TransactionId>,
- context_store: Entity<ContextStore>,
- project: WeakEntity<Project>,
- prompt_store: Option<Entity<PromptStore>>,
- telemetry: Arc<Telemetry>,
+ session_id: Uuid,
builder: Arc<PromptBuilder>,
cx: &mut Context<Self>,
) -> Self {
@@ -68,11 +92,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,
)
});
@@ -85,11 +106,8 @@ impl BufferCodegen {
buffer,
range,
initial_transaction_id,
- context_store,
- project,
- prompt_store,
- telemetry,
builder,
+ session_id,
};
this.activate(0, cx);
this
@@ -104,10 +122,18 @@ impl BufferCodegen {
.push(cx.subscribe(&codegen, |_, _, event, cx| cx.emit(*event)));
}
+ pub fn active_completion(&self, cx: &App) -> Option<String> {
+ self.active_alternative().read(cx).current_completion()
+ }
+
pub fn active_alternative(&self) -> &Entity<CodegenAlternative> {
&self.alternatives[self.active_alternative]
}
+ pub fn language_name(&self, cx: &App) -> Option<LanguageName> {
+ self.active_alternative().read(cx).language_name(cx)
+ }
+
pub fn status<'a>(&self, cx: &'a App) -> &'a CodegenStatus {
&self.active_alternative().read(cx).status
}
@@ -148,6 +174,7 @@ impl BufferCodegen {
&mut self,
primary_model: Arc<dyn LanguageModel>,
user_prompt: String,
+ context_task: Shared<Task<Option<LoadedContext>>>,
cx: &mut Context<Self>,
) -> Result<()> {
let alternative_models = LanguageModelRegistry::read_global(cx)
@@ -165,11 +192,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,
)
}));
@@ -180,7 +204,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)
})?;
}
@@ -228,6 +252,14 @@ impl BufferCodegen {
pub fn last_equal_ranges<'a>(&self, cx: &'a App) -> &'a [Range<Anchor>] {
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<CodegenEvent> for BufferCodegen {}
@@ -243,10 +275,6 @@ pub struct CodegenAlternative {
status: CodegenStatus,
generation: Task<()>,
diff: Diff,
- context_store: Option<Entity<ContextStore>>,
- project: WeakEntity<Project>,
- prompt_store: Option<Entity<PromptStore>>,
- telemetry: Option<Arc<Telemetry>>,
_subscription: gpui::Subscription,
builder: Arc<PromptBuilder>,
active: bool,
@@ -254,7 +282,11 @@ pub struct CodegenAlternative {
line_operations: Vec<LineOperation>,
elapsed_time: Option<f64>,
completion: Option<String>,
+ selected_text: Option<String>,
pub message_id: Option<String>,
+ session_id: Uuid,
+ pub description: Option<String>,
+ pub failure: Option<String>,
}
impl EventEmitter<CodegenEvent> for CodegenAlternative {}
@@ -264,11 +296,8 @@ impl CodegenAlternative {
buffer: Entity<MultiBuffer>,
range: Range<Anchor>,
active: bool,
- context_store: Option<Entity<ContextStore>>,
- project: WeakEntity<Project>,
- prompt_store: Option<Entity<PromptStore>>,
- telemetry: Option<Arc<Telemetry>>,
builder: Arc<PromptBuilder>,
+ session_id: Uuid,
cx: &mut Context<Self>,
) -> Self {
let snapshot = buffer.read(cx).snapshot(cx);
@@ -291,7 +320,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
});
@@ -307,21 +336,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<LanguageName> {
+ self.old_buffer
+ .read(cx)
+ .language()
+ .map(|language| language.name())
+ }
+
pub fn set_active(&mut self, active: bool, cx: &mut Context<Self>) {
if active != self.active {
self.active = active;
@@ -363,12 +399,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::<InlineAssistantUseToolFeatureFlag>()
+ && AgentSettings::get_global(cx).inline_assistant_use_streaming_tools
+ }
+
pub fn start(
&mut self,
user_prompt: String,
+ context_task: Shared<Task<Option<LoadedContext>>>,
model: Arc<dyn LanguageModel>,
cx: &mut Context<Self>,
) -> 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);
@@ -377,27 +423,39 @@ 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<Result<LanguageModelTextStream>> =
- 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<Result<LanguageModelTextStream>> =
+ 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, /* strip_invalid_spans: */ true, stream, cx);
+ }
+
Ok(())
}
- fn build_request(
+ fn build_request_tools(
&self,
model: &Arc<dyn LanguageModel>,
user_prompt: String,
+ context_task: Shared<Task<Option<LoadedContext>>>,
cx: &mut App,
) -> Result<Task<LanguageModelRequest>> {
let buffer = self.buffer.read(cx).snapshot(cx);
@@ -427,23 +485,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().and_then(|context_store| {
- if let Some(project) = self.project.upgrade() {
- let context = context_store
- .read(cx)
- .context()
- .cloned()
- .collect::<Vec<_>>();
- Some(load_context(context, &project, &self.prompt_store, cx))
- } else {
+ 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_TOOL_NAME.to_string(),
+ description: "Replaces text in <rewrite_this></rewrite_this> tags with your replacement_text.".to_string(),
+ input_schema: language_model::tool_schema::root_schema_for::<RewriteSectionInput>(tool_input_format).to_value(),
+ },
+ LanguageModelRequestTool {
+ name: FAILURE_MESSAGE_TOOL_NAME.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::<FailureMessageInput>(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<dyn LanguageModel>,
+ user_prompt: String,
+ context_task: Shared<Task<Option<LoadedContext>>>,
+ cx: &mut App,
+ ) -> Result<Task<LanguageModelRequest>> {
+ 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);
@@ -452,12 +606,11 @@ impl CodegenAlternative {
role: Role::User,
content: Vec::new(),
cache: false,
+ reasoning_details: None,
};
- if let Some(context_task) = context_task {
- context_task
- .await
- .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());
@@ -479,18 +632,31 @@ impl CodegenAlternative {
pub fn handle_stream(
&mut self,
- model_telemetry_id: String,
- model_provider_id: String,
- model_api_key: Option<String>,
+ model: Arc<dyn LanguageModel>,
+ strip_invalid_spans: bool,
stream: impl 'static + Future<Output = Result<LanguageModelTextStream>>,
cx: &mut Context<Self>,
- ) {
+ ) -> 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::<Rope>();
+ 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
@@ -512,8 +678,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);
@@ -530,8 +694,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()
@@ -544,17 +710,25 @@ 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<anyhow::Result<()>> =
- cx.background_spawn(async move {
+ let line_based_stream_diff: Task<anyhow::Result<()>> = 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 {
- let chunks = StripInvalidSpans::new(
- stream?.stream.map_err(|error| error.into()),
- );
- futures::pin_mut!(chunks);
+ let raw_stream = stream?.stream.map_err(|error| error.into());
+
+ let stripped;
+ let mut chunks: Pin<Box<dyn Stream<Item = Result<String>> + Send>> =
+ if strip_invalid_spans {
+ stripped = StripInvalidSpans::new(raw_stream);
+ Box::pin(stripped)
+ } else {
+ Box::pin(raw_stream)
+ };
+
let mut diff = StreamingDiff::new(selected_text.to_string());
let mut line_diff = LineDiff::default();
@@ -643,27 +817,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| {
@@ -741,12 +918,30 @@ impl CodegenAlternative {
output_tokens = usage.output_tokens,
)
}
+
cx.emit(CodegenEvent::Finished);
cx.notify();
})
.ok();
- });
- cx.notify();
+ })
+ }
+
+ pub fn current_completion(&self) -> Option<String> {
+ self.completion.clone()
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn current_description(&self) -> Option<String> {
+ self.description.clone()
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn current_failure(&self) -> Option<String> {
+ self.failure.clone()
+ }
+
+ pub fn selected_text(&self) -> Option<&str> {
+ self.selected_text.as_deref()
}
pub fn stop(&mut self, cx: &mut Context<Self>) {
@@ -920,6 +1115,224 @@ impl CodegenAlternative {
.ok();
})
}
+
+ fn handle_completion(
+ &mut self,
+ model: Arc<dyn LanguageModel>,
+ completion_stream: Task<
+ Result<
+ BoxStream<
+ 'static,
+ Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
+ >,
+ LanguageModelCompletionError,
+ >,
+ >,
+ cx: &mut Context<Self>,
+ ) -> 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<String>,
+ },
+ 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<ToolUseOutput> {
+ let mut chars_read_so_far = chars_read_so_far.lock();
+ match tool_use.name.as_ref() {
+ REWRITE_SECTION_TOOL_NAME => {
+ let Ok(input) =
+ serde_json::from_value::<RewriteSectionInput>(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_TOOL_NAME => {
+ let Ok(mut input) =
+ serde_json::from_value::<FailureMessageInput>(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::<ModelUpdate>();
+
+ 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,
+ /* strip_invalid_spans: */ false,
+ async { Ok(language_model_text_stream) },
+ cx,
+ )
+ })
+ .ok()
+ else {
+ return;
+ };
+
+ task.await;
+ })
+ }
}
#[derive(Copy, Clone, Debug)]
@@ -1075,15 +1488,19 @@ 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, tree_sitter_rust};
- use language_model::{LanguageModelRegistry, TokenUsage};
+ use language::{Buffer, Point};
+ use language_model::fake_provider::FakeLanguageModel;
+ use language_model::{
+ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRegistry,
+ LanguageModelToolUse, StopReason, TokenUsage,
+ };
+ use languages::rust_lang;
use rand::prelude::*;
use settings::SettingsStore;
use std::{future, sync::Arc};
@@ -1100,25 +1517,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,
)
});
@@ -1167,25 +1579,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,
)
});
@@ -1236,25 +1643,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,
)
});
@@ -1,41 +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::{HistoryEntry, HistoryStore};
-use agent_client_protocol as acp;
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, 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::{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;
-use crate::context_picker::file_context_picker::{FileMatch, search_files};
-use crate::context_picker::rules_context_picker::{RulesContextEntry, search_rules};
-use crate::context_picker::symbol_context_picker::SymbolMatch;
-use crate::context_picker::symbol_context_picker::search_symbols;
-use crate::context_picker::thread_context_picker::search_threads;
-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<Self, Self::Error> {
+ 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),
@@ -47,11 +139,6 @@ pub(crate) enum Match {
Entry(EntryMatch),
}
-pub struct EntryMatch {
- mat: Option<StringMatch>,
- entry: ContextPickerEntry,
-}
-
impl Match {
pub fn score(&self) -> f64 {
match self {
@@ -66,58 +153,95 @@ impl Match {
}
}
-pub struct ContextPickerCompletionProvider {
- message_editor: WeakEntity<MessageEditor>,
- workspace: WeakEntity<Workspace>,
+pub struct EntryMatch {
+ mat: Option<StringMatch>,
+ entry: PromptContextEntry,
+}
+
+#[derive(Debug, Clone)]
+pub struct RulesContextEntry {
+ pub prompt_id: UserPromptId,
+ pub title: SharedString,
+}
+
+#[derive(Debug, Clone)]
+pub struct AvailableCommand {
+ pub name: Arc<str>,
+ pub description: Arc<str>,
+ 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<PromptContextType>;
+ fn supports_images(&self, cx: &App) -> bool;
+
+ fn available_commands(&self, cx: &App) -> Vec<AvailableCommand>;
+ fn confirm_command(&self, cx: &mut App);
+}
+
+pub struct PromptCompletionProvider<T: PromptCompletionProviderDelegate> {
+ source: Arc<T>,
+ editor: WeakEntity<Editor>,
+ mention_set: Entity<MentionSet>,
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
- prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
- available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
+ workspace: WeakEntity<Workspace>,
}
-impl ContextPickerCompletionProvider {
+impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
pub fn new(
- message_editor: WeakEntity<MessageEditor>,
- workspace: WeakEntity<Workspace>,
+ source: T,
+ editor: WeakEntity<Editor>,
+ mention_set: Entity<MentionSet>,
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
- prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
- available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
+ workspace: WeakEntity<Workspace>,
) -> 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<Anchor>,
- message_editor: WeakEntity<MessageEditor>,
+ editor: WeakEntity<Editor>,
+ mention_set: WeakEntity<MentionSet>,
workspace: &Entity<Workspace>,
cx: &mut App,
) -> Option<Completion> {
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,
+ ),
}
}
@@ -125,7 +249,10 @@ impl ContextPickerCompletionProvider {
thread_entry: HistoryEntry,
source_range: Range<Anchor>,
recent: bool,
- editor: WeakEntity<MessageEditor>,
+ source: Arc<T>,
+ editor: WeakEntity<Editor>,
+ mention_set: WeakEntity<MentionSet>,
+ workspace: Entity<Workspace>,
cx: &mut App,
) -> Completion {
let uri = thread_entry.mention_uri();
@@ -146,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,
)),
}
}
@@ -160,7 +292,10 @@ impl ContextPickerCompletionProvider {
fn completion_for_rules(
rule: RulesContextEntry,
source_range: Range<Anchor>,
- editor: WeakEntity<MessageEditor>,
+ source: Arc<T>,
+ editor: WeakEntity<Editor>,
+ mention_set: WeakEntity<MentionSet>,
+ workspace: Entity<Workspace>,
cx: &mut App,
) -> Completion {
let uri = MentionUri::Rule {
@@ -177,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,
)),
}
}
@@ -194,20 +334,25 @@ impl ContextPickerCompletionProvider {
is_recent: bool,
is_directory: bool,
source_range: Range<Anchor>,
- message_editor: WeakEntity<MessageEditor>,
+ source: Arc<T>,
+ editor: WeakEntity<Editor>,
+ mention_set: WeakEntity<MentionSet>,
+ workspace: Entity<Workspace>,
project: Entity<Project>,
+ label_max_chars: usize,
cx: &mut App,
) -> Option<Completion> {
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)?;
@@ -233,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,
)),
})
}
@@ -247,8 +397,11 @@ impl ContextPickerCompletionProvider {
fn completion_for_symbol(
symbol: Symbol,
source_range: Range<Anchor>,
- message_editor: WeakEntity<MessageEditor>,
+ source: Arc<T>,
+ editor: WeakEntity<Editor>,
+ mention_set: WeakEntity<MentionSet>,
workspace: Entity<Workspace>,
+ label_max_chars: usize,
cx: &mut App,
) -> Option<Completion> {
let project = workspace.read(cx).project().clone();
@@ -267,7 +420,13 @@ impl ContextPickerCompletionProvider {
),
};
- let label = build_symbol_label(&symbol.name, &file_name, symbol.range.start.0.row + 1, cx);
+ 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,
@@ -284,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,
)),
})
}
@@ -298,7 +462,10 @@ impl ContextPickerCompletionProvider {
fn completion_for_fetch(
source_range: Range<Anchor>,
url_to_fetch: SharedString,
- message_editor: WeakEntity<MessageEditor>,
+ source: Arc<T>,
+ editor: WeakEntity<Editor>,
+ mention_set: WeakEntity<MentionSet>,
+ workspace: Entity<Workspace>,
cx: &mut App,
) -> Option<Completion> {
let new_text = format!("@fetch {} ", url_to_fetch);
@@ -316,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<Anchor>,
- message_editor: WeakEntity<MessageEditor>,
+ editor: WeakEntity<Editor>,
+ mention_set: WeakEntity<MentionSet>,
workspace: &Entity<Workspace>,
cx: &mut App,
) -> Option<Completion> {
let (new_text, on_action) = match action {
- ContextPickerAction::AddSelections => {
+ PromptContextAction::AddSelections => {
const PLACEHOLDER: &str = "selection ";
let selections = selection_ranges(workspace, cx)
.into_iter()
@@ -354,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
}
@@ -384,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
@@ -392,12 +571,8 @@ impl ContextPickerCompletionProvider {
})
}
- fn search_slash_commands(
- &self,
- query: String,
- cx: &mut App,
- ) -> Task<Vec<acp::AvailableCommand>> {
- let commands = self.available_commands.borrow().clone();
+ fn search_slash_commands(&self, query: String, cx: &mut App) -> Task<Vec<AvailableCommand>> {
+ let commands = self.source.available_commands(cx);
if commands.is_empty() {
return Task::ready(Vec::new());
}
@@ -429,7 +604,7 @@ impl ContextPickerCompletionProvider {
fn search_mentions(
&self,
- mode: Option<ContextPickerMode>,
+ mode: Option<PromptContextType>,
query: String,
cancellation_flag: Arc<AtomicBool>,
cx: &mut App,
@@ -438,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
@@ -449,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
@@ -460,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 {
@@ -472,7 +647,7 @@ impl ContextPickerCompletionProvider {
})
}
- Some(ContextPickerMode::Fetch) => {
+ Some(PromptContextType::Fetch) => {
if !query.is_empty() {
Task::ready(vec![Match::Fetch(query.into())])
} else {
@@ -480,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);
@@ -570,9 +745,8 @@ 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;
@@ -623,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
@@ -644,15 +818,14 @@ impl ContextPickerCompletionProvider {
&self,
workspace: &Entity<Workspace>,
cx: &mut App,
- ) -> Vec<ContextPickerEntry> {
- let embedded_context = self.prompt_capabilities.borrow().embedded_context;
+ ) -> Vec<PromptContextEntry> {
let mut entries = vec![
- ContextPickerEntry::Mode(ContextPickerMode::File),
- ContextPickerEntry::Mode(ContextPickerMode::Symbol),
+ PromptContextEntry::Mode(PromptContextType::File),
+ PromptContextEntry::Mode(PromptContextType::Symbol),
];
- if embedded_context {
- entries.push(ContextPickerEntry::Mode(ContextPickerMode::Thread));
+ if self.source.supports_context(PromptContextType::Thread, cx) {
+ entries.push(PromptContextEntry::Mode(PromptContextType::Thread));
}
let has_selection = workspace
@@ -665,69 +838,41 @@ impl ContextPickerCompletionProvider {
})
});
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_symbol_label(symbol_name: &str, file_name: &str, line: u32, cx: &App) -> CodeLabel {
- let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
- let mut label = CodeLabelBuilder::default();
-
- label.push_str(symbol_name, None);
- label.push_str(" ", None);
- label.push_str(&format!("{} L{}", file_name, line), comment_id);
-
- label.build()
-}
-
-fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel {
- let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
- let mut label = CodeLabelBuilder::default();
-
- label.push_str(file_name, None);
- label.push_str(" ", None);
-
- if let Some(directory) = directory {
- label.push_str(directory, comment_id);
- }
-
- label.build()
-}
-
-impl CompletionProvider for ContextPickerCompletionProvider {
+impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletionProvider<T> {
fn completions(
&self,
_excerpt_id: ExcerptId,
buffer: &Entity<Buffer>,
buffer_position: Anchor,
_trigger: CompletionContext,
- _window: &mut Window,
+ window: &mut Window,
cx: &mut Context<Editor>,
) -> Task<Result<Vec<CompletionResponse>>> {
- 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()));
@@ -742,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);
@@ -760,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,
@@ -770,28 +917,22 @@ 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 {
- editor.send(cx);
- }
- }
- CompletionIntent::Compose => {}
- }
- })
- .ok();
+ let source = source.clone();
+ move |cx| match intent {
+ CompletionIntent::Complete
+ | CompletionIntent::CompleteWithInsert
+ | CompletionIntent::CompleteWithReplace => {
+ source.confirm_command(cx);
+ }
+ CompletionIntent::Compose => {}
}
});
}
@@ -813,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::<AtomicBool>::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;
@@ -849,8 +1015,12 @@ impl CompletionProvider for ContextPickerCompletionProvider {
is_recent,
mat.is_dir,
source_range.clone(),
+ source.clone(),
editor.clone(),
+ mention_set.clone(),
+ workspace.clone(),
project.clone(),
+ label_max_chars,
cx,
)
}
@@ -859,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,
)
}
@@ -869,7 +1042,10 @@ impl CompletionProvider for ContextPickerCompletionProvider {
thread,
source_range.clone(),
false,
+ source.clone(),
editor.clone(),
+ mention_set.clone(),
+ workspace.clone(),
cx,
)),
@@ -877,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,
),
@@ -900,6 +1085,7 @@ impl CompletionProvider for ContextPickerCompletionProvider {
entry,
source_range.clone(),
editor.clone(),
+ mention_set.clone(),
&workspace,
cx,
)
@@ -928,7 +1114,6 @@ impl CompletionProvider for ContextPickerCompletionProvider {
position: language::Anchor,
_text: &str,
_trigger_in_words: bool,
- _menu_is_open: bool,
cx: &mut Context<Editor>,
) -> bool {
let buffer = buffer.read(cx);
@@ -937,27 +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,
- )
- .filter(|completion| {
- // Right now we don't support completing arguments of slash commands
- let is_slash_command_with_argument = matches!(
- completion,
- ContextCompletion::SlashCommand(SlashCommandCompletion {
- argument: Some(_),
- ..
- })
- );
- !is_slash_command_with_argument
- })
- .map(|completion| {
- completion.source_range().start <= offset_to_line + position.column as usize
- && completion.source_range().end >= offset_to_line + position.column as usize
- })
- .unwrap_or(false)
+ 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
}
@@ -972,44 +1154,56 @@ impl CompletionProvider for ContextPickerCompletionProvider {
}
}
-fn confirm_completion_callback(
+fn confirm_completion_callback<T: PromptCompletionProviderDelegate>(
crease_text: SharedString,
start: Anchor,
content_len: usize,
- message_editor: WeakEntity<MessageEditor>,
mention_uri: MentionUri,
+ source: Arc<T>,
+ editor: WeakEntity<Editor>,
+ mention_set: WeakEntity<MentionSet>,
+ workspace: Entity<Workspace>,
) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> 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<usize> {
match self {
Self::SlashCommand(completion) => completion.source_range.clone(),
@@ -1,764 +1,10 @@
-use agent::outline;
-use assistant_text_thread::TextThread;
-use futures::future;
-use futures::{FutureExt, future::Shared};
-use gpui::{App, AppContext as _, ElementId, Entity, SharedString, Task};
-use language::Buffer;
+use crate::mention_set::Mention;
+use gpui::{AppContext as _, Entity, Task};
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 ui::IconName;
-use util::markdown::MarkdownCodeBlock;
-use util::rel_path::RelPath;
-use util::{ResultExt as _, post_inc};
+use ui::App;
+use util::ResultExt as _;
-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<Buffer>` 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<Buffer>,
- 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<H: Hasher>(&self, state: &mut H) {
- self.buffer.hash(state)
- }
-
- pub fn project_path(&self, cx: &App) -> Option<ProjectPath> {
- 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<Option<AgentContext>> {
- 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)
- })
- }
-}
-
-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<DirectoryContextDescendant>,
-}
-
-#[derive(Debug, Clone)]
-pub struct DirectoryContextDescendant {
- /// Path within the directory.
- pub rel_path: Arc<RelPath>,
- 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<H: Hasher>(&self, state: &mut H) {
- self.entry_id.hash(state)
- }
-
- fn load(self, project: Entity<Project>, cx: &mut App) -> Task<Option<AgentContext>> {
- 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)
- })
- }));
-
- cx.background_spawn(async move {
- let descendants = descendants_future
- .await
- .into_iter()
- .flatten()
- .collect::<Vec<_>>();
- let context = AgentContext::Directory(DirectoryContext {
- handle: self,
- full_path: directory_full_path,
- descendants,
- });
- Some(context)
- })
- }
-}
-
-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<Buffer>,
- pub symbol: SharedString,
- pub range: Range<Anchor>,
- /// 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<Anchor>,
- pub context_id: ContextId,
-}
-
-#[derive(Debug, Clone)]
-pub struct SymbolContext {
- pub handle: SymbolContextHandle,
- pub full_path: String,
- pub line_range: Range<Point>,
- 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<H: Hasher>(&self, state: &mut H) {
- self.buffer.hash(state);
- self.symbol.hash(state);
- self.range.hash(state);
- }
-
- pub fn full_path(&self, cx: &App) -> Option<PathBuf> {
- Some(self.buffer.read(cx).file()?.full_path(cx))
- }
-
- pub fn enclosing_line_range(&self, cx: &App) -> Range<Point> {
- 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::<String>()
- .into()
- }
-
- fn load(self, cx: &App) -> Task<Option<AgentContext>> {
- 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 context = AgentContext::Symbol(SymbolContext {
- handle: self,
- full_path,
- line_range,
- text,
- });
- Task::ready(Some(context))
- }
-}
-
-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<Buffer>,
- pub range: Range<Anchor>,
- pub context_id: ContextId,
-}
-
-#[derive(Debug, Clone)]
-pub struct SelectionContext {
- pub handle: SelectionContextHandle,
- pub full_path: String,
- pub line_range: Range<Point>,
- 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<H: Hasher>(&self, state: &mut H) {
- self.buffer.hash(state);
- self.range.hash(state);
- }
-
- pub fn full_path(&self, cx: &App) -> Option<PathBuf> {
- Some(self.buffer.read(cx).file()?.full_path(cx))
- }
-
- pub fn line_range(&self, cx: &App) -> Range<Point> {
- 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::<String>()
- .into()
- }
-
- fn load(self, cx: &App) -> Task<Option<AgentContext>> {
- 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 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))
- }
-}
-
-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<H: Hasher>(&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<Option<AgentContext>> {
- Task::ready(Some(AgentContext::FetchedUrl(self)))
- }
-}
-
-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<agent::Thread>,
- 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<H: Hasher>(&self, state: &mut H) {
- self.thread.hash(state)
- }
-
- pub fn title(&self, cx: &App) -> SharedString {
- self.thread.read(cx).title()
- }
-
- fn load(self, cx: &mut App) -> Task<Option<AgentContext>> {
- let task = self.thread.update(cx, |thread, cx| thread.summary(cx));
- let title = self.title(cx);
- cx.background_spawn(async move {
- let text = task.await?;
- let context = AgentContext::Thread(ThreadContext {
- title,
- text,
- handle: self,
- });
- Some(context)
- })
- }
-}
-
-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 text_thread: Entity<TextThread>,
- 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.text_thread == other.text_thread
- }
-
- pub fn hash_for_key<H: Hasher>(&self, state: &mut H) {
- self.text_thread.hash(state)
- }
-
- pub fn title(&self, cx: &App) -> SharedString {
- self.text_thread.read(cx).summary().or_default()
- }
-
- fn load(self, cx: &App) -> Task<Option<AgentContext>> {
- let title = self.title(cx);
- let text = self.text_thread.read(cx).to_xml(cx);
- let context = AgentContext::TextThread(TextThreadContext {
- title,
- text: text.into(),
- handle: self,
- });
- Task::ready(Some(context))
- }
-}
-
-impl Display for TextThreadContext {
- fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
- write!(f, "<text_thread title=\"")?;
- for c in self.title.chars() {
- match c {
- '&' => write!(f, "&")?,
- '<' => write!(f, "<")?,
- '>' => write!(f, ">")?,
- '"' => write!(f, """)?,
- '\'' => write!(f, "'")?,
- _ => write!(f, "{}", c)?,
- }
- }
- writeln!(f, "\">")?;
- write!(f, "{}", self.text.trim())?;
- write!(f, "\n</text_thread>")
- }
-}
-
-#[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<SharedString>,
- 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<H: Hasher>(&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<Entity<PromptStore>>,
- cx: &App,
- ) -> Task<Option<AgentContext>> {
- 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)
- })
- }
-}
-
-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<ProjectPath>,
- pub full_path: Option<String>,
- pub original_image: Arc<gpui::Image>,
- // 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<Task<Option<LanguageModelImage>>>,
- 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<H: Hasher>(&self, state: &mut H) {
- self.original_image.id().hash(state);
- }
-
- pub fn image(&self) -> Option<LanguageModelImage> {
- self.image_task.clone().now_or_never().flatten()
- }
-
- pub fn status(&self, model: Option<&Arc<dyn language_model::LanguageModel>>) -> 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<Option<AgentContext>> {
- cx.background_spawn(async move {
- self.image_task.clone().await;
- Some(AgentContext::Image(self))
- })
- }
-}
+use crate::mention_set::MentionSet;
#[derive(Debug, Clone, Default)]
pub struct LoadedContext {
@@ -792,382 +38,26 @@ impl LoadedContext {
}
/// Loads and formats a collection of contexts.
-pub fn load_context(
- contexts: Vec<AgentContextHandle>,
- project: &Entity<Project>,
- prompt_store: &Option<Entity<PromptStore>>,
- cx: &mut App,
-) -> Task<LoadedContext> {
- 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();
-
+pub fn load_context(mention_set: &Entity<MentionSet>, cx: &mut App) -> Task<Option<LoadedContext>> {
+ let task = mention_set.update(cx, |mention_set, cx| mention_set.contents(true, cx));
cx.background_spawn(async move {
- let load_results = future::join_all(load_tasks).await;
-
- let mut text = String::new();
-
- 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 load_results.into_iter().flatten() {
- 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 LoadedContext { text, images };
- }
-
- text.push_str(
- "\n<context>\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("<files>");
- for context in file_context {
- text.push('\n');
- let _ = write!(text, "{context}");
- }
- text.push_str("</files>\n");
- }
-
- if !directory_context.is_empty() {
- text.push_str("<directories>");
- for context in directory_context {
- text.push('\n');
- let _ = write!(text, "{context}");
- }
- text.push_str("</directories>\n");
- }
-
- if !symbol_context.is_empty() {
- text.push_str("<symbols>");
- for context in symbol_context {
- text.push('\n');
- let _ = write!(text, "{context}");
- }
- text.push_str("</symbols>\n");
- }
-
- if !selection_context.is_empty() {
- text.push_str("<selections>");
- for context in selection_context {
- text.push('\n');
- let _ = write!(text, "{context}");
- }
- text.push_str("</selections>\n");
- }
-
- if !fetched_url_context.is_empty() {
- text.push_str("<fetched_urls>");
- for context in fetched_url_context {
- text.push('\n');
- let _ = write!(text, "{context}");
- }
- text.push_str("</fetched_urls>\n");
- }
-
- if !thread_context.is_empty() {
- text.push_str("<conversation_threads>");
- for context in thread_context {
- text.push('\n');
- let _ = write!(text, "{context}");
- }
- text.push_str("</conversation_threads>\n");
- }
-
- if !text_thread_context.is_empty() {
- text.push_str("<text_threads>");
- for context in text_thread_context {
- text.push('\n');
- let _ = writeln!(text, "{context}");
- }
- text.push_str("<text_threads>");
- }
-
- if !rules_context.is_empty() {
- text.push_str(
- "<user_rules>\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("</user_rules>\n");
- }
-
- text.push_str("</context>\n");
-
- LoadedContext { text, images }
+ 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)
})
}
-
-fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<Arc<RelPath>> {
- 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<Range<Point>>) -> 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<AgentContextHandle> 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<H: Hasher>(&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);
- });
- }
-
- // Helper to create a test project with test files
- async fn create_test_project(
- cx: &mut TestAppContext,
- files: serde_json::Value,
- ) -> Entity<Project> {
- 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 = load_context_for("file.txt", large_content, cx).await;
-
- assert!(
- file_context
- .text
- .contains(&format!("# File outline for {}", path!("test/file.txt"))),
- "Large files should not get an outline"
- );
-
- 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 = load_context_for("file.txt", small_content.to_string(), cx).await;
-
- assert!(
- !file_context
- .text
- .contains(&format!("# File outline for {}", path!("test/file.txt"))),
- "Small files should not get an outline"
- );
-
- assert!(
- file_context.text.contains(small_content),
- "Small files should use full content"
- );
- }
-
- async fn load_context_for(
- filename: &str,
- content: String,
- cx: &mut TestAppContext,
- ) -> LoadedContext {
- // Create a test project with the file
- let project = create_test_project(
- cx,
- json!({
- filename: content,
- }),
- )
- .await;
-
- // Open the buffer
- let buffer_path = project
- .read_with(cx, |project, cx| project.find_project_path(filename, 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
- }
-}
@@ -1,931 +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 agent::{HistoryEntry, HistoryEntryId, HistoryStore};
-use agent_client_protocol as acp;
-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::render_thread_context_entry;
-use ui::{
- ButtonLike, ContextMenu, ContextMenuEntry, ContextMenuItem, Disclosure, TintColor, prelude::*,
-};
-use util::paths::PathStyle;
-use util::rel_path::RelPath;
-use workspace::{Workspace, notifications::NotifyResultExt};
-
-use crate::context_picker::thread_context_picker::ThreadContextPicker;
-use crate::{context::RULES_ICON, context_store::ContextStore};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) enum ContextPickerEntry {
- 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<Self, Self::Error> {
- 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<ContextMenu>),
- File(Entity<FileContextPicker>),
- Symbol(Entity<SymbolContextPicker>),
- Fetch(Entity<FetchContextPicker>),
- Thread(Entity<ThreadContextPicker>),
- Rules(Entity<RulesContextPicker>),
-}
-
-pub(super) struct ContextPicker {
- mode: ContextPickerState,
- workspace: WeakEntity<Workspace>,
- context_store: WeakEntity<ContextStore>,
- thread_store: Option<WeakEntity<HistoryStore>>,
- prompt_store: Option<WeakEntity<PromptStore>>,
- _subscriptions: Vec<Subscription>,
-}
-
-impl ContextPicker {
- pub fn new(
- workspace: WeakEntity<Workspace>,
- thread_store: Option<WeakEntity<HistoryStore>>,
- prompt_store: Option<WeakEntity<PromptStore>>,
- context_store: WeakEntity<ContextStore>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> 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::<Vec<Subscription>>();
-
- ContextPicker {
- mode: ContextPickerState::Default(ContextMenu::build(
- window,
- cx,
- |menu, _window, _cx| menu,
- )),
- workspace,
- context_store,
- thread_store,
- prompt_store,
- _subscriptions: subscriptions,
- }
- }
-
- pub fn init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- self.mode = ContextPickerState::Default(self.build_menu(window, cx));
- cx.notify();
- }
-
- fn build_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Entity<ContextMenu> {
- 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::<Vec<_>>();
-
- 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<Self>,
- ) {
- 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) = self.thread_store.clone() {
- self.mode = ContextPickerState::Thread(cx.new(|cx| {
- ThreadContextPicker::new(
- thread_store,
- context_picker.clone(),
- self.context_store.clone(),
- self.workspace.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<Self>) {
- // 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<ContextPicker>,
- 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<Self>,
- ) {
- 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: HistoryEntry,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Task<Result<()>> {
- let Some(context_store) = self.context_store.upgrade() else {
- return Task::ready(Err(anyhow!("context store not available")));
- };
- let Some(project) = self
- .workspace
- .upgrade()
- .map(|workspace| workspace.read(cx).project().clone())
- else {
- return Task::ready(Err(anyhow!("project not available")));
- };
-
- match entry {
- HistoryEntry::AcpThread(thread) => {
- 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 load_thread_task =
- agent::load_agent_thread(thread.id, thread_store, project, cx);
- cx.spawn(async move |this, cx| {
- let thread = load_thread_task.await?;
- context_store.update(cx, |context_store, cx| {
- context_store.add_thread(thread, true, cx);
- })?;
- this.update(cx, |_this, cx| cx.notify())
- })
- }
- HistoryEntry::TextThread(thread) => {
- let Some(thread_store) = self
- .thread_store
- .as_ref()
- .and_then(|thread_store| thread_store.upgrade())
- else {
- return Task::ready(Err(anyhow!("text thread store not available")));
- };
-
- let task = thread_store.update(cx, |this, cx| {
- this.load_text_thread(thread.path.clone(), cx)
- });
- cx.spawn(async move |this, cx| {
- let thread = task.await?;
- context_store.update(cx, |context_store, cx| {
- context_store.add_text_thread(thread, true, cx);
- })?;
- this.update(cx, |_this, cx| cx.notify())
- })
- }
- }
- }
-
- fn recent_entries(&self, cx: &mut App) -> Vec<RecentEntry> {
- 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(),
- workspace,
- None,
- cx,
- )
- }
-
- fn notify_current_picker(&mut self, cx: &mut Context<Self>) {
- 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<DismissEvent> 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<Self>) -> 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<RelPath>,
- },
- Thread(HistoryEntry),
-}
-
-pub(crate) fn available_context_picker_entries(
- prompt_store: &Option<WeakEntity<PromptStore>>,
- thread_store: &Option<WeakEntity<HistoryStore>>,
- workspace: &Entity<Workspace>,
- cx: &mut App,
-) -> Vec<ContextPickerEntry> {
- 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::<Editor>())
- .is_some_and(|editor| {
- editor.update(cx, |editor, cx| {
- editor.has_non_empty_selection(&editor.display_snapshot(cx))
- })
- });
- if has_selection {
- entries.push(ContextPickerEntry::Action(
- ContextPickerAction::AddSelections,
- ));
- }
-
- if 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<ContextStore>,
- thread_store: Option<WeakEntity<HistoryStore>>,
- workspace: Entity<Workspace>,
- exclude_path: Option<ProjectPath>,
- cx: &App,
-) -> Vec<RecentEntry> {
- 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, workspace, &exclude_paths, exclude_threads, cx)
-}
-
-pub(crate) fn recent_context_picker_entries(
- thread_store: Option<WeakEntity<HistoryStore>>,
- workspace: Entity<Workspace>,
- exclude_paths: &HashSet<PathBuf>,
- exclude_threads: &HashSet<acp::SessionId>,
- cx: &App,
-) -> Vec<RecentEntry> {
- let mut recent = Vec::with_capacity(6);
- let workspace = workspace.read(cx);
- let project = workspace.project().read(cx);
- let include_root_name = workspace.visible_worktrees(cx).count() > 1;
-
- recent.extend(
- workspace
- .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| {
- let path_prefix = if include_root_name {
- worktree.read(cx).root_name().into()
- } else {
- RelPath::empty().into()
- };
- RecentEntry::File {
- project_path,
- path_prefix,
- }
- })
- }),
- );
-
- if let Some(thread_store) = thread_store.and_then(|store| store.upgrade()) {
- const RECENT_THREADS_COUNT: usize = 2;
- recent.extend(
- thread_store
- .read(cx)
- .recently_opened_entries(cx)
- .iter()
- .filter(|e| match e.id() {
- HistoryEntryId::AcpThread(session_id) => !exclude_threads.contains(&session_id),
- HistoryEntryId::TextThread(path) => {
- !exclude_paths.contains(&path.to_path_buf())
- }
- })
- .take(RECENT_THREADS_COUNT)
- .map(|thread| RecentEntry::Thread(thread.clone())),
- );
- }
-
- recent
-}
-
-fn add_selections_as_context(
- context_store: &Entity<ContextStore>,
- workspace: &Entity<Workspace>,
- 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<Workspace>,
- cx: &mut App,
-) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
- let Some(editor) = workspace
- .read(cx)
- .active_item(cx)
- .and_then(|item| item.act_as::<Editor>(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::<Vec<_>>()
- })
-}
-
-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<Editor>,
- window: &mut Window,
- cx: &mut App,
-) -> Option<CreaseId> {
- 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<Anchor>,
- editor_entity: WeakEntity<Editor>,
-) -> Crease<Anchor> {
- 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<Editor>,
-) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &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<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
- &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<usize>) -> 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: &HistoryEntry) -> String {
- match thread {
- HistoryEntry::AcpThread(thread) => {
- format!("[@{}]({}:{})", thread.title, Self::THREAD, thread.id)
- }
- HistoryEntry::TextThread(thread) => {
- let filename = thread
- .path
- .file_name()
- .unwrap_or_default()
- .to_string_lossy();
- let escaped_filename = urlencoding::encode(&filename);
- format!(
- "[@{}]({}:{}{})",
- thread.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)
- }
-}
@@ -1,1687 +0,0 @@
-use std::ops::Range;
-use std::path::{Path, PathBuf};
-use std::sync::Arc;
-use std::sync::atomic::AtomicBool;
-
-use agent::{HistoryEntry, HistoryStore};
-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, CodeLabelBuilder, HighlightId};
-use lsp::CompletionContext;
-use project::lsp_store::SymbolLocation;
-use project::{
- Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, Project,
- ProjectPath, Symbol, WorktreeId,
-};
-use prompt_store::PromptStore;
-use rope::Point;
-use text::{Anchor, OffsetRangeExt, ToPoint};
-use ui::prelude::*;
-use util::ResultExt as _;
-use util::paths::PathStyle;
-use util::rel_path::RelPath;
-use workspace::Workspace;
-
-use crate::{
- context::{AgentContextHandle, AgentContextKey, RULES_ICON},
- context_store::ContextStore,
-};
-
-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::search_threads;
-use super::{
- ContextPickerAction, ContextPickerEntry, ContextPickerMode, MentionLink, RecentEntry,
- available_context_picker_entries, recent_context_picker_entries_with_store, selection_ranges,
-};
-use crate::inline_prompt_editor::ContextCreasesAddon;
-
-pub(crate) enum Match {
- File(FileMatch),
- Symbol(SymbolMatch),
- Thread(HistoryEntry),
- RecentThread(HistoryEntry),
- Fetch(SharedString),
- Rules(RulesContextEntry),
- Entry(EntryMatch),
-}
-
-pub struct EntryMatch {
- mat: Option<StringMatch>,
- 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::RecentThread(_) => 1.,
- Match::Symbol(_) => 1.,
- Match::Fetch(_) => 1.,
- Match::Rules(_) => 1.,
- }
- }
-}
-
-fn search(
- mode: Option<ContextPickerMode>,
- query: String,
- cancellation_flag: Arc<AtomicBool>,
- recent_entries: Vec<RecentEntry>,
- prompt_store: Option<WeakEntity<PromptStore>>,
- thread_store: Option<WeakEntity<HistoryStore>>,
- workspace: Entity<Workspace>,
- cx: &mut App,
-) -> Task<Vec<Match>> {
- 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) = thread_store.as_ref().and_then(|t| t.upgrade()) {
- let search_threads_task =
- search_threads(query, cancellation_flag, &thread_store, cx);
- cx.background_spawn(async move {
- search_threads_task
- .await
- .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().and_then(|p| p.upgrade()) {
- let search_rules_task = search_rules(query, cancellation_flag, &prompt_store, cx);
- cx.background_spawn(async move {
- search_rules_task
- .await
- .into_iter()
- .map(Match::Rules)
- .collect::<Vec<_>>()
- })
- } 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(entry) => Match::RecentThread(entry),
- })
- .collect::<Vec<_>>();
-
- 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::<Vec<_>>();
-
- cx.background_spawn(async move {
- let mut matches = search_files_task
- .await
- .into_iter()
- .map(Match::File)
- .collect::<Vec<_>>();
-
- 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<Workspace>,
- context_store: WeakEntity<ContextStore>,
- thread_store: Option<WeakEntity<HistoryStore>>,
- prompt_store: Option<WeakEntity<PromptStore>>,
- editor: WeakEntity<Editor>,
- excluded_buffer: Option<WeakEntity<Buffer>>,
-}
-
-impl ContextPickerCompletionProvider {
- pub fn new(
- workspace: WeakEntity<Workspace>,
- context_store: WeakEntity<ContextStore>,
- thread_store: Option<WeakEntity<HistoryStore>>,
- prompt_store: Option<WeakEntity<PromptStore>>,
- editor: WeakEntity<Editor>,
- exclude_buffer: Option<WeakEntity<Buffer>>,
- ) -> Self {
- Self {
- workspace,
- context_store,
- thread_store,
- prompt_store,
- editor,
- excluded_buffer: exclude_buffer,
- }
- }
-
- fn completion_for_entry(
- entry: ContextPickerEntry,
- excerpt_id: ExcerptId,
- source_range: Range<Anchor>,
- editor: Entity<Editor>,
- context_store: Entity<ContextStore>,
- workspace: &Entity<Workspace>,
- cx: &mut App,
- ) -> Option<Completion> {
- 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::<Vec<_>>();
-
- 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: HistoryEntry,
- excerpt_id: ExcerptId,
- source_range: Range<Anchor>,
- recent: bool,
- editor: Entity<Editor>,
- context_store: Entity<ContextStore>,
- thread_store: Entity<HistoryStore>,
- project: Entity<Project>,
- ) -> 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 {
- HistoryEntry::AcpThread(thread) => {
- let context_store = context_store.clone();
- let load_thread_task = agent::load_agent_thread(
- thread.id.clone(),
- thread_store.clone(),
- project.clone(),
- cx,
- );
- window.spawn::<_, Option<_>>(cx, async move |cx| {
- let thread = load_thread_task.await.log_err()?;
- let context = context_store
- .update(cx, |context_store, cx| {
- context_store.add_thread(thread, false, cx)
- })
- .ok()??;
- Some(context)
- })
- }
- HistoryEntry::TextThread(thread) => {
- let path = thread.path.clone();
- let context_store = context_store.clone();
- let thread_store = thread_store.clone();
- cx.spawn::<_, Option<_>>(async move |cx| {
- let thread = thread_store
- .update(cx, |store, cx| store.load_text_thread(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<Anchor>,
- editor: Entity<Editor>,
- context_store: Entity<ContextStore>,
- ) -> 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<Anchor>,
- url_to_fetch: SharedString,
- excerpt_id: ExcerptId,
- editor: Entity<Editor>,
- context_store: Entity<ContextStore>,
- http_client: Arc<HttpClientWithUrl>,
- ) -> 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<Anchor>,
- path_style: PathStyle,
- editor: Entity<Editor>,
- context_store: Entity<ContextStore>,
- 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<Anchor>,
- editor: Entity<Editor>,
- context_store: Entity<ContextStore>,
- workspace: Entity<Workspace>,
- cx: &mut App,
- ) -> Option<Completion> {
- 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)?;
- let path_prefix = RelPath::empty();
-
- 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 = CodeLabelBuilder::default();
- label.push_str(&symbol.name, None);
- label.push_str(" ", None);
- label.push_str(&file_name, comment_id);
- label.push_str(&format!(" L{}", symbol.range.start.0.row + 1), comment_id);
-
- 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: label.build(),
- 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 = CodeLabelBuilder::default();
-
- label.push_str(file_name, None);
- label.push_str(" ", None);
-
- if let Some(directory) = directory {
- label.push_str(directory, comment_id);
- }
-
- label.build()
-}
-
-impl CompletionProvider for ContextPickerCompletionProvider {
- fn completions(
- &self,
- excerpt_id: ExcerptId,
- buffer: &Entity<Buffer>,
- buffer_position: Anchor,
- _trigger: CompletionContext,
- _window: &mut Window,
- cx: &mut Context<Editor>,
- ) -> Task<Result<Vec<CompletionResponse>>> {
- 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 prompt_store = self.prompt_store.clone();
- let editor = self.editor.clone();
- let http_client = workspace.read(cx).client().http_client();
- let path_style = workspace.read(cx).path_style(cx);
-
- let MentionCompletion { mode, argument, .. } = state;
- let query = argument.unwrap_or_else(|| "".to_string());
-
- 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(),
- workspace.clone(),
- excluded_path.clone(),
- cx,
- );
-
- let search_task = search(
- mode,
- query,
- Arc::<AtomicBool>::default(),
- recent_entries,
- prompt_store,
- thread_store.clone(),
- workspace.clone(),
- cx,
- );
- let project = workspace.read(cx).project().downgrade();
-
- cx.spawn(async move |_, cx| {
- let matches = search_task.await;
- let Some((editor, project)) = editor.upgrade().zip(project.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;
- }
-
- // If path is empty, this means we're matching with the root directory itself
- // so we use the path_prefix as the name
- let path_prefix = if mat.path.is_empty() {
- project
- .read(cx)
- .worktree_for_id(project_path.worktree_id, cx)
- .map(|wt| wt.read(cx).root_name().into())
- .unwrap_or_else(|| mat.path_prefix.clone())
- } else {
- mat.path_prefix.clone()
- };
-
- Some(Self::completion_for_path(
- project_path,
- &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(thread) => {
- let thread_store = thread_store.as_ref().and_then(|t| t.upgrade())?;
- Some(Self::completion_for_thread(
- thread,
- excerpt_id,
- source_range.clone(),
- false,
- editor.clone(),
- context_store.clone(),
- thread_store,
- project.clone(),
- ))
- }
- Match::RecentThread(thread) => {
- let thread_store = thread_store.as_ref().and_then(|t| t.upgrade())?;
- Some(Self::completion_for_thread(
- thread,
- excerpt_id,
- source_range.clone(),
- true,
- editor.clone(),
- context_store.clone(),
- thread_store,
- project.clone(),
- ))
- }
- Match::Rules(user_rules) => Some(Self::completion_for_rules(
- user_rules,
- excerpt_id,
- 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<language::Buffer>,
- position: language::Anchor,
- _text: &str,
- _trigger_in_words: bool,
- _menu_is_open: bool,
- cx: &mut Context<Editor>,
- ) -> 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<Editor>,
- context_store: Entity<ContextStore>,
- add_context_fn: impl Fn(&mut Window, &mut App) -> Task<Option<AgentContextHandle>>
- + Send
- + Sync
- + 'static,
-) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> 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::<ContextCreasesAddon>() {
- addon.add_creases(
- &context_store,
- AgentContextKey(context),
- [(crease_id, crease_text)],
- cx,
- );
- }
- })
- .ok()
- })
- .detach();
- });
- false
- })
-}
-
-#[derive(Debug, Default, PartialEq)]
-struct MentionCompletion {
- source_range: Range<usize>,
- mode: Option<ContextPickerMode>,
- argument: Option<String>,
-}
-
-impl MentionCompletion {
- fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
- 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<Editor>);
-
- 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<Self>) -> 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| {
- editor::init(cx);
- workspace::init(app_state.clone(), 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::<Vec<_>>();
- 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()));
-
- 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::<Editor>()?
- .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 b{slash}"),
- format!("six.txt b{slash}"),
- format!("five.txt b{slash}"),
- format!("four.txt 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 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:a{slash}one.txt) ")
- );
- assert!(!editor.has_visible_completions_menu());
- assert_eq!(
- fold_ranges(editor, cx),
- vec![Point::new(0, 6)..Point::new(0, 33)]
- );
- });
-
- cx.simulate_input(" ");
-
- editor.update(&mut cx, |editor, cx| {
- assert_eq!(
- editor.text(cx),
- format!("Lorem [@one.txt](@file:a{slash}one.txt) ")
- );
- assert!(!editor.has_visible_completions_menu());
- assert_eq!(
- fold_ranges(editor, cx),
- vec![Point::new(0, 6)..Point::new(0, 33)]
- );
- });
-
- cx.simulate_input("Ipsum ");
-
- editor.update(&mut cx, |editor, cx| {
- assert_eq!(
- editor.text(cx),
- format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum "),
- );
- assert!(!editor.has_visible_completions_menu());
- assert_eq!(
- fold_ranges(editor, cx),
- vec![Point::new(0, 6)..Point::new(0, 33)]
- );
- });
-
- cx.simulate_input("@file ");
-
- editor.update(&mut cx, |editor, cx| {
- assert_eq!(
- editor.text(cx),
- format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum @file "),
- );
- assert!(editor.has_visible_completions_menu());
- assert_eq!(
- fold_ranges(editor, cx),
- vec![Point::new(0, 6)..Point::new(0, 33)]
- );
- });
-
- 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:a{slash}one.txt) Ipsum [@seven.txt](@file:b{slash}seven.txt) ")
- );
- assert!(!editor.has_visible_completions_menu());
- assert_eq!(
- fold_ranges(editor, cx),
- vec![
- Point::new(0, 6)..Point::new(0, 33),
- Point::new(0, 41)..Point::new(0, 72)
- ]
- );
- });
-
- cx.simulate_input("\n@");
-
- editor.update(&mut cx, |editor, cx| {
- assert_eq!(
- editor.text(cx),
- format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum [@seven.txt](@file:b{slash}seven.txt) \n@")
- );
- assert!(editor.has_visible_completions_menu());
- assert_eq!(
- fold_ranges(editor, cx),
- vec![
- Point::new(0, 6)..Point::new(0, 33),
- Point::new(0, 41)..Point::new(0, 72)
- ]
- );
- });
-
- 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:a{slash}one.txt) Ipsum [@seven.txt](@file:b{slash}seven.txt) \n[@six.txt](@file:b{slash}six.txt) ")
- );
- assert!(!editor.has_visible_completions_menu());
- assert_eq!(
- fold_ranges(editor, cx),
- vec![
- Point::new(0, 6)..Point::new(0, 33),
- Point::new(0, 41)..Point::new(0, 72),
- Point::new(1, 0)..Point::new(1, 27)
- ]
- );
- });
- }
-
- #[gpui::test]
- async fn test_context_completion_provider_multiple_worktrees(cx: &mut TestAppContext) {
- init_test(cx);
-
- let app_state = cx.update(AppState::test);
-
- cx.update(|cx| {
- editor::init(cx);
- workspace::init(app_state.clone(), cx);
- });
-
- app_state
- .fs
- .as_fake()
- .insert_tree(
- path!("/project1"),
- json!({
- "a": {
- "one.txt": "",
- "two.txt": "",
- }
- }),
- )
- .await;
-
- app_state
- .fs
- .as_fake()
- .insert_tree(
- path!("/project2"),
- json!({
- "b": {
- "three.txt": "",
- "four.txt": "",
- }
- }),
- )
- .await;
-
- let project = Project::test(
- app_state.fs.clone(),
- [path!("/project1").as_ref(), path!("/project2").as_ref()],
- cx,
- )
- .await;
- let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
- let workspace = window.root(cx).unwrap();
-
- let worktrees = project.update(cx, |project, cx| {
- let worktrees = project.worktrees(cx).collect::<Vec<_>>();
- assert_eq!(worktrees.len(), 2);
- worktrees
- });
-
- let mut cx = VisualTestContext::from_window(*window.deref(), cx);
- let slash = PathStyle::local().separator();
-
- for (worktree_idx, paths) in [
- vec![rel_path("a/one.txt"), rel_path("a/two.txt")],
- vec![rel_path("b/three.txt"), rel_path("b/four.txt")],
- ]
- .iter()
- .enumerate()
- {
- let worktree_id = worktrees[worktree_idx].read_with(&cx, |wt, _| wt.id());
- for path in paths {
- workspace
- .update_in(&mut cx, |workspace, window, cx| {
- workspace.open_path(
- ProjectPath {
- worktree_id,
- path: (*path).into(),
- },
- None,
- false,
- window,
- cx,
- )
- })
- .await
- .unwrap();
- }
- }
-
- let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
- let editor = cx.new(|cx| {
- Editor::new(
- editor::EditorMode::full(),
- multi_buffer::MultiBuffer::build_simple("", cx),
- None,
- window,
- cx,
- )
- });
- workspace.active_pane().update(cx, |pane, cx| {
- pane.add_item(
- Box::new(cx.new(|_| AtMentionEditor(editor.clone()))),
- true,
- true,
- None,
- window,
- cx,
- );
- });
- editor
- });
-
- let context_store = cx.new(|_| ContextStore::new(project.downgrade()));
-
- let editor_entity = editor.downgrade();
- editor.update_in(&mut cx, |editor, window, cx| {
- window.focus(&editor.focus_handle(cx));
- editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
- workspace.downgrade(),
- context_store.downgrade(),
- None,
- None,
- editor_entity,
- None,
- ))));
- });
-
- cx.simulate_input("@");
-
- // With multiple worktrees, we should see the project name as prefix
- editor.update(&mut cx, |editor, cx| {
- assert_eq!(editor.text(cx), "@");
- assert!(editor.has_visible_completions_menu());
- let labels = current_completion_labels(editor);
-
- assert!(
- labels.contains(&format!("four.txt project2{slash}b{slash}")),
- "Expected 'four.txt project2{slash}b{slash}' in labels: {:?}",
- labels
- );
- assert!(
- labels.contains(&format!("three.txt project2{slash}b{slash}")),
- "Expected 'three.txt project2{slash}b{slash}' in labels: {:?}",
- labels
- );
- });
-
- editor.update_in(&mut cx, |editor, window, cx| {
- editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
- editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
- editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
- editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
- editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
- });
-
- cx.run_until_parked();
-
- editor.update(&mut cx, |editor, cx| {
- assert_eq!(editor.text(cx), "@file ");
- assert!(editor.has_visible_completions_menu());
- });
-
- cx.simulate_input("one");
-
- editor.update(&mut cx, |editor, cx| {
- assert_eq!(editor.text(cx), "@file one");
- assert!(editor.has_visible_completions_menu());
- assert_eq!(
- current_completion_labels(editor),
- vec![format!("one.txt project1{slash}a{slash}")]
- );
- });
-
- editor.update_in(&mut cx, |editor, window, cx| {
- editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
- });
-
- editor.update(&mut cx, |editor, cx| {
- assert_eq!(
- editor.text(cx),
- format!("[@one.txt](@file:project1{slash}a{slash}one.txt) ")
- );
- assert!(!editor.has_visible_completions_menu());
- });
- }
-
- fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
- 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<String> {
- let completions = editor.current_completions().expect("Missing completions");
- completions
- .into_iter()
- .map(|completion| completion.label.text)
- .collect::<Vec<_>>()
- }
-
- 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);
- });
- }
-}
@@ -1,252 +0,0 @@
-use std::cell::RefCell;
-use std::rc::Rc;
-use std::sync::Arc;
-
-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, context_store::ContextStore};
-
-pub struct FetchContextPicker {
- picker: Entity<Picker<FetchContextPickerDelegate>>,
-}
-
-impl FetchContextPicker {
- pub fn new(
- context_picker: WeakEntity<ContextPicker>,
- workspace: WeakEntity<Workspace>,
- context_store: WeakEntity<ContextStore>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> 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<Self>) -> 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<ContextPicker>,
- workspace: WeakEntity<Workspace>,
- context_store: WeakEntity<ContextStore>,
- url: String,
-}
-
-impl FetchContextPickerDelegate {
- pub fn new(
- context_picker: WeakEntity<ContextPicker>,
- workspace: WeakEntity<Workspace>,
- context_store: WeakEntity<ContextStore>,
- ) -> Self {
- FetchContextPickerDelegate {
- context_picker,
- workspace,
- context_store,
- url: String::new(),
- }
- }
-}
-
-pub(crate) async fn fetch_url_content(
- http_client: Arc<HttpClientWithUrl>,
- url: String,
-) -> Result<String> {
- 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<TagHandler> = 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<SharedString> {
- 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<Picker<Self>>,
- ) {
- }
-
- fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- "Enter a URL…".into()
- }
-
- fn update_matches(
- &mut self,
- query: String,
- _window: &mut Window,
- _cx: &mut Context<Picker<Self>>,
- ) -> Task<()> {
- self.url = query;
-
- Task::ready(())
- }
-
- fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
- 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<Picker<Self>>) {
- self.context_picker
- .update(cx, |_, cx| {
- cx.emit(DismissEvent);
- })
- .ok();
- }
-
- fn render_match(
- &self,
- ix: usize,
- selected: bool,
- _window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Option<Self::ListItem> {
- 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)),
- )
- }),
- )
- }
-}
@@ -1,392 +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,
- context_store::{ContextStore, FileInclusion},
-};
-
-pub struct FileContextPicker {
- picker: Entity<Picker<FileContextPickerDelegate>>,
-}
-
-impl FileContextPicker {
- pub fn new(
- context_picker: WeakEntity<ContextPicker>,
- workspace: WeakEntity<Workspace>,
- context_store: WeakEntity<ContextStore>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> 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<Self>) -> impl IntoElement {
- self.picker.clone()
- }
-}
-
-pub struct FileContextPickerDelegate {
- context_picker: WeakEntity<ContextPicker>,
- workspace: WeakEntity<Workspace>,
- context_store: WeakEntity<ContextStore>,
- matches: Vec<FileMatch>,
- selected_index: usize,
-}
-
-impl FileContextPickerDelegate {
- pub fn new(
- context_picker: WeakEntity<ContextPicker>,
- workspace: WeakEntity<Workspace>,
- context_store: WeakEntity<ContextStore>,
- ) -> 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<Picker<Self>>,
- ) {
- self.selected_index = ix;
- }
-
- fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- "Search files & directories…".into()
- }
-
- fn update_matches(
- &mut self,
- query: String,
- window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Task<()> {
- let Some(workspace) = self.workspace.upgrade() else {
- return Task::ready(());
- };
-
- let search_task = search_files(query, Arc::<AtomicBool>::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<Picker<Self>>) {
- 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<Picker<Self>>) {
- self.context_picker
- .update(cx, |_, cx| {
- cx.emit(DismissEvent);
- })
- .ok();
- }
-
- fn render_match(
- &self,
- ix: usize,
- selected: bool,
- _window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Option<Self::ListItem> {
- 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<AtomicBool>,
- workspace: &Entity<Workspace>,
- cx: &App,
-) -> Task<Vec<FileMatch>> {
- if query.is_empty() {
- let workspace = workspace.read(cx);
- let project = workspace.project().read(cx);
- let visible_worktrees = workspace.visible_worktrees(cx).collect::<Vec<_>>();
- 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<RelPath> = 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::<Vec<_>>();
- 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::<Vec<_>>();
-
- 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::<Vec<_>>()
- })
- }
-}
-
-pub fn extract_file_name_and_directory(
- path: &RelPath,
- path_prefix: &RelPath,
- path_style: PathStyle,
-) -> (SharedString, Option<SharedString>) {
- // If path is empty, this means we're matching with the root directory itself
- // so we use the path_prefix as the name
- if path.is_empty() && !path_prefix.is_empty() {
- return (path_prefix.display(path_style).to_string().into(), None);
- }
-
- let full_path = path_prefix.join(path);
- let file_name = full_path.file_name().unwrap_or_default();
- let display_path = full_path.display(path_style);
- let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len());
- (
- file_name.to_string().into(),
- Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()),
- )
-}
-
-pub fn render_file_context_entry(
- id: ElementId,
- worktree_id: WorktreeId,
- path: &Arc<RelPath>,
- path_prefix: &Arc<RelPath>,
- is_directory: bool,
- path_style: PathStyle,
- context_store: WeakEntity<ContextStore>,
- cx: &App,
-) -> Stateful<Div> {
- 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}")))
- }
- })
-}
@@ -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::RULES_ICON,
- context_picker::ContextPicker,
- context_store::{self, ContextStore},
-};
-
-pub struct RulesContextPicker {
- picker: Entity<Picker<RulesContextPickerDelegate>>,
-}
-
-impl RulesContextPicker {
- pub fn new(
- prompt_store: WeakEntity<PromptStore>,
- context_picker: WeakEntity<ContextPicker>,
- context_store: WeakEntity<context_store::ContextStore>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> 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<Self>) -> impl IntoElement {
- self.picker.clone()
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct RulesContextEntry {
- pub prompt_id: UserPromptId,
- pub title: SharedString,
-}
-
-pub struct RulesContextPickerDelegate {
- prompt_store: WeakEntity<PromptStore>,
- context_picker: WeakEntity<ContextPicker>,
- context_store: WeakEntity<context_store::ContextStore>,
- matches: Vec<RulesContextEntry>,
- selected_index: usize,
-}
-
-impl RulesContextPickerDelegate {
- pub fn new(
- prompt_store: WeakEntity<PromptStore>,
- context_picker: WeakEntity<ContextPicker>,
- context_store: WeakEntity<context_store::ContextStore>,
- ) -> 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<Picker<Self>>,
- ) {
- self.selected_index = ix;
- }
-
- fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- "Search available rules…".into()
- }
-
- fn update_matches(
- &mut self,
- query: String,
- window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Task<()> {
- let Some(prompt_store) = self.prompt_store.upgrade() else {
- return Task::ready(());
- };
- let search_task = search_rules(query, Arc::new(AtomicBool::default()), &prompt_store, cx);
- cx.spawn_in(window, async move |this, cx| {
- let matches = search_task.await;
- this.update(cx, |this, cx| {
- this.delegate.matches = matches;
- this.delegate.selected_index = 0;
- cx.notify();
- })
- .ok();
- })
- }
-
- fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
- 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<Picker<Self>>) {
- self.context_picker
- .update(cx, |_, cx| {
- cx.emit(DismissEvent);
- })
- .ok();
- }
-
- fn render_match(
- &self,
- ix: usize,
- selected: bool,
- _window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Option<Self::ListItem> {
- 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<ContextStore>,
- 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<AtomicBool>,
- prompt_store: &Entity<PromptStore>,
- cx: &mut App,
-) -> Task<Vec<RulesContextEntry>> {
- 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::<Vec<_>>()
- })
-}
@@ -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::AgentContextHandle, context_picker::ContextPicker, context_store::ContextStore,
-};
-
-pub struct SymbolContextPicker {
- picker: Entity<Picker<SymbolContextPickerDelegate>>,
-}
-
-impl SymbolContextPicker {
- pub fn new(
- context_picker: WeakEntity<ContextPicker>,
- workspace: WeakEntity<Workspace>,
- context_store: WeakEntity<ContextStore>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> 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<Self>) -> impl IntoElement {
- self.picker.clone()
- }
-}
-
-pub struct SymbolContextPickerDelegate {
- context_picker: WeakEntity<ContextPicker>,
- workspace: WeakEntity<Workspace>,
- context_store: WeakEntity<ContextStore>,
- matches: Vec<SymbolEntry>,
- selected_index: usize,
-}
-
-impl SymbolContextPickerDelegate {
- pub fn new(
- context_picker: WeakEntity<ContextPicker>,
- workspace: WeakEntity<Workspace>,
- context_store: WeakEntity<ContextStore>,
- ) -> 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<Picker<Self>>,
- ) {
- self.selected_index = ix;
- }
-
- fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- "Search symbols…".into()
- }
-
- fn update_matches(
- &mut self,
- query: String,
- window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Task<()> {
- let Some(workspace) = self.workspace.upgrade() else {
- return Task::ready(());
- };
-
- let search_task = search_symbols(query, Arc::<AtomicBool>::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<Picker<Self>>) {
- 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<Picker<Self>>) {
- self.context_picker
- .update(cx, |_, cx| {
- cx.emit(DismissEvent);
- })
- .ok();
- }
-
- fn render_match(
- &self,
- ix: usize,
- selected: bool,
- _window: &mut Window,
- _: &mut Context<Picker<Self>>,
- ) -> Option<Self::ListItem> {
- 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<Workspace>,
- context_store: WeakEntity<ContextStore>,
- cx: &mut App,
-) -> Task<Result<(Option<AgentContextHandle>, 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<DocumentSymbol> {
- 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<AtomicBool>,
- workspace: &Entity<Workspace>,
- cx: &mut App,
-) -> Task<Vec<SymbolMatch>> {
- 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<SymbolMatch>,
- context_store: &ContextStore,
- cx: &App,
-) -> Vec<SymbolEntry> {
- symbols
- .into_iter()
- .map(|SymbolMatch { symbol, .. }| SymbolEntry {
- is_included: context_store.includes_symbol(&symbol, cx),
- symbol,
- })
- .collect::<Vec<_>>()
-}
-
-pub fn render_symbol_context_entry(id: ElementId, entry: &SymbolEntry) -> Stateful<Div> {
- 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)),
- )
- })
-}
@@ -1,280 +0,0 @@
-use std::sync::Arc;
-use std::sync::atomic::AtomicBool;
-
-use crate::{
- context_picker::ContextPicker,
- context_store::{self, ContextStore},
-};
-use agent::{HistoryEntry, HistoryStore};
-use fuzzy::StringMatchCandidate;
-use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity};
-use picker::{Picker, PickerDelegate};
-use ui::{ListItem, prelude::*};
-use workspace::Workspace;
-
-pub struct ThreadContextPicker {
- picker: Entity<Picker<ThreadContextPickerDelegate>>,
-}
-
-impl ThreadContextPicker {
- pub fn new(
- thread_store: WeakEntity<HistoryStore>,
- context_picker: WeakEntity<ContextPicker>,
- context_store: WeakEntity<context_store::ContextStore>,
- workspace: WeakEntity<Workspace>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Self {
- let delegate = ThreadContextPickerDelegate::new(
- thread_store,
- context_picker,
- context_store,
- workspace,
- );
- 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<Self>) -> impl IntoElement {
- self.picker.clone()
- }
-}
-
-pub struct ThreadContextPickerDelegate {
- thread_store: WeakEntity<HistoryStore>,
- context_picker: WeakEntity<ContextPicker>,
- context_store: WeakEntity<context_store::ContextStore>,
- workspace: WeakEntity<Workspace>,
- matches: Vec<HistoryEntry>,
- selected_index: usize,
-}
-
-impl ThreadContextPickerDelegate {
- pub fn new(
- thread_store: WeakEntity<HistoryStore>,
- context_picker: WeakEntity<ContextPicker>,
- context_store: WeakEntity<context_store::ContextStore>,
- workspace: WeakEntity<Workspace>,
- ) -> Self {
- ThreadContextPickerDelegate {
- thread_store,
- context_picker,
- context_store,
- workspace,
- 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<Picker<Self>>,
- ) {
- self.selected_index = ix;
- }
-
- fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- "Search threads…".into()
- }
-
- fn update_matches(
- &mut self,
- query: String,
- window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Task<()> {
- let Some(thread_store) = self.thread_store.upgrade() else {
- return Task::ready(());
- };
-
- let search_task = search_threads(query, Arc::new(AtomicBool::default()), &thread_store, cx);
- cx.spawn_in(window, async move |this, cx| {
- let matches = search_task.await;
- this.update(cx, |this, cx| {
- this.delegate.matches = matches;
- this.delegate.selected_index = 0;
- cx.notify();
- })
- .ok();
- })
- }
-
- fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
- let Some(project) = self
- .workspace
- .upgrade()
- .map(|w| w.read(cx).project().clone())
- else {
- return;
- };
- let Some((entry, thread_store)) = self
- .matches
- .get(self.selected_index)
- .zip(self.thread_store.upgrade())
- else {
- return;
- };
-
- match entry {
- HistoryEntry::AcpThread(thread) => {
- let load_thread_task =
- agent::load_agent_thread(thread.id.clone(), thread_store, project, cx);
-
- cx.spawn(async move |this, cx| {
- let thread = load_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);
- }
- HistoryEntry::TextThread(thread) => {
- let task = thread_store.update(cx, |this, cx| {
- this.load_text_thread(thread.path.clone(), cx)
- });
-
- cx.spawn(async move |this, cx| {
- let thread = task.await?;
- 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<Picker<Self>>) {
- self.context_picker
- .update(cx, |_, cx| {
- cx.emit(DismissEvent);
- })
- .ok();
- }
-
- fn render_match(
- &self,
- ix: usize,
- selected: bool,
- _window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Option<Self::ListItem> {
- 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: &HistoryEntry,
- context_store: WeakEntity<ContextStore>,
- cx: &mut App,
-) -> Div {
- let is_added = match entry {
- HistoryEntry::AcpThread(thread) => context_store
- .upgrade()
- .is_some_and(|ctx_store| ctx_store.read(cx).includes_thread(&thread.id)),
- HistoryEntry::TextThread(thread) => context_store
- .upgrade()
- .is_some_and(|ctx_store| ctx_store.read(cx).includes_text_thread(&thread.path)),
- };
-
- h_flex()
- .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)),
- )
- })
-}
-
-pub(crate) fn search_threads(
- query: String,
- cancellation_flag: Arc<AtomicBool>,
- thread_store: &Entity<HistoryStore>,
- cx: &mut App,
-) -> Task<Vec<HistoryEntry>> {
- 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::<Vec<_>>();
- 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()
- })
-}
@@ -1,614 +0,0 @@
-use crate::context::{
- AgentContextHandle, AgentContextKey, ContextId, ContextKind, DirectoryContextHandle,
- FetchedUrlContext, FileContextHandle, ImageContext, RulesContextHandle, SelectionContextHandle,
- SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle,
-};
-use agent_client_protocol as acp;
-use anyhow::{Context as _, Result, anyhow};
-use assistant_text_thread::TextThread;
-use collections::{HashSet, IndexSet};
-use futures::{self, FutureExt};
-use gpui::{App, Context, Entity, EventEmitter, Image, SharedString, Task, WeakEntity};
-use language::{Buffer, File as _};
-use language_model::LanguageModelImage;
-use project::{
- Project, ProjectItem, ProjectPath, Symbol, image_store::is_image_file,
- 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<Project>,
- next_context_id: ContextId,
- context_set: IndexSet<AgentContextKey>,
- context_thread_ids: HashSet<acp::SessionId>,
- context_text_thread_paths: HashSet<Arc<Path>>,
-}
-
-pub enum ContextStoreEvent {
- ContextRemoved(AgentContextKey),
-}
-
-impl EventEmitter<ContextStoreEvent> for ContextStore {}
-
-impl ContextStore {
- pub fn new(project: WeakEntity<Project>) -> Self {
- Self {
- project,
- 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<Item = &AgentContextHandle> {
- self.context_set.iter().map(|entry| entry.as_ref())
- }
-
- pub fn clear(&mut self, cx: &mut Context<Self>) {
- self.context_set.clear();
- self.context_thread_ids.clear();
- cx.notify();
- }
-
- pub fn add_file_from_path(
- &mut self,
- project_path: ProjectPath,
- remove_if_exists: bool,
- cx: &mut Context<Self>,
- ) -> Task<Result<Option<AgentContextHandle>>> {
- 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<Buffer>,
- remove_if_exists: bool,
- cx: &mut Context<Self>,
- ) -> Option<AgentContextHandle> {
- 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<Self>,
- ) -> Result<Option<AgentContextHandle>> {
- 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<Buffer>,
- symbol: SharedString,
- range: Range<Anchor>,
- enclosing_range: Range<Anchor>,
- remove_if_exists: bool,
- cx: &mut Context<Self>,
- ) -> (Option<AgentContextHandle>, 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<agent::Thread>,
- remove_if_exists: bool,
- cx: &mut Context<Self>,
- ) -> Option<AgentContextHandle> {
- 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,
- text_thread: Entity<TextThread>,
- remove_if_exists: bool,
- cx: &mut Context<Self>,
- ) -> Option<AgentContextHandle> {
- let context_id = self.next_context_id.post_inc();
- let context = AgentContextHandle::TextThread(TextThreadContextHandle {
- text_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_rules(
- &mut self,
- prompt_id: UserPromptId,
- remove_if_exists: bool,
- cx: &mut Context<ContextStore>,
- ) -> Option<AgentContextHandle> {
- 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<SharedString>,
- cx: &mut Context<ContextStore>,
- ) -> 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<ContextStore>,
- ) -> Task<Result<Option<AgentContextHandle>>> {
- 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<Image>, cx: &mut Context<ContextStore>) {
- self.insert_image(None, None, image, false, cx);
- }
-
- fn insert_image(
- &mut self,
- project_path: Option<ProjectPath>,
- full_path: Option<String>,
- image: Arc<Image>,
- remove_if_exists: bool,
- cx: &mut Context<ContextStore>,
- ) -> Option<AgentContextHandle> {
- 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<Buffer>,
- range: Range<Anchor>,
- cx: &mut Context<ContextStore>,
- ) {
- 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<ContextStore>,
- ) {
- 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::TextThread {
- text_thread,
- name: _,
- } => {
- if let Some(text_thread) = text_thread.upgrade() {
- let context_id = self.next_context_id.post_inc();
- self.insert_context(
- AgentContextHandle::TextThread(TextThreadContextHandle {
- text_thread,
- context_id,
- }),
- cx,
- );
- }
- }
- }
- }
-
- fn insert_context(&mut self, context: AgentContextHandle, cx: &mut Context<Self>) -> 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.text_thread.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<Self>) {
- 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.text_thread.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<FileInclusion> {
- 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<FileInclusion> {
- 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: &acp::SessionId) -> bool {
- self.context_thread_ids.contains(thread_id)
- }
-
- pub fn includes_text_thread(&self, path: &Arc<Path>) -> 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<SharedString>) -> bool {
- self.context_set
- .contains(&FetchedUrlContext::lookup_key(url.into()))
- }
-
- pub fn get_url_context(&self, url: SharedString) -> Option<AgentContextHandle> {
- self.context_set
- .get(&FetchedUrlContext::lookup_key(url))
- .map(|key| key.as_ref().clone())
- }
-
- pub fn file_paths(&self, cx: &App) -> HashSet<ProjectPath> {
- 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::Thread(_)
- | AgentContextHandle::Selection(_)
- | AgentContextHandle::FetchedUrl(_)
- | AgentContextHandle::TextThread(_)
- | AgentContextHandle::Rules(_)
- | AgentContextHandle::Image(_) => None,
- })
- .collect()
- }
-
- pub fn thread_ids(&self) -> &HashSet<acp::SessionId> {
- &self.context_thread_ids
- }
-}
-
-#[derive(Clone)]
-pub enum SuggestedContext {
- File {
- name: SharedString,
- icon_path: Option<SharedString>,
- buffer: WeakEntity<Buffer>,
- },
- TextThread {
- name: SharedString,
- text_thread: WeakEntity<TextThread>,
- },
-}
-
-impl SuggestedContext {
- pub fn name(&self) -> &SharedString {
- match self {
- Self::File { name, .. } => name,
- Self::TextThread { name, .. } => name,
- }
- }
-
- pub fn icon_path(&self) -> Option<SharedString> {
- match self {
- Self::File { icon_path, .. } => icon_path.clone(),
- Self::TextThread { .. } => None,
- }
- }
-
- pub fn kind(&self) -> ContextKind {
- match self {
- Self::File { .. } => ContextKind::File,
- 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<Self> {
- 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<Self> {
- 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<Self> {
- 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
- }
- }
-}
@@ -1,619 +0,0 @@
-use crate::{
- AcceptSuggestedContext, AgentPanel, FocusDown, FocusLeft, FocusRight, FocusUp,
- ModelUsageContext, RemoveAllContext, RemoveFocusedContext, ToggleContextPicker,
- context_picker::ContextPicker,
- ui::{AddedContext, ContextPill},
-};
-use crate::{
- context::AgentContextHandle,
- context_store::{ContextStore, SuggestedContext},
-};
-use agent::HistoryStore;
-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 prompt_store::PromptStore;
-use rope::Point;
-use std::rc::Rc;
-use text::ToPoint as _;
-use ui::{PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
-use util::ResultExt as _;
-use workspace::Workspace;
-use zed_actions::assistant::OpenRulesLibrary;
-
-pub struct ContextStrip {
- context_store: Entity<ContextStore>,
- context_picker: Entity<ContextPicker>,
- context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
- focus_handle: FocusHandle,
- suggest_context_kind: SuggestContextKind,
- workspace: WeakEntity<Workspace>,
- prompt_store: Option<WeakEntity<PromptStore>>,
- _subscriptions: Vec<Subscription>,
- focused_index: Option<usize>,
- children_bounds: Option<Vec<Bounds<Pixels>>>,
- model_usage_context: ModelUsageContext,
-}
-
-impl ContextStrip {
- pub fn new(
- context_store: Entity<ContextStore>,
- workspace: WeakEntity<Workspace>,
- thread_store: Option<WeakEntity<HistoryStore>>,
- prompt_store: Option<WeakEntity<PromptStore>>,
- context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
- suggest_context_kind: SuggestContextKind,
- model_usage_context: ModelUsageContext,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Self {
- let context_picker = cx.new(|cx| {
- ContextPicker::new(
- workspace.clone(),
- thread_store.clone(),
- prompt_store.clone(),
- 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,
- prompt_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<AddedContext> {
- if let Some(workspace) = self.workspace.upgrade() {
- let project = workspace.read(cx).project().read(cx);
- let prompt_store = self.prompt_store.as_ref().and_then(|p| p.upgrade());
-
- 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.as_ref(),
- project,
- current_model.as_ref(),
- cx,
- )
- })
- .collect::<Vec<_>>()
- } else {
- Vec::new()
- }
- }
-
- fn suggested_context(&self, cx: &App) -> Option<SuggestedContext> {
- match self.suggest_context_kind {
- SuggestContextKind::Thread => self.suggested_thread(cx),
- }
- }
-
- fn suggested_thread(&self, cx: &App) -> Option<SuggestedContext> {
- if !self.context_picker.read(cx).allow_threads() {
- return None;
- }
-
- let workspace = self.workspace.upgrade()?;
- let panel = workspace.read(cx).panel::<AgentPanel>(cx)?.read(cx);
-
- if let Some(active_text_thread_editor) = panel.active_text_thread_editor() {
- let text_thread = active_text_thread_editor.read(cx).text_thread();
- let weak_text_thread = text_thread.downgrade();
- let text_thread = text_thread.read(cx);
- let path = text_thread.path()?;
-
- if self.context_store.read(cx).includes_text_thread(path) {
- return None;
- }
-
- Some(SuggestedContext::TextThread {
- name: text_thread.summary().or_default(),
- text_thread: weak_text_thread,
- })
- } else {
- None
- }
- }
-
- fn handle_context_picker_event(
- &mut self,
- _picker: &Entity<ContextPicker>,
- _event: &DismissEvent,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- cx.emit(ContextStripEvent::PickerDismissed);
- }
-
- fn handle_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- self.focused_index = self.last_pill_index();
- cx.notify();
- }
-
- fn handle_blur(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- self.focused_index = None;
- cx.notify();
- }
-
- fn focus_left(&mut self, _: &FocusLeft, _window: &mut Window, cx: &mut Context<Self>) {
- 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<Self>) {
- 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<Self>) {
- 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<Self>) {
- 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<Pixels>, &[Bounds<Pixels>])> {
- let pill_bounds = self.pill_bounds()?;
- let focused = pill_bounds.get(focused)?;
-
- Some((focused, pill_bounds))
- }
-
- fn pill_bounds(&self) -> Option<&[Bounds<Pixels>]> {
- 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<usize> {
- Some(self.pill_bounds()?.len() - 1)
- }
-
- fn find_best_horizontal_match<'a>(
- focused: &'a Bounds<Pixels>,
- iter: impl Iterator<Item = (usize, &'a Bounds<Pixels>)>,
- ) -> Option<usize> {
- 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::<AgentPanel>(cx) {
- let context = text_thread_context.text_thread.clone();
- window.defer(cx, move |window, cx| {
- panel.update(cx, |panel, cx| {
- panel.open_text_thread(context, window, cx)
- });
- });
- }
- })
- }
-
- AgentContextHandle::Rules(rules_context) => window.dispatch_action(
- Box::new(OpenRulesLibrary {
- prompt_to_select: Some(rules_context.prompt_id.0),
- }),
- cx,
- ),
-
- AgentContextHandle::Image(_) => {}
- }
- }
-
- fn remove_focused_context(
- &mut self,
- _: &RemoveFocusedContext,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- 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<AddedContext>) -> 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<Self>,
- ) {
- 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>) {
- 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<Self>) -> 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::<HashSet<SharedString>>();
- 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,
- 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,
- 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<ContextStripEvent> for ContextStrip {}
-
-pub enum SuggestContextKind {
- Thread,
-}
-
-fn open_editor_at_position(
- project_path: project::ProjectPath,
- target_position: Point,
- workspace: &Entity<Workspace>,
- 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::<Editor>())
- {
- active_editor
- .downgrade()
- .update_in(cx, |editor, window, cx| {
- editor.go_to_singleton_buffer_point(target_position, window, cx);
- })
- .log_err();
- }
- })
-}
@@ -0,0 +1,57 @@
+use std::sync::Arc;
+
+use agent_client_protocol::ModelId;
+use fs::Fs;
+use language_model::LanguageModel;
+use settings::{LanguageModelSelection, update_settings_file};
+use ui::App;
+
+fn language_model_to_selection(model: &Arc<dyn LanguageModel>) -> LanguageModelSelection {
+ LanguageModelSelection {
+ provider: model.provider_id().to_string().into(),
+ model: model.id().0.to_string(),
+ }
+}
+
+fn model_id_to_selection(model_id: &ModelId) -> LanguageModelSelection {
+ let id = model_id.0.as_ref();
+ let (provider, model) = id.split_once('/').unwrap_or(("", id));
+ LanguageModelSelection {
+ provider: provider.to_owned().into(),
+ model: model.to_owned(),
+ }
+}
+
+pub fn toggle_in_settings(
+ model: Arc<dyn LanguageModel>,
+ should_be_favorite: bool,
+ fs: Arc<dyn Fs>,
+ cx: &App,
+) {
+ let selection = language_model_to_selection(&model);
+ update_settings_file(fs, cx, move |settings, _| {
+ let agent = settings.agent.get_or_insert_default();
+ if should_be_favorite {
+ agent.add_favorite_model(selection.clone());
+ } else {
+ agent.remove_favorite_model(&selection);
+ }
+ });
+}
+
+pub fn toggle_model_id_in_settings(
+ model_id: ModelId,
+ should_be_favorite: bool,
+ fs: Arc<dyn Fs>,
+ cx: &App,
+) {
+ let selection = model_id_to_selection(&model_id);
+ update_settings_file(fs, cx, move |settings, _| {
+ let agent = settings.agent.get_or_insert_default();
+ if should_be_favorite {
+ agent.add_favorite_model(selection.clone());
+ } else {
+ agent.remove_favorite_model(&selection);
+ }
+ });
+}
@@ -1,21 +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},
- context_store::ContextStore,
inline_prompt_editor::{CodegenStatus, InlineAssistId, PromptEditor, PromptEditorEvent},
terminal_inline_assistant::TerminalInlineAssistant,
};
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;
@@ -29,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::*;
@@ -50,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<dyn Fs>,
- prompt_builder: Arc<PromptBuilder>,
- telemetry: Arc<Telemetry>,
- cx: &mut App,
-) {
- cx.set_global(InlineAssistant::new(fs, prompt_builder, telemetry));
+pub fn init(fs: Arc<dyn Fs>, prompt_builder: Arc<PromptBuilder>, cx: &mut App) {
+ cx.set_global(InlineAssistant::new(fs, prompt_builder));
cx.observe_global::<SettingsStore>(|cx| {
if DisableAiSettings::get_global(cx).disable_ai {
@@ -96,18 +95,14 @@ pub struct InlineAssistant {
confirmed_assists: HashMap<InlineAssistId, Entity<CodegenAlternative>>,
prompt_history: VecDeque<String>,
prompt_builder: Arc<PromptBuilder>,
- telemetry: Arc<Telemetry>,
fs: Arc<dyn Fs>,
+ _inline_assistant_completions: Option<mpsc::UnboundedSender<anyhow::Result<InlineAssistId>>>,
}
impl Global for InlineAssistant {}
impl InlineAssistant {
- pub fn new(
- fs: Arc<dyn Fs>,
- prompt_builder: Arc<PromptBuilder>,
- telemetry: Arc<Telemetry>,
- ) -> Self {
+ pub fn new(fs: Arc<dyn Fs>, prompt_builder: Arc<PromptBuilder>) -> Self {
Self {
next_assist_id: InlineAssistId::default(),
next_assist_group_id: InlineAssistGroupId::default(),
@@ -117,8 +112,8 @@ impl InlineAssistant {
confirmed_assists: HashMap::default(),
prompt_history: VecDeque::default(),
prompt_builder,
- telemetry,
fs,
+ _inline_assistant_completions: None,
}
}
@@ -212,16 +207,10 @@ impl InlineAssistant {
if let Some(editor) = item.act_as::<Editor>(cx) {
editor.update(cx, |editor, cx| {
if is_ai_enabled {
- let panel = workspace.read(cx).panel::<AgentPanel>(cx);
- let thread_store = panel
- .as_ref()
- .map(|agent_panel| agent_panel.read(cx).thread_store().downgrade());
-
editor.add_code_action_provider(
Rc::new(AssistantCodeActionProvider {
editor: cx.entity().downgrade(),
workspace: workspace.downgrade(),
- thread_store,
}),
window,
cx,
@@ -233,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(),
@@ -277,8 +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 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<Workspace>| match inline_assist_target {
@@ -287,14 +272,13 @@ impl InlineAssistant {
assistant.assist(
&active_editor,
cx.entity().downgrade(),
- context_store,
workspace.project().downgrade(),
- prompt_store,
thread_store,
+ prompt_store,
action.prompt.clone(),
window,
cx,
- )
+ );
})
}
InlineAssistTarget::Terminal(active_terminal) => {
@@ -303,13 +287,13 @@ impl InlineAssistant {
&active_terminal,
cx.entity().downgrade(),
workspace.project().downgrade(),
- prompt_store,
thread_store,
+ prompt_store,
action.prompt.clone(),
window,
cx,
- )
- })
+ );
+ });
}
};
@@ -350,25 +334,20 @@ impl InlineAssistant {
}
}
- pub fn assist(
+ fn codegen_ranges(
&mut self,
editor: &Entity<Editor>,
- workspace: WeakEntity<Workspace>,
- context_store: Entity<ContextStore>,
- project: WeakEntity<Project>,
- prompt_store: Option<Entity<PromptStore>>,
- thread_store: Option<WeakEntity<HistoryStore>>,
- initial_prompt: Option<String>,
+ snapshot: &EditorSnapshot,
window: &mut Window,
cx: &mut App,
- ) {
- let (snapshot, initial_selections, newest_selection) = editor.update(cx, |editor, cx| {
- let snapshot = editor.snapshot(window, cx);
- let selections = editor.selections.all::<Point>(&snapshot.display_snapshot);
- let newest_selection = editor
- .selections
- .newest::<Point>(&snapshot.display_snapshot);
- (snapshot, selections, newest_selection)
+ ) -> Option<(Vec<Range<Anchor>>, Selection<Point>)> {
+ let (initial_selections, newest_selection) = editor.update(cx, |editor, _| {
+ (
+ editor.selections.all::<Point>(&snapshot.display_snapshot),
+ editor
+ .selections
+ .newest::<Point>(&snapshot.display_snapshot),
+ )
});
// Check if there is already an inline assistant that contains the
@@ -381,7 +360,7 @@ impl InlineAssistant {
&& newest_selection.end.row <= range.end.row
{
self.focus_assist(*assist_id, window, cx);
- return;
+ return None;
}
}
}
@@ -389,17 +368,9 @@ impl InlineAssistant {
let mut selections = Vec::<Selection<Point>>::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;
@@ -426,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()
@@ -452,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<Editor>,
+ workspace: WeakEntity<Workspace>,
+ project: WeakEntity<Project>,
+ thread_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ initial_prompt: Option<String>,
+ window: &mut Window,
+ codegen_ranges: &[Range<Anchor>],
+ newest_selection: Option<Selection<Point>>,
+ initial_transaction_id: Option<TransactionId>,
+ cx: &mut App,
+ ) -> Option<InlineAssistId> {
+ 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)),
@@ -483,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,
)
@@ -507,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(),
- prompt_store.as_ref().map(|s| s.downgrade()),
+ 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,
));
}
@@ -544,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(
@@ -556,6 +582,7 @@ impl InlineAssistant {
editor,
&prompt_editor,
prompt_block_id,
+ tool_description_block_id,
end_block_id,
range,
codegen,
@@ -567,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<Editor>,
+ workspace: WeakEntity<Workspace>,
+ project: WeakEntity<Project>,
+ thread_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ initial_prompt: Option<String>,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Option<InlineAssistId> {
+ 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(
@@ -582,17 +648,11 @@ impl InlineAssistant {
initial_transaction_id: Option<TransactionId>,
focus: bool,
workspace: Entity<Workspace>,
+ thread_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
- thread_store: Option<WeakEntity<HistoryStore>>,
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);
@@ -601,68 +661,22 @@ impl InlineAssistant {
}
let project = workspace.read(cx).project().downgrade();
- let context_store = cx.new(|_cx| ContextStore::new(project.clone()));
-
- let codegen = cx.new(|cx| {
- BufferCodegen::new(
- editor.read(cx).buffer().clone(),
- range.clone(),
- initial_transaction_id,
- context_store.clone(),
- project,
- prompt_store.clone(),
- 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,
+ let assist_id = self
+ .batch_assist(
+ editor,
workspace.downgrade(),
+ project,
thread_store,
- prompt_store.map(|s| s.downgrade()),
+ prompt_store,
+ Some(initial_prompt),
window,
+ &[range],
+ None,
+ initial_transaction_id,
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,
- editor,
- &prompt_editor,
- prompt_block_id,
- end_block_id,
- range,
- codegen.clone(),
- workspace.downgrade(),
- window,
- 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);
@@ -677,7 +691,7 @@ impl InlineAssistant {
range: &Range<Anchor>,
prompt_editor: &Entity<PromptEditor<BufferCodegen>>,
cx: &mut App,
- ) -> [CustomBlockId; 2] {
+ ) -> [CustomBlockId; 3] {
let prompt_editor_height = prompt_editor.update(cx, |prompt_editor, cx| {
prompt_editor
.editor
@@ -691,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),
@@ -709,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]]
})
}
@@ -803,7 +825,7 @@ impl InlineAssistant {
(
editor
.selections
- .newest::<usize>(&editor.display_snapshot(cx)),
+ .newest::<MultiBufferOffset>(&editor.display_snapshot(cx)),
editor.buffer().read(cx).snapshot(cx),
)
});
@@ -836,7 +858,7 @@ impl InlineAssistant {
(
editor
.selections
- .newest::<usize>(&editor.display_snapshot(cx)),
+ .newest::<MultiBufferOffset>(&editor.display_snapshot(cx)),
editor.buffer().read(cx).snapshot(cx),
)
});
@@ -853,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 {
@@ -935,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::<usize>(*transaction_id, cx);
+ buffer.edited_ranges_for_transaction::<MultiBufferOffset>(*transaction_id, cx);
let snapshot = buffer.snapshot(cx);
for assist_id in editor_assists.assist_ids.clone() {
@@ -1036,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);
@@ -1046,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,
);
}
@@ -1099,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);
});
@@ -1151,7 +1197,7 @@ impl InlineAssistant {
assist
.editor
- .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
+ .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
.ok();
}
@@ -1163,7 +1209,7 @@ impl InlineAssistant {
if let Some(decorations) = assist.decorations.as_ref() {
decorations.prompt_editor.update(cx, |prompt_editor, cx| {
prompt_editor.editor.update(cx, |editor, cx| {
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
editor.select_all(&SelectAll, window, cx);
})
});
@@ -1274,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;
};
@@ -1290,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();
}
@@ -1438,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,
);
@@ -1449,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);
@@ -1533,6 +1585,27 @@ impl InlineAssistant {
.map(InlineAssistTarget::Terminal)
}
}
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn set_completion_receiver(
+ &mut self,
+ sender: mpsc::UnboundedSender<anyhow::Result<InlineAssistId>>,
+ ) {
+ 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<Entity<CodegenAlternative>> {
+ self.assists.get(&assist_id).map(|inline_assist| {
+ inline_assist
+ .codegen
+ .update(cx, |codegen, _cx| codegen.active_alternative().clone())
+ })
+ }
}
struct EditorInlineAssists {
@@ -1666,6 +1739,7 @@ impl InlineAssist {
editor: &Entity<Editor>,
prompt_editor: &Entity<PromptEditor<BufferCodegen>>,
prompt_block_id: CustomBlockId,
+ tool_description_block_id: CustomBlockId,
end_block_id: CustomBlockId,
range: Range<Anchor>,
codegen: Entity<BufferCodegen>,
@@ -1680,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,
@@ -1732,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;
@@ -1742,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() {
@@ -1758,22 +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<Entity<MentionSet>> {
+ let decorations = self.decorations.as_ref()?;
+ Some(decorations.prompt_editor.read(cx).mention_set().clone())
+ }
}
struct InlineAssistDecorations {
prompt_block_id: CustomBlockId,
prompt_editor: Entity<PromptEditor<BufferCodegen>>,
removed_line_block_ids: HashSet<CustomBlockId>,
+ model_explanation: Option<CustomBlockId>,
end_block_id: CustomBlockId,
}
struct AssistantCodeActionProvider {
editor: WeakEntity<Editor>,
workspace: WeakEntity<Workspace>,
- thread_store: Option<WeakEntity<HistoryStore>>,
}
-const ASSISTANT_CODE_ACTION_PROVIDER_ID: &str = "assistant2";
+const ASSISTANT_CODE_ACTION_PROVIDER_ID: &str = "assistant";
impl CodeActionProvider for AssistantCodeActionProvider {
fn id(&self) -> Arc<str> {
@@ -1841,10 +1936,20 @@ impl CodeActionProvider for AssistantCodeActionProvider {
) -> Task<Result<ProjectTransaction>> {
let editor = self.editor.clone();
let workspace = self.workspace.clone();
- let thread_store = self.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::<AgentPanel>(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| {
@@ -1887,8 +1992,8 @@ impl CodeActionProvider for AssistantCodeActionProvider {
None,
true,
workspace,
- prompt_store,
thread_store,
+ prompt_store,
window,
cx,
);
@@ -1921,3 +2026,387 @@ fn merge_ranges(ranges: &mut Vec<Range<Anchor>>, 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<String>,
+ description: Option<String>,
+ full_buffer_text: String,
+ },
+ Failure {
+ failure: String,
+ },
+ // These fields are used for logging
+ #[allow(unused)]
+ Malformed {
+ completion: Option<String>,
+ description: Option<String>,
+ failure: Option<String>,
+ },
+ }
+
+ pub fn run_inline_assistant_test<SetupF, TestF>(
+ 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,
+ );
+ }
+
+ #[test]
+ #[cfg_attr(not(feature = "unit-eval"), ignore)]
+ fn eval_empty_buffer() {
+ run_eval(
+ 20,
+ 1.0,
+ "Write a Python hello, world program".to_string(),
+ "ˇ".to_string(),
+ |output| match output {
+ InlineAssistantOutput::Success {
+ full_buffer_text, ..
+ } => {
+ if full_buffer_text.is_empty() {
+ EvalOutput::failed("expected some output".to_string())
+ } else {
+ EvalOutput::passed(format!("Produced {full_buffer_text}"))
+ }
+ }
+ 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
+ )),
+ },
+ );
+ }
+
+ fn run_eval(
+ iterations: usize,
+ expected_pass_ratio: f32,
+ prompt: impl Into<String>,
+ buffer: impl Into<String>,
+ 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<String>,
+ ) -> 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
+ )),
+ }
+ }
+}
@@ -1,19 +1,23 @@
use agent::HistoryStore;
use collections::{HashMap, VecDeque};
use editor::actions::Paste;
+use editor::code_context_menus::CodeContextMenu;
use editor::display_map::{CreaseId, EditorMargins};
-use editor::{Addon, AnchorRangeExt as _};
+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;
@@ -23,27 +27,41 @@ 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::{AgentContextHandle, AgentContextKey};
-use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention};
-use crate::context_store::{ContextStore, ContextStoreEvent};
-use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
-use crate::terminal_codegen::TerminalCodegen;
-use crate::{
- CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext, RemoveAllContext,
- ToggleContextPicker,
+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};
+
+actions!(inline_assistant, [ThumbsUpResult, ThumbsDownResult]);
+
+enum CompletionState {
+ Pending,
+ Generated { completion_text: Option<String> },
+ Rated,
+}
+
+struct SessionState {
+ session_id: Uuid,
+ completion: CompletionState,
+}
pub struct PromptEditor<T> {
pub editor: Entity<Editor>,
mode: PromptEditorMode,
- context_store: Entity<ContextStore>,
- context_strip: Entity<ContextStrip>,
- context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
+ mention_set: Entity<MentionSet>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ workspace: WeakEntity<Workspace>,
model_selector: Entity<AgentModelSelector>,
edited_since_done: bool,
prompt_history: VecDeque<String>,
@@ -51,8 +69,8 @@ pub struct PromptEditor<T> {
pending_prompt: String,
_codegen_subscription: Subscription,
editor_subscriptions: Vec<Subscription>,
- _context_strip_subscription: Subscription,
show_rate_limit_notice: bool,
+ session_state: SessionState,
_phantom: std::marker::PhantomData<T>,
}
@@ -65,7 +83,7 @@ impl<T: 'static> Render for PromptEditor<T> {
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,
@@ -83,38 +101,67 @@ impl<T: 'static> Render for PromptEditor<T> {
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));
@@ -123,19 +170,20 @@ impl<T: 'static> Render for PromptEditor<T> {
.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 {
@@ -166,26 +214,83 @@ impl<T: 'static> Render for PromptEditor<T> {
.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()
}
}
@@ -214,6 +319,19 @@ impl<T: 'static> PromptEditor<T> {
));
}
+ fn assign_completion_provider(&mut self, cx: &mut Context<Self>) {
+ 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,
@@ -226,27 +344,40 @@ impl<T: 'static> PromptEditor<T> {
pub fn unlink(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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));
+ window.focus(&editor.focus_handle(cx), 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::<HashMap<_, _>>();
+ mention_set.set_mentions(mentions);
+ });
+
+ self.assign_completion_provider(cx);
self.subscribe_to_editor(window, cx);
}
@@ -274,43 +405,29 @@ impl<T: 'static> PromptEditor<T> {
self.editor.read(cx).text(cx)
}
- fn paste(&mut self, _: &Paste, _window: &mut Window, cx: &mut Context<Self>) {
- 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::<Vec<_>>()
- })
- .unwrap_or_default();
-
- if images.is_empty() {
- return;
+ fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
+ 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>,
+ editor: &Entity<Editor>,
event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
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::<Workspace>().flatten() {
workspace.update(cx, |workspace, cx| {
let is_via_ssh = workspace.project().read(cx).is_via_remote_server();
@@ -321,7 +438,7 @@ impl<T: 'static> PromptEditor<T> {
.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)
@@ -331,6 +448,7 @@ impl<T: 'static> PromptEditor<T> {
}
self.edited_since_done = true;
+ self.session_state.completion = CompletionState::Pending;
cx.notify();
}
EditorEvent::Blurred => {
@@ -343,23 +461,44 @@ impl<T: 'static> PromptEditor<T> {
}
}
- fn toggle_context_picker(
- &mut self,
- _: &ToggleContextPicker,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- 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>,
- ) {
- 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>) {
+ 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::<text::Point>(&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(
@@ -381,22 +520,207 @@ impl<T: 'static> PromptEditor<T> {
fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Uuid>, cx: &mut Context<'_, PromptEditor<T>>) {
+ self.workspace
+ .update(cx, |workspace, cx| {
+ enum InlinePromptRating {}
+ workspace.show_toast(
+ {
+ let mut toast = Toast::new(
+ NotificationId::unique::<InlinePromptRating>(),
+ 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<Self>) {
if let Some(ix) = self.prompt_history_ix {
if ix > 0 {
@@ -434,8 +758,6 @@ impl<T: 'static> PromptEditor<T> {
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);
}
}
@@ -504,6 +826,9 @@ impl<T: 'static> PromptEditor<T> {
.into_any_element(),
]
} else {
+ let show_rating_buttons = cx.has_flag::<InlineAssistantUseToolFeatureFlag>();
+ let rated = matches!(self.session_state.completion, CompletionState::Rated);
+
let accept = IconButton::new("accept", IconName::Check)
.icon_color(Color::Info)
.shape(IconButtonShape::Square)
@@ -515,25 +840,92 @@ impl<T: 'static> PromptEditor<T> {
}))
.into_any_element();
- match &self.mode {
- PromptEditorMode::Terminal { .. } => vec![
- accept,
- 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 });
- }))
+ let mut buttons = Vec::new();
+
+ if show_rating_buttons {
+ buttons.push(
+ h_flex()
+ .pl_1()
+ .gap_1()
+ .border_l_1()
+ .border_color(cx.theme().colors().border_variant)
+ .child(
+ IconButton::new("thumbs-up", IconName::ThumbsUp)
+ .shape(IconButtonShape::Square)
+ .map(|this| {
+ if rated {
+ this.disabled(true)
+ .icon_color(Color::Ignored)
+ .tooltip(move |_, cx| {
+ Tooltip::with_meta(
+ "Good Result",
+ None,
+ "You already rated this result",
+ cx,
+ )
+ })
+ } else {
+ this.icon_color(Color::Muted)
+ .tooltip(Tooltip::text("Good Result"))
+ }
+ })
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.thumbs_up(&ThumbsUpResult, window, cx);
+ })),
+ )
+ .child(
+ IconButton::new("thumbs-down", IconName::ThumbsDown)
+ .shape(IconButtonShape::Square)
+ .map(|this| {
+ if rated {
+ this.disabled(true)
+ .icon_color(Color::Ignored)
+ .tooltip(move |_, cx| {
+ Tooltip::with_meta(
+ "Bad Result",
+ None,
+ "You already rated this result",
+ cx,
+ )
+ })
+ } else {
+ this.icon_color(Color::Muted)
+ .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(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,
}
}
}
@@ -568,10 +960,21 @@ impl<T: 'static> PromptEditor<T> {
}
fn render_close_button(&self, cx: &mut Context<Self>) -> AnyElement {
+ let focus_handle = self.editor.focus_handle(cx);
+
IconButton::new("cancel", IconName::Close)
.icon_color(Color::Muted)
.shape(IconButtonShape::Square)
- .tooltip(Tooltip::text("Close Assistant"))
+ .tooltip({
+ move |_window, cx| {
+ Tooltip::for_action_in(
+ "Close Assistant",
+ &editor::actions::Cancel,
+ &focus_handle,
+ cx,
+ )
+ }
+ })
.on_click(cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::CancelRequested)))
.into_any_element()
}
@@ -709,6 +1112,7 @@ impl<T: 'static> PromptEditor<T> {
EditorStyle {
background: colors.editor_background,
local_player: cx.theme().players().local(),
+ syntax: cx.theme().syntax().clone(),
text: text_style,
..Default::default()
},
@@ -717,19 +1121,8 @@ impl<T: 'static> PromptEditor<T> {
.into_any_element()
}
- fn handle_context_strip_event(
- &mut self,
- _context_strip: &Entity<ContextStrip>,
- event: &ContextStripEvent,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- match event {
- ContextStripEvent::PickerDismissed
- | ContextStripEvent::BlurredEmpty
- | ContextStripEvent::BlurredUp => self.editor.focus_handle(cx).focus(window),
- ContextStripEvent::BlurredDown => {}
- }
+ fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
+ MarkdownElement::new(markdown, style)
}
}
@@ -765,6 +1158,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<PromptContextType> {
+ 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<crate::completion_provider::AvailableCommand> {
+ Vec::new()
+ }
+
+ fn confirm_command(&self, _cx: &mut App) {}
+}
+
impl PromptEditor<BufferCodegen> {
pub fn new_buffer(
id: InlineAssistId,
@@ -772,16 +1195,16 @@ impl PromptEditor<BufferCodegen> {
prompt_history: VecDeque<String>,
prompt_buffer: Entity<MultiBuffer>,
codegen: Entity<BufferCodegen>,
+ session_id: Uuid,
fs: Arc<dyn Fs>,
- context_store: Entity<ContextStore>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ project: WeakEntity<Project>,
workspace: WeakEntity<Workspace>,
- thread_store: Option<WeakEntity<HistoryStore>>,
- prompt_store: Option<WeakEntity<PromptStore>>,
window: &mut Window,
cx: &mut Context<PromptEditor<BufferCodegen>>,
) -> PromptEditor<BufferCodegen> {
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,
@@ -805,7 +1228,6 @@ impl PromptEditor<BufferCodegen> {
// 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,
@@ -815,43 +1237,17 @@ impl PromptEditor<BufferCodegen> {
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(),
- prompt_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(),
- prompt_store,
- 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<BufferCodegen> = 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,
@@ -1,32 +1,44 @@
use std::{cmp::Reverse, sync::Arc};
-use collections::{HashSet, IndexMap};
+use agent_settings::AgentSettings;
+use collections::{HashMap, HashSet, 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,
+ AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProvider,
+ LanguageModelProviderId, LanguageModelRegistry,
};
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
-use ui::{ListItem, ListItemSpacing, prelude::*};
+use settings::Settings;
+use ui::prelude::*;
+use zed_actions::agent::OpenSettings;
+
+use crate::ui::{ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
+type OnToggleFavorite = Arc<dyn Fn(Arc<dyn LanguageModel>, bool, &App) + 'static>;
pub type LanguageModelSelector = Picker<LanguageModelPickerDelegate>;
pub fn language_model_selector(
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
+ on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &App) + 'static,
popover_styles: bool,
+ focus_handle: FocusHandle,
window: &mut Window,
cx: &mut Context<LanguageModelSelector>,
) -> LanguageModelSelector {
let delegate = LanguageModelPickerDelegate::new(
get_active_model,
on_model_changed,
+ on_toggle_favorite,
popover_styles,
+ focus_handle,
window,
cx,
);
@@ -42,7 +54,17 @@ pub fn language_model_selector(
}
fn all_models(cx: &App) -> GroupedModels {
- let providers = LanguageModelRegistry::global(cx).read(cx).providers();
+ let lm_registry = LanguageModelRegistry::global(cx).read(cx);
+ let providers = lm_registry.providers();
+
+ let mut favorites_index = FavoritesIndex::default();
+
+ for sel in &AgentSettings::get_global(cx).favorite_models {
+ favorites_index
+ .entry(sel.provider.0.clone().into())
+ .or_default()
+ .insert(sel.model.clone().into());
+ }
let recommended = providers
.iter()
@@ -50,51 +72,70 @@ fn all_models(cx: &App) -> GroupedModels {
provider
.recommended_models(cx)
.into_iter()
- .map(|model| ModelInfo {
- model,
- icon: provider.icon(),
- })
+ .map(|model| ModelInfo::new(&**provider, model, &favorites_index))
})
.collect();
- let other = providers
+ let all = providers
.iter()
.flat_map(|provider| {
provider
.provided_models(cx)
.into_iter()
- .map(|model| ModelInfo {
- model,
- icon: provider.icon(),
- })
+ .map(|model| ModelInfo::new(&**provider, model, &favorites_index))
})
.collect();
- GroupedModels::new(other, recommended)
+ GroupedModels::new(all, recommended)
}
+type FavoritesIndex = HashMap<LanguageModelProviderId, HashSet<LanguageModelId>>;
+
#[derive(Clone)]
struct ModelInfo {
model: Arc<dyn LanguageModel>,
icon: IconName,
+ is_favorite: bool,
+}
+
+impl ModelInfo {
+ fn new(
+ provider: &dyn LanguageModelProvider,
+ model: Arc<dyn LanguageModel>,
+ favorites_index: &FavoritesIndex,
+ ) -> Self {
+ let is_favorite = favorites_index
+ .get(&provider.id())
+ .map_or(false, |set| set.contains(&model.id()));
+
+ Self {
+ model,
+ icon: provider.icon(),
+ is_favorite,
+ }
+ }
}
pub struct LanguageModelPickerDelegate {
on_model_changed: OnModelChanged,
get_active_model: GetActiveModel,
+ on_toggle_favorite: OnToggleFavorite,
all_models: Arc<GroupedModels>,
filtered_entries: Vec<LanguageModelPickerEntry>,
selected_index: usize,
_authenticate_all_providers_task: Task<()>,
_subscriptions: Vec<Subscription>,
popover_styles: bool,
+ focus_handle: FocusHandle,
}
impl LanguageModelPickerDelegate {
fn new(
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
+ on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &App) + 'static,
popover_styles: bool,
+ focus_handle: FocusHandle,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Self {
@@ -108,6 +149,7 @@ impl LanguageModelPickerDelegate {
selected_index: Self::get_active_model_index(&entries, get_active_model(cx)),
filtered_entries: entries,
get_active_model: Arc::new(get_active_model),
+ on_toggle_favorite: Arc::new(on_toggle_favorite),
_authenticate_all_providers_task: Self::authenticate_all_providers(cx),
_subscriptions: vec![cx.subscribe_in(
&LanguageModelRegistry::global(cx),
@@ -128,6 +170,7 @@ impl LanguageModelPickerDelegate {
},
)],
popover_styles,
+ focus_handle,
}
}
@@ -206,80 +249,104 @@ impl LanguageModelPickerDelegate {
pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
(self.get_active_model)(cx)
}
+
+ pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+ if self.all_models.favorites.is_empty() {
+ return;
+ }
+
+ let active_model = (self.get_active_model)(cx);
+ let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
+ let active_model_id = active_model.as_ref().map(|m| m.model.id());
+
+ let current_index = self
+ .all_models
+ .favorites
+ .iter()
+ .position(|info| {
+ Some(info.model.provider_id()) == active_provider_id
+ && Some(info.model.id()) == active_model_id
+ })
+ .unwrap_or(usize::MAX);
+
+ let next_index = if current_index == usize::MAX {
+ 0
+ } else {
+ (current_index + 1) % self.all_models.favorites.len()
+ };
+
+ let next_model = self.all_models.favorites[next_index].model.clone();
+
+ (self.on_model_changed)(next_model, cx);
+
+ // Align the picker selection with the newly-active model
+ let new_index =
+ Self::get_active_model_index(&self.filtered_entries, (self.get_active_model)(cx));
+ self.set_selected_index(new_index, window, cx);
+ }
}
struct GroupedModels {
+ favorites: Vec<ModelInfo>,
recommended: Vec<ModelInfo>,
- other: IndexMap<LanguageModelProviderId, Vec<ModelInfo>>,
+ all: IndexMap<LanguageModelProviderId, Vec<ModelInfo>>,
}
impl GroupedModels {
- pub fn new(other: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
- let recommended_ids = recommended
+ pub fn new(all: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
+ let favorites = all
.iter()
- .map(|info| (info.model.provider_id(), info.model.id()))
- .collect::<HashSet<_>>();
-
- let mut other_by_provider: IndexMap<_, Vec<ModelInfo>> = IndexMap::default();
- for model in other {
- if recommended_ids.contains(&(model.model.provider_id(), model.model.id())) {
- continue;
- }
+ .filter(|info| info.is_favorite)
+ .cloned()
+ .collect();
+ let mut all_by_provider: IndexMap<_, Vec<ModelInfo>> = 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 {
+ favorites,
recommended,
- other: other_by_provider,
+ all: all_by_provider,
}
}
fn entries(&self) -> Vec<LanguageModelPickerEntry> {
let mut entries = Vec::new();
+ if !self.favorites.is_empty() {
+ entries.push(LanguageModelPickerEntry::Separator("Favorite".into()));
+ for info in &self.favorites {
+ entries.push(LanguageModelPickerEntry::Model(info.clone()));
+ }
+ }
+
if !self.recommended.is_empty() {
entries.push(LanguageModelPickerEntry::Separator("Recommended".into()));
- entries.extend(
- self.recommended
- .iter()
- .map(|info| LanguageModelPickerEntry::Model(info.clone())),
- );
+ for info in &self.recommended {
+ entries.push(LanguageModelPickerEntry::Model(info.clone()));
+ }
}
- for models in self.other.values() {
+ for models in self.all.values() {
if models.is_empty() {
continue;
}
entries.push(LanguageModelPickerEntry::Separator(
models[0].model.provider_name().0,
));
- entries.extend(
- models
- .iter()
- .map(|info| LanguageModelPickerEntry::Model(info.clone())),
- );
+ for info in models {
+ entries.push(LanguageModelPickerEntry::Model(info.clone()));
+ }
}
- entries
- }
- fn model_infos(&self) -> Vec<ModelInfo> {
- let other = self
- .other
- .values()
- .flat_map(|model| model.iter())
- .cloned()
- .collect::<Vec<_>>();
- self.recommended
- .iter()
- .chain(&other)
- .cloned()
- .collect::<Vec<_>>()
+ entries
}
}
@@ -425,8 +492,9 @@ impl PickerDelegate for LanguageModelPickerDelegate {
.collect::<Vec<_>>();
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::<Vec<_>>();
@@ -478,23 +546,9 @@ impl PickerDelegate for LanguageModelPickerDelegate {
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
match self.filtered_entries.get(ix)? {
- LanguageModelPickerEntry::Separator(title) => Some(
- div()
- .px_2()
- .pb_1()
- .when(ix > 1, |this| {
- this.mt_1()
- .pt_2()
- .border_t_1()
- .border_color(cx.theme().colors().border_variant)
- })
- .child(
- Label::new(title)
- .size(LabelSize::XSmall)
- .color(Color::Muted),
- )
- .into_any_element(),
- ),
+ LanguageModelPickerEntry::Separator(title) => {
+ Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
+ }
LanguageModelPickerEntry::Model(model_info) => {
let active_model = (self.get_active_model)(cx);
let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
@@ -503,37 +557,20 @@ impl PickerDelegate for LanguageModelPickerDelegate {
let is_selected = Some(model_info.model.provider_id()) == active_provider_id
&& Some(model_info.model.id()) == active_model_id;
- let model_icon_color = if is_selected {
- Color::Accent
- } else {
- Color::Muted
+ let is_favorite = model_info.is_favorite;
+ let handle_action_click = {
+ let model = model_info.model.clone();
+ let on_toggle_favorite = self.on_toggle_favorite.clone();
+ move |cx: &App| on_toggle_favorite(model.clone(), !is_favorite, cx)
};
Some(
- ListItem::new(ix)
- .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(Label::new(model_info.model.name().0).truncate()),
- )
- .end_slot(div().pr_3().when(is_selected, |this| {
- this.child(
- Icon::new(IconName::Check)
- .color(Color::Accent)
- .size(IconSize::Small),
- )
- }))
+ ModelSelectorListItem::new(ix, model_info.model.name().0)
+ .icon(model_info.icon)
+ .is_selected(is_selected)
+ .is_focused(selected)
+ .is_favorite(is_favorite)
+ .on_toggle_favorite(handle_action_click)
.into_any_element(),
)
}
@@ -543,35 +580,15 @@ impl PickerDelegate for LanguageModelPickerDelegate {
fn render_footer(
&self,
_window: &mut Window,
- cx: &mut Context<Picker<Self>>,
+ _cx: &mut Context<Picker<Self>>,
) -> Option<gpui::AnyElement> {
+ let focus_handle = self.focus_handle.clone();
+
if !self.popover_styles {
return None;
}
- 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(),
- )
+ Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
}
}
@@ -670,11 +687,24 @@ mod tests {
}
fn create_models(model_specs: Vec<(&str, &str)>) -> Vec<ModelInfo> {
+ create_models_with_favorites(model_specs, vec![])
+ }
+
+ fn create_models_with_favorites(
+ model_specs: Vec<(&str, &str)>,
+ favorites: Vec<(&str, &str)>,
+ ) -> Vec<ModelInfo> {
model_specs
.into_iter()
- .map(|(provider, name)| ModelInfo {
- model: Arc::new(TestLanguageModel::new(name, provider)),
- icon: IconName::Ai,
+ .map(|(provider, name)| {
+ let is_favorite = favorites
+ .iter()
+ .any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name);
+ ModelInfo {
+ model: Arc::new(TestLanguageModel::new(name, provider)),
+ icon: IconName::Ai,
+ is_favorite,
+ }
})
.collect()
}
@@ -764,46 +794,141 @@ 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::<Vec<_>>();
- // 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::<Vec<_>>();
- // 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"],
+ );
+ }
+
+ #[gpui::test]
+ fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
+ let recommended_models = create_models(vec![("zed", "claude")]);
+ let all_models = create_models_with_favorites(
+ vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")],
+ vec![("zed", "gemini")],
+ );
+
+ let grouped_models = GroupedModels::new(all_models, recommended_models);
+ let entries = grouped_models.entries();
+
+ assert!(matches!(
+ entries.first(),
+ Some(LanguageModelPickerEntry::Separator(s)) if s == "Favorite"
+ ));
+
+ assert_models_eq(grouped_models.favorites, vec!["zed/gemini"]);
+ }
+
+ #[gpui::test]
+ fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) {
+ let recommended_models = create_models(vec![("zed", "claude")]);
+ let all_models = create_models(vec![("zed", "claude"), ("zed", "gemini")]);
+
+ let grouped_models = GroupedModels::new(all_models, recommended_models);
+ let entries = grouped_models.entries();
+
+ assert!(matches!(
+ entries.first(),
+ Some(LanguageModelPickerEntry::Separator(s)) if s == "Recommended"
+ ));
+
+ assert!(grouped_models.favorites.is_empty());
+ }
+
+ #[gpui::test]
+ fn test_models_have_correct_actions(_cx: &mut TestAppContext) {
+ let recommended_models =
+ create_models_with_favorites(vec![("zed", "claude")], vec![("zed", "claude")]);
+ let all_models = create_models_with_favorites(
+ vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")],
+ vec![("zed", "claude")],
+ );
+
+ let grouped_models = GroupedModels::new(all_models, recommended_models);
+ let entries = grouped_models.entries();
+
+ for entry in &entries {
+ if let LanguageModelPickerEntry::Model(info) = entry {
+ if info.model.telemetry_id() == "zed/claude" {
+ assert!(info.is_favorite, "zed/claude should be a favorite");
+ } else {
+ assert!(
+ !info.is_favorite,
+ "{} should not be a favorite",
+ info.model.telemetry_id()
+ );
+ }
+ }
+ }
+ }
+
+ #[gpui::test]
+ fn test_favorites_appear_in_other_sections(_cx: &mut TestAppContext) {
+ let favorites = vec![("zed", "gemini"), ("openai", "gpt-4")];
+
+ let recommended_models =
+ create_models_with_favorites(vec![("zed", "claude")], favorites.clone());
+
+ let all_models = create_models_with_favorites(
+ vec![
+ ("zed", "claude"),
+ ("zed", "gemini"),
+ ("openai", "gpt-4"),
+ ("openai", "gpt-3.5"),
+ ],
+ favorites,
+ );
+
+ let grouped_models = GroupedModels::new(all_models, recommended_models);
+
+ assert_models_eq(grouped_models.favorites, vec!["zed/gemini", "openai/gpt-4"]);
+ assert_models_eq(grouped_models.recommended, vec!["zed/claude"]);
+ assert_models_eq(
+ grouped_models.all.values().flatten().cloned().collect(),
+ vec!["zed/claude", "zed/gemini", "openai/gpt-4", "openai/gpt-3.5"],
+ );
}
}
@@ -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<Task<Result<Mention, String>>>;
+
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub enum Mention {
+ Text {
+ content: String,
+ tracked_buffers: Vec<Entity<Buffer>>,
+ },
+ Image(MentionImage),
+ Link,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MentionImage {
+ pub data: SharedString,
+ pub format: ImageFormat,
+}
+
+pub struct MentionSet {
+ project: WeakEntity<Project>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ mentions: HashMap<CreaseId, (MentionUri, MentionTask)>,
+}
+
+impl MentionSet {
+ pub fn new(
+ project: WeakEntity<Project>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ ) -> Self {
+ Self {
+ project,
+ history_store,
+ prompt_store,
+ mentions: HashMap::default(),
+ }
+ }
+
+ pub fn contents(
+ &self,
+ full_mention_content: bool,
+ cx: &mut App,
+ ) -> Task<Result<HashMap<CreaseId, (MentionUri, Mention)>>> {
+ 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<CreaseId> {
+ self.mentions.keys().cloned().collect()
+ }
+
+ pub fn mentions(&self) -> HashSet<MentionUri> {
+ self.mentions.values().map(|(uri, _)| uri.clone()).collect()
+ }
+
+ pub fn set_mentions(&mut self, mentions: HashMap<CreaseId, (MentionUri, MentionTask)>) {
+ self.mentions = mentions;
+ }
+
+ pub fn clear(&mut self) -> impl Iterator<Item = (CreaseId, (MentionUri, MentionTask))> {
+ 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<Editor>,
+ workspace: &Entity<Workspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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<Self>,
+ ) -> Task<Result<Mention>> {
+ 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<HttpClientWithUrl>,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Mention>> {
+ 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<u32>,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Mention>> {
+ 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<Self>,
+ ) -> Task<Result<Mention>> {
+ 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<text::Anchor>,
+ selections: Vec<(Entity<Buffer>, Range<text::Anchor>, Range<usize>)>,
+ editor: Entity<Editor>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ 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::<String>();
+ 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<Self>,
+ ) -> Task<Result<Mention>> {
+ 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::<agent::NativeAgentConnection>().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<Self>,
+ ) -> Task<Result<Mention>> {
+ 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<Editor>,
+ mention_set: Entity<MentionSet>,
+ window: &mut Window,
+ cx: &mut App,
+) -> Option<Task<()>> {
+ 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<_>, 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<Arc<Path>>,
+ image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
+ editor: Entity<Editor>,
+ 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<Anchor>,
+ editor_entity: WeakEntity<Editor>,
+) -> Crease<Anchor> {
+ 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<Editor>,
+) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &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<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
+ &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<Project>,
+ abs_path: &Path,
+ cx: &mut App,
+) -> Task<Result<Mention>> {
+ fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc<RelPath>, 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<RelPath>, 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<Anchor>,
+ mut loading_finished: postage::barrier::Receiver,
+ image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
+ editor: WeakEntity<Editor>,
+ cx: &mut App,
+) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &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<Anchor>,
+ editor: WeakEntity<Editor>,
+ loading: Option<Task<()>>,
+ image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
+}
+
+impl Render for LoadingContext {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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::<ImageHover>(|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<Arc<Image>>,
+ _task: Task<()>,
+}
+
+impl Render for ImageHover {
+ fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<HttpClientWithUrl>, url: String) -> Result<String> {
+ #[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<TagHandler> = 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)?
+ ))
+ }
+ }
+}
@@ -1,4 +1,4 @@
-use crate::{ManageProfiles, ToggleProfileSelector};
+use crate::{CycleModeSelector, ManageProfiles, ToggleProfileSelector};
use agent_settings::{
AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles,
};
@@ -70,6 +70,29 @@ impl ProfileSelector {
self.picker_handle.clone()
}
+ pub fn cycle_profile(&mut self, cx: &mut Context<Self>) {
+ if !self.provider.profiles_supported(cx) {
+ return;
+ }
+
+ let profiles = AgentProfile::available_profiles(cx);
+ if profiles.is_empty() {
+ return;
+ }
+
+ let current_profile_id = self.provider.profile_id(cx);
+ let current_index = profiles
+ .keys()
+ .position(|id| id == ¤t_profile_id)
+ .unwrap_or(0);
+
+ let next_index = (current_index + 1) % profiles.len();
+
+ if let Some((next_profile_id, _)) = profiles.get_index(next_index) {
+ self.provider.set_profile(next_profile_id.clone(), cx);
+ }
+ }
+
fn ensure_picker(
&mut self,
window: &mut Window,
@@ -163,14 +186,29 @@ impl Render for ProfileSelector {
PickerPopoverMenu::new(
picker,
trigger_button,
- move |_window, cx| {
- Tooltip::for_action_in(
- "Toggle Profile Menu",
- &ToggleProfileSelector,
- &focus_handle,
- cx,
- )
- },
+ Tooltip::element({
+ move |_window, cx| {
+ let container = || h_flex().gap_1().justify_between();
+ v_flex()
+ .gap_1()
+ .child(
+ container()
+ .pb_1()
+ .border_b_1()
+ .border_color(cx.theme().colors().border_variant)
+ .child(Label::new("Cycle Through Profiles"))
+ .child(KeyBinding::for_action_in(
+ &CycleModeSelector,
+ &focus_handle,
+ cx,
+ )),
+ )
+ .child(container().child(Label::new("Toggle Profile Menu")).child(
+ KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx),
+ ))
+ .into_any()
+ }
+ }),
gpui::Corner::BottomRight,
cx,
)
@@ -542,7 +580,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)
@@ -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<Editor>,
) -> bool {
let buffer = buffer.read(cx);
@@ -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<Arc<Telemetry>>,
terminal: Entity<Terminal>,
generation: Task<()>,
pub message_id: Option<String>,
transaction: Option<TerminalTransaction>,
+ session_id: Uuid,
}
impl EventEmitter<CodegenEvent> for TerminalCodegen {}
impl TerminalCodegen {
- pub fn new(terminal: Entity<Terminal>, telemetry: Option<Arc<Telemetry>>) -> Self {
+ pub fn new(terminal: Entity<Terminal>, 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<LanguageModelRequest>, cx: &mut Context<Self>) {
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<String> {
+ self.transaction
+ .as_ref()
+ .map(|transaction| transaction.completion.clone())
+ }
+
pub fn stop(&mut self, cx: &mut Context<Self>) {
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<Terminal>,
}
impl TerminalTransaction {
pub fn start(terminal: Entity<Terminal>) -> 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()));
}
@@ -1,6 +1,5 @@
use crate::{
context::load_context,
- context_store::ContextStore,
inline_prompt_editor::{
CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId,
},
@@ -9,7 +8,7 @@ use crate::{
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<dyn Fs>,
- prompt_builder: Arc<PromptBuilder>,
- telemetry: Arc<Telemetry>,
- cx: &mut App,
-) {
- cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder, telemetry));
+pub fn init(fs: Arc<dyn Fs>, prompt_builder: Arc<PromptBuilder>, 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<TerminalInlineAssistId, TerminalInlineAssist>,
prompt_history: VecDeque<String>,
- telemetry: Option<Arc<Telemetry>>,
fs: Arc<dyn Fs>,
prompt_builder: Arc<PromptBuilder>,
}
@@ -53,16 +46,11 @@ pub struct TerminalInlineAssistant {
impl Global for TerminalInlineAssistant {}
impl TerminalInlineAssistant {
- pub fn new(
- fs: Arc<dyn Fs>,
- prompt_builder: Arc<PromptBuilder>,
- telemetry: Arc<Telemetry>,
- ) -> Self {
+ pub fn new(fs: Arc<dyn Fs>, prompt_builder: Arc<PromptBuilder>) -> Self {
Self {
next_assist_id: TerminalInlineAssistId::default(),
assists: HashMap::default(),
prompt_history: VecDeque::default(),
- telemetry: Some(telemetry),
fs,
prompt_builder,
}
@@ -73,22 +61,22 @@ impl TerminalInlineAssistant {
terminal_view: &Entity<TerminalView>,
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
+ thread_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
- thread_store: Option<WeakEntity<HistoryStore>>,
initial_prompt: Option<String>,
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));
- 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(
@@ -96,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(),
- prompt_store.as_ref().map(|s| s.downgrade()),
+ prompt_store.clone(),
+ project.clone(),
+ workspace.clone(),
window,
cx,
)
@@ -119,8 +108,6 @@ impl TerminalInlineAssistant {
terminal_view,
prompt_editor,
workspace.clone(),
- context_store,
- prompt_store,
window,
cx,
);
@@ -140,7 +127,7 @@ impl TerminalInlineAssistant {
if let Some(prompt_editor) = assist.prompt_editor.as_ref() {
prompt_editor.update(cx, |this, cx| {
this.editor.update(cx, |editor, cx| {
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
editor.select_all(&SelectAll, window, cx);
});
});
@@ -227,6 +214,10 @@ impl TerminalInlineAssistant {
assist_id: TerminalInlineAssistId,
cx: &mut App,
) -> Result<Task<LanguageModelRequest>> {
+ 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();
@@ -243,45 +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::<Vec<_>>();
- 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
- .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());
@@ -315,7 +292,7 @@ impl TerminalInlineAssistant {
.terminal
.update(cx, |this, cx| {
this.clear_block_below_cursor(cx);
- this.focus_handle(cx).focus(window);
+ this.focus_handle(cx).focus(window, cx);
})
.log_err();
@@ -323,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,
);
}
@@ -374,7 +369,7 @@ impl TerminalInlineAssistant {
.terminal
.update(cx, |this, cx| {
this.clear_block_below_cursor(cx);
- this.focus_handle(cx).focus(window);
+ this.focus_handle(cx).focus(window, cx);
})
.is_ok()
}
@@ -409,8 +404,6 @@ struct TerminalInlineAssist {
prompt_editor: Option<Entity<PromptEditor<TerminalCodegen>>>,
codegen: Entity<TerminalCodegen>,
workspace: WeakEntity<Workspace>,
- context_store: Entity<ContextStore>,
- prompt_store: Option<Entity<PromptStore>>,
_subscriptions: Vec<Subscription>,
}
@@ -420,8 +413,6 @@ impl TerminalInlineAssist {
terminal: &Entity<TerminalView>,
prompt_editor: Entity<PromptEditor<TerminalCodegen>>,
workspace: WeakEntity<Workspace>,
- context_store: Entity<ContextStore>,
- prompt_store: Option<Entity<PromptStore>>,
window: &mut Window,
cx: &mut App,
) -> Self {
@@ -431,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| {
@@ -2,15 +2,15 @@ use crate::{
language_model_selector::{LanguageModelSelector, language_model_selector},
ui::BurnModeTooltip,
};
-use agent_settings::CompletionMode;
+use agent_settings::{AgentSettings, CompletionMode};
use anyhow::Result;
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection, SlashCommandWorkingSet};
use assistant_slash_commands::{DefaultSlashCommand, FileSlashCommand, selections_creases};
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,
@@ -22,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,
@@ -66,13 +66,15 @@ 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::{AddSelectionToThread, ToggleModelSelector};
+use crate::CycleFavoriteModels;
+
use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker};
use assistant_text_thread::{
CacheStatus, Content, InvokedSlashCommandId, InvokedSlashCommandStatus, Message, MessageId,
@@ -280,6 +282,8 @@ impl TextThreadEditor {
.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 {
text_thread,
slash_commands,
@@ -302,19 +306,34 @@ impl TextThreadEditor {
language_model_selector: cx.new(|cx| {
language_model_selector(
|cx| LanguageModelRegistry::read_global(cx).default_model(),
- move |model, cx| {
- update_settings_file(fs.clone(), cx, move |settings, _| {
- let provider = model.provider_id().0.to_string();
- let model = model.id().0.to_string();
- settings.agent.get_or_insert_default().set_model(
- LanguageModelSelection {
- provider: LanguageModelProviderSetting(provider),
- model,
- },
- )
- });
+ {
+ let fs = fs.clone();
+ move |model, cx| {
+ update_settings_file(fs.clone(), cx, move |settings, _| {
+ let provider = model.provider_id().0.to_string();
+ let model = model.id().0.to_string();
+ settings.agent.get_or_insert_default().set_model(
+ LanguageModelSelection {
+ provider: LanguageModelProviderSetting(provider),
+ model,
+ },
+ )
+ });
+ }
+ },
+ {
+ let fs = fs.clone();
+ move |model, should_be_favorite, cx| {
+ crate::favorite_models::toggle_in_settings(
+ model,
+ should_be_favorite,
+ fs.clone(),
+ cx,
+ );
+ }
},
true, // Use popover styles for picker
+ focus_handle,
window,
cx,
)
@@ -390,7 +409,7 @@ impl TextThreadEditor {
let cursor = user_message
.start
.to_offset(self.text_thread.read(cx).buffer().read(cx));
- cursor..cursor
+ MultiBufferOffset(cursor)..MultiBufferOffset(cursor)
};
self.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
@@ -431,7 +450,7 @@ impl TextThreadEditor {
let cursors = self.cursors(cx);
self.text_thread.update(cx, |text_thread, cx| {
let messages = text_thread
- .messages_for_offsets(cursors, cx)
+ .messages_for_offsets(cursors.into_iter().map(|cursor| cursor.0), cx)
.into_iter()
.map(|message| message.id)
.collect();
@@ -439,9 +458,11 @@ impl TextThreadEditor {
});
}
- fn cursors(&self, cx: &mut App) -> Vec<usize> {
+ fn cursors(&self, cx: &mut App) -> Vec<MultiBufferOffset> {
let selections = self.editor.update(cx, |editor, cx| {
- editor.selections.all::<usize>(&editor.display_snapshot(cx))
+ editor
+ .selections
+ .all::<MultiBufferOffset>(&editor.display_snapshot(cx))
});
selections
.into_iter()
@@ -1320,7 +1341,7 @@ impl TextThreadEditor {
if let Some((text, _)) = Self::get_selection_or_code_block(&context_editor_view, cx) {
active_editor_view.update(cx, |editor, cx| {
editor.insert(&text, window, cx);
- editor.focus_handle(cx).focus(window);
+ editor.focus_handle(cx).focus(window, cx);
})
}
}
@@ -1580,7 +1601,11 @@ impl TextThreadEditor {
fn get_clipboard_contents(
&mut self,
cx: &mut Context<Self>,
- ) -> (String, CopyMetadata, Vec<text::Selection<usize>>) {
+ ) -> (
+ String,
+ CopyMetadata,
+ Vec<text::Selection<MultiBufferOffset>>,
+ ) {
let (mut selection, creases) = self.editor.update(cx, |editor, cx| {
let mut selection = editor
.selections
@@ -1638,30 +1663,26 @@ impl TextThreadEditor {
// If selection is empty, we want to copy the entire line
if selection.range().is_empty() {
- let snapshot = text_thread.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 text_thread
- .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 text_thread.messages(cx) {
- if message.offset_range.start >= selection.range().end {
+ 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 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');
}
}
@@ -1677,9 +1698,101 @@ impl TextThreadEditor {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ 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::<Vec<editor::ClipboardSelection>>()
+ }
+ _ => 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::<Point>(&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 {
@@ -1693,6 +1806,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 {
@@ -1709,7 +1856,7 @@ impl TextThreadEditor {
self.editor.update(cx, |editor, cx| {
let paste_position = editor
.selections
- .newest::<usize>(&editor.display_snapshot(cx))
+ .newest::<MultiBufferOffset>(&editor.display_snapshot(cx))
.head();
editor.paste(action, window, cx);
@@ -1757,13 +1904,16 @@ impl TextThreadEditor {
editor.transact(window, cx, |editor, _window, cx| {
let edits = editor
.selections
- .all::<usize>(&editor.display_snapshot(cx))
+ .all::<MultiBufferOffset>(&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::<usize>(&editor.display_snapshot(cx)) {
+ for selection in editor
+ .selections
+ .all::<MultiBufferOffset>(&editor.display_snapshot(cx))
+ {
image_positions.push(snapshot.anchor_before(selection.end));
}
});
@@ -1855,7 +2005,7 @@ impl TextThreadEditor {
let range = selection
.map(|endpoint| endpoint.to_offset(&buffer))
.range();
- text_thread.split_message(range, cx);
+ text_thread.split_message(range.start.0..range.end.0, cx);
}
});
}
@@ -2061,12 +2211,53 @@ 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)
};
+ let tooltip = Tooltip::element({
+ move |_, cx| {
+ let focus_handle = focus_handle.clone();
+ let should_show_cycle_row = !AgentSettings::get_global(cx)
+ .favorite_model_ids()
+ .is_empty();
+
+ v_flex()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .justify_between()
+ .child(Label::new("Change Model"))
+ .child(KeyBinding::for_action_in(
+ &ToggleModelSelector,
+ &focus_handle,
+ cx,
+ )),
+ )
+ .when(should_show_cycle_row, |this| {
+ this.child(
+ h_flex()
+ .pt_1()
+ .gap_2()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
+ .justify_between()
+ .child(Label::new("Cycle Favorited Models"))
+ .child(KeyBinding::for_action_in(
+ &CycleFavoriteModels,
+ &focus_handle,
+ cx,
+ )),
+ )
+ })
+ .into_any()
+ }
+ });
+
PickerPopoverMenu::new(
self.language_model_selector.clone(),
ButtonLike::new("active-model")
@@ -2083,9 +2274,7 @@ impl TextThreadEditor {
)
.child(Icon::new(icon).color(color).size(IconSize::XSmall)),
),
- move |_window, cx| {
- Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
- },
+ tooltip,
gpui::Corner::BottomRight,
cx,
)
@@ -2445,6 +2634,11 @@ impl Render for TextThreadEditor {
.on_action(move |_: &ToggleModelSelector, window, cx| {
language_model_selector.toggle(window, cx);
})
+ .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
+ this.language_model_selector.update(cx, |selector, cx| {
+ selector.delegate.cycle_favorite_models(window, cx);
+ });
+ }))
.size_full()
.child(
div()
@@ -2514,7 +2708,11 @@ impl Item for TextThreadEditor {
Some(self.title(cx).to_string().into())
}
- fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(
+ &self,
+ handle: &Entity<Self>,
+ _: &App,
+ ) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(handle.clone()))
}
@@ -2549,11 +2747,11 @@ impl Item for TextThreadEditor {
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
- ) -> Option<AnyView> {
+ ) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
- Some(self_handle.to_any())
+ Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
- Some(self.editor.to_any())
+ Some(self.editor.clone().into())
} else {
None
}
@@ -2576,11 +2774,13 @@ impl SearchableItem for TextThreadEditor {
fn update_matches(
&mut self,
matches: &[Self::Match],
+ active_match_index: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
- 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<Self>) -> String {
@@ -2592,12 +2792,11 @@ impl SearchableItem for TextThreadEditor {
&mut self,
index: usize,
matches: &[Self::Match],
- collapse: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
- editor.activate_match(index, matches, collapse, window, cx);
+ editor.activate_match(index, matches, window, cx);
});
}
@@ -2930,7 +3129,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;
@@ -3136,15 +3335,16 @@ mod tests {
text_thread: &Entity<TextThread>,
message_ix: usize,
cx: &mut TestAppContext,
- ) -> Range<usize> {
- text_thread.update(cx, |text_thread, cx| {
+ ) -> Range<MultiBufferOffset> {
+ let range = text_thread.update(cx, |text_thread, cx| {
text_thread
.messages(cx)
.nth(message_ix)
.unwrap()
.anchor_range
.to_offset(&text_thread.buffer().read(cx).snapshot())
- })
+ });
+ MultiBufferOffset(range.start)..MultiBufferOffset(range.end)
}
fn assert_copy_paste_text_thread_editor<T: editor::ToOffset>(
@@ -3184,7 +3384,6 @@ mod tests {
let mut text_thread = TextThread::local(
registry,
None,
- None,
prompt_builder.clone(),
Arc::new(SlashCommandWorkingSet::default()),
cx,
@@ -2,18 +2,18 @@ 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 model_selector_components;
mod onboarding_modal;
-mod unavailable_editing_tooltip;
mod usage_callout;
pub use acp_onboarding_modal::*;
pub use agent_notification::*;
pub use burn_mode_tooltip::*;
pub use claude_code_onboarding_modal::*;
-pub use context_pill::*;
pub use end_trial_upsell::*;
+pub use hold_for_default::*;
+pub use model_selector_components::*;
pub use onboarding_modal::*;
-pub use unavailable_editing_tooltip::*;
pub use usage_callout::*;
@@ -222,8 +222,8 @@ impl Render for AcpOnboardingModal {
acp_onboarding_event!("Canceled", trigger = "Action");
cx.emit(DismissEvent);
}))
- .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
- this.focus_handle.focus(window);
+ .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+ this.focus_handle.focus(window, cx);
}))
.child(illustration)
.child(
@@ -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()
@@ -230,8 +230,8 @@ impl Render for ClaudeCodeOnboardingModal {
claude_code_onboarding_event!("Canceled", trigger = "Action");
cx.emit(DismissEvent);
}))
- .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
- this.focus_handle.focus(window);
+ .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+ this.focus_handle.focus(window, cx);
}))
.child(illustration)
.child(
@@ -1,858 +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 util::paths::PathStyle;
-
-use crate::context::{
- AgentContextHandle, ContextId, ContextKind, DirectoryContextHandle, FetchedUrlContext,
- FileContextHandle, ImageContext, ImageStatus, RulesContextHandle, SelectionContextHandle,
- SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle,
-};
-
-#[derive(IntoElement)]
-pub enum ContextPill {
- Added {
- context: AddedContext,
- dupe_name: bool,
- focused: bool,
- on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
- on_remove: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
- },
- Suggested {
- name: SharedString,
- icon_path: Option<SharedString>,
- kind: ContextKind,
- focused: bool,
- on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
- },
-}
-
-impl ContextPill {
- pub fn added(
- context: AddedContext,
- dupe_name: bool,
- focused: bool,
- on_remove: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
- ) -> Self {
- Self::Added {
- context,
- dupe_name,
- on_remove,
- focused,
- on_click: None,
- }
- }
-
- pub fn suggested(
- name: SharedString,
- icon_path: Option<SharedString>,
- kind: ContextKind,
- focused: bool,
- ) -> Self {
- Self::Suggested {
- name,
- icon_path,
- kind,
- focused,
- on_click: None,
- }
- }
-
- pub fn on_click(mut self, listener: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>) -> 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", 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<SharedString>,
- pub tooltip: Option<SharedString>,
- pub icon_path: Option<SharedString>,
- pub status: ContextStatus,
- pub render_hover: Option<Rc<dyn Fn(&mut Window, &mut App) -> 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<PromptStore>>,
- project: &Project,
- model: Option<&Arc<dyn language_model::LanguageModel>>,
- cx: &App,
- ) -> Option<AddedContext> {
- 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<AddedContext> {
- 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<AddedContext> {
- 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<AddedContext> {
- 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<AddedContext> {
- 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_summary() {
- ContextStatus::Loading {
- message: "Summarizing…".into(),
- }
- } else {
- ContextStatus::Ready
- },
- render_hover: {
- let thread = handle.thread.clone();
- Some(Rc::new(move |_, cx| {
- let text = thread
- .update(cx, |thread, cx| thread.summary(cx))
- .now_or_never()
- .flatten()
- .unwrap_or_else(|| SharedString::from(thread.read(cx).to_markdown()));
- ContextPillHover::new_text(text, cx).into()
- }))
- },
- handle: AgentContextHandle::Thread(handle),
- }
- }
-
- fn 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 text_thread = handle.text_thread.clone();
- Some(Rc::new(move |_, cx| {
- let text = text_thread.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<PromptStore>>,
- cx: &App,
- ) -> Option<AddedContext> {
- 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<dyn language_model::LanguageModel>>,
- 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<SharedString>) {
- 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<SharedString>,
- pub icon_path: Option<SharedString>,
-}
-
-impl ContextFileExcerpt {
- pub fn new(full_path: &str, line_range: Range<Point>, 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<ContextPillHover> {
- 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<dyn Fn(&mut Window, &mut App) -> AnyElement>,
-}
-
-impl ContextPillHover {
- fn new(
- cx: &mut App,
- render_hover: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
- ) -> Entity<Self> {
- cx.new(|_| Self {
- render_hover: Box::new(render_hover),
- })
- }
-
- fn new_text(content: SharedString, cx: &mut App) -> Entity<Self> {
- 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<Self>) -> 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<AnyElement> {
- 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<dyn LanguageModel> = 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());
- }
-}
@@ -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")
+ }
+ }))
+ }
+}
@@ -0,0 +1,176 @@
+use gpui::{Action, FocusHandle, prelude::*};
+use ui::{ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
+
+#[derive(IntoElement)]
+pub struct ModelSelectorHeader {
+ title: SharedString,
+ has_border: bool,
+}
+
+impl ModelSelectorHeader {
+ pub fn new(title: impl Into<SharedString>, has_border: bool) -> Self {
+ Self {
+ title: title.into(),
+ has_border,
+ }
+ }
+}
+
+impl RenderOnce for ModelSelectorHeader {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ div()
+ .px_2()
+ .pb_1()
+ .when(self.has_border, |this| {
+ this.mt_1()
+ .pt_2()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
+ })
+ .child(
+ Label::new(self.title)
+ .size(LabelSize::XSmall)
+ .color(Color::Muted),
+ )
+ }
+}
+
+#[derive(IntoElement)]
+pub struct ModelSelectorListItem {
+ index: usize,
+ title: SharedString,
+ icon: Option<IconName>,
+ is_selected: bool,
+ is_focused: bool,
+ is_favorite: bool,
+ on_toggle_favorite: Option<Box<dyn Fn(&App) + 'static>>,
+}
+
+impl ModelSelectorListItem {
+ pub fn new(index: usize, title: impl Into<SharedString>) -> Self {
+ Self {
+ index,
+ title: title.into(),
+ icon: None,
+ is_selected: false,
+ is_focused: false,
+ is_favorite: false,
+ on_toggle_favorite: None,
+ }
+ }
+
+ pub fn icon(mut self, icon: IconName) -> Self {
+ self.icon = Some(icon);
+ self
+ }
+
+ pub fn is_selected(mut self, is_selected: bool) -> Self {
+ self.is_selected = is_selected;
+ self
+ }
+
+ pub fn is_focused(mut self, is_focused: bool) -> Self {
+ self.is_focused = is_focused;
+ self
+ }
+
+ pub fn is_favorite(mut self, is_favorite: bool) -> Self {
+ self.is_favorite = is_favorite;
+ self
+ }
+
+ pub fn on_toggle_favorite(mut self, handler: impl Fn(&App) + 'static) -> Self {
+ self.on_toggle_favorite = Some(Box::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for ModelSelectorListItem {
+ fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+ let model_icon_color = if self.is_selected {
+ Color::Accent
+ } else {
+ Color::Muted
+ };
+
+ let is_favorite = self.is_favorite;
+
+ ListItem::new(self.index)
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(self.is_focused)
+ .child(
+ h_flex()
+ .w_full()
+ .gap_1p5()
+ .when_some(self.icon, |this, icon| {
+ this.child(
+ Icon::new(icon)
+ .color(model_icon_color)
+ .size(IconSize::Small),
+ )
+ })
+ .child(Label::new(self.title).truncate()),
+ )
+ .end_slot(div().pr_2().when(self.is_selected, |this| {
+ this.child(Icon::new(IconName::Check).color(Color::Accent))
+ }))
+ .end_hover_slot(div().pr_1p5().when_some(self.on_toggle_favorite, {
+ |this, handle_click| {
+ let (icon, color, tooltip) = if is_favorite {
+ (IconName::StarFilled, Color::Accent, "Unfavorite Model")
+ } else {
+ (IconName::Star, Color::Default, "Favorite Model")
+ };
+ this.child(
+ IconButton::new(("toggle-favorite", self.index), icon)
+ .layer(ElevationIndex::ElevatedSurface)
+ .icon_color(color)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text(tooltip))
+ .on_click(move |_, _, cx| (handle_click)(cx)),
+ )
+ }
+ }))
+ }
+}
+
+#[derive(IntoElement)]
+pub struct ModelSelectorFooter {
+ action: Box<dyn Action>,
+ focus_handle: FocusHandle,
+}
+
+impl ModelSelectorFooter {
+ pub fn new(action: Box<dyn Action>, focus_handle: FocusHandle) -> Self {
+ Self {
+ action,
+ focus_handle,
+ }
+ }
+}
+
+impl RenderOnce for ModelSelectorFooter {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let action = self.action;
+ let focus_handle = self.focus_handle;
+
+ 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(action.as_ref(), &focus_handle, cx)
+ .map(|kb| kb.size(rems_from_px(12.))),
+ )
+ .on_click(move |_, window, cx| {
+ window.dispatch_action(action.boxed_clone(), cx);
+ }),
+ )
+ }
+}
@@ -83,8 +83,8 @@ impl Render for AgentOnboardingModal {
agent_onboarding_event!("Canceled", trigger = "Action");
cx.emit(DismissEvent);
}))
- .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
- this.focus_handle.focus(window);
+ .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+ this.focus_handle.focus(window, cx);
}))
.child(
div()
@@ -1,29 +0,0 @@
-use gpui::{Context, IntoElement, Render, Window};
-use ui::{prelude::*, tooltip_container};
-
-pub struct UnavailableEditingTooltip {
- agent_name: SharedString,
-}
-
-impl UnavailableEditingTooltip {
- pub fn new(agent_name: SharedString) -> Self {
- Self { agent_name }
- }
-}
-
-impl Render for UnavailableEditingTooltip {
- fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- tooltip_container(cx, |this, _| {
- this.child(Label::new("Unavailable Editing")).child(
- div().max_w_64().child(
- Label::new(format!(
- "Editing previous messages is not available for {} yet.",
- self.agent_name
- ))
- .size(LabelSize::Small)
- .color(Color::Muted),
- ),
- )
- })
- }
-}
@@ -0,0 +1,47 @@
+[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
+
+[features]
+test-support = ["agent/test-support"]
+
+
+[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
+
+[dev-dependencies]
+agent = { workspace = true, features = ["test-support"] }
@@ -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<HistoryEntryId> 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<Pixels>,
+ pub thread_id: Option<SerializedHistoryEntryId>,
+}
+
+pub enum AgentsUtilityPaneEvent {
+ StateChanged,
+}
+
+impl EventEmitter<AgentsUtilityPaneEvent> for AgentThreadPane {}
+impl EventEmitter<MinimizePane> for AgentThreadPane {}
+impl EventEmitter<ClosePane> for AgentThreadPane {}
+
+struct ActiveThreadView {
+ view: Entity<AcpThreadView>,
+ thread_id: HistoryEntryId,
+ _notify: Subscription,
+}
+
+pub struct AgentThreadPane {
+ focus_handle: gpui::FocusHandle,
+ expanded: bool,
+ width: Option<Pixels>,
+ thread_view: Option<ActiveThreadView>,
+ workspace: WeakEntity<Workspace>,
+}
+
+impl AgentThreadPane {
+ pub fn new(workspace: WeakEntity<Workspace>, cx: &mut ui::Context<Self>) -> Self {
+ let focus_handle = cx.focus_handle();
+ Self {
+ focus_handle,
+ expanded: false,
+ width: None,
+ thread_view: None,
+ workspace,
+ }
+ }
+
+ pub fn thread_id(&self) -> Option<HistoryEntryId> {
+ 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<dyn Fs>,
+ workspace: WeakEntity<Workspace>,
+ project: Entity<Project>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let thread_id = entry.id();
+
+ let resume_thread = match &entry {
+ HistoryEntry::AcpThread(thread) => Some(thread.clone()),
+ HistoryEntry::TextThread(_) => None,
+ };
+
+ let agent: Rc<dyn AgentServer> = 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<Self>) -> 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<Workspace>| {
+ 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>) {
+ 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<Pixels>, cx: &mut Context<Self>) {
+ self.width = width;
+ cx.emit(AgentsUtilityPaneEvent::StateChanged);
+ cx.notify();
+ }
+}
+
+impl Render for AgentThreadPane {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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)
+ }
+}
@@ -0,0 +1,4 @@
+mod agent_thread_pane;
+mod thread_history;
+
+pub mod agents_panel;
@@ -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<Pixels>,
+ pane: Option<SerializedAgentThreadPane>,
+}
+
+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::<AgentsPanel>(window, cx);
+ });
+ })
+ .detach();
+}
+
+pub struct AgentsPanel {
+ focus_handle: gpui::FocusHandle,
+ workspace: WeakEntity<Workspace>,
+ project: Entity<Project>,
+ agent_thread_pane: Option<Entity<AgentThreadPane>>,
+ history: Entity<AcpThreadHistory>,
+ history_store: Entity<HistoryStore>,
+ prompt_store: Option<Entity<PromptStore>>,
+ fs: Arc<dyn Fs>,
+ width: Option<Pixels>,
+ pending_serialization: Task<Option<()>>,
+ _subscriptions: Vec<Subscription>,
+}
+
+impl AgentsPanel {
+ pub fn load(
+ workspace: WeakEntity<Workspace>,
+ cx: AsyncWindowContext,
+ ) -> Task<Result<Entity<Self>, 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::<SerializedAgentsPanel>(&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<Workspace>,
+ fs: Arc<dyn Fs>,
+ project: Entity<Project>,
+ prompt_store: Option<Entity<PromptStore>>,
+ text_thread_store: Entity<TextThreadStore>,
+ window: &mut Window,
+ cx: &mut ui::Context<Self>,
+ ) -> 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<Self>,
+ ) {
+ 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<AgentThreadPane>,
+ event: &AgentsUtilityPaneEvent,
+ cx: &mut Context<Self>,
+ ) {
+ match event {
+ AgentsUtilityPaneEvent::StateChanged => {
+ self.serialize(cx);
+ cx.notify();
+ }
+ }
+ }
+
+ fn handle_close_pane_event(
+ &mut self,
+ _utility_pane: Entity<AgentThreadPane>,
+ _event: &ClosePane,
+ cx: &mut Context<Self>,
+ ) {
+ self.agent_thread_pane = None;
+ self.serialize(cx);
+ cx.notify();
+ }
+
+ fn handle_history_event(
+ &mut self,
+ _history: &Entity<AcpThreadHistory>,
+ event: &ThreadHistoryEvent,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ 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<Pixels>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<PanelEvent> 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<Self>,
+ ) {
+ 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<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
+ 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<IconName> {
+ (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<dyn Action> {
+ Box::new(ToggleAgentsPanel)
+ }
+
+ fn activation_priority(&self) -> u32 {
+ 4
+ }
+
+ fn enabled(&self, cx: &App) -> bool {
+ AgentSettings::get_global(cx).enabled(cx) && cx.has_flag::<AgentV2FeatureFlag>()
+ }
+}
+
+impl Render for AgentsPanel {
+ fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+ gpui::div().size_full().child(self.history.clone())
+ }
+}
@@ -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<HistoryStore>,
+ scroll_handle: UniformListScrollHandle,
+ selected_index: usize,
+ hovered_index: Option<usize>,
+ search_editor: Entity<Editor>,
+ search_query: SharedString,
+ visible_items: Vec<ListItemType>,
+ local_timezone: UtcOffset,
+ confirming_delete_history: bool,
+ _update_task: Task<()>,
+ _subscriptions: Vec<gpui::Subscription>,
+}
+
+enum ListItemType {
+ BucketSeparator(TimeBucket),
+ Entry {
+ entry: HistoryEntry,
+ format: EntryTimeFormat,
+ },
+ SearchResult {
+ entry: HistoryEntry,
+ positions: Vec<usize>,
+ },
+}
+
+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<ThreadHistoryEvent> for AcpThreadHistory {}
+
+impl AcpThreadHistory {
+ pub fn new(
+ history_store: Entity<agent::HistoryStore>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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<Self>) {
+ 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<HistoryEntry>, cx: &App) -> Task<Vec<ListItemType>> {
+ 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<HistoryEntry>,
+ cx: &App,
+ ) -> Task<Vec<ListItemType>> {
+ 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<Self>) {
+ 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<Self>,
+ ) {
+ 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<Self>,
+ ) {
+ 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>,
+ ) {
+ self.set_selected_index(0, Bias::Right, cx);
+ }
+
+ fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+ 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>) {
+ self.confirm_entry(self.selected_index, cx);
+ }
+
+ fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
+ 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>,
+ ) {
+ self.remove_thread(self.selected_index, cx)
+ }
+
+ fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
+ 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>) {
+ 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>) {
+ self.confirming_delete_history = true;
+ cx.notify();
+ }
+
+ fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+ self.confirming_delete_history = false;
+ cx.notify();
+ }
+
+ fn render_list_items(
+ &mut self,
+ range: Range<usize>,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Vec<AnyElement> {
+ 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<Self>) -> 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<usize>,
+ cx: &Context<Self>,
+ ) -> 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::<IconButton>(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<Self>) -> 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<usize>, 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<TimeBucket> 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);
+ }
+}
@@ -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(
@@ -131,6 +140,14 @@ impl Model {
}
pub fn from_id(id: &str) -> Result<Self> {
+ 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);
}
@@ -208,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",
@@ -230,6 +249,7 @@ 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",
@@ -249,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",
@@ -274,6 +296,8 @@ impl Model {
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4Thinking
| Self::ClaudeOpus4_1Thinking
+ | Self::ClaudeOpus4_5
+ | Self::ClaudeOpus4_5Thinking
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::ClaudeSonnet4_5
@@ -303,6 +327,8 @@ impl Model {
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4Thinking
| Self::ClaudeOpus4_1Thinking
+ | Self::ClaudeOpus4_5
+ | Self::ClaudeOpus4_5Thinking
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::ClaudeSonnet4_5
@@ -326,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
@@ -348,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
@@ -372,6 +402,7 @@ impl Model {
match self {
Self::ClaudeOpus4
| Self::ClaudeOpus4_1
+ | Self::ClaudeOpus4_5
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4_5
| Self::Claude3_5Sonnet
@@ -383,6 +414,7 @@ impl Model {
| Self::Claude3Haiku => AnthropicModelMode::Default,
Self::ClaudeOpus4Thinking
| Self::ClaudeOpus4_1Thinking
+ | Self::ClaudeOpus4_5Thinking
| Self::ClaudeSonnet4Thinking
| Self::ClaudeSonnet4_5Thinking
| Self::ClaudeHaiku4_5Thinking
@@ -393,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::<Vec<_>>();
+ pub fn beta_headers(&self) -> Option<String> {
+ 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, ..
@@ -420,7 +461,11 @@ impl Model {
_ => {}
}
- headers.join(",")
+ if headers.is_empty() {
+ None
+ } else {
+ Some(headers.join(","))
+ }
}
pub fn tool_model_id(&self) -> &str {
@@ -436,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: Option<String>,
+) -> Result<BoxStream<'static, Result<Event, AnthropicError>>, 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: String,
+ beta_headers: Option<String>,
) -> Result<Response, AnthropicError> {
+ 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<String>,
+) -> Result<(http::Response<AsyncBody>, 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<AsyncBody>,
+ 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<BoxStream<'static, Result<Event, AnthropicError>>, 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::<Event>(&body) {
+ Ok(Event::Error { error }) => AnthropicError::ApiError(error),
+ Ok(_) | Err(_) => AnthropicError::HttpResponseError {
+ status_code: response.status(),
+ message: body,
+ },
+ }
}
/// An individual rate limit.
@@ -583,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<String>,
) -> Result<
(
BoxStream<'static, Result<Event, AnthropicError>>,
@@ -595,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
@@ -633,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::<Event>(&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)
}
}
@@ -990,6 +1052,71 @@ pub fn parse_prompt_too_long(message: &str) -> Option<u64> {
.ok()
}
+/// Request body for the token counting API.
+/// Similar to `Request` but without `max_tokens` since it's not needed for counting.
+#[derive(Debug, Serialize)]
+pub struct CountTokensRequest {
+ pub model: String,
+ pub messages: Vec<Message>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub system: Option<StringOrContents>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub tools: Vec<Tool>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub thinking: Option<Thinking>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tool_choice: Option<ToolChoice>,
+}
+
+/// Response from the token counting API.
+#[derive(Debug, Deserialize)]
+pub struct CountTokensResponse {
+ pub input_tokens: u64,
+}
+
+/// Count the number of tokens in a message without creating it.
+pub async fn count_tokens(
+ client: &dyn HttpClient,
+ api_url: &str,
+ api_key: &str,
+ request: CountTokensRequest,
+) -> Result<CountTokensResponse, AnthropicError> {
+ let uri = format!("{api_url}/v1/messages/count_tokens");
+
+ 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(handle_error_response(response, rate_limits).await)
+ }
+}
+
#[test]
fn test_match_window_exceeded() {
let error = ApiError {
@@ -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<BatchRequest>,
+}
+
+#[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<String>,
+ pub created_at: String,
+ pub expires_at: String,
+ pub archived_at: Option<String>,
+ pub cancel_initiated_at: Option<String>,
+ pub results_url: Option<String>,
+}
+
+#[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<MessageBatch, AnthropicError> {
+ 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<MessageBatch, AnthropicError> {
+ 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<Vec<BatchIndividualResponse>, 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)
+ }
+}
@@ -205,13 +205,9 @@ impl PasswordProxy {
} else {
ShellKind::Posix
};
- let askpass_program = ASKPASS_PROGRAM
- .get_or_init(|| current_exec)
- .try_shell_safe(shell_kind)
- .context("Failed to shell-escape Askpass program path.")?
- .to_string();
+ 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 =
@@ -253,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()
);
@@ -334,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<String> {
+ 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<String> {
+ 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(),
- )
+ ))
}
@@ -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
@@ -233,18 +233,11 @@ fn collect_diagnostics(
options: Options,
cx: &mut App,
) -> Task<Result<Option<SlashCommandOutput>>> {
- 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;
}
@@ -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::<anyhow::Result<Vec<custom_path_matcher::PathMatcher>>>()
+ .collect::<anyhow::Result<Vec<util::paths::PathMatcher>>>()
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<Arc<RelPath>> = Vec::new();
+ let mut folded_directory_path: Option<Arc<RelPath>> = None;
let mut folded_directory_names: Arc<RelPath> = 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<String>,
- sources_with_trailing_slash: Vec<String>,
- 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<Self, globset::Error> {
- let globs = globs
- .iter()
- .map(|glob| Glob::new(&SanitizedPath::new(glob).to_string()))
- .collect::<Result<Vec<_>, _>>()?;
- 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>,
@@ -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,7 +46,7 @@ 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
@@ -50,7 +50,6 @@ fn test_inserting_and_removing_messages(cx: &mut App) {
TextThread::local(
registry,
None,
- None,
prompt_builder.clone(),
Arc::new(SlashCommandWorkingSet::default()),
cx,
@@ -189,7 +188,6 @@ fn test_message_splitting(cx: &mut App) {
TextThread::local(
registry.clone(),
None,
- None,
prompt_builder.clone(),
Arc::new(SlashCommandWorkingSet::default()),
cx,
@@ -294,7 +292,6 @@ fn test_messages_for_offsets(cx: &mut App) {
TextThread::local(
registry,
None,
- None,
prompt_builder.clone(),
Arc::new(SlashCommandWorkingSet::default()),
cx,
@@ -405,7 +402,6 @@ async fn test_slash_commands(cx: &mut TestAppContext) {
TextThread::local(
registry.clone(),
None,
- None,
prompt_builder.clone(),
Arc::new(SlashCommandWorkingSet::default()),
cx,
@@ -677,7 +673,6 @@ async fn test_serialization(cx: &mut TestAppContext) {
TextThread::local(
registry.clone(),
None,
- None,
prompt_builder.clone(),
Arc::new(SlashCommandWorkingSet::default()),
cx,
@@ -724,7 +719,6 @@ async fn test_serialization(cx: &mut TestAppContext) {
prompt_builder.clone(),
Arc::new(SlashCommandWorkingSet::default()),
None,
- None,
cx,
)
});
@@ -780,7 +774,6 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std
prompt_builder.clone(),
Arc::new(SlashCommandWorkingSet::default()),
None,
- None,
cx,
)
});
@@ -880,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(),
@@ -1042,7 +1034,6 @@ fn test_mark_cache_anchors(cx: &mut App) {
TextThread::local(
registry,
None,
- None,
prompt_builder.clone(),
Arc::new(SlashCommandWorkingSet::default()),
cx,
@@ -1369,7 +1360,6 @@ fn setup_context_editor_with_fake_model(
TextThread::local(
registry,
None,
- None,
prompt_builder.clone(),
Arc::new(SlashCommandWorkingSet::default()),
cx,
@@ -5,22 +5,25 @@ use assistant_slash_command::{
SlashCommandResult, SlashCommandWorkingSet,
};
use assistant_slash_commands::FileCommandMetadata;
-use client::{self, 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::text_threads_dir;
@@ -38,7 +41,7 @@ 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};
@@ -667,7 +670,7 @@ pub struct TextThread {
buffer: Entity<Buffer>,
pub(crate) parsed_slash_commands: Vec<ParsedSlashCommand>,
invoked_slash_commands: HashMap<InvokedSlashCommandId, InvokedSlashCommand>,
- edits_since_last_parse: language::Subscription,
+ edits_since_last_parse: language::Subscription<usize>,
slash_commands: Arc<SlashCommandWorkingSet>,
pub(crate) slash_command_output_sections: Vec<SlashCommandOutputSection<language::Anchor>>,
thought_process_output_sections: Vec<ThoughtProcessOutputSection<language::Anchor>>,
@@ -684,9 +687,8 @@ pub struct TextThread {
pending_cache_warming_task: Task<Option<()>>,
path: Option<Arc<Path>>,
_subscriptions: Vec<Subscription>,
- telemetry: Option<Arc<Telemetry>>,
language_registry: Arc<LanguageRegistry>,
- project: Option<Entity<Project>>,
+ project: Option<WeakEntity<Project>>,
prompt_builder: Arc<PromptBuilder>,
completion_mode: agent_settings::CompletionMode,
}
@@ -706,8 +708,7 @@ impl EventEmitter<TextThreadEvent> for TextThread {}
impl TextThread {
pub fn local(
language_registry: Arc<LanguageRegistry>,
- project: Option<Entity<Project>>,
- telemetry: Option<Arc<Telemetry>>,
+ project: Option<WeakEntity<Project>>,
prompt_builder: Arc<PromptBuilder>,
slash_commands: Arc<SlashCommandWorkingSet>,
cx: &mut Context<Self>,
@@ -720,7 +721,6 @@ impl TextThread {
prompt_builder,
slash_commands,
project,
- telemetry,
cx,
)
}
@@ -740,8 +740,7 @@ impl TextThread {
language_registry: Arc<LanguageRegistry>,
prompt_builder: Arc<PromptBuilder>,
slash_commands: Arc<SlashCommandWorkingSet>,
- project: Option<Entity<Project>>,
- telemetry: Option<Arc<Telemetry>>,
+ project: Option<WeakEntity<Project>>,
cx: &mut Context<Self>,
) -> Self {
let buffer = cx.new(|_cx| {
@@ -782,7 +781,6 @@ impl TextThread {
completion_mode: AgentSettings::get_global(cx).preferred_completion_mode,
path: None,
buffer,
- telemetry,
project,
language_registry,
slash_commands,
@@ -795,7 +793,7 @@ impl TextThread {
});
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,
@@ -871,8 +869,7 @@ impl TextThread {
language_registry: Arc<LanguageRegistry>,
prompt_builder: Arc<PromptBuilder>,
slash_commands: Arc<SlashCommandWorkingSet>,
- project: Option<Entity<Project>>,
- telemetry: Option<Arc<Telemetry>>,
+ project: Option<WeakEntity<Project>>,
cx: &mut Context<Self>,
) -> Self {
let id = saved_context.id.clone().unwrap_or_else(TextThreadId::new);
@@ -884,7 +881,6 @@ impl TextThread {
prompt_builder,
slash_commands,
project,
- telemetry,
cx,
);
this.path = Some(path);
@@ -1145,12 +1141,10 @@ impl TextThread {
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
}
@@ -1167,10 +1161,6 @@ impl TextThread {
self.language_registry.clone()
}
- pub fn project(&self) -> Option<Entity<Project>> {
- self.project.clone()
- }
-
pub fn prompt_builder(&self) -> Arc<PromptBuilder> {
self.prompt_builder.clone()
}
@@ -1416,6 +1406,7 @@ impl TextThread {
role: Role::User,
content: vec!["Respond only with OK, nothing else.".into()],
cache: false,
+ reasoning_details: None,
});
req
};
@@ -1851,14 +1842,17 @@ impl TextThread {
}
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, ""));
@@ -2073,16 +2067,22 @@ impl TextThread {
});
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;
}
@@ -2206,24 +2206,26 @@ impl TextThread {
.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 => {}
@@ -2306,6 +2308,7 @@ impl TextThread {
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() {
@@ -2677,6 +2680,7 @@ impl TextThread {
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
@@ -2844,7 +2848,8 @@ impl TextThread {
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 {
@@ -2920,6 +2925,7 @@ impl TextThread {
RenameOptions {
overwrite: true,
ignore_if_exists: true,
+ create_parents: false,
},
)
.await?;
@@ -2953,7 +2959,7 @@ impl TextThread {
}
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| {
@@ -4,7 +4,7 @@ use crate::{
};
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;
@@ -48,10 +48,9 @@ pub struct TextThreadStore {
fs: Arc<dyn Fs>,
languages: Arc<LanguageRegistry>,
slash_commands: Arc<SlashCommandWorkingSet>,
- telemetry: Arc<Telemetry>,
_watch_updates: Task<Option<()>>,
client: Arc<Client>,
- project: Entity<Project>,
+ project: WeakEntity<Project>,
project_is_shared: bool,
client_subscription: Option<client::Subscription>,
_project_subscriptions: Vec<gpui::Subscription>,
@@ -88,7 +87,6 @@ impl TextThreadStore {
) -> Task<Result<Entity<Self>>> {
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(text_threads_dir(), CONTEXT_WATCH_DURATION).await;
@@ -102,7 +100,6 @@ impl TextThreadStore {
fs,
languages,
slash_commands,
- telemetry,
_watch_updates: cx.spawn(async move |this, cx| {
async move {
while events.next().await.is_some() {
@@ -119,10 +116,10 @@ impl TextThreadStore {
],
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);
@@ -143,10 +140,9 @@ impl TextThreadStore {
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(),
@@ -180,8 +176,10 @@ impl TextThreadStore {
) -> Result<proto::OpenContextResponse> {
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"
);
@@ -211,8 +209,9 @@ impl TextThreadStore {
mut cx: AsyncApp,
) -> Result<proto::CreateContextResponse> {
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"
);
@@ -255,8 +254,9 @@ impl TextThreadStore {
mut cx: AsyncApp,
) -> Result<proto::SynchronizeContextsResponse> {
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"
);
@@ -293,8 +293,12 @@ impl TextThreadStore {
})?
}
- fn handle_project_shared(&mut self, _: Entity<Project>, cx: &mut Context<Self>) {
- let is_shared = self.project.read(cx).is_shared();
+ fn handle_project_shared(&mut self, cx: &mut Context<Self>) {
+ 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;
@@ -309,7 +313,7 @@ impl TextThreadStore {
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)
@@ -323,13 +327,13 @@ impl TextThreadStore {
fn handle_project_event(
&mut self,
- project: Entity<Project>,
+ _project: Entity<Project>,
event: &project::Event,
cx: &mut Context<Self>,
) {
match event {
project::Event::RemoteIdChanged(_) => {
- self.handle_project_shared(project, cx);
+ self.handle_project_shared(cx);
}
project::Event::Reshared => {
self.advertise_contexts(cx);
@@ -371,7 +375,6 @@ impl TextThreadStore {
TextThread::local(
self.languages.clone(),
Some(self.project.clone()),
- Some(self.telemetry.clone()),
self.prompt_builder.clone(),
self.slash_commands.clone(),
cx,
@@ -382,7 +385,10 @@ impl TextThreadStore {
}
pub fn create_remote(&mut self, cx: &mut Context<Self>) -> Task<Result<Entity<TextThread>>> {
- let project = self.project.read(cx);
+ 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")));
};
@@ -391,7 +397,7 @@ impl TextThreadStore {
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 });
@@ -408,7 +414,6 @@ impl TextThreadStore {
prompt_builder,
slash_commands,
Some(project),
- Some(telemetry),
cx,
)
})?;
@@ -446,7 +451,6 @@ impl TextThreadStore {
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 {
@@ -467,7 +471,6 @@ impl TextThreadStore {
prompt_builder,
slash_commands,
Some(project),
- Some(telemetry),
cx,
)
})?;
@@ -541,7 +544,10 @@ impl TextThreadStore {
text_thread_id: TextThreadId,
cx: &mut Context<Self>,
) -> Task<Result<Entity<TextThread>>> {
- let project = self.project.read(cx);
+ 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")));
};
@@ -554,7 +560,6 @@ impl TextThreadStore {
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: text_thread_id.to_proto(),
@@ -573,7 +578,6 @@ impl TextThreadStore {
prompt_builder,
slash_commands,
Some(project),
- Some(telemetry),
cx,
)
})?;
@@ -618,7 +622,10 @@ impl TextThreadStore {
event: &TextThreadEvent,
cx: &mut Context<Self>,
) {
- 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;
};
@@ -652,12 +659,14 @@ impl TextThreadStore {
}
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;
}
@@ -689,7 +698,10 @@ impl TextThreadStore {
}
fn synchronize_contexts(&mut self, cx: &mut Context<Self>) {
- 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;
};
@@ -828,7 +840,10 @@ impl TextThreadStore {
}
fn register_context_server_handlers(&self, cx: &mut Context<Self>) {
- 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();
@@ -21,6 +21,7 @@ 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
@@ -2,16 +2,17 @@ use anyhow::{Context as _, Result};
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::{HttpClient, HttpClientWithUrl};
use paths::remote_servers_dir;
use release_channel::{AppCommitSha, ReleaseChannel};
+use semver::Version;
use serde::{Deserialize, Serialize};
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::{
@@ -23,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";
@@ -43,7 +45,7 @@ actions!(
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VersionCheckType {
Sha(AppCommitSha),
- Semantic(SemanticVersion),
+ Semantic(Version),
}
#[derive(Serialize, Debug)]
@@ -99,7 +101,7 @@ impl AutoUpdateStatus {
pub struct AutoUpdater {
status: AutoUpdateStatus,
- current_version: SemanticVersion,
+ current_version: Version,
client: Arc<Client>,
pending_poll: Option<Task<Option<()>>>,
quit_subscription: Option<gpui::Subscription>,
@@ -121,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()
@@ -255,7 +257,7 @@ 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.client.http_client().build_url(&path);
@@ -321,7 +323,7 @@ impl AutoUpdater {
cx.default_global::<GlobalAutoUpdate>().0.clone()
}
- fn new(current_version: SemanticVersion, client: Arc<Client>, cx: &mut Context<Self>) -> Self {
+ fn new(current_version: Version, client: Arc<Client>, cx: &mut Context<Self>) -> 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
@@ -350,8 +352,7 @@ impl AutoUpdater {
pub fn start_polling(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
cx.spawn(async move |this, cx| {
- #[cfg(target_os = "windows")]
- {
+ if cfg!(target_os = "windows") {
use util::ResultExt;
cleanup_windows()
@@ -400,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 {
@@ -422,7 +423,7 @@ impl AutoUpdater {
// Ok(None).
pub async fn download_remote_server_release(
release_channel: ReleaseChannel,
- version: Option<SemanticVersion>,
+ version: Option<Version>,
os: &str,
arch: &str,
set_status: impl Fn(&str, &mut AsyncApp) + Send + 'static,
@@ -469,7 +470,7 @@ impl AutoUpdater {
pub async fn get_remote_server_release_url(
channel: ReleaseChannel,
- version: Option<SemanticVersion>,
+ version: Option<Version>,
os: &str,
arch: &str,
cx: &mut AsyncApp,
@@ -491,7 +492,7 @@ impl AutoUpdater {
async fn get_release_asset(
this: &Entity<Self>,
release_channel: ReleaseChannel,
- version: Option<SemanticVersion>,
+ version: Option<Version>,
asset: &str,
os: &str,
arch: &str,
@@ -509,7 +510,9 @@ impl AutoUpdater {
(None, None, None)
};
- let version = if let Some(version) = version {
+ 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()
@@ -554,7 +557,7 @@ impl AutoUpdater {
this.read_with(cx, |this, cx| {
(
this.client.http_client(),
- this.current_version,
+ this.current_version.clone(),
this.status.clone(),
ReleaseChannel::try_global(cx).unwrap_or(ReleaseChannel::Stable),
)
@@ -627,16 +630,20 @@ impl AutoUpdater {
fn check_if_fetched_version_is_newer(
release_channel: ReleaseChannel,
app_commit_sha: Result<Option<String>>,
- installed_version: SemanticVersion,
+ installed_version: Version,
fetched_version: String,
status: AutoUpdateStatus,
) -> Result<Option<VersionCheckType>> {
- let parsed_fetched_version = fetched_version.parse::<SemanticVersion>();
+ let parsed_fetched_version = fetched_version.parse::<Version>();
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);
@@ -655,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)));
@@ -708,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<Option<VersionCheckType>> {
+ // 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)
@@ -800,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")
@@ -835,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)
@@ -867,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")
@@ -887,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)
@@ -903,34 +917,22 @@ async fn install_release_macos(
Ok(None)
}
-#[cfg(target_os = "windows")]
async fn cleanup_windows() -> Result<()> {
- use util::ResultExt;
-
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
- .context("failed to remove updates dir")
- .log_err();
- smol::fs::remove_dir(parent.join("install"))
- .await
- .context("failed to remove install dir")
- .log_err();
- smol::fs::remove_dir(parent.join("old"))
- .await
- .context("failed to remove old version dir")
- .log_err();
+ _ = 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<Option<PathBuf>> {
- let output = Command::new(downloaded_installer)
+ let output = new_smol_command(downloaded_installer)
.arg("/verysilent")
.arg("/update=true")
.arg("!desktopicon")
@@ -1032,7 +1034,7 @@ mod tests {
cx.update(|cx| {
settings::init(cx);
- let current_version = SemanticVersion::new(0, 100, 0);
+ let current_version = semver::Version::new(0, 100, 0);
release_channel::init_test(current_version, ReleaseChannel::Stable, cx);
let clock = Arc::new(FakeSystemClock::new());
@@ -1071,7 +1073,7 @@ mod tests {
auto_updater.read_with(cx, |updater, _| {
assert_eq!(updater.status(), AutoUpdateStatus::Idle);
- assert_eq!(updater.current_version(), SemanticVersion::new(0, 100, 0));
+ assert_eq!(updater.current_version(), semver::Version::new(0, 100, 0));
});
release_available.store(true, atomic::Ordering::SeqCst);
@@ -1090,7 +1092,7 @@ mod tests {
assert_eq!(
status,
AutoUpdateStatus::Downloading {
- version: VersionCheckType::Semantic(SemanticVersion::new(0, 100, 1))
+ version: VersionCheckType::Semantic(semver::Version::new(0, 100, 1))
}
);
@@ -1120,7 +1122,7 @@ mod tests {
assert_eq!(
status,
AutoUpdateStatus::Updated {
- version: VersionCheckType::Semantic(SemanticVersion::new(0, 100, 1))
+ version: VersionCheckType::Semantic(semver::Version::new(0, 100, 1))
}
);
let will_restart = cx.expect_restart();
@@ -1134,9 +1136,9 @@ mod tests {
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,
@@ -1153,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,
@@ -1175,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,
@@ -1196,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,
@@ -1220,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,
@@ -1239,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();
@@ -1258,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,
@@ -1282,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,
@@ -1306,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();
@@ -1329,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,
@@ -1351,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())),
};
@@ -1,6 +1,6 @@
use std::{
- cell::LazyCell,
path::Path,
+ sync::LazyLock,
time::{Duration, Instant},
};
@@ -13,8 +13,8 @@ use windows::Win32::{
use crate::windows_impl::WM_JOB_UPDATED;
pub(crate) struct Job {
- pub apply: Box<dyn Fn(&Path) -> Result<()>>,
- pub rollback: Box<dyn Fn(&Path) -> Result<()>>,
+ pub apply: Box<dyn Fn(&Path) -> Result<()> + Send + Sync>,
+ pub rollback: Box<dyn Fn(&Path) -> Result<()> + Send + Sync>,
}
impl Job {
@@ -154,10 +154,8 @@ impl Job {
}
}
-// app is single threaded
#[cfg(not(test))]
-#[allow(clippy::declare_interior_mutable_const)]
-pub(crate) const JOBS: LazyCell<[Job; 22]> = LazyCell::new(|| {
+pub(crate) static JOBS: LazyLock<[Job; 22]> = LazyLock::new(|| {
fn p(value: &str) -> &Path {
Path::new(value)
}
@@ -206,10 +204,8 @@ pub(crate) const JOBS: LazyCell<[Job; 22]> = LazyCell::new(|| {
]
});
-// app is single threaded
#[cfg(test)]
-#[allow(clippy::declare_interior_mutable_const)]
-pub(crate) const JOBS: LazyCell<[Job; 9]> = LazyCell::new(|| {
+pub(crate) static JOBS: LazyLock<[Job; 9]> = LazyLock::new(|| {
fn p(value: &str) -> &Path {
Path::new(value)
}
@@ -20,6 +20,7 @@ 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
@@ -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::<UpdateNotification>(),
@@ -87,7 +87,7 @@ pub async fn stream_completion(
Ok(None) => None,
Err(err) => Some((
Err(BedrockError::ClientError(anyhow!(
- "{:?}",
+ "{}",
aws_sdk_bedrockruntime::error::DisplayErrorContext(err)
))),
stream,
@@ -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")]
@@ -141,7 +148,19 @@ impl Model {
}
pub fn from_id(id: &str) -> anyhow::Result<Self> {
- 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)
@@ -178,6 +197,8 @@ 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",
@@ -245,6 +266,9 @@ 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",
@@ -309,6 +333,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::Claude3_5SonnetV2 => "Claude 3.5 Sonnet v2",
Self::Claude3_5Sonnet => "Claude 3.5 Sonnet",
Self::Claude3Opus => "Claude 3 Opus",
@@ -379,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,
@@ -393,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 | Self::ClaudeHaiku4_5 => 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
@@ -418,6 +450,8 @@ impl Model {
| Self::ClaudeOpus4Thinking
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_1Thinking
+ | Self::ClaudeOpus4_5
+ | Self::ClaudeOpus4_5Thinking
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::ClaudeSonnet4_5
@@ -443,6 +477,8 @@ impl Model {
| Self::ClaudeOpus4Thinking
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_1Thinking
+ | Self::ClaudeOpus4_5
+ | Self::ClaudeOpus4_5Thinking
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::ClaudeSonnet4_5
@@ -484,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 {
@@ -506,7 +544,9 @@ 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,
}),
@@ -535,50 +575,109 @@ 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<String> {
+ pub fn cross_region_inference_id(
+ &self,
+ region: &str,
+ allow_global: bool,
+ ) -> anyhow::Result<String> {
+ // 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()),
+
+ (
+ 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)),
- match (self, region_group) {
- // Custom models can't have CRI IDs
- (Model::Custom { .. }, _) => Ok(self.request_id().into()),
+ (
+ 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)),
- // Models with US Gov only
- (Model::Claude3_5Sonnet, "us-gov") | (Model::Claude3Haiku, "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)),
- // Available everywhere
- (Model::AmazonNovaLite | Model::AmazonNovaMicro | Model::AmazonNovaPro, _) => {
- Ok(format!("{}.{}", region_group, 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))
}
- // Models in US
(
Model::AmazonNovaPremier
+ | Model::AmazonNovaLite
+ | Model::AmazonNovaMicro
+ | Model::AmazonNovaPro
| Model::Claude3_5Haiku
| Model::ClaudeHaiku4_5
| Model::Claude3_5Sonnet
@@ -593,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
@@ -613,16 +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
@@ -631,26 +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::ClaudeHaiku4_5
- | Model::Claude3Haiku
- | Model::Claude3Sonnet
| 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()),
}
}
}
@@ -663,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(())
@@ -681,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(())
@@ -703,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(())
@@ -721,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(())
@@ -735,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(())
@@ -754,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(())
@@ -769,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(())
@@ -784,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(())
@@ -808,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(())
}
@@ -850,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(())
+}
@@ -123,7 +123,7 @@ 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);
}
}
})
@@ -12,7 +12,7 @@ workspace = true
path = "src/buffer_diff.rs"
[features]
-test-support = []
+test-support = ["settings"]
[dependencies]
anyhow.workspace = true
@@ -24,6 +24,7 @@ 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
@@ -33,6 +34,7 @@ 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
@@ -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<TaskLabel> = 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<Entity<BufferDiff>>,
}
@@ -31,6 +36,7 @@ pub struct BufferDiffSnapshot {
#[derive(Clone)]
struct BufferDiffInner {
hunks: SumTree<InternalDiffHunk>,
+ // Used for making staging mo
pending_hunks: SumTree<PendingHunk>,
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<usize>,
pub secondary_status: DiffHunkSecondaryStatus,
+ // Anchors representing the word diff locations in the active buffer
+ pub buffer_word_diffs: Vec<Range<Anchor>>,
+ // Offsets relative to the start of the deleted diff that represent word diff locations
+ pub base_word_diffs: Vec<Range<usize>>,
}
/// 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<Anchor>,
diff_base_byte_range: Range<usize>,
+ base_word_diffs: Vec<Range<usize>>,
+ buffer_word_diffs: Vec<Range<Anchor>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -88,6 +107,7 @@ struct PendingHunk {
#[derive(Debug, Clone)]
pub struct DiffHunkSummary {
buffer_range: Range<Anchor>,
+ diff_base_byte_range: Range<usize>,
}
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(),
}
}
}
@@ -116,6 +138,7 @@ impl sum_tree::Summary for DiffHunkSummary {
fn zero(_cx: Self::Context<'_>) -> Self {
DiffHunkSummary {
buffer_range: Anchor::MIN..Anchor::MIN,
+ diff_base_byte_range: 0..0,
}
}
@@ -125,6 +148,15 @@ impl sum_tree::Summary for DiffHunkSummary {
.start
.min(&other.buffer_range.start, 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);
}
}
@@ -147,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 {
@@ -190,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()));
@@ -207,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 {
@@ -230,6 +274,12 @@ impl BufferDiffSnapshot {
base_text_snapshot: language::BufferSnapshot,
cx: &App,
) -> impl Future<Output = Self> + 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());
@@ -241,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,
@@ -300,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::<DiffHunkSummary>(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 {
@@ -339,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,
@@ -536,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),
),
]
});
@@ -559,8 +661,11 @@ impl BufferDiffInner {
let mut summaries = buffer.summaries_for_anchors_with_payload::<Point, _, _>(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;
@@ -630,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,
});
}
@@ -661,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(),
})
})
}
@@ -729,9 +838,36 @@ impl BufferDiffInner {
}
}
+fn build_diff_options(
+ file: Option<&Arc<dyn File>>,
+ language: Option<LanguageName>,
+ language_scope: Option<language::LanguageScope>,
+ cx: &App,
+) -> Option<DiffOptions> {
+ #[cfg(any(test, feature = "test-support"))]
+ {
+ if !cx.has_global::<settings::SettingsStore>() {
+ 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<String>, Rope)>,
buffer: text::BufferSnapshot,
+ diff_options: Option<DiffOptions>,
) -> SumTree<InternalDiffHunk> {
let mut tree = SumTree::new(&buffer);
@@ -757,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,
);
@@ -772,6 +910,7 @@ fn compute_hunks(
&diff_base_rope,
&buffer,
&mut divergence,
+ diff_options.as_ref(),
);
tree.push(hunk, &buffer);
}
@@ -779,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,
);
@@ -795,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);
@@ -859,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,
}
}
@@ -940,10 +1122,11 @@ impl BufferDiff {
pub fn clear_pending_hunks(&mut self, cx: &mut Context<Self>) {
if self.secondary_diff.is_some() {
self.inner.pending_hunks = SumTree::from_summary(DiffHunkSummary {
- buffer_range: Anchor::MIN..Anchor::MIN,
+ 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)),
});
}
}
@@ -1064,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
@@ -1125,7 +1311,11 @@ impl BufferDiff {
buffer_snapshot: &'a text::BufferSnapshot,
cx: &'a App,
) -> impl 'a + Iterator<Item = DiffHunk> {
- 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>(
@@ -1221,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 {
@@ -1388,7 +1580,10 @@ mod tests {
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())],
@@ -1397,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,
&[
@@ -1408,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,
&[],
@@ -1482,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,
@@ -1541,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
);
@@ -1948,7 +2155,7 @@ mod tests {
let range = diff_1.inner.compare(&empty_diff.inner, &buffer).unwrap();
assert_eq!(range.to_point(&buffer), Point::new(0, 0)..Point::new(8, 0));
- // Edit does not affect the diff.
+ // Edit does affects the diff because it recalculates word diffs.
buffer.edit_via_marked_text(
&"
one
@@ -1963,7 +2170,14 @@ mod tests {
.unindent(),
);
let diff_2 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx);
- assert_eq!(None, diff_2.inner.compare(&diff_1.inner, &buffer));
+ assert_eq!(
+ Point::new(4, 0)..Point::new(5, 0),
+ diff_2
+ .inner
+ .compare(&diff_1.inner, &buffer)
+ .unwrap()
+ .to_point(&buffer)
+ );
// Edit turns a deletion hunk into a modification.
buffer.edit_via_marked_text(
@@ -2154,8 +2368,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::<Vec<_>>()
+ diff.hunks_intersecting_range(
+ Anchor::min_max_range_for_buffer(diff.buffer_id),
+ &working_copy,
+ cx,
+ )
+ .collect::<Vec<_>>()
});
if hunks.is_empty() {
return;
@@ -2184,8 +2402,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::<Vec<_>>()
+ diff.hunks_intersecting_range(
+ Anchor::min_max_range_for_buffer(diff.buffer_id),
+ &working_copy,
+ cx,
+ )
+ .collect::<Vec<_>>()
});
assert_eq!(hunks.len(), found_hunks.len());
@@ -2203,4 +2425,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}"
+ );
+ }
+ }
}
@@ -305,6 +305,7 @@ impl Room {
pub(crate) fn leave(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
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<Self>) {
+ 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<Self>,
client: Arc<Client>,
@@ -524,6 +533,16 @@ impl Room {
self.id
}
+ pub fn room_id(&self) -> impl Future<Output = Option<String>> + '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<Stream: ?Sized> {
+ #[default]
None,
Pending {
publish_id: usize,
@@ -1694,12 +1718,6 @@ enum LocalTrack<Stream: ?Sized> {
},
}
-impl<T: ?Sized> Default for LocalTrack<T> {
- fn default() -> Self {
- Self::None
- }
-}
-
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum RoomStatus {
Online,
@@ -37,6 +37,7 @@ 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"] }
@@ -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,7 +236,7 @@ fn test_dangling_channel_paths(cx: &mut App) {
fn init_test(cx: &mut App) -> Entity<ChannelStore> {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
- release_channel::init(SemanticVersion::default(), cx);
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
let clock = Arc::new(FakeSystemClock::new());
let http = FakeHttpClient::with_404_response();
@@ -34,6 +34,10 @@ util.workspace = true
tempfile.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
fork.workspace = true
@@ -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}");
+ }
}
@@ -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,6 +61,8 @@ 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
@@ -129,37 +133,177 @@ struct Args {
askpass: Option<String>,
}
+/// 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<String> {
- 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<T>(path: &Path, f: impl FnOnce() -> anyhow::Result<T>) -> anyhow::Result<T> {
+ 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<String> {
let mut source = PathWithPosition::parse_str(source);
- let mut command = util::command::new_std_command("wsl.exe");
let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') {
if user.is_empty() {
@@ -170,22 +314,35 @@ fn parse_path_in_wsl(source: &str, wsl: &str) -> Result<String> {
(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)
+ 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")
- .arg("wslpath")
- .arg("-m")
- .arg(&source.path)
+ .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().strip_prefix(&prefix).unwrap_or(&result)).to_owned();
+ source.path = Path::new(result.trim()).to_owned();
Ok(source.to_string(|path| path.to_string_lossy().into_owned()))
}
@@ -433,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()?;
@@ -554,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())?;
}
@@ -587,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() {
@@ -601,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<OsString> =
+ 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)
@@ -788,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(
@@ -941,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;
@@ -991,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);
@@ -53,7 +53,7 @@ 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
@@ -70,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]
@@ -150,9 +150,8 @@ pub fn init(client: &Arc<Client>, 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() {
@@ -162,9 +161,8 @@ pub fn init(client: &Arc<Client>, cx: &mut App) {
.detach();
}
}
- });
-
- cx.on_action({
+ })
+ .on_action({
let client = client;
move |_: &Reconnect, cx| {
if let Some(client) = client.upgrade() {
@@ -1723,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<String>,
+ },
+}
+
/// 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<ZedLink> {
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::<u64>().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
@@ -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 _,
@@ -293,10 +293,11 @@ impl Telemetry {
}
pub fn metrics_enabled(self: &Arc<Self>) -> 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<Self>) -> bool {
+ self.state.lock().settings.diagnostics
}
pub fn set_authenticated_user_info(
@@ -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,
@@ -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)
+ )
+}
@@ -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<PredictEditsGitInfo>,
+ /// 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)]
@@ -192,6 +206,41 @@ pub struct AcceptEditPredictionBody {
pub request_id: String,
}
+#[derive(Debug, Clone, Deserialize)]
+pub struct RejectEditPredictionsBody {
+ pub rejections: Vec<EditPredictionRejection>,
+}
+
+#[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)]
#[serde(rename_all = "snake_case")]
pub enum CompletionMode {
@@ -322,6 +371,8 @@ 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,
@@ -3,13 +3,13 @@ use serde::{Deserialize, Serialize};
use std::{
fmt::{Display, Write as _},
ops::{Add, Range, Sub},
- path::{Path, PathBuf},
+ path::Path,
sync::Arc,
};
use strum::EnumIter;
use uuid::Uuid;
-use crate::PredictEditsGitInfo;
+use crate::{PredictEditsGitInfo, PredictEditsRequestTrigger};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanContextRetrievalRequest {
@@ -17,7 +17,7 @@ pub struct PlanContextRetrievalRequest {
pub excerpt_path: Arc<Path>,
pub excerpt_line_range: Range<Line>,
pub cursor_file_max_row: Line,
- pub events: Vec<Event>,
+ pub events: Vec<Arc<Event>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -31,18 +31,10 @@ pub struct PredictEditsRequest {
/// Within `signatures`
pub excerpt_parent: Option<usize>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
- pub included_files: Vec<IncludedFile>,
- #[serde(skip_serializing_if = "Vec::is_empty", default)]
- pub signatures: Vec<Signature>,
- #[serde(skip_serializing_if = "Vec::is_empty", default)]
- pub referenced_declarations: Vec<ReferencedDeclaration>,
- pub events: Vec<Event>,
+ pub related_files: Vec<RelatedFile>,
+ pub events: Vec<Arc<Event>>,
#[serde(default)]
pub can_collect_data: bool,
- #[serde(skip_serializing_if = "Vec::is_empty", default)]
- pub diagnostic_groups: Vec<DiagnosticGroup>,
- #[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<PredictEditsGitInfo>,
@@ -53,10 +45,12 @@ pub struct PredictEditsRequest {
pub prompt_max_bytes: Option<usize>,
#[serde(default)]
pub prompt_format: PromptFormat,
+ #[serde(default)]
+ pub trigger: PredictEditsRequestTrigger,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct IncludedFile {
+pub struct RelatedFile {
pub path: Arc<Path>,
pub max_row: Line,
pub excerpts: Vec<Excerpt>,
@@ -70,16 +64,20 @@ pub struct Excerpt {
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, EnumIter)]
pub enum PromptFormat {
- MarkedExcerpt,
- LabeledSections,
- NumLinesUniDiff,
+ /// XML old_tex/new_text
OldTextNewText,
- /// Prompt format intended for use via zeta_cli
+ /// 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::NumLinesUniDiff;
+ pub const DEFAULT: PromptFormat = PromptFormat::Minimal;
}
impl Default for PromptFormat {
@@ -97,11 +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::NumLinesUniDiff => write!(f, "Numbered Lines / Unified Diff"),
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"),
}
}
}
@@ -111,10 +109,11 @@ impl std::fmt::Display for PromptFormat {
#[serde(tag = "event")]
pub enum Event {
BufferChange {
- path: Option<PathBuf>,
- old_path: Option<PathBuf>,
+ path: Arc<Path>,
+ old_path: Arc<Path>,
diff: String,
predicted: bool,
+ in_open_source_repo: bool,
},
}
@@ -126,23 +125,21 @@ impl Display for Event {
old_path,
diff,
predicted,
+ ..
} => {
- let new_path = path.as_deref().unwrap_or(Path::new("untitled"));
- let old_path = old_path.as_deref().unwrap_or(new_path);
-
if *predicted {
write!(
f,
"// User accepted prediction:\n--- a/{}\n+++ b/{}\n{diff}",
DiffPathFmt(old_path),
- DiffPathFmt(new_path)
+ DiffPathFmt(path)
)
} else {
write!(
f,
"--- a/{}\n+++ b/{}\n{diff}",
DiffPathFmt(old_path),
- DiffPathFmt(new_path)
+ DiffPathFmt(path)
)
}
}
@@ -168,67 +165,6 @@ impl<'a> std::fmt::Display for DiffPathFmt<'a> {
}
}
-#[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<usize>,
- /// 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<Line>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ReferencedDeclaration {
- pub path: Arc<Path>,
- pub text: String,
- pub text_is_truncated: bool,
- /// Range of `text` within file, possibly truncated according to `text_is_truncated`
- pub range: Range<Line>,
- /// Range within `text`
- pub signature_range: Range<usize>,
- /// Index within `signatures`.
- #[serde(skip_serializing_if = "Option::is_none", default)]
- pub parent_index: Option<usize>,
- pub score_components: DeclarationScoreComponents,
- pub signature_score: f32,
- pub declaration_score: f32,
-}
-
-#[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 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,
- pub path_import_match_count: usize,
- pub wildcard_path_import_match_count: usize,
- pub import_similarity: f32,
- pub max_import_similarity: f32,
- pub normalized_import_similarity: f32,
- pub wildcard_import_similarity: f32,
- pub normalized_wildcard_import_similarity: f32,
- pub included_by_others: usize,
- pub includes_others: usize,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(transparent)]
-pub struct DiagnosticGroup(pub Box<serde_json::value::RawValue>);
-
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PredictEditsResponse {
pub request_id: Uuid,
@@ -252,10 +188,6 @@ pub struct Edit {
pub content: String,
}
-fn is_default<T: Default + PartialEq>(value: &T) -> bool {
- *value == T::default()
-}
-
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Ord)]
pub struct Point {
pub line: Line,
@@ -291,10 +223,11 @@ mod tests {
#[test]
fn test_event_display() {
let ev = Event::BufferChange {
- path: None,
- old_path: None,
+ 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(),
@@ -308,10 +241,11 @@ mod tests {
);
let ev = Event::BufferChange {
- path: Some(PathBuf::from("foo/bar.txt")),
- old_path: Some(PathBuf::from("foo/bar.txt")),
+ 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(),
@@ -325,10 +259,11 @@ mod tests {
);
let ev = Event::BufferChange {
- path: Some(PathBuf::from("abc.txt")),
- old_path: Some(PathBuf::from("123.txt")),
+ 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(),
@@ -342,10 +277,11 @@ mod tests {
);
let ev = Event::BufferChange {
- path: Some(PathBuf::from("abc.txt")),
- old_path: Some(PathBuf::from("123.txt")),
+ 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(),
@@ -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
-schemars.workspace = true
-serde.workspace = true
-strum.workspace = true
@@ -1,792 +0,0 @@
-//! Zeta2 prompt planning and generation code shared with cloud.
-pub mod retrieval_prompt;
-
-use anyhow::{Context as _, Result, anyhow};
-use cloud_llm_client::predict_edits_v3::{
- self, DiffPathFmt, Excerpt, Line, Point, PromptFormat, ReferencedDeclaration,
-};
-use indoc::indoc;
-use ordered_float::OrderedFloat;
-use rustc_hash::{FxHashMap, FxHashSet};
-use serde::Serialize;
-use std::cmp;
-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 = "<|user_cursor|>";
-/// 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_INSTRUCTIONS: &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 <|user_cursor|>. Please respond with edited code for that region.
-
- Other code is provided for context, and `…` indicates when code has been skipped.
-
- # Edit History:
-
-"};
-
-const LABELED_SECTIONS_INSTRUCTIONS: &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 `<|user_cursor|>` 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}");
- }
-
- # Edit History:
-
-"#};
-
-const NUMBERED_LINES_INSTRUCTIONS: &str = indoc! {r#"
- # Instructions
-
- You are an edit prediction agent in a code editor.
- Your job is to predict the next edit that the user will make,
- based on their last few edits and their current cursor location.
-
- ## Output Format
-
- You must briefly explain your understanding of the user's goal, in one
- or two sentences, and then specify their next edit in the form of a
- unified diff, like this:
-
- ```
- --- a/src/myapp/cli.py
- +++ b/src/myapp/cli.py
- @@ ... @@
- import os
- import time
- import sys
- +from constants import LOG_LEVEL_WARNING
- @@ ... @@
- config.headless()
- config.set_interactive(false)
- -config.set_log_level(LOG_L)
- +config.set_log_level(LOG_LEVEL_WARNING)
- config.set_use_color(True)
- ```
-
- ## Edit History
-
-"#};
-
-const UNIFIED_DIFF_REMINDER: &str = indoc! {"
- ---
-
- Analyze the edit history and the files, then provide the unified diff for your predicted edits.
- Do not include the cursor marker in your output.
- Your diff should include edited file paths in its file headers (lines beginning with `---` and `+++`).
- Do not include line numbers in the hunk headers, use `@@ ... @@`.
- Removed lines begin with `-`.
- Added lines begin with `+`.
- Context lines begin with an extra space.
- Context and removed lines are used to match the target edit location, so make sure to include enough of them
- to uniquely identify it amongst all excerpts of code provided.
-"};
-
-const XML_TAGS_INSTRUCTIONS: &str = indoc! {r#"
- # Instructions
-
- You are an edit prediction agent in a code editor.
- Your job is to predict the next edit that the user will make,
- based on their last few edits and their current cursor location.
-
- # Output Format
-
- You must briefly explain your understanding of the user's goal, in one
- or two sentences, and then specify their next edit, using the following
- XML format:
-
- <edits path="my-project/src/myapp/cli.py">
- <old_text>
- OLD TEXT 1 HERE
- </old_text>
- <new_text>
- NEW TEXT 1 HERE
- </new_text>
-
- <old_text>
- OLD TEXT 1 HERE
- </old_text>
- <new_text>
- NEW TEXT 1 HERE
- </new_text>
- </edits>
-
- - Specify the file to edit using the `path` attribute.
- - Use `<old_text>` and `<new_text>` tags to replace content
- - `<old_text>` must exactly match existing file content, including indentation
- - `<old_text>` cannot be empty
- - Do not escape quotes, newlines, or other characters within tags
- - Always close all tags properly
- - Don't include the <|user_cursor|> marker in your output.
-
- # Edit History:
-
-"#};
-
-const OLD_TEXT_NEW_TEXT_REMINDER: &str = indoc! {r#"
- ---
-
- Remember that the edits in the edit history have already been deployed.
- The files are currently as shown in the Code Excerpts section.
-"#};
-
-pub fn build_prompt(
- request: &predict_edits_v3::PredictEditsRequest,
-) -> Result<(String, SectionLabels)> {
- let mut insertions = match request.prompt_format {
- PromptFormat::MarkedExcerpt => vec![
- (
- Point {
- line: request.excerpt_line_range.start,
- column: 0,
- },
- EDITABLE_REGION_START_MARKER_WITH_NEWLINE,
- ),
- (request.cursor_point, CURSOR_MARKER),
- (
- Point {
- line: request.excerpt_line_range.end,
- column: 0,
- },
- EDITABLE_REGION_END_MARKER_WITH_NEWLINE,
- ),
- ],
- PromptFormat::LabeledSections
- | PromptFormat::NumLinesUniDiff
- | PromptFormat::OldTextNewText => {
- vec![(request.cursor_point, CURSOR_MARKER)]
- }
- PromptFormat::OnlySnippets => vec![],
- };
-
- let mut prompt = match request.prompt_format {
- PromptFormat::MarkedExcerpt => MARKED_EXCERPT_INSTRUCTIONS.to_string(),
- PromptFormat::LabeledSections => LABELED_SECTIONS_INSTRUCTIONS.to_string(),
- PromptFormat::NumLinesUniDiff => NUMBERED_LINES_INSTRUCTIONS.to_string(),
- PromptFormat::OldTextNewText => XML_TAGS_INSTRUCTIONS.to_string(),
- PromptFormat::OnlySnippets => String::new(),
- };
-
- if request.events.is_empty() {
- prompt.push_str("(No edit history)\n\n");
- } else {
- prompt.push_str("Here are the latest edits made by the user, from earlier to later.\n\n");
- push_events(&mut prompt, &request.events);
- }
-
- prompt.push_str(indoc! {"
- # Code Excerpts
-
- The cursor marker <|user_cursor|> indicates the current user cursor position.
- The file is in current state, edits from edit history have been applied.
- "});
-
- if request.prompt_format == PromptFormat::NumLinesUniDiff {
- prompt.push_str(indoc! {"
- We prepend line numbers (e.g., `123|<actual line>`); they are not part of the file.
- "});
- }
-
- prompt.push('\n');
-
- let mut section_labels = Default::default();
-
- if !request.referenced_declarations.is_empty() || !request.signatures.is_empty() {
- let syntax_based_prompt = SyntaxBasedPrompt::populate(request)?;
- section_labels = syntax_based_prompt.write(&mut insertions, &mut prompt)?;
- } else {
- if request.prompt_format == PromptFormat::LabeledSections {
- anyhow::bail!("PromptFormat::LabeledSections cannot be used with ContextMode::Llm");
- }
-
- for related_file in &request.included_files {
- write_codeblock(
- &related_file.path,
- &related_file.excerpts,
- if related_file.path == request.excerpt_path {
- &insertions
- } else {
- &[]
- },
- related_file.max_row,
- request.prompt_format == PromptFormat::NumLinesUniDiff,
- &mut prompt,
- );
- }
- }
-
- match request.prompt_format {
- PromptFormat::NumLinesUniDiff => {
- prompt.push_str(UNIFIED_DIFF_REMINDER);
- }
- PromptFormat::OldTextNewText => {
- prompt.push_str(OLD_TEXT_NEW_TEXT_REMINDER);
- }
- _ => {}
- }
-
- Ok((prompt, section_labels))
-}
-
-pub fn write_codeblock<'a>(
- path: &Path,
- excerpts: impl IntoIterator<Item = &'a Excerpt>,
- sorted_insertions: &[(Point, &str)],
- file_line_count: Line,
- include_line_numbers: bool,
- output: &'a mut String,
-) {
- writeln!(output, "`````{}", DiffPathFmt(path)).unwrap();
- write_excerpts(
- excerpts,
- sorted_insertions,
- file_line_count,
- include_line_numbers,
- output,
- );
- write!(output, "`````\n\n").unwrap();
-}
-
-pub fn write_excerpts<'a>(
- excerpts: impl IntoIterator<Item = &'a Excerpt>,
- sorted_insertions: &[(Point, &str)],
- file_line_count: Line,
- include_line_numbers: bool,
- output: &mut String,
-) {
- let mut current_row = Line(0);
- let mut sorted_insertions = sorted_insertions.iter().peekable();
-
- for excerpt in excerpts {
- if excerpt.start_line > current_row {
- writeln!(output, "…").unwrap();
- }
- if excerpt.text.is_empty() {
- return;
- }
-
- current_row = excerpt.start_line;
-
- for mut line in excerpt.text.lines() {
- if include_line_numbers {
- write!(output, "{}|", current_row.0 + 1).unwrap();
- }
-
- while let Some((insertion_location, insertion_marker)) = sorted_insertions.peek() {
- match current_row.cmp(&insertion_location.line) {
- cmp::Ordering::Equal => {
- let (prefix, suffix) = line.split_at(insertion_location.column as usize);
- output.push_str(prefix);
- output.push_str(insertion_marker);
- line = suffix;
- sorted_insertions.next();
- }
- cmp::Ordering::Less => break,
- cmp::Ordering::Greater => {
- sorted_insertions.next();
- break;
- }
- }
- }
- output.push_str(line);
- output.push('\n');
- current_row.0 += 1;
- }
- }
-
- if current_row < file_line_count {
- writeln!(output, "…").unwrap();
- }
-}
-
-pub fn push_events(output: &mut String, events: &[predict_edits_v3::Event]) {
- if events.is_empty() {
- return;
- };
-
- writeln!(output, "`````diff").unwrap();
- for event in events {
- writeln!(output, "{}", event).unwrap();
- }
- writeln!(output, "`````\n").unwrap();
-}
-
-pub struct SyntaxBasedPrompt<'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<PlannedSnippet<'a>>,
- budget_used: usize,
-}
-
-#[derive(Clone, Debug)]
-pub struct PlannedSnippet<'a> {
- path: Arc<Path>,
- range: Range<Line>,
- 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(Default, Clone, Debug, Serialize)]
-pub struct SectionLabels {
- pub excerpt_index: usize,
- pub section_ranges: Vec<(Arc<Path>, Range<Line>)>,
-}
-
-impl<'a> SyntaxBasedPrompt<'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<Self> {
- let mut this = Self {
- 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<f32>,
- declaration_index: usize,
- style: DeclarationStyle,
- }
-
- // Initialize priority queue with the best score for each snippet.
- let mut queue: BinaryHeap<QueueEntry> = 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::<usize>();
- 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()
- ));
- };
- let signature_start_line = declaration.range.start
- + Line(
- declaration.text[..declaration.signature_range.start]
- .lines()
- .count() as u32,
- );
- let signature_end_line = signature_start_line
- + Line(
- declaration.text
- [declaration.signature_range.start..declaration.signature_range.end]
- .lines()
- .count() as u32,
- );
- let range = signature_start_line..signature_end_line;
-
- PlannedSnippet {
- path: declaration.path.clone(),
- range,
- 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<usize>,
- 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<Path>,
- parent_index: Option<usize>,
- included_parents: &FxHashSet<usize>,
- ) -> Result<Vec<(usize, PlannedSnippet<'a>)>> {
- 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<Path>,
- parent_index: Option<usize>,
- included_parents: &FxHashSet<usize>,
- 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 write(
- &'a self,
- excerpt_file_insertions: &mut Vec<(Point, &'static str)>,
- prompt: &mut String,
- ) -> Result<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_line_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 section_labels =
- self.push_file_snippets(prompt, excerpt_file_insertions, file_snippets)?;
-
- Ok(section_labels)
- }
-
- fn push_file_snippets(
- &self,
- output: &mut String,
- excerpt_file_insertions: &mut Vec<(Point, &'static str)>,
- file_snippets: Vec<(&'a Path, Vec<&'a PlannedSnippet>, bool)>,
- ) -> Result<SectionLabels> {
- 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<Line>)> = None;
- let mut disjoint_snippets: Vec<(&PlannedSnippet, Range<Line>)> = Vec::new();
- for snippet in snippets {
- if let Some((_, current_snippet_range)) = current_snippet.as_mut()
- && snippet.range.start <= current_snippet_range.end
- {
- current_snippet_range.end = current_snippet_range.end.max(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, "`````path={}", 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
- | PromptFormat::OldTextNewText
- | PromptFormat::NumLinesUniDiff => {
- if range.start.0 > 0 && !skipped_last_snippet {
- output.push_str("…\n");
- }
- }
- PromptFormat::LabeledSections => {
- if is_excerpt_file
- && range.start <= self.request.excerpt_line_range.start
- && range.end >= self.request.excerpt_line_range.end
- {
- writeln!(output, "<|current_section|>").ok();
- } else {
- writeln!(output, "<|section_{}|>", section_index).ok();
- }
- }
- }
-
- let push_full_snippet = |output: &mut String| {
- if self.request.prompt_format == PromptFormat::NumLinesUniDiff {
- for (i, line) in snippet.text.lines().enumerate() {
- writeln!(output, "{}|{}", i as u32 + range.start.0 + 1, line)?;
- }
- } else {
- output.push_str(&snippet.text);
- }
- anyhow::Ok(())
- };
-
- if is_excerpt_file {
- if self.request.prompt_format == PromptFormat::OnlySnippets {
- if range.start >= self.request.excerpt_line_range.start
- && range.end <= self.request.excerpt_line_range.end
- {
- skipped_last_snippet = true;
- } else {
- skipped_last_snippet = false;
- output.push_str(snippet.text);
- }
- } else if !excerpt_file_insertions.is_empty() {
- let lines = snippet.text.lines().collect::<Vec<_>>();
- let push_line = |output: &mut String, line_ix: usize| {
- if self.request.prompt_format == PromptFormat::NumLinesUniDiff {
- write!(output, "{}|", line_ix as u32 + range.start.0 + 1)?;
- }
- anyhow::Ok(writeln!(output, "{}", lines[line_ix])?)
- };
- let mut last_line_ix = 0;
- let mut insertion_ix = 0;
- while insertion_ix < excerpt_file_insertions.len() {
- let (point, insertion) = &excerpt_file_insertions[insertion_ix];
- let found = point.line >= range.start && point.line <= range.end;
- if found {
- excerpt_index = Some(section_index);
- let insertion_line_ix = (point.line.0 - range.start.0) as usize;
- for line_ix in last_line_ix..insertion_line_ix {
- push_line(output, line_ix)?;
- }
- if let Some(next_line) = lines.get(insertion_line_ix) {
- if self.request.prompt_format == PromptFormat::NumLinesUniDiff {
- write!(
- output,
- "{}|",
- insertion_line_ix as u32 + range.start.0 + 1
- )?
- }
- output.push_str(&next_line[..point.column as usize]);
- output.push_str(insertion);
- writeln!(output, "{}", &next_line[point.column as usize..])?;
- } else {
- writeln!(output, "{}", insertion)?;
- }
- last_line_ix = insertion_line_ix + 1;
- excerpt_file_insertions.remove(insertion_ix);
- continue;
- }
- insertion_ix += 1;
- }
- skipped_last_snippet = false;
- for line_ix in last_line_ix..lines.len() {
- push_line(output, line_ix)?;
- }
- } else {
- skipped_last_snippet = false;
- push_full_snippet(output)?;
- }
- } else {
- skipped_last_snippet = false;
- push_full_snippet(output)?;
- }
-
- 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(),
- }
-}
@@ -1,94 +0,0 @@
-use anyhow::Result;
-use cloud_llm_client::predict_edits_v3::{self, Excerpt};
-use indoc::indoc;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use std::fmt::Write;
-
-use crate::{push_events, write_codeblock};
-
-pub fn build_prompt(request: predict_edits_v3::PlanContextRetrievalRequest) -> Result<String> {
- let mut prompt = SEARCH_INSTRUCTIONS.to_string();
-
- if !request.events.is_empty() {
- writeln!(&mut prompt, "## User Edits\n")?;
- push_events(&mut prompt, &request.events);
- }
-
- writeln!(&mut prompt, "## Cursor context")?;
- write_codeblock(
- &request.excerpt_path,
- &[Excerpt {
- start_line: request.excerpt_line_range.start,
- text: request.excerpt.into(),
- }],
- &[],
- request.cursor_file_max_row,
- true,
- &mut prompt,
- );
-
- writeln!(&mut prompt, "{TOOL_USE_REMINDER}")?;
-
- Ok(prompt)
-}
-
-/// Search for relevant code
-///
-/// For the best results, run multiple queries at once with a single invocation of this tool.
-#[derive(Clone, Deserialize, Serialize, JsonSchema)]
-pub struct SearchToolInput {
- /// An array of queries to run for gathering context relevant to the next prediction
- #[schemars(length(max = 3))]
- pub queries: Box<[SearchToolQuery]>,
-}
-
-/// Search for relevant code by path, syntax hierarchy, and content.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
-pub struct SearchToolQuery {
- /// 1. A glob pattern to match file paths in the codebase to search in.
- pub glob: String,
- /// 2. Regular expressions to match syntax nodes **by their first line** and hierarchy.
- ///
- /// Subsequent regexes match nodes within the full content of the nodes matched by the previous regexes.
- ///
- /// Example: Searching for a `User` class
- /// ["class\s+User"]
- ///
- /// Example: Searching for a `get_full_name` method under a `User` class
- /// ["class\s+User", "def\sget_full_name"]
- ///
- /// Skip this field to match on content alone.
- #[schemars(length(max = 3))]
- #[serde(default)]
- pub syntax_node: Vec<String>,
- /// 3. An optional regular expression to match the final content that should appear in the results.
- ///
- /// - Content will be matched within all lines of the matched syntax nodes.
- /// - If syntax node regexes are provided, this field can be skipped to include as much of the node itself as possible.
- /// - If no syntax node regexes are provided, the content will be matched within the entire file.
- pub content: Option<String>,
-}
-
-pub const TOOL_NAME: &str = "search";
-
-const SEARCH_INSTRUCTIONS: &str = indoc! {r#"
- You are part of an edit prediction system in a code editor.
- Your role is to search for code that will serve as context for predicting the next edit.
-
- - Analyze the user's recent edits and current cursor context
- - Use the `search` tool to find code that is relevant for predicting the next edit
- - Focus on finding:
- - Code patterns that might need similar changes based on the recent edits
- - Functions, variables, types, and constants referenced in the current cursor context
- - Related implementations, usages, or dependencies that may require consistent updates
- - How items defined in the cursor excerpt are used or altered
- - You will not be able to filter results or perform subsequent queries, so keep searches as targeted as possible
- - Use `syntax_node` parameter whenever you're looking for a particular type, class, or function
- - Avoid using wildcard globs if you already know the file path of the content you're looking for
-"#};
-
-const TOOL_USE_REMINDER: &str = indoc! {"
- --
- Analyze the user's intent in one to two sentences, then call the `search` tool.
-"};
@@ -10,7 +10,7 @@ path = "src/codestral.rs"
[dependencies]
anyhow.workspace = true
-edit_prediction.workspace = true
+edit_prediction_types.workspace = true
edit_prediction_context.workspace = true
futures.workspace = true
gpui.workspace = true
@@ -1,6 +1,6 @@
use anyhow::{Context as _, Result};
-use edit_prediction::{Direction, EditPrediction, EditPredictionProvider};
use edit_prediction_context::{EditPredictionExcerpt, EditPredictionExcerptOptions};
+use edit_prediction_types::{EditPrediction, EditPredictionDelegate};
use futures::AsyncReadExt;
use gpui::{App, Context, Entity, Task};
use http_client::HttpClient;
@@ -43,17 +43,17 @@ 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<Vec<(Range<Anchor>, Arc<str>)>> {
- edit_prediction::interpolate_edits(&self.snapshot, new_snapshot, &self.edits)
+ edit_prediction_types::interpolate_edits(&self.snapshot, new_snapshot, &self.edits)
}
}
-pub struct CodestralCompletionProvider {
+pub struct CodestralEditPredictionDelegate {
http_client: Arc<dyn HttpClient>,
pending_request: Option<Task<Result<()>>>,
current_completion: Option<CurrentCompletion>,
}
-impl CodestralCompletionProvider {
+impl CodestralEditPredictionDelegate {
pub fn new(http_client: Arc<dyn HttpClient>) -> Self {
Self {
http_client,
@@ -165,7 +165,7 @@ impl CodestralCompletionProvider {
}
}
-impl EditPredictionProvider for CodestralCompletionProvider {
+impl EditPredictionDelegate for CodestralEditPredictionDelegate {
fn name() -> &'static str {
"codestral"
}
@@ -174,7 +174,7 @@ impl EditPredictionProvider for CodestralCompletionProvider {
"Codestral"
}
- fn show_completions_in_menu() -> bool {
+ fn show_predictions_in_menu() -> bool {
true
}
@@ -182,7 +182,7 @@ impl EditPredictionProvider for CodestralCompletionProvider {
Self::api_key(cx).is_some()
}
- fn is_refreshing(&self) -> bool {
+ fn is_refreshing(&self, _cx: &App) -> bool {
self.pending_request.is_some()
}
@@ -239,7 +239,6 @@ impl EditPredictionProvider for CodestralCompletionProvider {
cursor_point,
&snapshot,
&EXCERPT_OPTIONS,
- None,
)
.context("Line containing cursor doesn't fit in excerpt max bytes")?;
@@ -301,16 +300,6 @@ impl EditPredictionProvider for CodestralCompletionProvider {
}));
}
- fn cycle(
- &mut self,
- _buffer: Entity<Buffer>,
- _cursor_position: Anchor,
- _direction: Direction,
- _cx: &mut Context<Self>,
- ) {
- // Codestral doesn't support multiple completions, so cycling does nothing
- }
-
fn accept(&mut self, _cx: &mut Context<Self>) {
log::debug!("Codestral: Completion accepted");
self.pending_request = None;
@@ -50,7 +50,6 @@ scrypt = "0.11"
# 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"
-semantic_version.workspace = true
semver.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -66,7 +65,7 @@ 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
@@ -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 <name>
-```
-
-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.
@@ -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
@@ -121,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)
);
@@ -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
-);
@@ -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");
@@ -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");
@@ -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");
@@ -1,4 +0,0 @@
-ALTER TABLE "signups"
- ADD "wants_releases" BOOLEAN,
- ADD "wants_updates" BOOLEAN,
- ADD "wants_community" BOOLEAN;
@@ -1 +0,0 @@
-DROP TABLE IF EXISTS "signups";
@@ -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);
@@ -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");
@@ -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");
@@ -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;
@@ -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");
@@ -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");
@@ -1,2 +0,0 @@
-ALTER TABLE "users"
- ADD "metrics_id" uuid NOT NULL DEFAULT gen_random_uuid();
@@ -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");
@@ -1,2 +0,0 @@
-ALTER TABLE "signups"
- ADD "added_to_mailing_list" BOOLEAN NOT NULL DEFAULT FALSE;
@@ -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");
@@ -1 +0,0 @@
-CREATE INDEX "index_room_participants_on_room_id" ON "room_participants" ("room_id");
@@ -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");
@@ -1,3 +0,0 @@
-ALTER TABLE "worktree_entries"
- ADD COLUMN "scan_id" INT8,
- ADD COLUMN "is_deleted" BOOL;
@@ -1,3 +0,0 @@
-ALTER TABLE worktrees
- ALTER COLUMN is_complete SET DEFAULT FALSE,
- ADD COLUMN completed_scan_id INT8;
@@ -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");
@@ -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");
@@ -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");
@@ -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");
@@ -1,2 +0,0 @@
-ALTER TABLE "worktree_entries"
-ADD "git_status" INT8;
@@ -1,2 +0,0 @@
-ALTER TABLE "worktree_entries"
-ADD "is_external" BOOL NOT NULL DEFAULT FALSE;
@@ -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;
@@ -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");
@@ -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");
@@ -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");
@@ -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");
@@ -1 +0,0 @@
-ALTER TABLE room_participants ADD COLUMN participant_index INTEGER;
@@ -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");
@@ -1 +0,0 @@
-ALTER TABLE rooms ADD COLUMN enviroment TEXT;
@@ -1 +0,0 @@
-CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id");
@@ -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';
@@ -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;
@@ -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";
@@ -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");
@@ -1 +0,0 @@
-ALTER TABLE room_participants ADD COLUMN role TEXT;
@@ -1 +0,0 @@
-ALTER TABLE rooms ADD COLUMN environment TEXT;
@@ -1 +0,0 @@
-ALTER TABLE access_tokens ADD COLUMN impersonated_user_id integer;
@@ -1,5 +0,0 @@
-CREATE TABLE contributors (
- user_id INTEGER REFERENCES users(id),
- signed_at TIMESTAMP NOT NULL DEFAULT NOW(),
- PRIMARY KEY (user_id)
-);
@@ -1 +0,0 @@
-ALTER TABLE "channels" ADD COLUMN "requires_zed_cla" BOOLEAN NOT NULL DEFAULT FALSE;
@@ -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);
@@ -1 +0,0 @@
-ALTER TABLE channel_messages ADD reply_to_message_id INTEGER DEFAULT NULL
@@ -1,3 +0,0 @@
--- Add migration script here
-
-ALTER TABLE room_participants ADD COLUMN in_call BOOL NOT NULL DEFAULT FALSE;
@@ -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;
@@ -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");
@@ -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);
@@ -1 +0,0 @@
-ALTER TABLE channel_messages ADD edited_at TIMESTAMP DEFAULT 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);
@@ -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 != '');
@@ -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;
@@ -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;
@@ -1,4 +0,0 @@
--- Add migration script here
-
-ALTER TABLE channel_members ALTER role SET NOT NULL;
-ALTER TABLE channel_members DROP COLUMN admin;
@@ -1,2 +0,0 @@
--- Add migration script here
-ALTER TABLE channels ALTER parent_path SET NOT NULL;
@@ -1,2 +0,0 @@
--- Add migration script here
-ALTER TABLE extension_versions ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 0;
@@ -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);
@@ -1 +0,0 @@
-ALTER TABLE extension_versions ADD COLUMN wasm_api_version TEXT;
@@ -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);
@@ -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");
@@ -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;
@@ -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);
@@ -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;
@@ -1,2 +0,0 @@
-ALTER TABLE projects DROP COLUMN remote_project_id;
-DROP TABLE remote_projects;
@@ -1 +0,0 @@
-ALTER TABLE dev_servers ADD COLUMN ssh_connection_string TEXT;
@@ -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;
@@ -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);
@@ -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);
@@ -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;
@@ -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);
@@ -1 +0,0 @@
-ALTER TABLE billing_subscriptions ADD COLUMN stripe_cancel_at TIMESTAMP WITHOUT TIME ZONE;
@@ -1 +0,0 @@
-ALTER TABLE users ADD accepted_tos_at TIMESTAMP WITHOUT TIME ZONE;
@@ -1 +0,0 @@
-ALTER TABLE "users" ADD COLUMN "github_user_created_at" TIMESTAMP WITHOUT TIME ZONE;
@@ -1 +0,0 @@
-alter table feature_flags add column enabled_for_all boolean not null default false;
@@ -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);
@@ -1,2 +0,0 @@
-ALTER TABLE "worktree_entries"
-ADD "is_fifo" BOOL NOT NULL DEFAULT FALSE;
@@ -1 +0,0 @@
-ALTER TABLE "worktree_settings_files" ADD COLUMN "kind" VARCHAR;
@@ -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);
@@ -1,2 +0,0 @@
-ALTER TABLE worktree_entries ADD COLUMN canonical_path text;
-ALTER TABLE worktree_entries ALTER COLUMN is_symlink SET DEFAULT false;
@@ -1 +0,0 @@
-alter table users add column custom_llm_monthly_allowance_in_cents integer;
@@ -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;
@@ -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");
@@ -1,2 +0,0 @@
-alter table billing_subscriptions
-add column stripe_cancellation_reason text;
@@ -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;
@@ -1 +0,0 @@
-ALTER TABLE users ADD COLUMN name TEXT;
@@ -1,2 +0,0 @@
-alter table billing_customers
-add column has_overdue_invoices bool not null default false;
@@ -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;
@@ -1,2 +0,0 @@
-ALTER TABLE worktree_repositories
-ADD COLUMN current_merge_conflicts VARCHAR NULL;
@@ -1,2 +0,0 @@
-ALTER TABLE worktree_repositories
-ADD COLUMN worktree_repositories VARCHAR NULL;
@@ -1 +0,0 @@
-ALTER TABLE worktree_repositories ADD COLUMN branch_summary TEXT 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");
@@ -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;
@@ -1,2 +0,0 @@
-alter table billing_customers
- add column trial_started_at timestamp without time zone;
@@ -1,2 +0,0 @@
-alter table project_repositories
- add column head_commit_details varchar;
@@ -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;
@@ -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");
@@ -1,4 +0,0 @@
-alter table project_collaborators
- add column committer_name varchar;
-alter table project_collaborators
- add column committer_email varchar;
@@ -1,2 +0,0 @@
-alter table extension_versions
-add column provides_debug_adapters bool not null default false
@@ -1,2 +0,0 @@
-alter table extension_versions
-add column provides_agent_servers bool not null default false
@@ -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;
@@ -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;
@@ -1,5 +0,0 @@
-ALTER TABLE language_servers
- ADD COLUMN capabilities TEXT NOT NULL DEFAULT '{}';
-
-ALTER TABLE language_servers
- ALTER COLUMN capabilities DROP DEFAULT;
@@ -1,2 +0,0 @@
-alter table users
-alter column admin set not null;
@@ -1,2 +0,0 @@
-alter table billing_customers
- add column orb_customer_id text;
@@ -1 +0,0 @@
-drop table rate_buckets;
@@ -1 +0,0 @@
-ALTER TABLE "project_repositories" ADD COLUMN "merge_message" VARCHAR;
@@ -1,2 +0,0 @@
-alter table billing_subscriptions
- add column orb_subscription_id text;
@@ -1,3 +0,0 @@
-alter table billing_subscriptions
- alter column stripe_subscription_id drop not null,
- alter column stripe_subscription_status drop not 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;
@@ -1,2 +0,0 @@
-ALTER TABLE language_servers
- ADD COLUMN worktree_id BIGINT;
@@ -1,2 +0,0 @@
-alter table billing_subscriptions
- add column orb_cancellation_date timestamp without time zone;
@@ -1,2 +0,0 @@
-alter table billing_customers
- add column orb_portal_url text;
@@ -1 +0,0 @@
-ALTER TABLE projects ADD COLUMN windows_paths BOOLEAN DEFAULT FALSE;
@@ -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;
@@ -1,2 +0,0 @@
-ALTER TABLE "worktree_entries"
-ADD "is_hidden" BOOL NOT NULL DEFAULT FALSE;
@@ -1,3 +0,0 @@
-drop table observed_channel_messages;
-drop table channel_message_mentions;
-drop table channel_messages;
@@ -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;
@@ -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);
@@ -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);
@@ -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;
@@ -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;
@@ -1 +0,0 @@
-alter table usages add column is_staff boolean not null default false;
@@ -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);
@@ -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);
@@ -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;
@@ -1,3 +0,0 @@
-alter table usages
- drop column cache_creation_input_tokens_this_month,
- drop column cache_read_input_tokens_this_month;
@@ -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);
@@ -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);
@@ -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;
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -1,2 +0,0 @@
-drop table subscription_usage_meters;
-drop table subscription_usages;
@@ -1,2 +0,0 @@
-drop table monthly_usages;
-drop table lifetime_usages;
@@ -1 +0,0 @@
-drop table billing_events;
@@ -54,6 +54,26 @@ async fn check_is_contributor(
) -> Result<Json<CheckIsContributorResponse>> {
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,71 @@ 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;
+ /// The alias of the GitHub copilot user. Although https://api.github.com/users/copilot
+ /// yields a 404, GitHub still refers to the copilot bot user as @Copilot in some cases.
+ const NAME_ALIAS: &'static str = "copilot";
+
+ /// Returns the `created_at` timestamp for the Dependabot bot user.
+ fn created_at() -> &'static NaiveDateTime {
+ static CREATED_AT: OnceLock<NaiveDateTime> = 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 || github_login == Self::NAME_ALIAS
+ }
+ 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<NaiveDateTime> = 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 +198,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<NaiveDateTime> = 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,
@@ -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(
@@ -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<i32>,
- pub wasm_api_versions: RangeInclusive<SemanticVersion>,
+ pub wasm_api_versions: RangeInclusive<semver::Version>,
}
impl LocalSettingsKind {
@@ -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;
@@ -1,94 +0,0 @@
-use super::*;
-use time::Duration;
-use time::OffsetDateTime;
-
-impl Database {
- pub async fn get_embeddings(
- &self,
- model: &str,
- digests: &[Vec<u8>],
- ) -> Result<HashMap<Vec<u8>, Vec<f32>>> {
- 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<u8>, Vec<f32>>,
- ) -> 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
- }
-}
@@ -69,7 +69,7 @@ impl Database {
extensions: &[extension::Model],
constraints: Option<&ExtensionVersionConstraints>,
tx: &DatabaseTransaction,
- ) -> Result<HashMap<ExtensionId, (extension_version::Model, SemanticVersion)>> {
+ ) -> Result<HashMap<ExtensionId, (extension_version::Model, Version)>> {
let mut versions = extension_version::Entity::find()
.filter(
extension_version::Column::ExtensionId
@@ -79,11 +79,10 @@ impl Database {
.await?;
let mut max_versions =
- HashMap::<ExtensionId, (extension_version::Model, SemanticVersion)>::default();
+ HashMap::<ExtensionId, (extension_version::Model, Version)>::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;
}
@@ -362,6 +362,8 @@ impl Database {
entry_ids: ActiveValue::set("[]".into()),
head_commit_details: ActiveValue::set(None),
merge_message: ActiveValue::set(None),
+ remote_upstream_url: ActiveValue::set(None),
+ remote_origin_url: ActiveValue::set(None),
}
}),
)
@@ -511,6 +513,8 @@ impl Database {
serde_json::to_string(&update.current_merge_conflicts).unwrap(),
)),
merge_message: ActiveValue::set(update.merge_message.clone()),
+ remote_upstream_url: ActiveValue::set(update.remote_upstream_url.clone()),
+ remote_origin_url: ActiveValue::set(update.remote_origin_url.clone()),
})
.on_conflict(
OnConflict::columns([
@@ -1005,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(),
});
}
}
@@ -796,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(),
});
}
}
@@ -8,7 +8,6 @@ pub mod channel_chat_participant;
pub mod channel_member;
pub mod contact;
pub mod contributor;
-pub mod embedding;
pub mod extension;
pub mod extension_version;
pub mod follower;
@@ -23,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;
@@ -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<u8>,
- pub dimensions: Vec<f32>,
- pub retrieved_at: PrimitiveDateTime,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {}
-
-impl ActiveModelBehavior for ActiveModel {}
@@ -22,6 +22,8 @@ pub struct Model {
pub branch_summary: Option<String>,
// A JSON object representing the current Head commit values
pub head_commit_details: Option<String>,
+ pub remote_upstream_url: Option<String>,
+ pub remote_origin_url: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -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<String>,
- pub user_id: Option<UserId>,
- pub inviting_user_id: Option<UserId>,
- pub platform_mac: bool,
- pub platform_linux: bool,
- pub platform_windows: bool,
- pub platform_unknown: bool,
- pub editor_features: Option<Vec<String>>,
- pub programming_languages: Option<Vec<String>>,
- pub added_to_mailing_list: bool,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {}
-
-impl ActiveModelBehavior for ActiveModel {}
@@ -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<super::access_token::Entity> for Entity {
fn to() -> RelationDef {
Relation::AccessToken.def()
@@ -2,26 +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 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<Arc<Database>>,
@@ -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"
- );
-}
@@ -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;
@@ -1 +0,0 @@
-pub mod db;
@@ -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<tokio::runtime::Runtime>,
-}
-
-impl LlmDatabase {
- /// Connects to the database with the given options
- pub async fn new(options: ConnectOptions, executor: Executor) -> Result<Self> {
- 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<F, Fut, T>(&self, f: F) -> Result<T>
- where
- F: Send + Fn(TransactionHandle) -> Fut,
- Fut: Send + Future<Output = Result<T>>,
- {
- 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<F, Fut, T>(&self, f: &F) -> Result<(DatabaseTransaction, Result<T>)>
- where
- F: Send + Fn(TransactionHandle) -> Fut,
- Fut: Send + Future<Output = Result<T>>,
- {
- 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<F, T>(&self, future: F) -> Result<T>
- where
- F: Future<Output = Result<T>>,
- {
- #[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
- }
- }
-}
@@ -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::<Config>().expect("error loading config");
- setup_app_database(&config).await?;
- }
Some("seed") => {
let config = envy::from_env::<Config>().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 <version | migrate | seed | serve <api|collab|all>>"
+ "usage: collab <version | seed | serve <api|collab|all>>"
))?;
}
};
@@ -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<ServiceMode>) -> String {
format!("zed:{mode} v{VERSION} ({})", REVISION.unwrap_or("unknown"))
}
@@ -50,7 +50,7 @@ use rpc::{
RequestMessage, ShareProject, UpdateChannelBufferCollaborators,
},
};
-use semantic_version::SemanticVersion;
+use semver::Version;
use serde::{Serialize, Serializer};
use std::{
any::TypeId,
@@ -453,6 +453,7 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::StashPop>)
.add_request_handler(forward_mutating_project_request::<proto::StashDrop>)
.add_request_handler(forward_mutating_project_request::<proto::Commit>)
+ .add_request_handler(forward_mutating_project_request::<proto::RunGitHook>)
.add_request_handler(forward_mutating_project_request::<proto::GitInit>)
.add_request_handler(forward_read_only_project_request::<proto::GetRemotes>)
.add_request_handler(forward_read_only_project_request::<proto::GitShow>)
@@ -468,6 +469,8 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::GetBlobContent>)
.add_request_handler(forward_mutating_project_request::<proto::GitCreateBranch>)
.add_request_handler(forward_mutating_project_request::<proto::GitChangeBranch>)
+ .add_request_handler(forward_mutating_project_request::<proto::GitCreateRemote>)
+ .add_request_handler(forward_mutating_project_request::<proto::GitRemoveRemote>)
.add_request_handler(forward_mutating_project_request::<proto::CheckForPushedCommits>)
.add_message_handler(broadcast_project_message_from_host::<proto::AdvertiseContexts>)
.add_message_handler(update_context)
@@ -984,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?;
}
@@ -1135,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<HeaderName> = OnceLock::new();
@@ -2833,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(
@@ -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<ConnectionId>,
}
-#[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 {
@@ -32,13 +32,13 @@ impl ZedVersion {
pub fn can_collaborate(&self) -> bool {
// 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 < SemanticVersion::new(0, 204, 1) {
+ 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;
}
@@ -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<Room>, cx: &mut TestAppContext) -> RoomPartic
fn channel_id(room: &Entity<Room>, cx: &mut TestAppContext) -> Option<ChannelId> {
cx.read(|cx| room.read(cx).channel_id())
}
-
-fn rust_lang() -> Arc<Language> {
- 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()),
- ))
-}
@@ -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::<Vec<_>>();
assert_eq!(
@@ -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, FETCH_COLORS_DEBOUNCE_TIMEOUT, 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},
@@ -21,8 +20,9 @@ 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::{
ProgressToken, ProjectPath, SERVER_PROGRESS_THROTTLE_TIMEOUT,
lsp_store::lsp_ext_command::{ExpandedMacro, LspExtExpandMacro},
@@ -288,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;
@@ -307,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::<lsp::request::Completion, _, _>(
+ |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<E>".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::<lsp::request::Completion, _, _>(
+ |_, _| 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()
},
@@ -352,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())
@@ -362,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::<lsp::request::Completion, _, _>(|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<E>".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
@@ -460,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!(
@@ -479,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);
@@ -522,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::<lsp::request::Completion, _, _>(|_, _| 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::<lsp::request::ResolveCompletionItem, _, _>(|params, _| async move {
@@ -545,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());
@@ -563,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::<lsp::request::Completion, _, _>(|_, _| async move { Ok(None) });
+
+ let mut second_lsp_completion = second_fake_language_server
+ .set_request_handler::<lsp::request::Completion, _, _>(|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<T>".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)]
@@ -850,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()
});
@@ -877,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::<usize>(&rename_editor.display_snapshot(cx));
+ let rename_selection = rename_editor.selections.newest::<MultiBufferOffset>(&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);
});
});
});
@@ -898,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()
});
@@ -925,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::<usize>(&rename_editor.display_snapshot(cx));
+ let rename_selection = rename_editor.selections.newest::<MultiBufferOffset>(&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);
});
});
});
@@ -1137,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();
@@ -1159,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();
@@ -1181,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();
@@ -1479,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
});
@@ -1520,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
});
@@ -1619,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);
});
@@ -1728,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);
});
@@ -1956,7 +2061,7 @@ 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);
});
@@ -1980,7 +2085,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
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);
});
@@ -2169,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()
@@ -2408,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);
});
@@ -2845,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::<Vec<_>>();
assert_eq!(
all_diagnostics.len(),
@@ -2974,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::<Vec<_>>();
assert_eq!(
all_diagnostics.len(),
@@ -3021,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::<Vec<_>>();
assert_eq!(
all_diagnostics.len(),
@@ -3068,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::<Vec<_>>();
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!(
@@ -3135,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::<Vec<_>>();
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!(
@@ -3258,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!(
@@ -3270,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::<Vec<_>>();
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()),
@@ -3288,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::<Vec<_>>();
assert_eq!(all_diagnostics.len(), 2);
@@ -3307,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::<Vec<_>>();
assert_eq!(all_diagnostics.len(), 2);
let expected_messages = [
@@ -3396,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")),
@@ -3481,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)
- );
}
});
});
@@ -4156,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::<Editor>()
+ .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::<Editor>()
+ .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::<Editor>()
+ .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::<Editor>()
+ .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::<Editor>()
+ .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::<Editor>()
+ .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,
@@ -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,
@@ -124,7 +124,7 @@ async fn test_basic_following(
editor.select_left(&Default::default(), window, cx);
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- vec![3..2]
+ vec![MultiBufferOffset(3)..MultiBufferOffset(2)]
);
});
editor_a2.update_in(cx_a, |editor, window, cx| {
@@ -133,7 +133,7 @@ async fn test_basic_following(
editor.select_left(&Default::default(), window, cx);
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- vec![2..1]
+ vec![MultiBufferOffset(2)..MultiBufferOffset(1)]
);
});
@@ -158,13 +158,13 @@ async fn test_basic_following(
editor_b2.update(cx_b, |editor, cx| editor
.selections
.ranges(&editor.display_snapshot(cx))),
- vec![2..1]
+ vec![MultiBufferOffset(2)..MultiBufferOffset(1)]
);
assert_eq!(
editor_b1.update(cx_b, |editor, cx| editor
.selections
.ranges(&editor.display_snapshot(cx))),
- vec![3..3]
+ vec![MultiBufferOffset(3)..MultiBufferOffset(3)]
);
executor.run_until_parked();
@@ -386,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);
@@ -396,7 +399,10 @@ async fn test_basic_following(
editor_b1.update(cx_b, |editor, cx| {
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- &[1..1, 2..2]
+ &[
+ MultiBufferOffset(1)..MultiBufferOffset(1),
+ MultiBufferOffset(2)..MultiBufferOffset(2)
+ ]
);
});
@@ -408,7 +414,7 @@ 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);
});
@@ -417,7 +423,7 @@ async fn test_basic_following(
editor_b1.update(cx_b, |editor, cx| {
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- &[3..3]
+ &[MultiBufferOffset(3)..MultiBufferOffset(3)]
);
});
@@ -523,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::<TestPanel>(window, cx);
@@ -1694,7 +1700,7 @@ 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()
@@ -1703,7 +1709,7 @@ async fn test_following_stops_on_unshare(cx_a: &mut TestAppContext, cx_b: &mut T
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- vec![1..1]
+ vec![MultiBufferOffset(1)..MultiBufferOffset(1)]
)
});
@@ -1719,7 +1725,7 @@ 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()
@@ -1728,7 +1734,7 @@ async fn test_following_stops_on_unshare(cx_a: &mut TestAppContext, cx_b: &mut T
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- vec![1..1]
+ vec![MultiBufferOffset(1)..MultiBufferOffset(1)]
)
});
cx_b.update(|_, cx| {
@@ -1829,7 +1835,7 @@ async fn test_following_into_excluded_file(
editor.select_left(&Default::default(), window, cx);
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- vec![3..2]
+ vec![MultiBufferOffset(3)..MultiBufferOffset(2)]
);
});
editor_for_excluded_a.update_in(cx_a, |editor, window, cx| {
@@ -1838,7 +1844,7 @@ async fn test_following_into_excluded_file(
editor.select_left(&Default::default(), window, cx);
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- vec![18..17]
+ vec![MultiBufferOffset(18)..MultiBufferOffset(17)]
);
});
@@ -1864,7 +1870,7 @@ async fn test_following_into_excluded_file(
editor_for_excluded_b.update(cx_b, |editor, cx| editor
.selections
.ranges(&editor.display_snapshot(cx))),
- vec![18..17]
+ vec![MultiBufferOffset(18)..MultiBufferOffset(17)]
);
editor_for_excluded_a.update_in(cx_a, |editor, window, cx| {
@@ -2040,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)]);
});
});
});
@@ -2076,8 +2082,8 @@ async fn test_following_to_channel_notes_without_a_shared_project(
assert_eq!(
editor
.selections
- .ranges::<usize>(&editor.display_snapshot(cx)),
- &[3..4]
+ .ranges::<MultiBufferOffset>(&editor.display_snapshot(cx)),
+ &[MultiBufferOffset(3)..MultiBufferOffset(4)]
);
})
});
@@ -2,7 +2,7 @@ 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};
@@ -26,7 +26,7 @@ use language::{
Diagnostic, DiagnosticEntry, DiagnosticSourceKind, FakeLspAdapter, Language, LanguageConfig,
LanguageMatcher, LineEnding, OffsetRangeExt, Point, Rope,
language_settings::{Formatter, FormatterList},
- tree_sitter_rust, tree_sitter_typescript,
+ rust_lang, tree_sitter_rust, tree_sitter_typescript,
};
use lsp::{LanguageServerId, OneOf};
use parking_lot::Mutex;
@@ -6551,12 +6551,12 @@ async fn test_pane_split_left(cx: &mut TestAppContext) {
assert!(workspace.items(cx).collect::<Vec<_>>().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::<Vec<_>>().len() == 2);
+ assert!(workspace.items(cx).collect::<Vec<_>>().len() == 3);
});
}
@@ -4,33 +4,39 @@ use collections::{HashMap, HashSet};
use dap::{Capabilities, adapters::DebugTaskDefinition, transport::RequestHandling};
use debugger_ui::debugger_panel::DebugPanel;
+use editor::{Editor, EditorMode, MultiBuffer};
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, language_settings},
- tree_sitter_typescript,
+ rust_lang, tree_sitter_typescript,
};
use node_runtime::NodeRuntime;
use project::{
ProjectPath,
debugger::session::ThreadId,
lsp_store::{FormatTrigger, LspFormatTarget},
+ trusted_worktrees::{PathTrust, TrustedWorktrees},
};
use remote::RemoteClient;
use remote_server::{HeadlessAppState, HeadlessProject};
use rpc::proto;
use serde_json::json;
-use settings::{LanguageServerFormatterSpecifier, PrettierSettingsContent, SettingsStore};
+use settings::{
+ InlayHintSettingsContent, LanguageServerFormatterSpecifier, PrettierSettingsContent,
+ SettingsStore,
+};
use std::{
path::Path,
- sync::{Arc, atomic::AtomicUsize},
+ sync::{
+ Arc,
+ atomic::{AtomicUsize, Ordering},
+ },
+ time::Duration,
};
use task::TcpArgumentsTemplate;
use util::{path, rel_path::rel_path};
@@ -43,10 +49,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;
@@ -93,13 +99,14 @@ async fn test_sharing_an_ssh_remote_project(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
+ false,
cx,
)
});
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, worktree_id) = client_a
- .build_ssh_project(path!("/code/project1"), client_ssh, cx_a)
+ .build_ssh_project(path!("/code/project1"), client_ssh, false, cx_a)
.await;
// While the SSH worktree is being scanned, user A shares the remote project.
@@ -211,10 +218,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;
@@ -253,13 +260,14 @@ async fn test_ssh_collaboration_git_branches(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
+ false,
cx,
)
});
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, _) = client_a
- .build_ssh_project("/project", client_ssh, cx_a)
+ .build_ssh_project("/project", client_ssh, false, cx_a)
.await;
// While the SSH worktree is being scanned, user A shares the remote project.
@@ -396,10 +404,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;
@@ -457,13 +465,14 @@ async fn test_ssh_collaboration_formatting_with_prettier(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
+ false,
cx,
)
});
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, worktree_id) = client_a
- .build_ssh_project(path!("/project"), client_ssh, cx_a)
+ .build_ssh_project(path!("/project"), client_ssh, false, cx_a)
.await;
// While the SSH worktree is being scanned, user A shares the remote project.
@@ -583,13 +592,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);
@@ -618,6 +627,7 @@ async fn test_remote_server_debugger(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
+ false,
cx,
)
});
@@ -630,7 +640,7 @@ async fn test_remote_server_debugger(
command_palette_hooks::init(cx);
});
let (project_a, _) = client_a
- .build_ssh_project(path!("/code"), client_ssh.clone(), cx_a)
+ .build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a)
.await;
let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a);
@@ -691,13 +701,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);
@@ -726,6 +736,7 @@ async fn test_slow_adapter_startup_retries(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
+ false,
cx,
)
});
@@ -738,7 +749,7 @@ async fn test_slow_adapter_startup_retries(
command_palette_hooks::init(cx);
});
let (project_a, _) = client_a
- .build_ssh_project(path!("/code"), client_ssh.clone(), cx_a)
+ .build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a)
.await;
let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a);
@@ -841,3 +852,261 @@ async fn test_slow_adapter_startup_retries(
shutdown_session.await.unwrap();
}
+
+#[gpui::test]
+async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &mut TestAppContext) {
+ use project::trusted_worktrees::RemoteHostLocation;
+
+ cx_a.update(|cx| {
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
+ project::trusted_worktrees::init(HashMap::default(), None, None, cx);
+ });
+ server_cx.update(|cx| {
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
+ project::trusted_worktrees::init(HashMap::default(), None, None, cx);
+ });
+
+ let mut server = TestServer::start(cx_a.executor().clone()).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+
+ let server_name = "override-rust-analyzer";
+ let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0));
+
+ let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
+ let remote_fs = FakeFs::new(server_cx.executor());
+ remote_fs
+ .insert_tree(
+ path!("/projects"),
+ json!({
+ "project_a": {
+ ".zed": {
+ "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"#
+ },
+ "main.rs": "fn main() {}"
+ },
+ "project_b": { "lib.rs": "pub fn lib() {}" }
+ }),
+ )
+ .await;
+
+ server_cx.update(HeadlessProject::init);
+ let remote_http_client = Arc::new(BlockedHttpClient);
+ let node = NodeRuntime::unavailable();
+ let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
+ languages.add(rust_lang());
+
+ let capabilities = lsp::ServerCapabilities {
+ inlay_hint_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ };
+ let mut fake_language_servers = languages.register_fake_lsp(
+ "Rust",
+ FakeLspAdapter {
+ name: server_name,
+ capabilities: capabilities.clone(),
+ initializer: Some(Box::new({
+ let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
+ move |fake_server| {
+ let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
+ fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>(
+ move |_params, _| {
+ lsp_inlay_hint_request_count.fetch_add(1, Ordering::Release);
+ async move {
+ Ok(Some(vec![lsp::InlayHint {
+ position: lsp::Position::new(0, 0),
+ label: lsp::InlayHintLabel::String("hint".to_string()),
+ kind: None,
+ text_edits: None,
+ tooltip: None,
+ padding_left: None,
+ padding_right: None,
+ data: None,
+ }]))
+ }
+ },
+ );
+ }
+ })),
+ ..FakeLspAdapter::default()
+ },
+ );
+
+ let _headless_project = server_cx.new(|cx| {
+ HeadlessProject::new(
+ HeadlessAppState {
+ session: server_ssh,
+ fs: remote_fs.clone(),
+ http_client: remote_http_client,
+ node_runtime: node,
+ languages,
+ extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
+ },
+ true,
+ cx,
+ )
+ });
+
+ let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
+ let (project_a, worktree_id_a) = client_a
+ .build_ssh_project(path!("/projects/project_a"), client_ssh.clone(), true, cx_a)
+ .await;
+
+ cx_a.update(|cx| {
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
+
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings(cx, |settings| {
+ let language_settings = &mut settings.project.all_languages.defaults;
+ language_settings.inlay_hints = Some(InlayHintSettingsContent {
+ enabled: Some(true),
+ ..InlayHintSettingsContent::default()
+ })
+ });
+ });
+ });
+
+ project_a
+ .update(cx_a, |project, cx| {
+ project.languages().add(rust_lang());
+ project.languages().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ name: server_name,
+ capabilities,
+ ..FakeLspAdapter::default()
+ },
+ );
+ project.find_or_create_worktree(path!("/projects/project_b"), true, cx)
+ })
+ .await
+ .unwrap();
+
+ cx_a.run_until_parked();
+
+ let worktree_ids = project_a.read_with(cx_a, |project, cx| {
+ project
+ .worktrees(cx)
+ .map(|wt| wt.read(cx).id())
+ .collect::<Vec<_>>()
+ });
+ assert_eq!(worktree_ids.len(), 2);
+
+ let remote_host = project_a.read_with(cx_a, |project, cx| {
+ project
+ .remote_connection_options(cx)
+ .map(RemoteHostLocation::from)
+ });
+
+ let trusted_worktrees =
+ cx_a.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist"));
+
+ let can_trust_a =
+ trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
+ let can_trust_b =
+ trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
+ assert!(!can_trust_a, "project_a should be restricted initially");
+ assert!(!can_trust_b, "project_b should be restricted initially");
+
+ let worktree_store = project_a.read_with(cx_a, |project, _| project.worktree_store());
+ let has_restricted = trusted_worktrees.read_with(cx_a, |store, cx| {
+ store.has_restricted_worktrees(&worktree_store, cx)
+ });
+ assert!(has_restricted, "should have restricted worktrees");
+
+ let buffer_before_approval = project_a
+ .update(cx_a, |project, cx| {
+ project.open_buffer((worktree_id_a, rel_path("main.rs")), cx)
+ })
+ .await
+ .unwrap();
+
+ let (editor, cx_a) = cx_a.add_window_view(|window, cx| {
+ Editor::new(
+ EditorMode::full(),
+ cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)),
+ Some(project_a.clone()),
+ window,
+ cx,
+ )
+ });
+ cx_a.run_until_parked();
+ let fake_language_server = fake_language_servers.next();
+
+ cx_a.read(|cx| {
+ let file = buffer_before_approval.read(cx).file();
+ assert_eq!(
+ language_settings(Some("Rust".into()), file, cx).language_servers,
+ ["...".to_string()],
+ "remote .zed/settings.json must not sync before trust approval"
+ )
+ });
+
+ editor.update_in(cx_a, |editor, window, cx| {
+ editor.handle_input("1", window, cx);
+ });
+ cx_a.run_until_parked();
+ cx_a.executor().advance_clock(Duration::from_secs(1));
+ assert_eq!(
+ lsp_inlay_hint_request_count.load(Ordering::Acquire),
+ 0,
+ "inlay hints must not be queried before trust approval"
+ );
+
+ trusted_worktrees.update(cx_a, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(worktree_ids[0])]),
+ remote_host.clone(),
+ cx,
+ );
+ });
+ cx_a.run_until_parked();
+
+ cx_a.read(|cx| {
+ let file = buffer_before_approval.read(cx).file();
+ assert_eq!(
+ language_settings(Some("Rust".into()), file, cx).language_servers,
+ ["override-rust-analyzer".to_string()],
+ "remote .zed/settings.json should sync after trust approval"
+ )
+ });
+ let _fake_language_server = fake_language_server.await.unwrap();
+ editor.update_in(cx_a, |editor, window, cx| {
+ editor.handle_input("1", window, cx);
+ });
+ cx_a.run_until_parked();
+ cx_a.executor().advance_clock(Duration::from_secs(1));
+ assert!(
+ lsp_inlay_hint_request_count.load(Ordering::Acquire) > 0,
+ "inlay hints should be queried after trust approval"
+ );
+
+ let can_trust_a =
+ trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
+ let can_trust_b =
+ trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
+ assert!(can_trust_a, "project_a should be trusted after trust()");
+ assert!(!can_trust_b, "project_b should still be restricted");
+
+ trusted_worktrees.update(cx_a, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]),
+ remote_host.clone(),
+ cx,
+ );
+ });
+
+ let can_trust_a =
+ trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
+ let can_trust_b =
+ trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
+ assert!(can_trust_a, "project_a should remain trusted");
+ assert!(can_trust_b, "project_b should now be trusted");
+
+ let has_restricted_after = trusted_worktrees.read_with(cx_a, |store, cx| {
+ store.has_restricted_worktrees(&worktree_store, cx)
+ });
+ assert!(
+ !has_restricted_after,
+ "should have no restricted worktrees after trusting both"
+ );
+}
@@ -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;
@@ -173,7 +172,7 @@ impl TestServer {
let settings = SettingsStore::test(cx);
cx.set_global(settings);
theme::init(theme::LoadThemes::JustBase, cx);
- release_channel::init(SemanticVersion::default(), 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,
@@ -762,6 +761,7 @@ impl TestClient {
&self,
root_path: impl AsRef<Path>,
ssh: Entity<RemoteClient>,
+ init_worktree_trust: bool,
cx: &mut TestAppContext,
) -> (Entity<Project>, WorktreeId) {
let project = cx.update(|cx| {
@@ -772,6 +772,7 @@ impl TestClient {
self.app_state.user_store.clone(),
self.app_state.languages.clone(),
self.app_state.fs.clone(),
+ init_worktree_trust,
cx,
)
});
@@ -840,6 +841,7 @@ impl TestClient {
self.app_state.languages.clone(),
self.app_state.fs.clone(),
None,
+ false,
cx,
)
})
@@ -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};
@@ -441,11 +441,11 @@ impl Item for ChannelView {
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
- ) -> Option<AnyView> {
+ ) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
- Some(self_handle.to_any())
+ Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
- Some(self.editor.to_any())
+ Some(self.editor.clone().into())
} else {
None
}
@@ -541,7 +541,7 @@ impl Item for ChannelView {
})
}
- fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
@@ -32,12 +32,12 @@ use std::{mem, sync::Arc};
use theme::{ActiveTheme, ThemeSettings};
use ui::{
Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, Facepile, HighlightedLabel,
- Icon, IconButton, IconName, IconSize, Indicator, Label, ListHeader, ListItem, Tooltip,
+ 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},
};
@@ -109,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::<RoomIdCopiedToast>(),
+ "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:?}");
@@ -287,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
});
@@ -672,20 +684,25 @@ impl CollabPanel {
{
self.entries.push(ListEntry::ChannelEditor { depth: 0 });
}
+
+ let should_respect_collapse = query.is_empty();
let mut collapse_depth = None;
+
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;
+ }
}
}
@@ -1235,7 +1252,7 @@ impl CollabPanel {
context_menu
});
- window.focus(&context_menu.focus_handle(cx));
+ window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe_in(
&context_menu,
window,
@@ -1407,7 +1424,7 @@ impl CollabPanel {
context_menu
});
- window.focus(&context_menu.focus_handle(cx));
+ window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe_in(
&context_menu,
window,
@@ -1470,7 +1487,7 @@ impl CollabPanel {
})
});
- window.focus(&context_menu.focus_handle(cx));
+ window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe_in(
&context_menu,
window,
@@ -1491,7 +1508,7 @@ impl CollabPanel {
fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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 {
@@ -1504,9 +1521,9 @@ impl CollabPanel {
if cx.stop_active_drag(window) {
return;
} else if self.take_editing_state(window, cx) {
- window.focus(&self.filter_editor.focus_handle(cx));
+ window.focus(&self.filter_editor.focus_handle(cx), cx);
} else if !self.reset_filter_editor_text(window, cx) {
- self.focus_handle.focus(window);
+ self.focus_handle.focus(window, cx);
}
if self.context_menu.is_some() {
@@ -1809,7 +1826,7 @@ impl CollabPanel {
});
self.update_entries(false, cx);
self.select_channel_editor();
- window.focus(&self.channel_name_editor.focus_handle(cx));
+ window.focus(&self.channel_name_editor.focus_handle(cx), cx);
cx.notify();
}
@@ -1834,7 +1851,7 @@ impl CollabPanel {
});
self.update_entries(false, cx);
self.select_channel_editor();
- window.focus(&self.channel_name_editor.focus_handle(cx));
+ window.focus(&self.channel_name_editor.focus_handle(cx), cx);
cx.notify();
}
@@ -1883,7 +1900,7 @@ impl CollabPanel {
editor.set_text(channel.name.clone(), window, cx);
editor.select_all(&Default::default(), window, cx);
});
- window.focus(&self.channel_name_editor.focus_handle(cx));
+ window.focus(&self.channel_name_editor.focus_handle(cx), cx);
self.update_entries(false, cx);
self.select_channel_editor();
}
@@ -2407,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(),
@@ -2414,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(
@@ -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")
@@ -640,7 +642,7 @@ impl ChannelModalDelegate {
});
menu
});
- window.focus(&context_menu.focus_handle(cx));
+ window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe_in(
&context_menu,
window,
@@ -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
@@ -2,7 +2,7 @@ mod persistence;
use std::{
cmp::{self, Reverse},
- collections::HashMap,
+ collections::{HashMap, VecDeque},
sync::Arc,
time::Duration,
};
@@ -19,6 +19,7 @@ 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;
@@ -163,6 +164,7 @@ pub struct CommandPaletteDelegate {
Task<()>,
postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>, CommandInterceptResult)>,
)>,
+ query_history: QueryHistory,
}
struct Command {
@@ -170,6 +172,91 @@ struct Command {
action: Box<dyn Action>,
}
+#[derive(Default)]
+struct QueryHistory {
+ history: Option<VecDeque<String>>,
+ cursor: Option<usize>,
+ prefix: Option<String>,
+}
+
+impl QueryHistory {
+ fn history(&mut self) -> &mut VecDeque<String> {
+ 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<usize> {
+ 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 {
@@ -196,6 +283,7 @@ impl CommandPaletteDelegate {
previous_focus_handle,
latest_query: String::new(),
updating_matches: None,
+ query_history: Default::default(),
}
}
@@ -271,6 +359,11 @@ impl CommandPaletteDelegate {
// 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 {
@@ -280,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<String> {
+ 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()
}
@@ -439,6 +564,12 @@ impl PickerDelegate for CommandPaletteDelegate {
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!(
@@ -457,7 +588,7 @@ impl PickerDelegate for CommandPaletteDelegate {
})
.detach_and_log_err(cx);
let action = command.action;
- window.focus(&self.previous_focus_handle);
+ window.focus(&self.previous_focus_handle, cx);
self.dismissed(window, cx);
window.dispatch_action(action, cx);
}
@@ -588,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;
@@ -653,7 +784,7 @@ mod tests {
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
- editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
+ editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
});
cx.simulate_keystrokes("cmd-shift-p");
@@ -724,7 +855,7 @@ mod tests {
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
- editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
+ editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
});
// Test normalize (trimming whitespace and double colons)
@@ -799,7 +930,9 @@ mod tests {
"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"
}
}
]"#,
@@ -808,4 +941,264 @@ mod tests {
app_state
})
}
+
+ fn open_palette_with_history(
+ workspace: &Entity<Workspace>,
+ history: &[&str],
+ cx: &mut VisualTestContext,
+ ) -> Entity<Picker<CommandPaletteDelegate>> {
+ cx.simulate_keystrokes("cmd-shift-p");
+ cx.run_until_parked();
+
+ let palette = workspace.update(cx, |workspace, cx| {
+ workspace
+ .active_modal::<CommandPalette>(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), "");
+ });
+ }
}
@@ -123,6 +123,16 @@ impl CommandPaletteDB {
ORDER BY COUNT(1) DESC
}
}
+
+ query! {
+ pub fn list_recent_queries() -> Result<Vec<String>> {
+ SELECT user_query
+ FROM command_invocations
+ WHERE user_query != ""
+ GROUP BY user_query
+ ORDER BY MAX(last_invoked) ASC
+ }
+ }
}
#[cfg(test)]
@@ -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
@@ -28,7 +29,12 @@ schemars.workspace = true
serde_json.workspace = true
serde.workspace = true
settings.workspace = true
+slotmap.workspace = true
smol.workspace = true
tempfile.workspace = true
url = { workspace = true, features = ["serde"] }
util.workspace = true
+terminal.workspace = true
+
+[dev-dependencies]
+gpui = { workspace = true, features = ["test-support"] }
@@ -6,6 +6,7 @@ use parking_lot::Mutex;
use postage::barrier;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::{Value, value::RawValue};
+use slotmap::SlotMap;
use smol::channel;
use std::{
fmt,
@@ -50,7 +51,7 @@ pub(crate) struct Client {
next_id: AtomicI32,
outbound_tx: channel::Sender<String>,
name: Arc<str>,
- notification_handlers: Arc<Mutex<HashMap<&'static str, NotificationHandler>>>,
+ subscription_set: Arc<Mutex<NotificationSubscriptionSet>>,
response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
#[allow(clippy::type_complexity)]
#[allow(dead_code)]
@@ -191,21 +192,20 @@ impl Client {
let (outbound_tx, outbound_rx) = channel::unbounded::<String>();
let (output_done_tx, output_done_rx) = barrier::channel();
- let notification_handlers =
- Arc::new(Mutex::new(HashMap::<_, NotificationHandler>::default()));
+ let subscription_set = Arc::new(Mutex::new(NotificationSubscriptionSet::default()));
let response_handlers =
Arc::new(Mutex::new(Some(HashMap::<_, ResponseHandler>::default())));
let request_handlers = Arc::new(Mutex::new(HashMap::<_, RequestHandler>::default()));
let receive_input_task = cx.spawn({
- let notification_handlers = notification_handlers.clone();
+ let subscription_set = subscription_set.clone();
let response_handlers = response_handlers.clone();
let request_handlers = request_handlers.clone();
let transport = transport.clone();
async move |cx| {
Self::handle_input(
transport,
- notification_handlers,
+ subscription_set,
request_handlers,
response_handlers,
cx,
@@ -236,7 +236,7 @@ impl Client {
Ok(Self {
server_id,
- notification_handlers,
+ subscription_set,
response_handlers,
name: server_name,
next_id: Default::default(),
@@ -257,7 +257,7 @@ impl Client {
/// to pending requests) and notifications (which trigger registered handlers).
async fn handle_input(
transport: Arc<dyn Transport>,
- notification_handlers: Arc<Mutex<HashMap<&'static str, NotificationHandler>>>,
+ subscription_set: Arc<Mutex<NotificationSubscriptionSet>>,
request_handlers: Arc<Mutex<HashMap<&'static str, RequestHandler>>>,
response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
cx: &mut AsyncApp,
@@ -282,10 +282,11 @@ impl Client {
handler(Ok(message.to_string()));
}
} else if let Ok(notification) = serde_json::from_str::<AnyNotification>(&message) {
- let mut notification_handlers = notification_handlers.lock();
- if let Some(handler) = notification_handlers.get_mut(notification.method.as_str()) {
- handler(notification.params.unwrap_or(Value::Null), cx.clone());
- }
+ subscription_set.lock().notify(
+ ¬ification.method,
+ notification.params.unwrap_or(Value::Null),
+ cx,
+ )
} else {
log::error!("Unhandled JSON from context_server: {}", message);
}
@@ -451,12 +452,18 @@ impl Client {
Ok(())
}
+ #[must_use]
pub fn on_notification(
&self,
method: &'static str,
f: Box<dyn 'static + Send + FnMut(Value, AsyncApp)>,
- ) {
- self.notification_handlers.lock().insert(method, f);
+ ) -> NotificationSubscription {
+ let mut notification_subscriptions = self.subscription_set.lock();
+
+ NotificationSubscription {
+ id: notification_subscriptions.add_handler(method, f),
+ set: self.subscription_set.clone(),
+ }
}
}
@@ -485,3 +492,73 @@ impl fmt::Debug for Client {
.finish_non_exhaustive()
}
}
+
+slotmap::new_key_type! {
+ struct NotificationSubscriptionId;
+}
+
+#[derive(Default)]
+pub struct NotificationSubscriptionSet {
+ // we have very few subscriptions at the moment
+ methods: Vec<(&'static str, Vec<NotificationSubscriptionId>)>,
+ handlers: SlotMap<NotificationSubscriptionId, NotificationHandler>,
+}
+
+impl NotificationSubscriptionSet {
+ #[must_use]
+ fn add_handler(
+ &mut self,
+ method: &'static str,
+ handler: NotificationHandler,
+ ) -> NotificationSubscriptionId {
+ let id = self.handlers.insert(handler);
+ if let Some((_, handler_ids)) = self
+ .methods
+ .iter_mut()
+ .find(|(probe_method, _)| method == *probe_method)
+ {
+ debug_assert!(
+ handler_ids.len() < 20,
+ "Too many MCP handlers for {}. Consider using a different data structure.",
+ method
+ );
+
+ handler_ids.push(id);
+ } else {
+ self.methods.push((method, vec![id]));
+ };
+ id
+ }
+
+ fn notify(&mut self, method: &str, payload: Value, cx: &mut AsyncApp) {
+ let Some((_, handler_ids)) = self
+ .methods
+ .iter_mut()
+ .find(|(probe_method, _)| method == *probe_method)
+ else {
+ return;
+ };
+
+ for handler_id in handler_ids {
+ if let Some(handler) = self.handlers.get_mut(*handler_id) {
+ handler(payload.clone(), cx.clone());
+ }
+ }
+ }
+}
+
+pub struct NotificationSubscription {
+ id: NotificationSubscriptionId,
+ set: Arc<Mutex<NotificationSubscriptionSet>>,
+}
+
+impl Drop for NotificationSubscription {
+ fn drop(&mut self) {
+ let mut set = self.set.lock();
+ set.handlers.remove(self.id);
+ set.methods.retain_mut(|(_, handler_ids)| {
+ handler_ids.retain(|id| *id != self.id);
+ !handler_ids.is_empty()
+ });
+ }
+}
@@ -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<str>);
@@ -52,6 +57,25 @@ impl ContextServer {
}
}
+ pub fn http(
+ id: ContextServerId,
+ endpoint: &Url,
+ headers: HashMap<String, String>,
+ http_client: Arc<dyn HttpClient>,
+ executor: gpui::BackgroundExecutor,
+ ) -> Result<Self> {
+ 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<dyn crate::transport::Transport>) -> Self {
Self {
id,
@@ -72,22 +96,6 @@ impl ContextServer {
self.initialize(self.new_client(cx)?).await
}
- /// Starts the context server, making sure handlers are registered before initialization happens
- pub async fn start_with_handlers(
- &self,
- notification_handlers: Vec<(
- &'static str,
- Box<dyn 'static + Send + FnMut(serde_json::Value, AsyncApp)>,
- )>,
- cx: &AsyncApp,
- ) -> Result<()> {
- let client = self.new_client(cx)?;
- for (method, handler) in notification_handlers {
- client.on_notification(method, handler);
- }
- self.initialize(client).await
- }
-
fn new_client(&self, cx: &AsyncApp) -> Result<Client> {
Ok(match &self.configuration {
ContextServerTransport::Stdio(command, working_directory) => Client::stdio(
@@ -12,7 +12,7 @@ use futures::channel::oneshot;
use gpui::AsyncApp;
use serde_json::Value;
-use crate::client::Client;
+use crate::client::{Client, NotificationSubscription};
use crate::types::{self, Notification, Request};
pub struct ModelContextProtocol {
@@ -119,7 +119,7 @@ impl InitializedContextServerProtocol {
&self,
method: &'static str,
f: Box<dyn 'static + Send + FnMut(Value, AsyncApp)>,
- ) {
- self.inner.on_notification(method, f);
+ ) -> NotificationSubscription {
+ self.inner.on_notification(method, f)
}
}
@@ -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]
@@ -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<dyn HttpClient>,
+ endpoint: String,
+ session_id: Arc<SyncMutex<Option<String>>>,
+ executor: BackgroundExecutor,
+ response_tx: channel::Sender<String>,
+ response_rx: channel::Receiver<String>,
+ error_tx: channel::Sender<String>,
+ error_rx: channel::Receiver<String>,
+ // Authentication headers to include in requests
+ headers: HashMap<String, String>,
+}
+
+impl HttpTransport {
+ pub fn new(
+ http_client: Arc<dyn HttpClient>,
+ endpoint: String,
+ headers: HashMap<String, String>,
+ 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<AsyncBody>) -> 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<Box<dyn Stream<Item = String> + Send>> {
+ Box::pin(self.response_rx.clone())
+ }
+
+ fn receive_err(&self) -> Pin<Box<dyn Stream<Item = String> + 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();
+ }
+ }
+}
@@ -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<PathBuf>,
cx: &AsyncApp,
) -> Result<Self> {
- 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())
@@ -330,7 +330,7 @@ pub struct PromptMessage {
pub content: MessageContent,
}
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
@@ -33,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,6 +52,7 @@ ui.workspace = true
util.workspace = true
workspace.workspace = true
itertools.workspace = true
+url.workspace = true
[target.'cfg(windows)'.dependencies]
async-std = { version = "1.12.0", features = ["unstable"] }
@@ -1,10 +1,11 @@
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::request::NextEditSuggestions;
+use crate::sign_in::initiate_sign_out;
use ::fs::Fs;
use anyhow::{Context as _, Result, anyhow};
use collections::{HashMap, HashSet};
@@ -18,7 +19,7 @@ use http_client::HttpClient;
use language::language_settings::CopilotSettings;
use language::{
Anchor, Bias, Buffer, BufferSnapshot, Language, PointUtf16, ToPointUtf16,
- language_settings::{EditPredictionProvider, all_language_settings, language_settings},
+ language_settings::{EditPredictionProvider, all_language_settings},
point_from_lsp, point_to_lsp,
};
use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId, LanguageServerName};
@@ -28,12 +29,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,
@@ -42,12 +41,14 @@ use std::{
sync::Arc,
};
use sum_tree::Dimensions;
-use util::rel_path::RelPath;
use util::{ResultExt, fs::remove_matching};
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,
@@ -98,21 +99,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();
@@ -322,6 +316,15 @@ struct GlobalCopilot(Entity<Copilot>);
impl Global for GlobalCopilot {}
+/// Copilot's NextEditSuggestion response, with coordinates converted to Anchors.
+struct CopilotEditPrediction {
+ buffer: Entity<Buffer>,
+ range: Range<Anchor>,
+ text: String,
+ command: Option<lsp::Command>,
+ snapshot: BufferSnapshot,
+}
+
impl Copilot {
pub fn global(cx: &App) -> Option<Entity<Self>> {
cx.try_global::<GlobalCopilot>()
@@ -375,7 +378,7 @@ impl Copilot {
}
}
- fn start_copilot(
+ pub fn start_copilot(
&mut self,
check_edit_prediction_provider: bool,
awaiting_sign_in_after_start: bool,
@@ -563,6 +566,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 {
@@ -584,7 +595,17 @@ impl Copilot {
.ok();
}
- pub(crate) fn sign_in(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
+ 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<Self>) -> Task<Result<()>> {
if let CopilotServer::Running(server) = &mut self.server {
let task = match &server.sign_in_status {
SignInStatus::Authorized => Task::ready(Ok(())).shared(),
@@ -807,7 +828,7 @@ impl Copilot {
.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(());
@@ -862,101 +883,19 @@ impl Copilot {
}
}
- pub fn completions<T>(
- &mut self,
- buffer: &Entity<Buffer>,
- position: T,
- cx: &mut Context<Self>,
- ) -> Task<Result<Vec<Completion>>>
- where
- T: ToPointUtf16,
- {
- self.request_completions::<request::GetCompletions, _>(buffer, position, cx)
- }
-
- pub fn completions_cycling<T>(
+ pub(crate) fn completions(
&mut self,
buffer: &Entity<Buffer>,
- position: T,
- cx: &mut Context<Self>,
- ) -> Task<Result<Vec<Completion>>>
- where
- T: ToPointUtf16,
- {
- self.request_completions::<request::GetCompletionsCycling, _>(buffer, position, cx)
- }
-
- pub fn accept_completion(
- &mut self,
- completion: &Completion,
+ position: Anchor,
cx: &mut Context<Self>,
- ) -> Task<Result<()>> {
- let server = match self.server.as_authenticated() {
- Ok(server) => server,
- Err(error) => return Task::ready(Err(error)),
- };
- let request =
- server
- .lsp
- .request::<request::NotifyAccepted>(request::NotifyAcceptedParams {
- uuid: completion.uuid.clone(),
- });
- cx.background_spawn(async move {
- request
- .await
- .into_response()
- .context("copilot: notify accepted")?;
- Ok(())
- })
- }
-
- pub fn discard_completions(
- &mut self,
- completions: &[Completion],
- cx: &mut Context<Self>,
- ) -> Task<Result<()>> {
- let server = match self.server.as_authenticated() {
- Ok(server) => server,
- Err(_) => return Task::ready(Ok(())),
- };
- let request =
- server
- .lsp
- .request::<request::NotifyRejected>(request::NotifyRejectedParams {
- uuids: completions
- .iter()
- .map(|completion| completion.uuid.clone())
- .collect(),
- });
- cx.background_spawn(async move {
- request
- .await
- .into_response()
- .context("copilot: notify rejected")?;
- Ok(())
- })
- }
-
- fn request_completions<R, T>(
- &mut self,
- buffer: &Entity<Buffer>,
- position: T,
- cx: &mut Context<Self>,
- ) -> Task<Result<Vec<Completion>>>
- where
- R: 'static
- + lsp::request::Request<
- Params = request::GetCompletionsParams,
- Result = request::GetCompletionsResult,
- >,
- T: ToPointUtf16,
- {
+ ) -> Task<Result<Vec<CopilotEditPrediction>>> {
self.register_buffer(buffer, cx);
let server = match self.server.as_authenticated() {
Ok(server) => server,
Err(error) => return Task::ready(Err(error)),
};
+ let buffer_entity = buffer.clone();
let lsp = server.lsp.clone();
let registered_buffer = server
.registered_buffers
@@ -966,46 +905,31 @@ impl Copilot {
let buffer = buffer.read(cx);
let uri = registered_buffer.uri.clone();
let position = position.to_point_utf16(buffer);
- let settings = language_settings(
- buffer.language_at(position).map(|l| l.name()),
- buffer.file(),
- cx,
- );
- let tab_size = settings.tab_size;
- let hard_tabs = settings.hard_tabs;
- let relative_path = buffer
- .file()
- .map_or(RelPath::empty().into(), |file| file.path().clone());
cx.background_spawn(async move {
let (version, snapshot) = snapshot.await?;
let result = lsp
- .request::<R>(request::GetCompletionsParams {
- doc: request::GetCompletionsDocument {
- uri,
- tab_size: tab_size.into(),
- indent_size: 1,
- insert_spaces: !hard_tabs,
- relative_path: relative_path.to_proto(),
- position: point_to_lsp(position),
- version: version.try_into().unwrap(),
- },
+ .request::<NextEditSuggestions>(request::NextEditSuggestionsParams {
+ text_document: lsp::VersionedTextDocumentIdentifier { uri, version },
+ position: point_to_lsp(position),
})
.await
.into_response()
.context("copilot: get completions")?;
let completions = result
- .completions
+ .edits
.into_iter()
.map(|completion| {
let start = snapshot
.clip_point_utf16(point_from_lsp(completion.range.start), Bias::Left);
let end =
snapshot.clip_point_utf16(point_from_lsp(completion.range.end), Bias::Left);
- Completion {
- uuid: completion.uuid,
+ CopilotEditPrediction {
+ buffer: buffer_entity.clone(),
range: snapshot.anchor_before(start)..snapshot.anchor_after(end),
text: completion.text,
+ command: completion.command,
+ snapshot: snapshot.clone(),
}
})
.collect();
@@ -1013,6 +937,35 @@ impl Copilot {
})
}
+ pub(crate) fn accept_completion(
+ &mut self,
+ completion: &CopilotEditPrediction,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ let server = match self.server.as_authenticated() {
+ Ok(server) => server,
+ Err(error) => return Task::ready(Err(error)),
+ };
+ if let Some(command) = &completion.command {
+ let request = server
+ .lsp
+ .request::<lsp::ExecuteCommand>(lsp::ExecuteCommandParams {
+ command: command.command.clone(),
+ arguments: command.arguments.clone().unwrap_or_default(),
+ ..Default::default()
+ });
+ cx.background_spawn(async move {
+ request
+ .await
+ .into_response()
+ .context("copilot: notify accepted")?;
+ Ok(())
+ })
+ } else {
+ Task::ready(Ok(()))
+ }
+ }
+
pub fn status(&self) -> Status {
match &self.server {
CopilotServer::Starting { task } => Status::Starting { task: task.clone() },
@@ -1235,7 +1188,10 @@ async fn get_copilot_lsp(fs: Arc<dyn Fs>, node_runtime: NodeRuntime) -> anyhow::
.await;
if should_install {
node_runtime
- .npm_install_packages(paths::copilot_dir(), &[(PACKAGE_NAME, &latest_version)])
+ .npm_install_packages(
+ paths::copilot_dir(),
+ &[(PACKAGE_NAME, &latest_version.to_string())],
+ )
.await?;
}
@@ -1246,7 +1202,11 @@ async fn get_copilot_lsp(fs: Arc<dyn Fs>, node_runtime: NodeRuntime) -> anyhow::
mod tests {
use super::*;
use gpui::TestAppContext;
- use util::{path, paths::PathStyle, rel_path::rel_path};
+ use util::{
+ path,
+ paths::PathStyle,
+ rel_path::{RelPath, rel_path},
+ };
#[gpui::test(iterations = 10)]
async fn test_buffer_management(cx: &mut TestAppContext) {
@@ -294,6 +294,10 @@ pub enum ChatMessage {
content: ChatMessageContent,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tool_calls: Vec<ToolCall>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ reasoning_opaque: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ reasoning_text: Option<String>,
},
User {
content: ChatMessageContent,
@@ -353,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<String>,
}
#[derive(Deserialize, Debug)]
@@ -384,6 +390,8 @@ pub struct ResponseDelta {
pub role: Option<Role>,
#[serde(default)]
pub tool_calls: Vec<ToolCallChunk>,
+ pub reasoning_opaque: Option<String>,
+ pub reasoning_text: Option<String>,
}
#[derive(Deserialize, Debug, Eq, PartialEq)]
pub struct ToolCallChunk {
@@ -396,6 +404,7 @@ pub struct ToolCallChunk {
pub struct FunctionChunk {
pub name: Option<String>,
pub arguments: Option<String>,
+ pub thought_signature: Option<String>,
}
#[derive(Deserialize)]
@@ -783,13 +792,13 @@ async fn stream_completion(
is_user_initiated: bool,
) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
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" };
@@ -1,53 +1,33 @@
-use crate::{Completion, Copilot};
+use crate::{Copilot, CopilotEditPrediction};
use anyhow::Result;
-use edit_prediction::{Direction, EditPrediction, EditPredictionProvider};
-use gpui::{App, Context, Entity, EntityId, Task};
-use language::{Buffer, OffsetRangeExt, ToOffset, language_settings::AllLanguageSettings};
-use settings::Settings;
-use std::{path::Path, time::Duration};
+use edit_prediction_types::{EditPrediction, EditPredictionDelegate, interpolate_edits};
+use gpui::{App, Context, Entity, Task};
+use language::{Anchor, Buffer, EditPreview, OffsetRangeExt};
+use std::{ops::Range, sync::Arc, time::Duration};
pub const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75);
-pub struct CopilotCompletionProvider {
- cycled: bool,
- buffer_id: Option<EntityId>,
- completions: Vec<Completion>,
- active_completion_index: usize,
- file_extension: Option<String>,
+pub struct CopilotEditPredictionDelegate {
+ completion: Option<(CopilotEditPrediction, EditPreview)>,
pending_refresh: Option<Task<Result<()>>>,
- pending_cycling_refresh: Option<Task<Result<()>>>,
copilot: Entity<Copilot>,
}
-impl CopilotCompletionProvider {
+impl CopilotEditPredictionDelegate {
pub fn new(copilot: Entity<Copilot>) -> Self {
Self {
- cycled: false,
- buffer_id: None,
- completions: Vec::new(),
- active_completion_index: 0,
- file_extension: None,
+ completion: None,
pending_refresh: None,
- pending_cycling_refresh: None,
copilot,
}
}
- fn active_completion(&self) -> Option<&Completion> {
- self.completions.get(self.active_completion_index)
- }
-
- fn push_completion(&mut self, new_completion: Completion) {
- for completion in &self.completions {
- if completion.text == new_completion.text && completion.range == new_completion.range {
- return;
- }
- }
- self.completions.push(new_completion);
+ fn active_completion(&self) -> Option<&(CopilotEditPrediction, EditPreview)> {
+ self.completion.as_ref()
}
}
-impl EditPredictionProvider for CopilotCompletionProvider {
+impl EditPredictionDelegate for CopilotEditPredictionDelegate {
fn name() -> &'static str {
"copilot"
}
@@ -56,7 +36,7 @@ impl EditPredictionProvider for CopilotCompletionProvider {
"Copilot"
}
- fn show_completions_in_menu() -> bool {
+ fn show_predictions_in_menu() -> bool {
true
}
@@ -64,12 +44,8 @@ impl EditPredictionProvider for CopilotCompletionProvider {
true
}
- fn supports_jump_to_edit() -> bool {
- false
- }
-
- fn is_refreshing(&self) -> bool {
- self.pending_refresh.is_some() && self.completions.is_empty()
+ fn is_refreshing(&self, _cx: &App) -> bool {
+ self.pending_refresh.is_some() && self.completion.is_none()
}
fn is_enabled(
@@ -102,160 +78,96 @@ impl EditPredictionProvider for CopilotCompletionProvider {
})?
.await?;
- this.update(cx, |this, cx| {
- if !completions.is_empty() {
- this.cycled = false;
+ if let Some(mut completion) = completions.into_iter().next()
+ && let Some(trimmed_completion) = cx
+ .update(|cx| trim_completion(&completion, cx))
+ .ok()
+ .flatten()
+ {
+ let preview = buffer
+ .update(cx, |this, cx| {
+ this.preview_edits(Arc::from(std::slice::from_ref(&trimmed_completion)), cx)
+ })?
+ .await;
+ this.update(cx, |this, cx| {
this.pending_refresh = None;
- this.pending_cycling_refresh = None;
- this.completions.clear();
- this.active_completion_index = 0;
- this.buffer_id = Some(buffer.entity_id());
- this.file_extension = buffer.read(cx).file().and_then(|file| {
- Some(
- Path::new(file.file_name(cx))
- .extension()?
- .to_str()?
- .to_string(),
- )
- });
-
- for completion in completions {
- this.push_completion(completion);
- }
+ completion.range = trimmed_completion.0;
+ completion.text = trimmed_completion.1.to_string();
+ this.completion = Some((completion, preview));
+
cx.notify();
- }
- })?;
+ })?;
+ }
Ok(())
}));
}
- fn cycle(
- &mut self,
- buffer: Entity<Buffer>,
- cursor_position: language::Anchor,
- direction: Direction,
- cx: &mut Context<Self>,
- ) {
- if self.cycled {
- match direction {
- Direction::Prev => {
- self.active_completion_index = if self.active_completion_index == 0 {
- self.completions.len().saturating_sub(1)
- } else {
- self.active_completion_index - 1
- };
- }
- Direction::Next => {
- if self.completions.is_empty() {
- self.active_completion_index = 0
- } else {
- self.active_completion_index =
- (self.active_completion_index + 1) % self.completions.len();
- }
- }
- }
-
- cx.notify();
- } else {
- let copilot = self.copilot.clone();
- self.pending_cycling_refresh = Some(cx.spawn(async move |this, cx| {
- let completions = copilot
- .update(cx, |copilot, cx| {
- copilot.completions_cycling(&buffer, cursor_position, cx)
- })?
- .await?;
-
- this.update(cx, |this, cx| {
- this.cycled = true;
- this.file_extension = buffer.read(cx).file().and_then(|file| {
- Some(
- Path::new(file.file_name(cx))
- .extension()?
- .to_str()?
- .to_string(),
- )
- });
- for completion in completions {
- this.push_completion(completion);
- }
- this.cycle(buffer, cursor_position, direction, cx);
- })?;
-
- Ok(())
- }));
- }
- }
-
fn accept(&mut self, cx: &mut Context<Self>) {
- if let Some(completion) = self.active_completion() {
+ if let Some((completion, _)) = self.active_completion() {
self.copilot
.update(cx, |copilot, cx| copilot.accept_completion(completion, cx))
.detach_and_log_err(cx);
}
}
- fn discard(&mut self, cx: &mut Context<Self>) {
- let settings = AllLanguageSettings::get_global(cx);
-
- let copilot_enabled = settings.show_edit_predictions(None, cx);
-
- if !copilot_enabled {
- return;
- }
-
- self.copilot
- .update(cx, |copilot, cx| {
- copilot.discard_completions(&self.completions, cx)
- })
- .detach_and_log_err(cx);
- }
+ fn discard(&mut self, _: &mut Context<Self>) {}
fn suggest(
&mut self,
buffer: &Entity<Buffer>,
- cursor_position: language::Anchor,
+ _: language::Anchor,
cx: &mut Context<Self>,
) -> Option<EditPrediction> {
let buffer_id = buffer.entity_id();
let buffer = buffer.read(cx);
- let completion = self.active_completion()?;
- if Some(buffer_id) != self.buffer_id
+ let (completion, edit_preview) = self.active_completion()?;
+
+ if Some(buffer_id) != Some(completion.buffer.entity_id())
|| !completion.range.start.is_valid(buffer)
|| !completion.range.end.is_valid(buffer)
{
return None;
}
+ let edits = vec![(
+ completion.range.clone(),
+ Arc::from(completion.text.as_ref()),
+ )];
+ let edits = interpolate_edits(&completion.snapshot, &buffer.snapshot(), &edits)
+ .filter(|edits| !edits.is_empty())?;
+
+ Some(EditPrediction::Local {
+ id: None,
+ edits,
+ edit_preview: Some(edit_preview.clone()),
+ })
+ }
+}
- let mut completion_range = completion.range.to_offset(buffer);
- let prefix_len = common_prefix(
- buffer.chars_for_range(completion_range.clone()),
- completion.text.chars(),
- );
- completion_range.start += prefix_len;
- let suffix_len = common_prefix(
- buffer.reversed_chars_for_range(completion_range.clone()),
- completion.text[prefix_len..].chars().rev(),
- );
- completion_range.end = completion_range.end.saturating_sub(suffix_len);
-
- if completion_range.is_empty()
- && completion_range.start == cursor_position.to_offset(buffer)
- {
- let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len];
- if completion_text.trim().is_empty() {
- None
- } else {
- let position = cursor_position.bias_right(buffer);
- Some(EditPrediction::Local {
- id: None,
- edits: vec![(position..position, completion_text.into())],
- edit_preview: None,
- })
- }
- } else {
- None
- }
+fn trim_completion(
+ completion: &CopilotEditPrediction,
+ cx: &mut App,
+) -> Option<(Range<Anchor>, Arc<str>)> {
+ let buffer = completion.buffer.read(cx);
+ let mut completion_range = completion.range.to_offset(buffer);
+ let prefix_len = common_prefix(
+ buffer.chars_for_range(completion_range.clone()),
+ completion.text.chars(),
+ );
+ completion_range.start += prefix_len;
+ let suffix_len = common_prefix(
+ buffer.reversed_chars_for_range(completion_range.clone()),
+ completion.text[prefix_len..].chars().rev(),
+ );
+ completion_range.end = completion_range.end.saturating_sub(suffix_len);
+ let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len];
+ if completion_text.trim().is_empty() {
+ None
+ } else {
+ let completion_range =
+ buffer.anchor_after(completion_range.start)..buffer.anchor_after(completion_range.end);
+
+ Some((completion_range, Arc::from(completion_text)))
}
}
@@ -269,8 +181,9 @@ fn common_prefix<T1: Iterator<Item = char>, T2: Iterator<Item = char>>(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;
@@ -281,6 +194,7 @@ mod tests {
Point,
language_settings::{CompletionSettingsContent, LspInsertMode, WordsCompletionMode},
};
+ use lsp::Uri;
use project::Project;
use serde_json::json;
use settings::{AllLanguageSettingsContent, SettingsStore};
@@ -314,7 +228,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)
});
@@ -336,12 +250,15 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
- vec![crate::request::Completion {
+ vec![crate::request::NextEditSuggestion {
text: "one.copilot1".into(),
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
- ..Default::default()
+ command: None,
+ text_document: lsp::VersionedTextDocumentIdentifier {
+ uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+ version: 0,
+ },
}],
- vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
@@ -382,12 +299,15 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
- vec![crate::request::Completion {
+ vec![crate::request::NextEditSuggestion {
text: "one.copilot1".into(),
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
- ..Default::default()
+ command: None,
+ text_document: lsp::VersionedTextDocumentIdentifier {
+ uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+ version: 0,
+ },
}],
- vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
@@ -411,12 +331,15 @@ mod tests {
// After debouncing, new Copilot completions should be requested.
handle_copilot_completion_request(
&copilot_lsp,
- vec![crate::request::Completion {
+ vec![crate::request::NextEditSuggestion {
text: "one.copilot2".into(),
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 5)),
- ..Default::default()
+ command: None,
+ text_document: lsp::VersionedTextDocumentIdentifier {
+ uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+ version: 0,
+ },
}],
- vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
@@ -478,45 +401,6 @@ mod tests {
assert_eq!(editor.display_text(cx), "one.cop\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.cop\ntwo\nthree\n");
});
-
- // Reset the editor to verify how suggestions behave when tabbing on leading indentation.
- cx.update_editor(|editor, window, cx| {
- editor.set_text("fn foo() {\n \n}", window, cx);
- editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
- s.select_ranges([Point::new(1, 2)..Point::new(1, 2)])
- });
- });
- handle_copilot_completion_request(
- &copilot_lsp,
- vec![crate::request::Completion {
- text: " let x = 4;".into(),
- range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)),
- ..Default::default()
- }],
- vec![],
- );
-
- cx.update_editor(|editor, window, cx| {
- editor.next_edit_prediction(&Default::default(), window, cx)
- });
- executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
- cx.update_editor(|editor, window, cx| {
- assert!(editor.has_active_edit_prediction());
- assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}");
- assert_eq!(editor.text(cx), "fn foo() {\n \n}");
-
- // Tabbing inside of leading whitespace inserts indentation without accepting the suggestion.
- editor.tab(&Default::default(), window, cx);
- assert!(editor.has_active_edit_prediction());
- assert_eq!(editor.text(cx), "fn foo() {\n \n}");
- assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}");
-
- // Using AcceptEditPrediction again accepts the suggestion.
- editor.accept_edit_prediction(&Default::default(), window, cx);
- assert!(!editor.has_active_edit_prediction());
- assert_eq!(editor.text(cx), "fn foo() {\n let x = 4;\n}");
- assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}");
- });
}
#[gpui::test(iterations = 10)]
@@ -546,7 +430,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)
});
@@ -569,25 +453,30 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
- vec![crate::request::Completion {
+ vec![crate::request::NextEditSuggestion {
text: "one.copilot1".into(),
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
- ..Default::default()
+ command: None,
+ text_document: lsp::VersionedTextDocumentIdentifier {
+ uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+ version: 0,
+ },
}],
- vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
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");
@@ -611,19 +500,22 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
- vec![crate::request::Completion {
+ vec![crate::request::NextEditSuggestion {
text: "one.123. copilot\n 456".into(),
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
- ..Default::default()
+ command: None,
+ text_document: lsp::VersionedTextDocumentIdentifier {
+ uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+ version: 0,
+ },
}],
- vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
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 +524,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 +533,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 +562,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)
});
@@ -683,15 +575,18 @@ mod tests {
handle_copilot_completion_request(
&copilot_lsp,
- vec![crate::request::Completion {
+ vec![crate::request::NextEditSuggestion {
text: "two.foo()".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)),
- ..Default::default()
+ command: None,
+ text_document: lsp::VersionedTextDocumentIdentifier {
+ uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+ version: 0,
+ },
}],
- vec![],
);
cx.update_editor(|editor, window, cx| {
- editor.next_edit_prediction(&Default::default(), window, cx)
+ editor.show_edit_prediction(&Default::default(), window, cx)
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
@@ -700,15 +595,22 @@ mod tests {
assert_eq!(editor.text(cx), "one\ntw\nthree\n");
editor.backspace(&Default::default(), window, cx);
+ });
+ executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
+ cx.run_until_parked();
+ cx.update_editor(|editor, window, cx| {
assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
assert_eq!(editor.text(cx), "one\nt\nthree\n");
editor.backspace(&Default::default(), window, cx);
+ });
+ executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
+ cx.run_until_parked();
+ cx.update_editor(|editor, window, cx| {
assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
assert_eq!(editor.text(cx), "one\n\nthree\n");
-
// Deleting across the original suggestion range invalidates it.
editor.backspace(&Default::default(), window, cx);
assert!(!editor.has_active_edit_prediction());
@@ -750,10 +652,10 @@ mod tests {
editor
.update(cx, |editor, window, cx| {
use gpui::Focusable;
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), 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)
@@ -762,19 +664,22 @@ mod tests {
handle_copilot_completion_request(
&copilot_lsp,
- vec![crate::request::Completion {
+ vec![crate::request::NextEditSuggestion {
text: "b = 2 + a".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 5)),
- ..Default::default()
+ command: None,
+ text_document: lsp::VersionedTextDocumentIdentifier {
+ uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+ version: 0,
+ },
}],
- vec![],
);
_ = editor.update(cx, |editor, window, cx| {
// Ensure copilot suggestions are shown for the first excerpt.
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(1, 5)..Point::new(1, 5)])
});
- editor.next_edit_prediction(&Default::default(), window, cx);
+ editor.show_edit_prediction(&Default::default(), window, cx);
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
_ = editor.update(cx, |editor, _, cx| {
@@ -788,12 +693,15 @@ mod tests {
handle_copilot_completion_request(
&copilot_lsp,
- vec![crate::request::Completion {
+ vec![crate::request::NextEditSuggestion {
text: "d = 4 + c".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 6)),
- ..Default::default()
+ command: None,
+ text_document: lsp::VersionedTextDocumentIdentifier {
+ uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+ version: 0,
+ },
}],
- vec![],
);
_ = editor.update(cx, |editor, window, cx| {
// Move to another excerpt, ensuring the suggestion gets cleared.
@@ -848,7 +756,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)
});
@@ -870,15 +778,18 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
- vec![crate::request::Completion {
+ vec![crate::request::NextEditSuggestion {
text: "two.foo()".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)),
- ..Default::default()
+ command: None,
+ text_document: lsp::VersionedTextDocumentIdentifier {
+ uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+ version: 0,
+ },
}],
- vec![],
);
cx.update_editor(|editor, window, cx| {
- editor.next_edit_prediction(&Default::default(), window, cx)
+ editor.show_edit_prediction(&Default::default(), window, cx)
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
@@ -900,12 +811,15 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
- vec![crate::request::Completion {
+ vec![crate::request::NextEditSuggestion {
text: "two.foo()".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 3)),
- ..Default::default()
+ command: None,
+ text_document: lsp::VersionedTextDocumentIdentifier {
+ uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+ version: 0,
+ },
}],
- vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
@@ -927,12 +841,15 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
- vec![crate::request::Completion {
+ vec![crate::request::NextEditSuggestion {
text: "two.foo()".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 4)),
- ..Default::default()
+ command: None,
+ text_document: lsp::VersionedTextDocumentIdentifier {
+ uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+ version: 0,
+ },
}],
- vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
@@ -997,10 +914,10 @@ mod tests {
editor
.update(cx, |editor, window, cx| {
use gpui::Focusable;
- window.focus(&editor.focus_handle(cx))
+ window.focus(&editor.focus_handle(cx), 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)
@@ -1008,16 +925,20 @@ mod tests {
.unwrap();
let mut copilot_requests = copilot_lsp
- .set_request_handler::<crate::request::GetCompletions, _, _>(
+ .set_request_handler::<crate::request::NextEditSuggestions, _, _>(
move |_params, _cx| async move {
- Ok(crate::request::GetCompletionsResult {
- completions: vec![crate::request::Completion {
+ Ok(crate::request::NextEditSuggestionsResult {
+ edits: vec![crate::request::NextEditSuggestion {
text: "next line".into(),
range: lsp::Range::new(
lsp::Position::new(1, 0),
lsp::Position::new(1, 0),
),
- ..Default::default()
+ command: None,
+ text_document: lsp::VersionedTextDocumentIdentifier {
+ uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+ version: 0,
+ },
}],
})
},
@@ -1046,23 +967,14 @@ mod tests {
fn handle_copilot_completion_request(
lsp: &lsp::FakeLanguageServer,
- completions: Vec<crate::request::Completion>,
- completions_cycling: Vec<crate::request::Completion>,
+ completions: Vec<crate::request::NextEditSuggestion>,
) {
- lsp.set_request_handler::<crate::request::GetCompletions, _, _>(move |_params, _cx| {
- let completions = completions.clone();
- async move {
- Ok(crate::request::GetCompletionsResult {
- completions: completions.clone(),
- })
- }
- });
- lsp.set_request_handler::<crate::request::GetCompletionsCycling, _, _>(
+ lsp.set_request_handler::<crate::request::NextEditSuggestions, _, _>(
move |_params, _cx| {
- let completions_cycling = completions_cycling.clone();
+ let completions = completions.clone();
async move {
- Ok(crate::request::GetCompletionsResult {
- completions: completions_cycling.clone(),
+ Ok(crate::request::NextEditSuggestionsResult {
+ edits: completions.clone(),
})
}
},
@@ -1081,8 +993,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::<lsp::request::Completion, _, _>(move |url, params, _| {
@@ -127,6 +127,8 @@ pub enum ResponseInputItem {
arguments: String,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ItemStatus>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ thought_signature: Option<String>,
},
FunctionCallOutput {
call_id: String,
@@ -251,6 +253,8 @@ pub enum ResponseOutputItem {
arguments: String,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ItemStatus>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ thought_signature: Option<String>,
},
Reasoning {
id: String,
@@ -309,7 +313,8 @@ pub async fn stream_response(
};
let is_streaming = request.stream;
- let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?;
+ 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() {
@@ -1,3 +1,4 @@
+use lsp::VersionedTextDocumentIdentifier;
use serde::{Deserialize, Serialize};
pub enum CheckStatus {}
@@ -88,72 +89,6 @@ impl lsp::request::Request for SignOut {
const METHOD: &'static str = "signOut";
}
-pub enum GetCompletions {}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct GetCompletionsParams {
- pub doc: GetCompletionsDocument,
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct GetCompletionsDocument {
- pub tab_size: u32,
- pub indent_size: u32,
- pub insert_spaces: bool,
- pub uri: lsp::Uri,
- pub relative_path: String,
- pub position: lsp::Position,
- pub version: usize,
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct GetCompletionsResult {
- pub completions: Vec<Completion>,
-}
-
-#[derive(Clone, Debug, Default, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct Completion {
- pub text: String,
- pub position: lsp::Position,
- pub uuid: String,
- pub range: lsp::Range,
- pub display_text: String,
-}
-
-impl lsp::request::Request for GetCompletions {
- type Params = GetCompletionsParams;
- type Result = GetCompletionsResult;
- const METHOD: &'static str = "getCompletions";
-}
-
-pub enum GetCompletionsCycling {}
-
-impl lsp::request::Request for GetCompletionsCycling {
- type Params = GetCompletionsParams;
- type Result = GetCompletionsResult;
- const METHOD: &'static str = "getCompletionsCycling";
-}
-
-pub enum LogMessage {}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct LogMessageParams {
- pub level: u8,
- pub message: String,
- pub metadata_str: String,
- pub extra: Vec<String>,
-}
-
-impl lsp::notification::Notification for LogMessage {
- type Params = LogMessageParams;
- const METHOD: &'static str = "LogMessage";
-}
-
pub enum StatusNotification {}
#[derive(Debug, Serialize, Deserialize)]
@@ -223,3 +158,36 @@ impl lsp::request::Request for NotifyRejected {
type Result = String;
const METHOD: &'static str = "notifyRejected";
}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NextEditSuggestions;
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NextEditSuggestionsParams {
+ pub(crate) text_document: VersionedTextDocumentIdentifier,
+ pub(crate) position: lsp::Position,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NextEditSuggestion {
+ pub text: String,
+ pub text_document: VersionedTextDocumentIdentifier,
+ pub range: lsp::Range,
+ pub command: Option<lsp::Command>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NextEditSuggestionsResult {
+ pub edits: Vec<NextEditSuggestion>,
+}
+
+impl lsp::request::Request for NextEditSuggestions {
+ type Params = NextEditSuggestionsParams;
+ type Result = NextEditSuggestionsResult;
+
+ const METHOD: &'static str = "textDocument/copilotInlineEdit";
+}
@@ -1,166 +1,159 @@
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 url::Url;
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::<Workspace>().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::<Workspace>().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<Copilot>, 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::<CopilotStatusToast>();
+
let Some(workspace) = window.root::<Workspace>().flatten() else {
return;
};
- workspace.update(cx, |workspace, cx| {
- reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx);
- });
-}
-pub fn reinstall_and_sign_in_within_workspace(
- workspace: &mut Workspace,
- copilot: Entity<Copilot>,
- window: &mut Window,
- cx: &mut Context<Workspace>,
-) {
- let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx));
- let is_reinstall = true;
- initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, 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 initiate_sign_in_within_workspace(
- workspace: &mut Workspace,
- copilot: Entity<Copilot>,
- is_reinstall: bool,
- window: &mut Window,
- cx: &mut Context<Workspace>,
-) {
+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::<CopilotStatusToast>(),
- 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::<CopilotStatusToast>(),
- "Copilot has started.",
- ),
- cx,
- ),
- _ => {
- workspace.dismiss_toast(
- &NotificationId::unique::<CopilotStatusToast>(),
- 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<Copilot>,
- cx: &mut Context<Workspace>,
-) {
- workspace.show_toast(
- Toast::new(
- NotificationId::unique::<CopilotStatusToast>(),
- "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::<CopilotStatusToast>(),
- "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,
focus_handle: FocusHandle,
copilot: Entity<Copilot>,
_subscription: Subscription,
+ sign_up_url: Option<String>,
}
impl Focusable for CopilotCodeVerification {
@@ -170,29 +163,44 @@ impl Focusable for CopilotCodeVerification {
}
impl EventEmitter<DismissEvent> for CopilotCodeVerification {}
-impl ModalView for CopilotCodeVerification {
- fn on_before_dismiss(
- &mut self,
- _: &mut Window,
- cx: &mut Context<Self>,
- ) -> workspace::DismissDecision {
- self.copilot.update(cx, |copilot, cx| {
- if matches!(copilot.status(), Status::SigningIn { .. }) {
- copilot.sign_out(cx).detach_and_log_err(cx);
+
+impl CopilotCodeVerification {
+ pub fn new(copilot: &Entity<Copilot>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+ window.on_window_should_close(cx, |window, cx| {
+ if let Some(this) = window.root::<CopilotCodeVerification>().flatten() {
+ this.update(cx, |this, cx| {
+ this.before_dismiss(cx);
+ });
}
+ true
});
- workspace::DismissDecision::Dismiss(true)
- }
-}
+ cx.subscribe_in(
+ &cx.entity(),
+ window,
+ |this, _, _: &DismissEvent, window, cx| {
+ window.remove_window();
+ this.before_dismiss(cx);
+ },
+ )
+ .detach();
-impl CopilotCodeVerification {
- pub fn new(copilot: &Entity<Copilot>, cx: &mut Context<Self>) -> Self {
let status = copilot.read(cx).status();
+ // Determine sign-up URL based on verification_uri domain if available
+ let sign_up_url = if let Status::SigningIn {
+ prompt: Some(ref prompt),
+ } = status
+ {
+ // Extract domain from verification_uri to construct sign-up URL
+ Self::get_sign_up_url_from_verification(&prompt.verification_uri)
+ } else {
+ None
+ };
Self {
status,
connect_clicked: false,
focus_handle: cx.focus_handle(),
copilot: copilot.clone(),
+ sign_up_url,
_subscription: cx.observe(copilot, |this, copilot, cx| {
let status = copilot.read(cx).status();
match status {
@@ -206,54 +214,74 @@ impl CopilotCodeVerification {
}
pub fn set_status(&mut self, status: Status, cx: &mut Context<Self>) {
+ // Update sign-up URL if we have a new verification URI
+ if let Status::SigningIn {
+ prompt: Some(ref prompt),
+ } = status
+ {
+ self.sign_up_url = Self::get_sign_up_url_from_verification(&prompt.verification_uri);
+ }
self.status = status;
cx.notify();
}
+ fn get_sign_up_url_from_verification(verification_uri: &str) -> Option<String> {
+ // Extract domain from verification URI using url crate
+ if let Ok(url) = Url::parse(verification_uri)
+ && let Some(host) = url.host_str()
+ && !host.contains("github.com")
+ {
+ // For GHE, construct URL from domain
+ Some(format!("https://{}/features/copilot", host))
+ } else {
+ None
+ }
+ }
+
fn render_device_code(data: &PromptUserDeviceFlow, cx: &mut Context<Self>) -> impl IntoElement {
let copied = cx
.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<Self>,
) -> 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,110 +289,154 @@ 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),
- )
- .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),
+ .color(Color::Muted),
)
.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<Self>) -> 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<Self>) -> impl Element {
- v_flex()
- .child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large))
+ fn render_unauthorized_modal(&self, cx: &mut Context<Self>) -> impl Element {
+ let sign_up_url = self
+ .sign_up_url
+ .as_deref()
+ .unwrap_or(COPILOT_SIGN_UP_URL)
+ .to_owned();
+ 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()
- .on_click(|_, _, cx| cx.open_url(COPILOT_SIGN_UP_URL)),
+ .style(ButtonStyle::Outlined)
+ .size(ButtonSize::Medium)
+ .on_click(move |_, _, cx| cx.open_url(&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<Self>) -> 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<Self>) -> 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<Self>) -> impl IntoElement {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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(),
Status::Unauthorized => {
self.connect_clicked = false;
- Self::render_unauthorized_modal(cx).into_any_element()
+ self.render_unauthorized_modal(cx).into_any_element()
}
Status::Authorized => {
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);
}))
- .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _| {
- window.focus(&this.focus_handle);
+ .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+ window.focus(&this.focus_handle, cx);
}))
.child(
Vector::new(VectorName::ZedXCopilot, rems(8.), rems(4.))
@@ -373,3 +445,243 @@ impl Render for CopilotCodeVerification {
.child(prompt)
}
}
+
+pub struct ConfigurationView {
+ copilot_status: Option<Status>,
+ is_authenticated: fn(cx: &App) -> bool,
+ edit_prediction: bool,
+ _subscription: Option<Subscription>,
+}
+
+pub enum ConfigurationMode {
+ Chat,
+ EditPrediction,
+}
+
+impl ConfigurationView {
+ pub fn new(
+ is_authenticated: fn(cx: &App) -> bool,
+ mode: ConfigurationMode,
+ cx: &mut Context<Self>,
+ ) -> 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<SharedString> {
+ 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<SharedString>,
+ 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<Self>) -> 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()
+ }
+ }
+}
@@ -23,6 +23,9 @@ zstd.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
mach2.workspace = true
+[target.'cfg(target_os = "windows")'.dependencies]
+windows.workspace = true
+
[lints]
workspace = true
@@ -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")]
@@ -51,11 +53,13 @@ pub async fn init(crash_init: InitCrashHandler) {
unsafe { env::set_var("RUST_BACKTRACE", "1") };
old_hook(info);
// prevent the macOS crash dialog from popping up
- std::process::exit(1);
+ if cfg!(target_os = "macos") {
+ std::process::exit(1);
+ }
}));
return;
}
- (Some(true), _) | (None, _) => {
+ _ => {
panic::set_hook(Box::new(panic_hook));
}
}
@@ -68,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");
@@ -289,26 +298,29 @@ impl minidumper::ServerHandler for CrashServer {
pub fn panic_hook(info: &PanicHookInfo) {
// Don't handle a panic on threads that are not relevant to the main execution.
if extension_host::wasm_host::IS_WASM_THREAD.with(|v| v.load(Ordering::Acquire)) {
+ log::error!("wasm thread panicked!");
return;
}
- let message = info
- .payload()
- .downcast_ref::<&str>()
- .map(|s| s.to_string())
- .or_else(|| info.payload().downcast_ref::<String>().cloned())
- .unwrap_or_else(|| "Box<Any>".to_string());
+ let message = info.payload_as_str().unwrap_or("Box<Any>").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("<unnamed>");
+
// 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(|| "<unknown>".to_owned(), |location| location.to_string());
+ log::error!("thread '{thread_name}' panicked at {location}:\n{message}...");
client
.send_message(
2,
@@ -337,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<u16> = 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::<STARTUPINFOW>() 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");
@@ -324,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| {
@@ -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,
})
}
@@ -368,6 +368,9 @@ impl PythonDebugAdapter {
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 {
@@ -867,7 +870,7 @@ impl DebugAdapter for PythonDebugAdapter {
.active_toolchain(
delegate.worktree_id(),
base_path.into_arc(),
- language::LanguageName::new(Self::LANGUAGE_NAME),
+ language::LanguageName::new_static(Self::LANGUAGE_NAME),
cx,
)
.await
@@ -998,7 +998,11 @@ impl Item for DapLogView {
None
}
- fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(
+ &self,
+ handle: &Entity<Self>,
+ _: &App,
+ ) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(handle.clone()))
}
}
@@ -1013,11 +1017,13 @@ impl SearchableItem for DapLogView {
fn update_matches(
&mut self,
matches: &[Self::Match],
+ active_match_index: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
- 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<Self>) -> String {
@@ -1029,13 +1035,11 @@ impl SearchableItem for DapLogView {
&mut self,
index: usize,
matches: &[Self::Match],
- collapse: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
- self.editor.update(cx, |e, cx| {
- e.activate_match(index, matches, collapse, window, cx)
- })
+ self.editor
+ .update(cx, |e, cx| e.activate_match(index, matches, window, cx))
}
fn select_matches(
@@ -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
@@ -70,6 +71,7 @@ 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.workspace = true
@@ -81,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
@@ -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,6 +10,7 @@ use task::ZedDebugConfig;
use util::debug_panic;
use std::sync::Arc;
+
use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind};
use ui::{Context, Tooltip, prelude::*};
use ui::{ListItem, ListItemSpacing};
@@ -23,11 +25,16 @@ pub(super) struct Candidate {
pub(super) command: Vec<String>,
}
+pub(crate) enum ModalIntent {
+ ResolveProcessId(Option<oneshot::Sender<Option<i32>>>),
+ AttachToProcess(ZedDebugConfig),
+}
+
pub(crate) struct AttachModalDelegate {
selected_index: usize,
matches: Vec<StringMatch>,
placeholder_text: Arc<str>,
- pub(crate) definition: ZedDebugConfig,
+ pub(crate) intent: ModalIntent,
workspace: WeakEntity<Workspace>,
candidates: Arc<[Candidate]>,
}
@@ -35,13 +42,13 @@ pub(crate) struct AttachModalDelegate {
impl AttachModalDelegate {
fn new(
workspace: WeakEntity<Workspace>,
- 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<Workspace>,
project: Entity<Project>,
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<Workspace>,
- definition: ZedDebugConfig,
processes: Arc<[Candidate]>,
modal: bool,
+ intent: ModalIntent,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Picker<Self>>) {
+ fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
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.intent {
+ ModalIntent::ResolveProcessId(sender) => {
+ 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;
+ if let Some(sender) = sender.take() {
+ sender
+ .send(candidate.map(|candidate| candidate.pid as i32))
+ .ok();
+ }
}
- }
-
- let workspace = self.workspace.clone();
- let Some(panel) = workspace
- .update(cx, |workspace, cx| workspace.panel::<DebugPanel>(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::<DapRegistry, _>(|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;
- };
+ 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;
+ }
+ }
- panel
- .update_in(cx, |panel, window, cx| {
- panel.start_session(scenario, Default::default(), None, None, window, cx);
+ let workspace = self.workspace.clone();
+ let Some(panel) = workspace
+ .update(cx, |workspace, cx| workspace.panel::<DebugPanel>(cx))
+ .ok()
+ .flatten()
+ else {
+ return;
+ };
+
+ let Some(adapter) = cx.read_global::<DapRegistry, _>(|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();
})
- .ok();
- this.update(cx, |_, cx| {
- cx.emit(DismissEvent);
- })
- .ok();
- })
- .detach();
+ .detach();
+ }
+ }
}
fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
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<Project>, cx: &mut App) -> Task<Ar
if let Some(remote_client) = project.remote_client() {
let proto_client = remote_client.read(cx).proto_client();
- cx.spawn(async move |_cx| {
+ cx.background_spawn(async move {
let response = proto_client
.request(proto::GetProcesses {
project_id: proto::REMOTE_SERVER_PROJECT_ID,
@@ -389,8 +413,21 @@ fn get_processes_for_project(project: &Entity<Project>, cx: &mut App) -> Task<Ar
}
}
-#[cfg(any(test, feature = "test-support"))]
-pub(crate) fn _process_names(modal: &AttachModal, cx: &mut Context<AttachModal>) -> Vec<String> {
+#[cfg(test)]
+pub(crate) fn set_candidates(
+ modal: &AttachModal,
+ candidates: Arc<[Candidate]>,
+ window: &mut Window,
+ cx: &mut Context<AttachModal>,
+) {
+ 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<AttachModal>) -> Vec<String> {
modal.picker.read_with(cx, |picker, _| {
picker
.delegate
@@ -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,12 @@ 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 {
@@ -285,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);
}
@@ -569,7 +577,7 @@ impl DebugPanel {
menu
});
- window.focus(&context_menu.focus_handle(cx));
+ window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
this.context_menu.take();
cx.notify();
@@ -652,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()
@@ -659,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()
@@ -669,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();
@@ -730,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,
@@ -752,32 +773,29 @@ impl DebugPanel {
}),
)
.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,
- 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,
@@ -864,36 +882,53 @@ impl DebugPanel {
}
}),
)
+ .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,
- )
- .disabled(
- thread_status != ThreadStatus::Stopped
- && thread_status != ThreadStatus::Running,
+ cx.has_flag::<DebuggerHistoryFeatureFlag>(),
+ |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(),
)
- .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,
- )
- }
- }),
+ .style(ui::SplitButtonStyle::Outlined),
)
},
)
@@ -958,6 +993,7 @@ impl DebugPanel {
.child(edit_debug_json_button())
.child(documentation_button())
.child(logs_button())
+ .child(close_bottom_panel_button)
}),
),
),
@@ -1016,7 +1052,7 @@ impl DebugPanel {
cx: &mut Context<Self>,
) {
debug_assert!(self.sessions_with_children.contains_key(&session_item));
- session_item.focus_handle(cx).focus(window);
+ session_item.focus_handle(cx).focus(window, cx);
session_item.update(cx, |this, cx| {
this.running_state().update(cx, |this, cx| {
this.go_to_selected_stack_frame(window, cx);
@@ -1216,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}")));
@@ -1234,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))
@@ -1308,6 +1345,97 @@ impl DebugPanel {
});
}
}
+
+ fn render_history_button(
+ &self,
+ running_state: &Entity<RunningState>,
+ 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<RunningState>,
+ ) -> 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<usize>, running_state: Entity<RunningState>, 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(
@@ -1429,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))
})
});
}
@@ -1692,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| {
@@ -1702,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| {
@@ -1716,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")),
@@ -1728,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| {
@@ -1746,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(
@@ -1769,7 +1903,18 @@ 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()
@@ -1,7 +1,7 @@
use std::any::TypeId;
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;
@@ -387,14 +387,17 @@ pub fn init(cx: &mut App) {
window.on_action(
TypeId::of::<editor::actions::EvaluateSelectedText>(),
move |_, _, window, cx| {
- maybe!({
+ let status = maybe!({
let text = editor
.update(cx, |editor, cx| {
+ let range = editor
+ .selections
+ .newest::<MultiBufferOffsetUtf16>(
+ &editor.display_snapshot(cx),
+ )
+ .range();
editor.text_for_range(
- editor
- .selections
- .newest(&editor.display_snapshot(cx))
- .range(),
+ range.start.0.0..range.end.0.0,
&mut None,
window,
cx,
@@ -408,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();
});
});
@@ -416,6 +425,9 @@ pub fn init(cx: &mut App) {
Some(())
});
+ if status.is_some() {
+ cx.stop_propagation();
+ }
},
);
})
@@ -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, shell::ShellKind};
+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<Workspace>,
@@ -398,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");
+ }
+ }
})
});
}
@@ -441,7 +447,7 @@ impl NewProcessModal {
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
- ) -> ui::DropdownMenu {
+ ) -> DropdownMenu {
let workspace = self.workspace.clone();
let weak = cx.weak_entity();
let active_buffer = self.task_contexts(cx).and_then(|tc| {
@@ -501,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),
+ })
}
}
@@ -533,44 +546,6 @@ impl Focusable for NewProcessMode {
}
}
-fn render_editor(editor: &Entity<Editor>, 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,
@@ -599,7 +574,7 @@ impl Render for NewProcessModal {
NewProcessMode::Launch => NewProcessMode::Task,
};
- this.mode_focus_handle(cx).focus(window);
+ this.mode_focus_handle(cx).focus(window, cx);
}))
.on_action(
cx.listener(|this, _: &pane::ActivatePreviousItem, window, cx| {
@@ -610,7 +585,7 @@ impl Render for NewProcessModal {
NewProcessMode::Launch => NewProcessMode::Attach,
};
- this.mode_focus_handle(cx).focus(window);
+ this.mode_focus_handle(cx).focus(window, cx);
}),
)
.child(
@@ -620,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;
-
- 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(),
+ 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);
+ 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);
+ 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);
+ 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);
+ 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)))
@@ -789,22 +756,26 @@ impl RenderOnce for AttachMode {
#[derive(Clone)]
pub(super) struct ConfigureMode {
- program: Entity<Editor>,
- cwd: Entity<Editor>,
+ program: Entity<InputField>,
+ cwd: Entity<InputField>,
stop_on_entry: ToggleState,
save_to_debug_json: ToggleState,
}
impl ConfigureMode {
pub(super) fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
- 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 {
@@ -816,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);
}
});
}
@@ -869,55 +840,48 @@ impl ConfigureMode {
}
}
+ fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
+ window.focus_next(cx);
+ }
+
+ fn on_tab_prev(
+ &mut self,
+ _: &menu::SelectPrevious,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ window.focus_prev(cx);
+ }
+
fn render(
&mut self,
adapter_menu: DropdownMenu,
- window: &mut Window,
+ _: &mut Window,
cx: &mut ui::Context<Self>,
) -> 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,8 +915,15 @@ impl AttachMode {
stop_on_entry: Some(false),
};
let attach_picker = cx.new(|cx| {
- let modal = AttachModal::new(definition.clone(), workspace, project, false, window, cx);
- window.focus(&modal.focus_handle(cx));
+ let modal = AttachModal::new(
+ ModalIntent::AttachToProcess(definition.clone()),
+ workspace,
+ project,
+ false,
+ window,
+ cx,
+ );
+ window.focus(&modal.focus_handle(cx), cx);
modal
});
@@ -1053,7 +1022,7 @@ impl DebugDelegate {
Some(TaskSourceKind::Lsp { language_name, .. }) => {
Some(format!("LSP: {language_name}"))
}
- Some(TaskSourceKind::Language { name }) => Some(format!("Lang: {name}")),
+ Some(TaskSourceKind::Language { name }) => Some(format!("Language: {name}")),
_ => context.clone().and_then(|ctx| {
ctx.task_context
.task_variables
@@ -1550,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::<IconWithIndicator>(icon)
.spacing(ListItemSpacing::Sparse)
@@ -83,8 +83,8 @@ impl Render for DebuggerOnboardingModal {
debugger_onboarding_event!("Canceled", trigger = "Action");
cx.emit(DismissEvent);
}))
- .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
- this.focus_handle.focus(window);
+ .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+ this.focus_handle.focus(window, cx);
}))
.child(
div()
@@ -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<String> =
+ LazyLock::new(|| task::VariableName::PickProcessId.template_value());
+
pub struct RunningState {
session: Entity<Session>,
thread_id: Option<ThreadId>,
@@ -276,10 +286,10 @@ impl Item for SubView {
impl Render for SubView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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)
})
})
@@ -474,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()
@@ -597,7 +604,7 @@ impl DebugTerminal {
let focus_handle = cx.focus_handle();
let focus_subscription = cx.on_focus(&focus_handle, window, |this, window, cx| {
if let Some(terminal) = this.terminal.as_ref() {
- terminal.focus_handle(cx).focus(window);
+ terminal.focus_handle(cx).focus(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,
@@ -955,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::<Option<i32>>();
+
+ 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)) {
@@ -1396,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();
}
@@ -1674,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 {
@@ -1823,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);
}
}
@@ -310,7 +310,7 @@ impl BreakpointList {
fn dismiss(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
if self.input.focus_handle(cx).contains_focused(window, cx) {
- self.focus_handle.focus(window);
+ self.focus_handle.focus(window, cx);
} else if self.strip_mode.is_some() {
self.strip_mode.take();
cx.notify();
@@ -364,9 +364,9 @@ impl BreakpointList {
}
}
}
- self.focus_handle.focus(window);
+ self.focus_handle.focus(window, cx);
} else {
- handle.focus(window);
+ handle.focus(window, cx);
}
return;
@@ -575,7 +575,7 @@ impl BreakpointList {
)
.with_horizontal_sizing_behavior(gpui::ListHorizontalSizingBehavior::Unconstrained)
.with_width_from_item(self.max_width_index)
- .track_scroll(self.scroll_handle.clone())
+ .track_scroll(&self.scroll_handle)
.flex_1()
}
@@ -627,7 +627,7 @@ impl BreakpointList {
.on_click({
let focus_handle = focus_handle.clone();
move |_, window, cx| {
- focus_handle.focus(window);
+ focus_handle.focus(window, cx);
window.dispatch_action(ToggleEnableBreakpoint.boxed_clone(), cx)
}
}),
@@ -654,7 +654,7 @@ impl BreakpointList {
)
.on_click({
move |_, window, cx| {
- focus_handle.focus(window);
+ focus_handle.focus(window, cx);
window.dispatch_action(UnsetBreakpoint.boxed_clone(), cx)
}
}),
@@ -776,7 +776,7 @@ impl Render for BreakpointList {
.child(self.render_list(cx))
.custom_scrollbars(
ui::Scrollbars::new(ScrollAxes::Both)
- .tracked_scroll_handle(self.scroll_handle.clone())
+ .tracked_scroll_handle(&self.scroll_handle)
.with_track_along(ScrollAxes::Both, cx.theme().colors().panel_background)
.tracked_entity(cx.entity_id()),
window,
@@ -1407,7 +1407,6 @@ impl RenderOnce for BreakpointOptionsStrip {
h_flex()
.gap_px()
- .mr_3() // Space to avoid overlapping with the scrollbar
.justify_end()
.when(has_logs || self.is_selected, |this| {
this.child(
@@ -8,7 +8,7 @@ use collections::HashMap;
use dap::{CompletionItem, CompletionItemType, OutputEvent};
use editor::{
Bias, CompletionProvider, Editor, EditorElement, EditorMode, EditorStyle, ExcerptId,
- SizingBehavior,
+ MultiBufferOffset, SizingBehavior,
};
use fuzzy::StringMatchCandidate;
use gpui::{
@@ -18,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;
@@ -105,7 +105,7 @@ impl Console {
cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events),
cx.on_focus(&focus_handle, window, |console, window, cx| {
if console.is_running(cx) {
- console.query_bar.focus_handle(cx).focus(window);
+ console.query_bar.focus_handle(cx).focus(window, cx);
}
}),
];
@@ -161,7 +161,9 @@ impl Console {
) -> Task<Result<()>> {
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();
@@ -227,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,
@@ -240,6 +242,7 @@ impl Console {
start_offset,
vec![range],
style,
+ false,
cx,
);
}
@@ -247,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::<ConsoleAnsiHighlight>(
start_offset,
&[range],
- color_fetcher(color),
+ move |_, theme| color_fn(theme),
cx,
);
}
@@ -550,24 +554,12 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider {
}
}
- fn apply_additional_edits_for_completion(
- &self,
- _buffer: Entity<Buffer>,
- _completions: Rc<RefCell<Box<[Completion]>>>,
- _completion_index: usize,
- _push_to_history: bool,
- _cx: &mut Context<Editor>,
- ) -> gpui::Task<anyhow::Result<Option<language::Transaction>>> {
- Task::ready(Ok(None))
- }
-
fn is_completion_trigger(
&self,
buffer: &Entity<Buffer>,
position: language::Anchor,
text: &str,
trigger_in_words: bool,
- menu_is_open: bool,
cx: &mut Context<Editor>,
) -> bool {
let mut chars = text.chars();
@@ -578,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)
@@ -677,6 +666,8 @@ impl ConsoleQueryBarCompletionProvider {
),
new_text: string_match.string.clone(),
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(),
@@ -790,6 +781,8 @@ impl ConsoleQueryBarCompletionProvider {
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,
@@ -957,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;
@@ -989,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,
@@ -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();
}
@@ -229,7 +229,7 @@ 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();
@@ -403,7 +403,7 @@ impl MemoryView {
this.set_placeholder_text("Write to Selected Memory Range", window, cx);
});
self.is_writing_memory = true;
- self.query_editor.focus_handle(cx).focus(window);
+ self.query_editor.focus_handle(cx).focus(window, cx);
} else {
self.query_editor.update(cx, |this, cx| {
this.clear(window, cx);
@@ -921,7 +921,7 @@ impl Render for MemoryView {
}))
.custom_scrollbars(
ui::Scrollbars::new(ui::ScrollAxes::Both)
- .tracked_scroll_handle(self.view_state_handle.clone())
+ .tracked_scroll_handle(&self.view_state_handle)
.with_track_along(
ui::ScrollAxes::Both,
cx.theme().colors().panel_background,
@@ -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)
}
}
@@ -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<StackFrameFilter> 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();
})
@@ -806,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();
}
@@ -913,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)
}
}
@@ -217,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();
@@ -225,7 +231,6 @@ impl VariableList {
SessionEvent::Variables | SessionEvent::Watchers => {
this.build_entries(cx);
}
-
_ => {}
}),
cx.on_focus_out(&focus_handle, window, |this, _, _, cx| {
@@ -524,7 +529,7 @@ impl VariableList {
fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
self.edited_path.take();
- self.focus_handle.focus(window);
+ self.focus_handle.focus(window, cx);
cx.notify();
}
@@ -1062,7 +1067,7 @@ impl VariableList {
editor.select_all(&editor::actions::SelectAll, window, cx);
editor
});
- editor.focus_handle(cx).focus(window);
+ editor.focus_handle(cx).focus(window, cx);
editor
}
@@ -1557,7 +1562,7 @@ 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)
@@ -1574,10 +1579,10 @@ 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.clone())
+ .tracked_scroll_handle(&self.list_handle)
.with_track_along(ScrollAxes::Both, cx.theme().colors().panel_background)
.tracked_entity(cx.entity_id()),
window,
@@ -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};
@@ -418,17 +418,17 @@ impl Item for StackTraceView {
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
- ) -> Option<AnyView> {
+ ) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
- Some(self_handle.to_any())
+ Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
- Some(self.editor.to_any())
+ Some(self.editor.clone().into())
} else {
None
}
}
- fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
@@ -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::<dap::requests::Attach, _>(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::<AttachModal>(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::<AttachModal>(cx).is_none(),
+ "Attach modal should be dismissed after selection"
+ );
+ })
+ .unwrap();
+}
@@ -4,7 +4,7 @@ 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,
+ Language, LanguageConfig, LanguageMatcher, rust_lang, tree_sitter_python,
tree_sitter_typescript,
};
use project::{FakeFs, Project};
@@ -224,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| {
@@ -1521,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);
@@ -1859,21 +1842,23 @@ fn python_lang() -> Language {
.unwrap()
}
-fn go_lang() -> Language {
+fn go_lang() -> Arc<Language> {
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
@@ -1891,7 +1876,7 @@ async fn test_inline_values_util(
before: &str,
after: &str,
active_debug_line: Option<usize>,
- language: Language,
+ language: Arc<Language>,
executor: BackgroundExecutor,
cx: &mut TestAppContext,
) {
@@ -2091,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| {
@@ -2276,55 +2261,61 @@ fn main() {
.await;
}
-fn javascript_lang() -> Language {
+fn javascript_lang() -> Arc<Language> {
let debug_variables_query = include_str!("../../../languages/src/javascript/debugger.scm");
- Language::new(
- LanguageConfig {
- name: "JavaScript".into(),
- matcher: LanguageMatcher {
- path_suffixes: vec!["js".to_string()],
+ Arc::new(
+ Language::new(
+ LanguageConfig {
+ name: "JavaScript".into(),
+ matcher: LanguageMatcher {
+ path_suffixes: vec!["js".to_string()],
+ ..Default::default()
+ },
..Default::default()
},
- ..Default::default()
- },
- Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
+ Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
+ )
+ .with_debug_variables_query(debug_variables_query)
+ .unwrap(),
)
- .with_debug_variables_query(debug_variables_query)
- .unwrap()
}
-fn typescript_lang() -> Language {
+fn typescript_lang() -> Arc<Language> {
let debug_variables_query = include_str!("../../../languages/src/typescript/debugger.scm");
- Language::new(
- LanguageConfig {
- name: "TypeScript".into(),
- matcher: LanguageMatcher {
- path_suffixes: vec!["ts".to_string()],
+ Arc::new(
+ Language::new(
+ LanguageConfig {
+ name: "TypeScript".into(),
+ matcher: LanguageMatcher {
+ path_suffixes: vec!["ts".to_string()],
+ ..Default::default()
+ },
..Default::default()
},
- ..Default::default()
- },
- Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
+ Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
+ )
+ .with_debug_variables_query(debug_variables_query)
+ .unwrap(),
)
- .with_debug_variables_query(debug_variables_query)
- .unwrap()
}
-fn tsx_lang() -> Language {
+fn tsx_lang() -> Arc<Language> {
let debug_variables_query = include_str!("../../../languages/src/tsx/debugger.scm");
- Language::new(
- LanguageConfig {
- name: "TSX".into(),
- matcher: LanguageMatcher {
- path_suffixes: vec!["tsx".to_string()],
+ Arc::new(
+ Language::new(
+ LanguageConfig {
+ name: "TSX".into(),
+ matcher: LanguageMatcher {
+ path_suffixes: vec!["tsx".to_string()],
+ ..Default::default()
+ },
..Default::default()
},
- ..Default::default()
- },
- Some(tree_sitter_typescript::LANGUAGE_TSX.into()),
+ Some(tree_sitter_typescript::LANGUAGE_TSX.into()),
+ )
+ .with_debug_variables_query(debug_variables_query)
+ .unwrap(),
)
- .with_debug_variables_query(debug_variables_query)
- .unwrap()
}
#[gpui::test]
@@ -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::<Threads, _>({
+ let threads_response = threads_response.clone();
+ move |_, _| Ok(threads_response.clone())
+ });
+
+ client.on_request::<Scopes, _>(move |_, _| Ok(dap::ScopesResponse { scopes: vec![] }));
+
+ client.on_request::<StackTrace, _>({
+ 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::<Threads, _>({
+ let threads_response = threads_response.clone();
+ move |_, _| Ok(threads_response.clone())
+ });
+
+ client2.on_request::<Scopes, _>(move |_, _| Ok(dap::ScopesResponse { scopes: vec![] }));
+
+ client2.on_request::<StackTrace, _>({
+ 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"
+ );
+ });
+}
@@ -155,6 +155,8 @@ pub enum RequestMessage {
content: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tool_calls: Vec<ToolCall>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ reasoning_content: Option<String>,
},
User {
content: String,
@@ -6,7 +6,7 @@ use crate::{
use anyhow::Result;
use collections::HashMap;
use editor::{
- Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
+ Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey,
display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
multibuffer_context_lines,
};
@@ -175,7 +175,7 @@ impl BufferDiagnosticsEditor {
// `BufferDiagnosticsEditor` instance.
EditorEvent::Focused => {
if buffer_diagnostics_editor.multibuffer.read(cx).is_empty() {
- window.focus(&buffer_diagnostics_editor.focus_handle);
+ window.focus(&buffer_diagnostics_editor.focus_handle, cx);
}
}
EditorEvent::Blurred => {
@@ -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,
)
})?;
@@ -512,7 +517,7 @@ impl BufferDiagnosticsEditor {
.editor
.read(cx)
.focus_handle(cx)
- .focus(window);
+ .focus(window, cx);
}
}
}
@@ -612,7 +617,7 @@ impl BufferDiagnosticsEditor {
// not empty, focus on the editor instead, which will allow the user to
// start interacting and editing the buffer's contents.
if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
- self.editor.focus_handle(cx).focus(window)
+ self.editor.focus_handle(cx).focus(window, cx)
}
}
@@ -675,11 +680,11 @@ impl Item for BufferDiagnosticsEditor {
type_id: std::any::TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
- ) -> Option<gpui::AnyView> {
+ ) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
- Some(self_handle.to_any())
+ Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
- Some(self.editor.to_any())
+ Some(self.editor.clone().into())
} else {
None
}
@@ -696,8 +701,12 @@ impl Item for BufferDiagnosticsEditor {
});
}
- fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
- ToolbarItemLocation::PrimaryLeft
+ fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
+ if EditorSettings::get_global(cx).toolbar.breadcrumbs {
+ ToolbarItemLocation::PrimaryLeft
+ } else {
+ ToolbarItemLocation::Hidden
+ }
}
fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
@@ -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<DiagnosticEntryRef<'_, Point>>,
buffer_id: BufferId,
diagnostics_editor: Option<Arc<dyn DiagnosticsToolbarEditor>>,
+ language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut App,
) -> Vec<DiagnosticBlock> {
let Some(primary_ix) = diagnostic_group
@@ -75,11 +76,14 @@ 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)),
+ markdown: cx.new(|cx| {
+ Markdown::new(markdown.into(), language_registry.clone(), None, cx)
+ }),
});
} else {
if entry.range.start.row.abs_diff(primary.range.start.row) >= 5 {
@@ -91,7 +95,9 @@ impl DiagnosticRenderer {
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)
+ }),
});
}
}
@@ -118,9 +124,16 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer {
buffer_id: BufferId,
snapshot: EditorSnapshot,
editor: WeakEntity<Editor>,
+ language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut App,
) -> Vec<BlockProperties<Anchor>> {
- 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()
@@ -146,9 +159,16 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer {
diagnostic_group: Vec<DiagnosticEntryRef<'_, Point>>,
range: Range<Point>,
buffer_id: BufferId,
+ language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut App,
) -> Option<Entity<Markdown>> {
- 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))
@@ -206,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
@@ -259,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,
);
@@ -290,6 +315,6 @@ impl DiagnosticBlock {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges([range.start..range.start]);
});
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
}
}
@@ -12,12 +12,12 @@ use buffer_diagnostics::BufferDiagnosticsEditor;
use collections::{BTreeSet, HashMap, HashSet};
use diagnostic_renderer::DiagnosticBlock;
use editor::{
- Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
+ Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey,
display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
multibuffer_context_lines,
};
use gpui::{
- AnyElement, AnyView, App, AsyncApp, Context, Entity, EventEmitter, FocusHandle, FocusOutEvent,
+ AnyElement, App, AsyncApp, Context, Entity, EventEmitter, FocusHandle, FocusOutEvent,
Focusable, Global, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
Styled, Subscription, Task, WeakEntity, Window, actions, div,
};
@@ -73,7 +73,7 @@ pub fn init(cx: &mut App) {
}
pub(crate) struct ProjectDiagnosticsEditor {
- project: Entity<Project>,
+ pub project: Entity<Project>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
editor: Entity<Editor>,
@@ -182,7 +182,6 @@ impl ProjectDiagnosticsEditor {
project::Event::DiskBasedDiagnosticsFinished { language_server_id } => {
log::debug!("disk based diagnostics finished for server {language_server_id}");
this.close_diagnosticless_buffers(
- window,
cx,
this.editor.focus_handle(cx).contains_focused(window, cx)
|| this.focus_handle.contains_focused(window, cx),
@@ -244,13 +243,13 @@ impl ProjectDiagnosticsEditor {
match event {
EditorEvent::Focused => {
if this.multibuffer.read(cx).is_empty() {
- window.focus(&this.focus_handle);
+ window.focus(&this.focus_handle, cx);
}
}
- EditorEvent::Blurred => this.close_diagnosticless_buffers(window, cx, false),
- EditorEvent::Saved => this.close_diagnosticless_buffers(window, cx, true),
+ EditorEvent::Blurred => this.close_diagnosticless_buffers(cx, false),
+ EditorEvent::Saved => this.close_diagnosticless_buffers(cx, true),
EditorEvent::SelectionsChanged { .. } => {
- this.close_diagnosticless_buffers(window, cx, true)
+ this.close_diagnosticless_buffers(cx, true)
}
_ => {}
}
@@ -298,12 +297,7 @@ impl ProjectDiagnosticsEditor {
/// - 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,
- _window: &mut Window,
- cx: &mut Context<Self>,
- retain_selections: bool,
- ) {
+ fn close_diagnosticless_buffers(&mut self, cx: &mut Context<Self>, retain_selections: bool) {
let snapshot = self
.editor
.update(cx, |editor, cx| editor.display_snapshot(cx));
@@ -314,7 +308,7 @@ impl ProjectDiagnosticsEditor {
.selections
.all_anchors(&snapshot)
.iter()
- .filter_map(|anchor| anchor.start.buffer_id)
+ .filter_map(|anchor| anchor.start.text_anchor.buffer_id)
.collect::<HashSet<_>>()
});
for buffer_id in buffer_ids {
@@ -440,14 +434,14 @@ impl ProjectDiagnosticsEditor {
fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
- self.editor.focus_handle(cx).focus(window)
+ self.editor.focus_handle(cx).focus(window, cx)
}
}
fn focus_out(&mut self, _: FocusOutEvent, window: &mut Window, cx: &mut Context<Self>) {
if !self.focus_handle.is_focused(window) && !self.editor.focus_handle(cx).is_focused(window)
{
- self.close_diagnosticless_buffers(window, cx, false);
+ self.close_diagnosticless_buffers(cx, false);
}
}
@@ -461,8 +455,7 @@ impl ProjectDiagnosticsEditor {
});
}
});
- self.multibuffer
- .update(cx, |multibuffer, cx| multibuffer.clear(cx));
+ self.close_diagnosticless_buffers(cx, false);
self.project.update(cx, |project, cx| {
self.paths_to_update = project
.diagnostic_summaries(false, cx)
@@ -498,7 +491,7 @@ impl ProjectDiagnosticsEditor {
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let was_empty = self.multibuffer.read(cx).is_empty();
- let mut buffer_snapshot = buffer.read(cx).snapshot();
+ let buffer_snapshot = buffer.read(cx).snapshot();
let buffer_id = buffer_snapshot.remote_id();
let max_severity = if self.include_warnings {
@@ -552,11 +545,15 @@ impl ProjectDiagnosticsEditor {
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(diagnostics_toolbar_editor.clone()),
+ languages,
cx,
)
})?;
@@ -605,7 +602,6 @@ impl ProjectDiagnosticsEditor {
cx,
)
.await;
- buffer_snapshot = cx.update(|_, cx| buffer.read(cx).snapshot())?;
let initial_range = buffer_snapshot.anchor_after(b.initial_range.start)
..buffer_snapshot.anchor_before(b.initial_range.end);
let excerpt_range = ExcerptRange {
@@ -654,7 +650,7 @@ impl ProjectDiagnosticsEditor {
})
});
if this.focus_handle.is_focused(window) {
- this.editor.read(cx).focus_handle(cx).focus(window);
+ this.editor.read(cx).focus_handle(cx).focus(window, cx);
}
}
@@ -884,22 +880,26 @@ impl Item for ProjectDiagnosticsEditor {
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
- ) -> Option<AnyView> {
+ ) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
- Some(self_handle.to_any())
+ Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
- Some(self.editor.to_any())
+ Some(self.editor.clone().into())
} else {
None
}
}
- fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
- fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
- ToolbarItemLocation::PrimaryLeft
+ fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
+ if EditorSettings::get_global(cx).toolbar.breadcrumbs {
+ ToolbarItemLocation::PrimaryLeft
+ } else {
+ ToolbarItemLocation::Hidden
+ }
}
fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
@@ -1013,11 +1013,14 @@ async fn heuristic_syntactic_expand(
snapshot: BufferSnapshot,
cx: &mut AsyncApp,
) -> Option<RangeInclusive<BufferRow>> {
+ 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() {
@@ -1046,54 +1049,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);
}
- }
- 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;
+ 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;
}
}
- *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;
}
@@ -1,7 +1,7 @@
use super::*;
use collections::{HashMap, HashSet};
use editor::{
- DisplayPoint, EditorSettings, Inlay,
+ DisplayPoint, EditorSettings, Inlay, MultiBufferOffset,
actions::{GoToDiagnostic, GoToPreviousDiagnostic, Hover, MoveToBeginning},
display_map::DisplayRow,
test::{
@@ -878,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}/{}: {:?}",
@@ -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,
@@ -171,14 +171,19 @@ impl DiagnosticIndicator {
let buffer = editor.buffer().read(cx).snapshot(cx);
let cursor_position = editor
.selections
- .newest::<usize>(&editor.display_snapshot(cx))
+ .newest::<MultiBufferOffset>(&editor.display_snapshot(cx))
.head();
(buffer, cursor_position)
});
let new_diagnostic = buffer
- .diagnostics_in_range::<usize>(cursor_position..cursor_position)
+ .diagnostics_in_range::<MultiBufferOffset>(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();
@@ -11,7 +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
+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
@@ -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<Point>, Range<Point>) {
+ 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<Point>,
+ mut remaining_tokens: usize,
+) -> Range<Point> {
+ 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
+}
@@ -1,292 +1,2132 @@
-use std::{ops::Range, sync::Arc};
+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 client::EditPredictionUsage;
-use gpui::{App, Context, Entity, SharedString};
-use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt};
+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};
-// 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,
+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;
+
+#[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";
}
-#[derive(Clone)]
-pub enum EditPrediction {
- /// Edits within the buffer that requested the prediction
- Local {
- id: Option<SharedString>,
- edits: Vec<(Range<language::Anchor>, Arc<str>)>,
- edit_preview: Option<language::EditPreview>,
- },
- /// Jump to a different file from the one that requested the prediction
- Jump {
- id: Option<SharedString>,
- snapshot: language::BufferSnapshot,
- target: language::Anchor,
+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<bool> =
+ LazyLock::new(|| env::var("ZED_ZETA2_OLLAMA").is_ok_and(|var| !var.is_empty()));
+
+static EDIT_PREDICTIONS_MODEL_ID: LazyLock<String> = 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
+ }
}
-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(Clone)]
+struct EditPredictionStoreGlobal(Entity<EditPredictionStore>);
+
+impl Global for EditPredictionStoreGlobal {}
+
+pub struct EditPredictionStore {
+ client: Arc<Client>,
+ user_store: Entity<UserStore>,
+ llm_token: LlmApiToken,
+ _llm_token_subscription: Subscription,
+ projects: HashMap<EntityId, ProjectState>,
+ use_context: bool,
+ options: ZetaOptions,
+ update_required: bool,
+ #[cfg(feature = "cli-support")]
+ eval_cache: Option<Arc<dyn EvalCache>>,
+ edit_prediction_model: EditPredictionModel,
+ pub sweep_ai: SweepAi,
+ pub mercury: Mercury,
+ data_collection_choice: DataCollectionChoice,
+ reject_predictions_tx: mpsc::UnboundedSender<EditPredictionRejection>,
+ shown_predictions: VecDeque<EditPrediction>,
+ rated_predictions: HashSet<EditPredictionId>,
+ custom_predict_edits_url: Option<Arc<Url>>,
}
-impl DataCollectionState {
- pub fn is_supported(&self) -> bool {
- !matches!(self, DataCollectionState::Unsupported)
+#[derive(Copy, Clone, Default, PartialEq, Eq)]
+pub enum EditPredictionModel {
+ #[default]
+ Zeta1,
+ Zeta2,
+ Sweep,
+ Mercury,
+}
+
+pub struct EditPredictionModelInput {
+ project: Entity<Project>,
+ buffer: Entity<Buffer>,
+ snapshot: BufferSnapshot,
+ position: Anchor,
+ events: Vec<Arc<zeta_prompt::Event>>,
+ related_files: Arc<[RelatedFile]>,
+ recent_paths: VecDeque<ProjectPath>,
+ trigger: PredictEditsRequestTrigger,
+ diagnostic_search_range: Range<Point>,
+ debug_tx: Option<mpsc::UnboundedSender<DebugEvent>>,
+}
+
+#[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<Buffer>,
+ pub position: Anchor,
+ pub prompt: Option<String>,
+}
+
+#[derive(Debug)]
+pub struct EditPredictionFinishedDebugEvent {
+ pub buffer: WeakEntity<Buffer>,
+ pub position: Anchor,
+ pub model_output: Option<String>,
+}
+
+pub type RequestDebugInfo = predict_edits_v3::DebugInfo;
+
+struct ProjectState {
+ events: VecDeque<Arc<zeta_prompt::Event>>,
+ last_event: Option<LastEvent>,
+ recent_paths: VecDeque<ProjectPath>,
+ registered_buffers: HashMap<gpui::EntityId, RegisteredBuffer>,
+ current_prediction: Option<CurrentEditPrediction>,
+ next_pending_prediction_id: usize,
+ pending_predictions: ArrayVec<PendingPrediction, 2>,
+ debug_tx: Option<mpsc::UnboundedSender<DebugEvent>>,
+ last_prediction_refresh: Option<(EntityId, Instant)>,
+ cancelled_predictions: HashSet<usize>,
+ context: Entity<RelatedExcerptStore>,
+ license_detection_watchers: HashMap<WorktreeId, Rc<LicenseDetectionWatcher>>,
+ _subscription: gpui::Subscription,
+}
+
+impl ProjectState {
+ pub fn events(&self, cx: &App) -> Vec<Arc<zeta_prompt::Event>> {
+ self.events
+ .iter()
+ .cloned()
+ .chain(
+ self.last_event
+ .as_ref()
+ .and_then(|event| event.finalize(&self.license_detection_watchers, cx)),
+ )
+ .collect()
+ }
+
+ pub fn events_split_by_pause(&self, cx: &App) -> Vec<Arc<zeta_prompt::Event>> {
+ 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()
+ }
+
+ fn cancel_pending_prediction(
+ &mut self,
+ pending_prediction: PendingPrediction,
+ cx: &mut Context<EditPredictionStore>,
+ ) {
+ 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()
}
- pub fn is_enabled(&self) -> bool {
- matches!(self, DataCollectionState::Enabled { .. })
+ fn active_buffer(
+ &self,
+ project: &Entity<Project>,
+ cx: &App,
+ ) -> Option<(Entity<Buffer>, Option<Anchor>)> {
+ 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;
+ };
- pub fn is_project_open_source(&self) -> bool {
+ 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
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+enum PredictionRequestedBy {
+ DiagnosticsUpdate,
+ Buffer(EntityId),
+}
+
+impl PredictionRequestedBy {
+ pub fn buffer_id(&self) -> Option<EntityId> {
match self {
- Self::Enabled {
- is_project_open_source,
- }
- | Self::Disabled {
- is_project_open_source,
- } => *is_project_open_source,
- _ => false,
+ PredictionRequestedBy::DiagnosticsUpdate => None,
+ PredictionRequestedBy::Buffer(buffer_id) => Some(*buffer_id),
+ }
+ }
+}
+
+#[derive(Debug)]
+struct PendingPrediction {
+ id: usize,
+ task: Task<Option<EditPredictionId>>,
+}
+
+/// 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,
}
}
}
-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
+struct RegisteredBuffer {
+ file: Option<Arc<dyn File>>,
+ snapshot: TextBufferSnapshot,
+ last_position: Option<Anchor>,
+ _subscriptions: [gpui::Subscription; 2],
+}
+
+#[derive(Clone)]
+struct LastEvent {
+ old_snapshot: TextBufferSnapshot,
+ new_snapshot: TextBufferSnapshot,
+ old_file: Option<Arc<dyn File>>,
+ new_file: Option<Arc<dyn File>>,
+ end_edit_anchor: Option<Anchor>,
+ snapshot_after_last_editing_pause: Option<TextBufferSnapshot>,
+ last_edit_time: Option<Instant>,
+}
+
+impl LastEvent {
+ pub fn finalize(
+ &self,
+ license_detection_watchers: &HashMap<WorktreeId, Rc<LicenseDetectionWatcher>>,
+ cx: &App,
+ ) -> Option<Arc<zeta_prompt::Event>> {
+ 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,
+ }))
+ }
}
- fn supports_jump_to_edit() -> bool {
- true
+
+ pub fn split_by_pause(&self) -> (LastEvent, Option<LastEvent>) {
+ 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<dyn File>>,
+ snapshot: &TextBufferSnapshot,
+ cx: &App,
+) -> Arc<Path> {
+ 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<Entity<Self>> {
+ cx.try_global::<EditPredictionStoreGlobal>()
+ .map(|global| global.0.clone())
+ }
+
+ pub fn global(
+ client: &Arc<Client>,
+ user_store: &Entity<UserStore>,
+ cx: &mut App,
+ ) -> Entity<Self> {
+ cx.try_global::<EditPredictionStoreGlobal>()
+ .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<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> 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::<SettingsStore>(|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;
}
- fn data_collection_state(&self, _cx: &App) -> DataCollectionState {
- DataCollectionState::Unsupported
+ pub fn has_sweep_api_token(&self, cx: &App) -> bool {
+ self.sweep_ai.api_token.read(cx).has_key()
}
- fn usage(&self, _cx: &App) -> Option<EditPredictionUsage> {
- None
+ pub fn has_mercury_api_token(&self, cx: &App) -> bool {
+ self.mercury.api_token.read(cx).has_key()
}
- fn toggle_data_collection(&mut self, _cx: &mut App) {}
- fn is_enabled(
+ #[cfg(feature = "cli-support")]
+ pub fn with_eval_cache(&mut self, cache: Arc<dyn EvalCache>) {
+ 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<Project>) {
+ if let Some(project_state) = self.projects.get_mut(&project.entity_id()) {
+ project_state.events.clear();
+ }
+ }
+
+ pub fn edit_history_for_project(
&self,
- buffer: &Entity<Buffer>,
- cursor_position: language::Anchor,
+ project: &Entity<Project>,
+ cx: &App,
+ ) -> Vec<Arc<zeta_prompt::Event>> {
+ 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<Project>,
cx: &App,
- ) -> bool;
- fn is_refreshing(&self) -> bool;
- fn refresh(
+ ) -> Vec<Arc<zeta_prompt::Event>> {
+ 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<Project>,
+ 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<Project>,
+ cx: &'a App,
+ ) -> Option<impl 'a + Iterator<Item = (RelatedFile, Entity<Buffer>)>> {
+ self.projects
+ .get(&project.entity_id())
+ .map(|project| project.context.read(cx).related_files_with_buffers())
+ }
+
+ pub fn usage(&self, cx: &App) -> Option<EditPredictionUsage> {
+ 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<Project>, cx: &mut Context<Self>) {
+ self.get_or_init_project(project, cx);
+ }
+
+ pub fn register_buffer(
&mut self,
- buffer: Entity<Buffer>,
- cursor_position: language::Anchor,
- debounce: bool,
+ buffer: &Entity<Buffer>,
+ project: &Entity<Project>,
cx: &mut Context<Self>,
- );
- fn cycle(
+ ) {
+ 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,
- buffer: Entity<Buffer>,
- cursor_position: language::Anchor,
- direction: Direction,
+ project: &Entity<Project>,
+ cx: &mut Context<Self>,
+ ) -> &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<Project>) {
+ 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<Project>,
+ cx: &mut Context<Self>,
+ ) -> mpsc::UnboundedReceiver<DebugEvent> {
+ 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<Project>,
+ event: &project::Event,
+ cx: &mut Context<Self>,
+ ) {
+ // 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::<Zeta2FeatureFlag>() {
+ self.refresh_prediction_from_diagnostics(project, cx);
+ }
+ }
+ _ => (),
+ }
+ }
+
+ fn register_buffer_impl<'a>(
+ project_state: &'a mut ProjectState,
+ buffer: &Entity<Buffer>,
+ project: &Entity<Project>,
cx: &mut Context<Self>,
- );
- fn accept(&mut self, cx: &mut Context<Self>);
- fn discard(&mut self, cx: &mut Context<Self>);
- fn suggest(
+ ) -> &'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<Buffer>,
- cursor_position: language::Anchor,
+ project: &Entity<Project>,
cx: &mut Context<Self>,
- ) -> Option<EditPrediction>;
-}
+ ) {
+ let project_state = self.get_or_init_project(project, cx);
+ let registered_buffer = Self::register_buffer_impl(project_state, buffer, project, cx);
-pub trait EditPredictionProviderHandle {
- fn name(&self) -> &'static str;
- fn display_name(&self) -> &'static str;
- fn is_enabled(
- &self,
+ 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::<Point>(&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<Buffer>,
- cursor_position: language::Anchor,
+ position: Option<language::Anchor>,
+ project: &Entity<Project>,
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<EditPredictionUsage>;
- fn toggle_data_collection(&self, cx: &mut App);
- fn is_refreshing(&self, cx: &App) -> bool;
- fn refresh(
- &self,
- buffer: Entity<Buffer>,
- cursor_position: language::Anchor,
- debounce: bool,
- cx: &mut App,
- );
- fn cycle(
- &self,
- buffer: Entity<Buffer>,
- 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<Buffer>,
- cursor_position: language::Anchor,
- cx: &mut App,
- ) -> Option<EditPrediction>;
-}
+ ) -> Option<BufferEditPrediction<'_>> {
+ 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);
+ }
-impl<T> EditPredictionProviderHandle for Entity<T>
-where
- T: EditPredictionProvider,
-{
- fn name(&self) -> &'static str {
- T::name()
+ 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 display_name(&self) -> &'static str {
- T::display_name()
+ fn accept_current_prediction(&mut self, project: &Entity<Project>, cx: &mut Context<Self>) {
+ 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<EditPredictionRejection>,
+ client: Arc<Client>,
+ 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 show_completions_in_menu(&self) -> bool {
- T::show_completions_in_menu()
+ fn reject_current_prediction(
+ &mut self,
+ reason: EditPredictionRejectReason,
+ project: &Entity<Project>,
+ ) {
+ 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 show_tab_accept_marker(&self) -> bool {
- T::show_tab_accept_marker()
+ fn did_show_current_prediction(&mut self, project: &Entity<Project>, _cx: &mut Context<Self>) {
+ 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 supports_jump_to_edit(&self) -> bool {
- T::supports_jump_to_edit()
+ 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 data_collection_state(&self, cx: &App) -> DataCollectionState {
- self.read(cx).data_collection_state(cx)
+ fn is_refreshing(&self, project: &Entity<Project>) -> bool {
+ self.projects
+ .get(&project.entity_id())
+ .is_some_and(|project_state| !project_state.pending_predictions.is_empty())
}
- fn usage(&self, cx: &App) -> Option<EditPredictionUsage> {
- self.read(cx).usage(cx)
+ pub fn refresh_prediction_from_buffer(
+ &mut self,
+ project: Entity<Project>,
+ buffer: Entity<Buffer>,
+ position: language::Anchor,
+ cx: &mut Context<Self>,
+ ) {
+ 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()),
+ )
+ })
+ })
+ })
+ })
}
- fn toggle_data_collection(&self, cx: &mut App) {
- self.update(cx, |this, cx| this.toggle_data_collection(cx))
+ pub fn refresh_prediction_from_diagnostics(
+ &mut self,
+ project: Entity<Project>,
+ cx: &mut Context<Self>,
+ ) {
+ 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 is_enabled(
- &self,
- buffer: &Entity<Buffer>,
- cursor_position: language::Anchor,
+ fn predictions_enabled_at(
+ snapshot: &BufferSnapshot,
+ position: Option<language::Anchor>,
cx: &App,
) -> bool {
- self.read(cx).is_enabled(buffer, cursor_position, cx)
- }
+ 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;
+ }
+ }
- fn is_refreshing(&self, cx: &App) -> bool {
- self.read(cx).is_refreshing()
+ true
}
- fn refresh(
- &self,
- buffer: Entity<Buffer>,
- cursor_position: language::Anchor,
- debounce: bool,
- cx: &mut App,
+ #[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<Project>,
+ throttle_entity: EntityId,
+ cx: &mut Context<Self>,
+ do_refresh: impl FnOnce(
+ WeakEntity<Self>,
+ &mut AsyncApp,
+ )
+ -> Task<Result<Option<(EditPredictionResult, PredictionRequestedBy)>>>
+ + 'static,
) {
- self.update(cx, |this, cx| {
- this.refresh(buffer, cursor_position, debounce, cx)
- })
+ 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 cycle(
- &self,
- buffer: Entity<Buffer>,
- cursor_position: language::Anchor,
- direction: Direction,
- cx: &mut App,
- ) {
- self.update(cx, |this, cx| {
- this.cycle(buffer, cursor_position, direction, cx)
+ pub fn request_prediction(
+ &mut self,
+ project: &Entity<Project>,
+ active_buffer: &Entity<Buffer>,
+ position: language::Anchor,
+ trigger: PredictEditsRequestTrigger,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Option<EditPredictionResult>>> {
+ self.request_prediction_internal(
+ project.clone(),
+ active_buffer.clone(),
+ position,
+ trigger,
+ cx.has_flag::<Zeta2FeatureFlag>(),
+ cx,
+ )
+ }
+
+ fn request_prediction_internal(
+ &mut self,
+ project: Entity<Project>,
+ active_buffer: Entity<Buffer>,
+ position: language::Anchor,
+ trigger: PredictEditsRequestTrigger,
+ allow_jump: bool,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Option<EditPredictionResult>>> {
+ 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)
})
}
- fn accept(&self, cx: &mut App) {
- self.update(cx, |this, cx| this.accept(cx))
+ async fn next_diagnostic_location(
+ active_buffer: Entity<Buffer>,
+ active_buffer_snapshot: &BufferSnapshot,
+ active_buffer_diagnostic_search_range: Range<Point>,
+ active_buffer_cursor_point: Point,
+ project: &Entity<Project>,
+ cx: &mut AsyncApp,
+ ) -> Result<Option<(Entity<Buffer>, 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<Client>,
+ llm_token: LlmApiToken,
+ app_version: Version,
+ #[cfg(feature = "cli-support")] eval_cache: Option<Arc<dyn EvalCache>>,
+ #[cfg(feature = "cli-support")] eval_cache_kind: EvalCacheEntryKind,
+ ) -> Result<(open_ai::Response, Option<EditPredictionUsage>)> {
+ 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 discard(&self, cx: &mut App) {
- self.update(cx, |this, cx| this.discard(cx))
+ fn handle_api_response<T>(
+ this: &WeakEntity<Self>,
+ response: Result<(T, Option<EditPredictionUsage>)>,
+ cx: &mut gpui::AsyncApp,
+ ) -> Result<T> {
+ 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::<ZedUpdateRequiredError>() {
+ 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::<ZedUpdateRequiredError>(),
+ cx,
+ move |cx| {
+ cx.new(|cx| {
+ ErrorMessagePrompt::new(error_message.clone(), cx)
+ .with_link_button("Update Zed", "https://zed.dev/releases")
+ })
+ },
+ );
+ })
+ .ok();
+ }
+ Err(err)
+ }
+ }
}
- fn suggest(
- &self,
- buffer: &Entity<Buffer>,
- cursor_position: language::Anchor,
- cx: &mut App,
- ) -> Option<EditPrediction> {
- self.update(cx, |this, cx| this.suggest(buffer, cursor_position, cx))
- }
-}
-
-/// Returns edits updated based on user edits since the old snapshot. None is returned if any user
-/// edit is not a prefix of a predicted insertion.
-pub fn interpolate_edits(
- old_snapshot: &BufferSnapshot,
- new_snapshot: &BufferSnapshot,
- current_edits: &[(Range<Anchor>, Arc<str>)],
-) -> Option<Vec<(Range<Anchor>, Arc<str>)>> {
- let mut edits = Vec::new();
-
- let mut model_edits = current_edits.iter().peekable();
- for user_edit in new_snapshot.edits_since::<usize>(&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()));
+ async fn send_api_request<Res>(
+ build: impl Fn(http_client::http::request::Builder) -> Result<http_client::Request<AsyncBody>>,
+ client: Arc<Client>,
+ llm_token: LlmApiToken,
+ app_version: Version,
+ require_auth: bool,
+ ) -> Result<(Res, Option<EditPredictionUsage>)>
+ 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 {
- break;
+ 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,
+ project: &Entity<Project>,
+ buffer: &Entity<language::Buffer>,
+ cursor_position: language::Anchor,
+ cx: &mut Context<Self>,
+ ) {
+ if self.use_context {
+ self.get_or_init_project(project, cx)
+ .context
+ .update(cx, |store, cx| {
+ store.refresh(buffer.clone(), cursor_position, cx);
+ });
+ }
+ }
- 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::<String>();
+ #[cfg(feature = "cli-support")]
+ pub fn set_context_for_buffer(
+ &mut self,
+ project: &Entity<Project>,
+ related_files: Vec<RelatedFile>,
+ cx: &mut Context<Self>,
+ ) {
+ self.get_or_init_project(project, cx)
+ .context
+ .update(cx, |store, _| {
+ store.set_related_files(related_files);
+ });
+ }
- 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()));
- }
+ fn is_file_open_source(
+ &self,
+ project: &Entity<Project>,
+ file: &Arc<dyn File>,
+ cx: &App,
+ ) -> bool {
+ 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 can_collect_file(&self, project: &Entity<Project>, file: &Arc<dyn File>, cx: &App) -> bool {
+ self.data_collection_choice.is_enabled() && self.is_file_open_source(project, file, cx)
+ }
- model_edits.next();
- continue;
+ fn can_collect_events(&self, events: &[Arc<zeta_prompt::Event>]) -> 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 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>) {
+ 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<Item = &EditPrediction> {
+ 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>,
+ ) {
+ 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 configure_context_retrieval(&mut self, cx: &mut Context<'_, EditPredictionStore>) {
+ self.use_context = cx.has_flag::<Zeta2FeatureFlag>()
+ && 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<String>;
+ 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,
+ }
+ }
+}
- return None;
+impl From<bool> 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()
+}
- edits.extend(model_edits.cloned());
+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,
+ )
+ },
+ );
- if edits.is_empty() { None } else { Some(edits) }
+ 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();
}
@@ -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::<Vec<_>>(),
+ [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::<serde_json::Value>(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 = "<editable_region>\n";
+ let close = "</editable_region>";
+ 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<open_ai::Response>)>,
+ reject: mpsc::UnboundedReceiver<(RejectEditPredictionsBody, oneshot::Sender<()>)>,
+}
+
+fn init_test_with_fake_client(
+ cx: &mut TestAppContext,
+) -> (Entity<EditPredictionStore>, 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<Anchor>, Arc<str>)]> = 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<Buffer>,
+ project: &Entity<Project>,
+ ep_store: &Entity<EditPredictionStore>,
+ 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<Project>,
+ cx: &mut TestAppContext,
+) -> (
+ Entity<EditPredictionStore>,
+ Arc<Mutex<Option<PredictEditsBody>>>,
+ Arc<Mutex<String>>,
+) {
+ let default_response = indoc! {"
+ ```main.rs
+ <|start_of_file|>
+ <|editable_region_start|>
+ hello world
+ <|editable_region_end|>
+ ```"
+ };
+ let captured_request: Arc<Mutex<Option<PredictEditsBody>>> = Arc::new(Mutex::new(None));
+ let completion_response: Arc<Mutex<String>> =
+ 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::<Vec<_>>();
+ 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<Item = (Range<usize>, Arc<str>)>,
+ buffer: &Entity<Buffer>,
+ cx: &App,
+) -> Vec<(Range<Anchor>, Arc<str>)> {
+ 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<Anchor>, Arc<str>)],
+ buffer: &Entity<Buffer>,
+ cx: &App,
+) -> Vec<(Range<usize>, Arc<str>)> {
+ 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();
+}
@@ -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<Path>,
+ 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<Self> {
+ 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)
+ }
+}
@@ -735,6 +735,7 @@ mod tests {
true,
fs.clone(),
Default::default(),
+ true,
&mut cx.to_async(),
)
.await
@@ -758,6 +759,7 @@ mod tests {
true,
fs.clone(),
Default::default(),
+ true,
&mut cx.to_async(),
)
.await
@@ -816,6 +818,7 @@ mod tests {
true,
fs.clone(),
Default::default(),
+ true,
&mut cx.to_async(),
)
.await
@@ -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<ApiKeyState>,
+}
+
+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<Result<Option<EditPredictionResult>>> {
+ 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<Path> = 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::<String>()
+ .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<u8> = 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::<String>();
+ 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<EnvVar> = env_var!("MERCURY_AI_TOKEN");
+pub static MERCURY_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
+
+pub fn mercury_api_token(cx: &mut App) -> Entity<ApiKeyState> {
+ 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<Result<(), language_model::AuthenticateError>> {
+ mercury_api_token(cx).update(cx, |key_state, cx| {
+ key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx)
+ })
+}
@@ -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<EditPredictionOnboarding>,
@@ -121,8 +131,8 @@ impl Render for ZedPredictModal {
onboarding_event!("Cancelled", trigger = "Action");
cx.emit(DismissEvent);
}))
- .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
- this.focus_handle.focus(window);
+ .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+ this.focus_handle.focus(window, cx);
}))
.child(
div()
@@ -0,0 +1,31 @@
+pub fn text_from_response(mut res: open_ai::Response) -> Option<String> {
+ 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)
+}
@@ -1,7 +1,14 @@
-use std::{ops::Range, sync::Arc};
-
+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, OffsetRangeExt, TextBufferSnapshot};
+use language::{Anchor, Buffer, BufferSnapshot, EditPreview, TextBufferSnapshot};
+use zeta_prompt::ZetaPromptInput;
#[derive(Clone, Default, Debug, PartialEq, Eq, Hash)]
pub struct EditPredictionId(pub SharedString);
@@ -18,55 +25,92 @@ impl std::fmt::Display for EditPredictionId {
}
}
-#[derive(Clone)]
-pub struct EditPrediction {
+/// A prediction response that was returned from the provider, whether it was ultimately valid or not.
+pub struct EditPredictionResult {
pub id: EditPredictionId,
- pub edits: Arc<[(Range<Anchor>, Arc<str>)]>,
- 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.
- pub buffer: Entity<Buffer>,
+ pub prediction: Result<EditPrediction, EditPredictionRejectReason>,
}
-impl EditPrediction {
+impl EditPredictionResult {
pub async fn new(
id: EditPredictionId,
edited_buffer: &Entity<Buffer>,
edited_buffer_snapshot: &BufferSnapshot,
- edits: Vec<(Range<Anchor>, Arc<str>)>,
+ edits: Arc<[(Range<Anchor>, Arc<str>)]>,
+ buffer_snapshotted_at: Instant,
+ response_received_at: Instant,
+ inputs: ZetaPromptInput,
cx: &mut AsyncApp,
- ) -> Option<Self> {
- let (edits, snapshot, edit_preview_task) = edited_buffer
+ ) -> 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())?.into();
+ interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits)?.into();
Some((edits.clone(), new_snapshot, buffer.preview_edits(edits, cx)))
})
- .ok()??;
+ .ok()
+ .flatten()
+ else {
+ return Self {
+ id,
+ prediction: Err(EditPredictionRejectReason::InterpolatedEmpty),
+ };
+ };
let edit_preview = edit_preview_task.await;
- Some(EditPrediction {
- id,
- edits,
- snapshot,
- edit_preview,
- buffer: edited_buffer.clone(),
- })
+ 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<Anchor>, Arc<str>)]>,
+ pub snapshot: BufferSnapshot,
+ pub edit_preview: EditPreview,
+ pub buffer: Entity<Buffer>,
+ 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<Vec<(Range<Anchor>, Arc<str>)>> {
- interpolate_edits(&self.snapshot, new_snapshot, self.edits.clone())
+ 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 {
@@ -78,57 +122,14 @@ impl std::fmt::Debug for EditPrediction {
}
}
-pub fn interpolate_edits(
- old_snapshot: &TextBufferSnapshot,
- new_snapshot: &TextBufferSnapshot,
- current_edits: Arc<[(Range<Anchor>, Arc<str>)]>,
-) -> Option<Vec<(Range<Anchor>, Arc<str>)>> {
- let mut edits = Vec::new();
-
- let mut model_edits = current_edits.iter().peekable();
- for user_edit in new_snapshot.edits_since::<usize>(&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::<String>();
-
- 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) }
-}
-
#[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) {
@@ -147,6 +148,16 @@ mod tests {
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| {
@@ -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<ApiKeyState>,
+ pub debug_info: Arc<str>,
+}
+
+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<Result<Option<EditPredictionResult>>> {
+ 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<Path> = 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::<Vec<_>>();
+
+ 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::<Vec<_>>();
+
+ 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<u8> = 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<u8> = 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::<String>();
+ 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::<Vec<_>>();
+
+ 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<EnvVar> = env_var!("SWEEP_AI_TOKEN");
+pub static SWEEP_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
+
+pub fn sweep_api_token(cx: &mut App) -> Entity<ApiKeyState> {
+ 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<Result<(), language_model::AuthenticateError>> {
+ 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<str>,
+ pub repo_name: String,
+ pub branch: Option<String>,
+ pub file_path: Arc<Path>,
+ pub file_contents: String,
+ pub recent_changes: String,
+ pub cursor_position: usize,
+ pub original_file_contents: String,
+ pub file_chunks: Vec<FileChunk>,
+ pub retrieval_chunks: Vec<FileChunk>,
+ pub recent_user_actions: Vec<UserAction>,
+ 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<u64>,
+}
+
+#[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<serde_json::Value>,
+ #[allow(dead_code)]
+ pub finish_reason: Option<String>,
+ #[allow(dead_code)]
+ pub elapsed_time_ms: u64,
+ #[allow(dead_code)]
+ #[serde(default, rename = "completions")]
+ pub additional_completions: Vec<AdditionalCompletion>,
+}
+
+#[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<serde_json::Value>,
+ pub finish_reason: Option<String>,
+}
+
+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<str> {
+ 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()
+}
@@ -14,87 +14,48 @@ use anyhow::anyhow;
use collections::HashMap;
use gpui::AsyncApp;
use gpui::Entity;
-use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, TextBufferSnapshot};
-use project::Project;
+use language::{Anchor, Buffer, OffsetRangeExt as _, TextBufferSnapshot};
+use project::{Project, ProjectPath};
+use util::paths::PathStyle;
+use util::rel_path::RelPath;
-pub async fn parse_diff<'a>(
- diff_str: &'a str,
- get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range<Anchor>])> + Send,
-) -> Result<(&'a BufferSnapshot, Vec<(Range<Anchor>, Arc<str>)>)> {
- let mut diff = DiffParser::new(diff_str);
- let mut edited_buffer = None;
- let mut edits = Vec::new();
-
- while let Some(event) = diff.next()? {
- match event {
- DiffEvent::Hunk {
- path: file_path,
- hunk,
- } => {
- let (buffer, ranges) = match edited_buffer {
- None => {
- edited_buffer = get_buffer(&Path::new(file_path.as_ref()));
- edited_buffer
- .as_ref()
- .context("Model tried to edit a file that wasn't included")?
- }
- Some(ref current) => current,
- };
-
- edits.extend(
- resolve_hunk_edits_in_buffer(hunk, &buffer.text, ranges)
- .with_context(|| format!("Diff:\n{diff_str}"))?,
- );
- }
- DiffEvent::FileEnd { renamed_to } => {
- let (buffer, _) = edited_buffer
- .take()
- .expect("Got a FileEnd event before an Hunk event");
-
- if renamed_to.is_some() {
- anyhow::bail!("edit predictions cannot rename files");
- }
-
- if diff.next()?.is_some() {
- anyhow::bail!("Edited more than one file");
- }
-
- return Ok((buffer, edits));
- }
- }
- }
-
- Err(anyhow::anyhow!("No EOF"))
-}
-
-#[derive(Debug)]
-pub struct OpenedBuffers<'a>(#[allow(unused)] HashMap<Cow<'a, str>, Entity<Buffer>>);
+#[derive(Clone, Debug)]
+pub struct OpenedBuffers(#[allow(unused)] HashMap<String, Entity<Buffer>>);
#[must_use]
-pub async fn apply_diff<'a>(
- diff_str: &'a str,
+pub async fn apply_diff(
+ diff_str: &str,
project: &Entity<Project>,
cx: &mut AsyncApp,
-) -> Result<OpenedBuffers<'a>> {
+) -> Result<OpenedBuffers> {
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 =
- project
- .find_project_path(path.as_ref(), cx)
- .with_context(|| {
- format!("Failed to find worktree for new path: {}", path)
- })?;
+ 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, buffer);
+ included_files.insert(path.to_string(), buffer);
}
}
@@ -113,7 +74,7 @@ pub async fn apply_diff<'a>(
let (buffer, ranges) = match current_file {
None => {
let buffer = included_files
- .get_mut(&file_path)
+ .get_mut(file_path.as_ref())
.expect("Opened all files in diff");
current_file = Some((buffer, ranges.as_slice()));
@@ -133,7 +94,7 @@ pub async fn apply_diff<'a>(
DiffEvent::FileEnd { renamed_to } => {
let (buffer, _) = current_file
.take()
- .expect("Got a FileEnd event before an Hunk event");
+ .context("Got a FileEnd event before an Hunk event")?;
if let Some(renamed_to) = renamed_to {
project
@@ -167,6 +128,29 @@ pub async fn apply_diff<'a>(
Ok(OpenedBuffers(included_files))
}
+pub fn apply_diff_to_string(diff_str: &str, text: &str) -> Result<String> {
+ 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>,
@@ -391,10 +375,12 @@ impl<'a> DiffLine<'a> {
return Some(Self::HunkHeader(None));
}
- let (start_line_old, header) = header.strip_prefix('-')?.split_once(',')?;
- let mut parts = header.split_ascii_whitespace();
- let count_old = parts.next()?;
- let (start_line_new, count_new) = parts.next()?.strip_prefix('+')?.split_once(',')?;
+ 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::<u32>().ok()?.saturating_sub(1),
@@ -490,7 +476,6 @@ mod tests {
use super::*;
use gpui::TestAppContext;
use indoc::indoc;
- use language::Point;
use pretty_assertions::assert_eq;
use project::{FakeFs, Project};
use serde_json::json;
@@ -752,38 +737,38 @@ mod tests {
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
let diff = indoc! {r#"
- --- a/root/file1
- +++ b/root/file1
+ --- a/file1
+ +++ b/file1
one
two
-three
+3
four
five
- --- a/root/file1
- +++ b/root/file1
+ --- a/file1
+ +++ b/file1
3
-four
-five
+4
+5
- --- a/root/file1
- +++ b/root/file1
+ --- a/file1
+ +++ b/file1
-one
-two
3
4
- --- a/root/file2
- +++ b/root/file2
+ --- a/file2
+ +++ b/file2
+5
six
- --- a/root/file2
- +++ b/root/file2
+ --- a/file2
+ +++ b/file2
seven
+7.5
eight
- --- a/root/file2
- +++ b/root/file2
+ --- a/file2
+ +++ b/file2
ten
+11
"#};
@@ -815,137 +800,6 @@ mod tests {
});
}
- #[gpui::test]
- async fn test_apply_diff_non_unique(cx: &mut TestAppContext) {
- let fs = init_test(cx);
-
- let buffer_1_text = indoc! {r#"
- one
- two
- three
- four
- five
- one
- two
- three
- four
- five
- "# };
-
- fs.insert_tree(
- path!("/root"),
- json!({
- "file1": buffer_1_text,
- }),
- )
- .await;
-
- let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
- let buffer = project
- .update(cx, |project, cx| {
- project.open_local_buffer(path!("/root/file1"), cx)
- })
- .await
- .unwrap();
- let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
-
- let diff = indoc! {r#"
- --- a/root/file1
- +++ b/root/file1
- one
- two
- -three
- +3
- four
- five
- "#};
-
- let final_text = indoc! {r#"
- one
- two
- three
- four
- five
- one
- two
- 3
- four
- five
- "#};
-
- apply_diff(diff, &project, &mut cx.to_async())
- .await
- .expect_err("Non-unique edits should fail");
-
- let ranges = [buffer_snapshot.anchor_before(Point::new(1, 0))
- ..buffer_snapshot.anchor_after(buffer_snapshot.max_point())];
-
- let (edited_snapshot, edits) = parse_diff(diff, |_path| Some((&buffer_snapshot, &ranges)))
- .await
- .unwrap();
-
- assert_eq!(edited_snapshot.remote_id(), buffer_snapshot.remote_id());
- buffer.update(cx, |buffer, cx| {
- buffer.edit(edits, None, cx);
- assert_eq!(buffer.text(), final_text);
- });
- }
-
- #[gpui::test]
- async fn test_parse_diff_with_edits_within_line(cx: &mut TestAppContext) {
- let fs = init_test(cx);
-
- let buffer_1_text = indoc! {r#"
- one two three four
- five six seven eight
- nine ten eleven twelve
- "# };
-
- fs.insert_tree(
- path!("/root"),
- json!({
- "file1": buffer_1_text,
- }),
- )
- .await;
-
- let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
- let buffer = project
- .update(cx, |project, cx| {
- project.open_local_buffer(path!("/root/file1"), cx)
- })
- .await
- .unwrap();
- let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
-
- let diff = indoc! {r#"
- --- a/root/file1
- +++ b/root/file1
- one two three four
- -five six seven eight
- +five SIX seven eight!
- nine ten eleven twelve
- "#};
-
- let (buffer, edits) = parse_diff(diff, |_path| {
- Some((&buffer_snapshot, &[(Anchor::MIN..Anchor::MAX)] as &[_]))
- })
- .await
- .unwrap();
-
- let edits = edits
- .into_iter()
- .map(|(range, text)| (range.to_point(&buffer), text))
- .collect::<Vec<_>>();
- assert_eq!(
- edits,
- &[
- (Point::new(1, 5)..Point::new(1, 8), "SIX".into()),
- (Point::new(1, 20)..Point::new(1, 20), "!".into())
- ]
- );
- }
-
#[gpui::test]
async fn test_apply_diff_unique_via_previous_context(cx: &mut TestAppContext) {
let fs = init_test(cx);
@@ -983,8 +837,8 @@ mod tests {
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
let diff = indoc! {r#"
- --- a/root/file1
- +++ b/root/file1
+ --- a/file1
+ +++ b/file1
one
two
-three
@@ -0,0 +1,230 @@
+use std::{cmp, sync::Arc};
+
+use client::{Client, UserStore};
+use cloud_llm_client::EditPredictionRejectReason;
+use edit_prediction_types::{DataCollectionState, EditPredictionDelegate};
+use gpui::{App, Entity, prelude::*};
+use language::{Buffer, ToPoint as _};
+use project::Project;
+
+use crate::{BufferEditPrediction, EditPredictionModel, EditPredictionStore};
+
+pub struct ZedEditPredictionDelegate {
+ store: Entity<EditPredictionStore>,
+ project: Entity<Project>,
+ singleton_buffer: Option<Entity<Buffer>>,
+}
+
+impl ZedEditPredictionDelegate {
+ pub fn new(
+ project: Entity<Project>,
+ singleton_buffer: Option<Entity<Buffer>>,
+ client: &Arc<Client>,
+ user_store: &Entity<UserStore>,
+ cx: &mut Context<Self>,
+ ) -> 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<client::EditPredictionUsage> {
+ self.store.read(cx).usage(cx)
+ }
+
+ fn is_enabled(
+ &self,
+ _buffer: &Entity<language::Buffer>,
+ _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<language::Buffer>,
+ cursor_position: language::Anchor,
+ _debounce: bool,
+ cx: &mut Context<Self>,
+ ) {
+ 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 accept(&mut self, cx: &mut Context<Self>) {
+ self.store.update(cx, |store, cx| {
+ store.accept_current_prediction(&self.project, cx);
+ });
+ }
+
+ fn discard(&mut self, cx: &mut Context<Self>) {
+ self.store.update(cx, |store, _cx| {
+ store.reject_current_prediction(EditPredictionRejectReason::Discarded, &self.project);
+ });
+ }
+
+ fn did_show(&mut self, cx: &mut Context<Self>) {
+ self.store.update(cx, |store, cx| {
+ store.did_show_current_prediction(&self.project, cx);
+ });
+ }
+
+ fn suggest(
+ &mut self,
+ buffer: &Entity<language::Buffer>,
+ cursor_position: language::Anchor,
+ cx: &mut Context<Self>,
+ ) -> Option<edit_prediction_types::EditPrediction> {
+ 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()),
+ })
+ })
+ }
+}
@@ -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<EditPredictionStore>,
+) -> Task<Result<Option<EditPredictionResult>>> {
+ 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<Path> = 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::<PredictEditsResponse>(
+ |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::<String>()
+ .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::<ZedUpdateRequiredError>() {
+ 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::<ZedUpdateRequiredError>(),
+ 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::<u8>() <= 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<Buffer>,
+ snapshot: &BufferSnapshot,
+ editable_range: Range<usize>,
+ inputs: ZetaPromptInput,
+ buffer_snapshotted_at: Instant,
+ received_response_at: Instant,
+ cx: &AsyncApp,
+) -> Task<Result<EditPredictionResult>> {
+ 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<str> = output_excerpt.into();
+
+ let edits: Arc<[(Range<Anchor>, Arc<str>)]> = 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<str>,
+ editable_range: Range<usize>,
+ snapshot: &BufferSnapshot,
+) -> Result<Vec<(Range<Anchor>, Arc<str>)>> {
+ let content = output_excerpt.replace(CURSOR_MARKER, "");
+
+ let start_markers = content
+ .match_indices(EDITABLE_REGION_START_MARKER)
+ .collect::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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::<String>();
+
+ 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<Anchor>, Arc<str>)> {
+ 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<T1: Iterator<Item = char>, T2: Iterator<Item = char>>(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>,
+ project_path: &ProjectPath,
+ cx: &App,
+) -> Option<PredictEditsGitInfo> {
+ 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<Point>,
+ pub editable_range: Range<usize>,
+ 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<Result<GatherContextOutput>> {
+ 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<Event>], 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<Point>,
+ pub editable_range: Range<Point>,
+ 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<Point>,
+ 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<i32> {
+ 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<i32> {
+ <|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<i32> {
+ 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));
+ ```"#}
+ );
+ }
+}
@@ -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<EditPredictionStore>,
+) -> Task<Result<Option<EditPredictionResult>>> {
+ let buffer_snapshotted_at = Instant::now();
+
+ let Some(excerpt_path) = snapshot
+ .file()
+ .map(|file| -> Arc<Path> { 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::<String>();
+ 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<Arc<zeta_prompt::Event>>,
+ excerpt_path: Arc<Path>,
+ cursor_offset: usize,
+) -> (std::ops::Range<usize>, 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::<String>()
+ .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<String> {
+ 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())
+}
@@ -1,5 +1,5 @@
[package]
-name = "zeta_cli"
+name = "edit_prediction_cli"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
@@ -9,38 +9,37 @@ 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
-ordered-float.workspace = true
paths.workspace = true
-polars = { version = "0.51", features = ["lazy", "dtype-struct", "parquet"] }
project.workspace = true
prompt_store.workspace = true
-pulldown-cmark.workspace = true
release_channel.workspace = true
reqwest_client.workspace = true
serde.workspace = true
@@ -48,14 +47,22 @@ serde_json.workspace = true
settings.workspace = true
shellexpand.workspace = true
smol.workspace = true
-soa-rs = "0.8.1"
+sqlez.workspace = true
+sqlez_macros.workspace = true
terminal_view.workspace = true
-toml.workspace = true
util.workspace = true
watch.workspace = true
-zeta.workspace = true
-zeta2 = { workspace = true, features = ["llm-response-cache"] }
-zlog.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
@@ -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<dyn HttpClient>,
+ api_key: String,
+}
+
+impl PlainLlmClient {
+ fn new() -> Result<Self> {
+ let http_client: Arc<dyn http_client::HttpClient> = 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<Message>,
+ ) -> Result<AnthropicResponse> {
+ 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<dyn HttpClient>,
+ api_key: String,
+}
+
+struct CacheRow {
+ request_hash: String,
+ request: Option<String>,
+ response: Option<String>,
+ batch_id: Option<String>,
+}
+
+impl StaticColumnCount for CacheRow {
+ fn column_count() -> usize {
+ 4
+ }
+}
+
+impl Bind for CacheRow {
+ fn bind(&self, statement: &sqlez::statement::Statement, start_index: i32) -> Result<i32> {
+ 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<SerializableMessage>,
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+struct SerializableMessage {
+ role: String,
+ content: String,
+}
+
+impl BatchingLlmClient {
+ fn new(cache_path: &Path) -> Result<Self> {
+ let http_client: Arc<dyn http_client::HttpClient> = 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<Option<AnthropicResponse>> {
+ let request_hash_str = Self::request_hash(model, max_tokens, messages);
+ let response: Vec<String> = 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<SerializableMessage> = 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<Message>,
+ ) -> Result<Option<AnthropicResponse>> {
+ 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<String> = 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<String> {
+ 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<Message> = 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::<Vec<_>>();
+
+ 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::<Vec<String>>()
+ .join("\n")
+}
+
+pub enum AnthropicClient {
+ // No batching
+ Plain(PlainLlmClient),
+ Batch(BatchingLlmClient),
+ Dummy,
+}
+
+impl AnthropicClient {
+ pub fn plain() -> Result<Self> {
+ Ok(Self::Plain(PlainLlmClient::new()?))
+ }
+
+ pub fn batch(cache_path: &Path) -> Result<Self> {
+ 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<Message>,
+ ) -> Result<Option<AnthropicResponse>> {
+ 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"),
+ }
+ }
+}
@@ -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(())
+}
@@ -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<ExampleBuffer>,
+
+ /// 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<ExampleContext>,
+
+ /// The input and expected output from the edit prediction model.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub prompt: Option<ExamplePrompt>,
+
+ /// The actual predictions from the model.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub predictions: Vec<ExamplePrediction>,
+
+ /// The scores, for how well the actual predictions match the expected
+ /// predictions.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub score: Vec<ExampleScore>,
+
+ /// The application state used to process this example.
+ #[serde(skip)]
+ pub state: Option<ExampleState>,
+}
+
+#[derive(Clone, Debug)]
+pub struct ExampleState {
+ pub project: Entity<Project>,
+ pub buffer: Entity<Buffer>,
+ 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<Example> {
+ 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::<Example>(&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::<Example>(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::<Vec<Example>>(),
+ ),
+ "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<Vec<&mut Example>> {
+ 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<Example> {
+ let spec = ExampleSpec::from_markdown(name, input)?;
+ Ok(Example {
+ spec,
+ buffer: None,
+ context: None,
+ prompt: None,
+ predictions: Vec::new(),
+ score: Vec::new(),
+ state: None,
+ })
+}
@@ -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<EpAppState>,
+ 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<String> {
+ // 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::<Vec<_>>();
+
+ 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
+
+ "}
+ );
+ }
+}
@@ -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;
@@ -7,27 +8,46 @@ use gpui_tokio::Tokio;
use language::LanguageRegistry;
use language_extension::LspAccess;
use node_runtime::{NodeBinaryOptions, NodeRuntime};
-use project::project_settings::ProjectSettings;
-use release_channel::AppVersion;
+use project::{Project, project_settings::ProjectSettings};
+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<LanguageRegistry>,
pub client: Arc<Client>,
pub user_store: Entity<UserStore>,
pub fs: Arc<dyn fs::Fs>,
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<HashMap<String, Entity<Project>>>);
+
+impl ProjectCache {
+ pub fn insert(&self, repository_url: String, project: Entity<Project>) {
+ self.0.lock().unwrap().insert(repository_url, project);
+ }
+
+ pub fn get(&self, repository_url: &String) -> Option<Entity<Project>> {
+ 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());
@@ -106,11 +126,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,
}
}
@@ -0,0 +1,357 @@
+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<EpAppState>,
+ 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<Project>,
+ cx: &mut AsyncApp,
+) -> Result<(Entity<Buffer>, 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::<LanguageNotFound>()
+ {
+ 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<EpAppState>,
+ step_progress: &StepProgress,
+ cx: &mut AsyncApp,
+) -> Result<Entity<Project>> {
+ 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::<Vec<_>>()
+ })?;
+ 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,
+ false,
+ 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<PathBuf> {
+ 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<Project>,
+ cx: &mut AsyncApp,
+) -> Result<OpenedBuffers> {
+ edit_prediction::udiff::apply_diff(&example.spec.edit_history, project, cx).await
+}
+
+thread_local! {
+ static REPO_LOCKS: RefCell<HashMap<PathBuf, Arc<Mutex<()>>>> = RefCell::new(HashMap::default());
+}
+
+#[must_use]
+pub async fn lock_repo(path: impl AsRef<Path>) -> 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<String> {
+ 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())
+}
@@ -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<Command>,
+ #[clap(global = true)]
+ inputs: Vec<PathBuf>,
+ #[arg(long, short, global = true)]
+ output: Option<PathBuf>,
+ #[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<PathBuf> {
+ 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();
+ });
+}
@@ -0,0 +1,371 @@
+use collections::{HashMap, HashSet};
+use edit_prediction::udiff::DiffLine;
+use serde::{Deserialize, Serialize};
+
+type Counts = HashMap<String, usize>;
+type CountsDelta = HashMap<String, isize>;
+
+#[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<String>,
+ actual: &HashSet<String>,
+ ) -> 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<Item = &'a ClassificationMetrics>,
+ ) -> 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<Counts> {
+ // 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::<String>(),
+ };
+
+ (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<char> = 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);
+ }
+}
@@ -0,0 +1,27 @@
+use std::{
+ path::{Path, PathBuf},
+ sync::LazyLock,
+};
+
+pub static DATA_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
+ let dir = dirs::home_dir().unwrap().join(".zed_ep");
+ ensure_dir(&dir)
+});
+pub static CACHE_DIR: LazyLock<PathBuf> = LazyLock::new(|| ensure_dir(&DATA_DIR.join("cache")));
+pub static REPOS_DIR: LazyLock<PathBuf> = LazyLock::new(|| ensure_dir(&DATA_DIR.join("repos")));
+pub static WORKTREES_DIR: LazyLock<PathBuf> =
+ LazyLock::new(|| ensure_dir(&DATA_DIR.join("worktrees")));
+pub static RUN_DIR: LazyLock<PathBuf> = 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<PathBuf> = LazyLock::new(|| DATA_DIR.join("latest"));
+pub static LLM_CACHE_DB: LazyLock<PathBuf> = LazyLock::new(|| CACHE_DIR.join("llm_cache.sqlite"));
+pub static FAILED_EXAMPLES_DIR: LazyLock<PathBuf> =
+ 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()
+}
@@ -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<PredictionProvider>,
+ repetition_count: usize,
+ app_state: Arc<EpAppState>,
+ 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<Shared<Task<()>>> = 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::<Vec<String>>()
+ .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(())
+}
@@ -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<ProgressInner>,
+}
+
+struct ProgressInner {
+ completed: Vec<CompletedTask>,
+ in_progress: HashMap<String, InProgressTask>,
+ 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<String>,
+ 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<Arc<Progress>> = 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<Progress> {
+ 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<Self>, 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:<name_width$} {dim}│{reset} {info_part}",
+ color = task.step.color_code(),
+ label = task.step.label(),
+ name = task.example_name,
+ );
+
+ let duration_with_margin = format!("{duration} ");
+ let padding_needed = inner
+ .terminal_width
+ .saturating_sub(MARGIN)
+ .saturating_sub(duration_with_margin.len())
+ .saturating_sub(strip_ansi_len(&prefix));
+ let padding = " ".repeat(padding_needed);
+
+ eprintln!("{prefix}{padding}{dim}{duration_with_margin}{reset}");
+ } else {
+ let info_part = task
+ .info
+ .as_ref()
+ .map(|(s, _)| format!(" | {}", s))
+ .unwrap_or_default();
+
+ eprintln!(
+ "{label:>12} {name:<name_width$}{info_part} {duration}",
+ label = task.step.label(),
+ name = task.example_name,
+ );
+ }
+ }
+
+ fn print_status_lines(inner: &mut ProgressInner) {
+ if !inner.is_tty || inner.in_progress.is_empty() {
+ inner.status_lines_displayed = 0;
+ return;
+ }
+
+ let reset = "\x1b[0m";
+ let bold = "\x1b[1m";
+ let dim = "\x1b[2m";
+
+ // Build the done/in-progress/total label
+ let done_count = inner.completed.len();
+ let in_progress_count = inner.in_progress.len();
+ let failed_count = inner.failed_examples;
+
+ let failed_label = if failed_count > 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:<name_width$} {dim}│{reset} {substatus_part}",
+ name = name,
+ );
+
+ let duration_with_margin = format!("{elapsed} ");
+ let padding_needed = inner
+ .terminal_width
+ .saturating_sub(MARGIN)
+ .saturating_sub(duration_with_margin.len())
+ .saturating_sub(strip_ansi_len(&prefix));
+ let padding = " ".repeat(padding_needed);
+
+ eprintln!("{prefix}{padding}{dim}{duration_with_margin}{reset}");
+ lines_printed += 1;
+ }
+
+ // Show "+N more" on its own line if there are more tasks
+ if total_tasks > 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<Progress>,
+ step: Step,
+ example_name: String,
+}
+
+impl StepProgress {
+ pub fn set_substatus(&self, substatus: impl Into<Cow<'static, str>>) {
+ 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<String>, 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)
+ }
+}
@@ -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<EpAppState>,
+ 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<StepProgress> = 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<Project>,
+ buffer: &Entity<Buffer>,
+ step_progress: &Arc<StepProgress>,
+ 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::<HashSet<_>>();
+ (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(())
+}
@@ -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<EpAppState>,
+ 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<DiffLine<'_>> {
+ 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::<f32>() / 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])
+ }
+}
@@ -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}}
@@ -12,41 +12,33 @@ 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
-indoc.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
+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
settings = {workspace= true, features = ["test-support"]}
text = { workspace = true, features = ["test-support"] }
-tree-sitter-c.workspace = true
-tree-sitter-cpp.workspace = true
-tree-sitter-go.workspace = true
util = { workspace = true, features = ["test-support"] }
zlog.workspace = true
@@ -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<Range<Point>>,
+) -> Vec<RelatedExcerpt> {
+ 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<Point>,
+ inward: bool,
+ buffer: &BufferSnapshot,
+) -> Range<Point> {
+ 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<Point>,
+ body_range: Option<Range<Point>>,
+ buffer: &BufferSnapshot,
+ outline_ranges: &mut Vec<Range<Point>>,
+) {
+ 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<Range<Point>>) {
+ 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;
+ }
+ }
+}
@@ -1,350 +0,0 @@
-use cloud_llm_client::predict_edits_v3::{self, Line};
-use language::{Language, LanguageId};
-use project::ProjectEntryId;
-use std::ops::Range;
-use std::sync::Arc;
-use std::{borrow::Cow, path::Path};
-use text::{Bias, BufferId, Rope};
-use util::paths::{path_ends_with, strip_path_suffix};
-use util::rel_path::RelPath;
-
-use crate::outline::OutlineDeclaration;
-
-#[derive(Debug, Clone, Eq, PartialEq, Hash)]
-pub struct Identifier {
- pub name: Arc<str>,
- pub language_id: LanguageId,
-}
-
-slotmap::new_key_type! {
- pub struct DeclarationId;
-}
-
-#[derive(Debug, Clone)]
-pub enum Declaration {
- File {
- project_entry_id: ProjectEntryId,
- declaration: FileDeclaration,
- cached_path: CachedDeclarationPath,
- },
- Buffer {
- project_entry_id: ProjectEntryId,
- buffer_id: BufferId,
- rope: Rope,
- declaration: BufferDeclaration,
- cached_path: CachedDeclarationPath,
- },
-}
-
-const ITEM_TEXT_TRUNCATION_LENGTH: usize = 1024;
-
-impl Declaration {
- pub fn identifier(&self) -> &Identifier {
- match self {
- Declaration::File { declaration, .. } => &declaration.identifier,
- Declaration::Buffer { declaration, .. } => &declaration.identifier,
- }
- }
-
- pub fn parent(&self) -> Option<DeclarationId> {
- match self {
- Declaration::File { declaration, .. } => declaration.parent,
- Declaration::Buffer { declaration, .. } => declaration.parent,
- }
- }
-
- pub fn as_buffer(&self) -> Option<&BufferDeclaration> {
- match self {
- Declaration::File { .. } => None,
- Declaration::Buffer { declaration, .. } => Some(declaration),
- }
- }
-
- pub fn as_file(&self) -> Option<&FileDeclaration> {
- match self {
- Declaration::Buffer { .. } => None,
- Declaration::File { declaration, .. } => Some(declaration),
- }
- }
-
- pub fn project_entry_id(&self) -> ProjectEntryId {
- match self {
- Declaration::File {
- project_entry_id, ..
- } => *project_entry_id,
- Declaration::Buffer {
- project_entry_id, ..
- } => *project_entry_id,
- }
- }
-
- pub fn cached_path(&self) -> &CachedDeclarationPath {
- match self {
- Declaration::File { cached_path, .. } => cached_path,
- Declaration::Buffer { cached_path, .. } => cached_path,
- }
- }
-
- pub fn item_range(&self) -> Range<usize> {
- match self {
- Declaration::File { declaration, .. } => declaration.item_range.clone(),
- Declaration::Buffer { declaration, .. } => declaration.item_range.clone(),
- }
- }
-
- pub fn item_line_range(&self) -> Range<Line> {
- match self {
- Declaration::File { declaration, .. } => declaration.item_line_range.clone(),
- Declaration::Buffer {
- declaration, rope, ..
- } => {
- Line(rope.offset_to_point(declaration.item_range.start).row)
- ..Line(rope.offset_to_point(declaration.item_range.end).row)
- }
- }
- }
-
- pub fn item_text(&self) -> (Cow<'_, str>, bool) {
- match self {
- Declaration::File { declaration, .. } => (
- declaration.text.as_ref().into(),
- declaration.text_is_truncated,
- ),
- Declaration::Buffer {
- rope, declaration, ..
- } => (
- rope.chunks_in_range(declaration.item_range.clone())
- .collect::<Cow<str>>(),
- 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::<Cow<str>>(),
- declaration.signature_range_is_truncated,
- ),
- }
- }
-
- pub fn signature_range(&self) -> Range<usize> {
- match self {
- Declaration::File { declaration, .. } => declaration.signature_range.clone(),
- Declaration::Buffer { declaration, .. } => declaration.signature_range.clone(),
- }
- }
-
- pub fn signature_line_range(&self) -> Range<Line> {
- match self {
- Declaration::File { declaration, .. } => declaration.signature_line_range.clone(),
- Declaration::Buffer {
- declaration, rope, ..
- } => {
- Line(rope.offset_to_point(declaration.signature_range.start).row)
- ..Line(rope.offset_to_point(declaration.signature_range.end).row)
- }
- }
- }
-
- pub fn signature_range_in_item_text(&self) -> Range<usize> {
- 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<usize>,
- limit: usize,
- rope: &Rope,
-) -> (Range<usize>, Range<predict_edits_v3::Line>, bool) {
- let mut point_range = rope.offset_to_point(range.start)..rope.offset_to_point(range.end);
- point_range.start.column = 0;
- point_range.end.row += 1;
- point_range.end.column = 0;
-
- let mut item_range =
- rope.point_to_offset(point_range.start)..rope.point_to_offset(point_range.end);
- let is_truncated = item_range.len() > limit;
- if is_truncated {
- item_range.end = item_range.start + limit;
- }
- item_range.end = rope.clip_offset(item_range.end, Bias::Left);
-
- let line_range =
- predict_edits_v3::Line(point_range.start.row)..predict_edits_v3::Line(point_range.end.row);
- (item_range, line_range, is_truncated)
-}
-
-#[derive(Debug, Clone)]
-pub struct FileDeclaration {
- pub parent: Option<DeclarationId>,
- pub identifier: Identifier,
- /// offset range of the declaration in the file, expanded to line boundaries and truncated
- pub item_range: Range<usize>,
- /// line range of the declaration in the file, potentially truncated
- pub item_line_range: Range<predict_edits_v3::Line>,
- /// text of `item_range`
- pub text: Arc<str>,
- /// 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<usize>,
- /// line range of the signature in the file, truncated
- pub signature_line_range: Range<Line>,
- /// whether `signature` was truncated
- pub signature_is_truncated: bool,
-}
-
-impl FileDeclaration {
- pub fn from_outline(declaration: OutlineDeclaration, rope: &Rope) -> FileDeclaration {
- let (item_range_in_file, item_line_range_in_file, text_is_truncated) =
- expand_range_to_line_boundaries_and_truncate(
- &declaration.item_range,
- ITEM_TEXT_TRUNCATION_LENGTH,
- rope,
- );
-
- let (mut signature_range_in_file, signature_line_range, mut signature_is_truncated) =
- expand_range_to_line_boundaries_and_truncate(
- &declaration.signature_range,
- ITEM_TEXT_TRUNCATION_LENGTH,
- rope,
- );
-
- if signature_range_in_file.start < item_range_in_file.start {
- signature_range_in_file.start = item_range_in_file.start;
- signature_is_truncated = true;
- }
- if signature_range_in_file.end > item_range_in_file.end {
- signature_range_in_file.end = item_range_in_file.end;
- signature_is_truncated = true;
- }
-
- FileDeclaration {
- parent: None,
- identifier: declaration.identifier,
- signature_range: signature_range_in_file,
- signature_line_range,
- signature_is_truncated,
- text: rope
- .chunks_in_range(item_range_in_file.clone())
- .collect::<String>()
- .into(),
- text_is_truncated,
- item_range: item_range_in_file,
- item_line_range: item_line_range_in_file,
- }
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct BufferDeclaration {
- pub parent: Option<DeclarationId>,
- pub identifier: Identifier,
- pub item_range: Range<usize>,
- pub item_range_is_truncated: bool,
- pub signature_range: Range<usize>,
- pub signature_range_is_truncated: bool,
-}
-
-impl BufferDeclaration {
- pub fn from_outline(declaration: OutlineDeclaration, rope: &Rope) -> Self {
- let (item_range, _item_line_range, item_range_is_truncated) =
- expand_range_to_line_boundaries_and_truncate(
- &declaration.item_range,
- ITEM_TEXT_TRUNCATION_LENGTH,
- rope,
- );
- let (signature_range, _signature_line_range, signature_range_is_truncated) =
- expand_range_to_line_boundaries_and_truncate(
- &declaration.signature_range,
- ITEM_TEXT_TRUNCATION_LENGTH,
- rope,
- );
- Self {
- parent: None,
- identifier: declaration.identifier,
- item_range,
- item_range_is_truncated,
- signature_range,
- signature_range_is_truncated,
- }
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct CachedDeclarationPath {
- pub worktree_abs_path: Arc<Path>,
- pub rel_path: Arc<RelPath>,
- /// The relative path of the file, possibly stripped according to `import_path_strip_regex`.
- pub rel_path_after_regex_stripping: Arc<RelPath>,
-}
-
-impl CachedDeclarationPath {
- pub fn new(
- worktree_abs_path: Arc<Path>,
- path: &Arc<RelPath>,
- language: Option<&Arc<Language>>,
- ) -> Self {
- let rel_path = path.clone();
- let rel_path_after_regex_stripping = if let Some(language) = language
- && let Some(strip_regex) = language.config().import_path_strip_regex.as_ref()
- && let Ok(stripped) = RelPath::unix(&Path::new(
- strip_regex.replace_all(rel_path.as_unix_str(), "").as_ref(),
- )) {
- Arc::from(stripped)
- } else {
- rel_path.clone()
- };
- CachedDeclarationPath {
- worktree_abs_path,
- rel_path,
- rel_path_after_regex_stripping,
- }
- }
-
- #[cfg(test)]
- pub fn new_for_test(worktree_abs_path: &str, rel_path: &str) -> Self {
- let rel_path: Arc<RelPath> = util::rel_path::rel_path(rel_path).into();
- CachedDeclarationPath {
- worktree_abs_path: std::path::PathBuf::from(worktree_abs_path).into(),
- rel_path_after_regex_stripping: rel_path.clone(),
- rel_path,
- }
- }
-
- pub fn ends_with_posix_path(&self, path: &Path) -> bool {
- if path.as_os_str().len() <= self.rel_path_after_regex_stripping.as_unix_str().len() {
- path_ends_with(self.rel_path_after_regex_stripping.as_std_path(), path)
- } else {
- if let Some(remaining) =
- strip_path_suffix(path, self.rel_path_after_regex_stripping.as_std_path())
- {
- path_ends_with(&self.worktree_abs_path, remaining)
- } else {
- false
- }
- }
- }
-
- pub fn equals_absolute_path(&self, path: &Path) -> bool {
- if let Some(remaining) =
- strip_path_suffix(path, &self.rel_path_after_regex_stripping.as_std_path())
- {
- self.worktree_abs_path.as_ref() == remaining
- } else {
- false
- }
- }
-}
@@ -1,539 +0,0 @@
-use cloud_llm_client::predict_edits_v3::DeclarationScoreComponents;
-use collections::HashMap;
-use language::BufferSnapshot;
-use ordered_float::OrderedFloat;
-use project::ProjectEntryId;
-use serde::Serialize;
-use std::{cmp::Reverse, ops::Range, path::Path, sync::Arc};
-use strum::EnumIter;
-use text::{Point, ToPoint};
-use util::RangeExt as _;
-
-use crate::{
- CachedDeclarationPath, Declaration, EditPredictionExcerpt, Identifier,
- imports::{Import, Imports, Module},
- reference::{Reference, ReferenceRegion},
- syntax_index::SyntaxIndexState,
- text_similarity::{Occurrences, jaccard_similarity, weighted_overlap_coefficient},
-};
-
-const MAX_IDENTIFIER_DECLARATION_COUNT: usize = 16;
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct EditPredictionScoreOptions {
- pub omit_excerpt_overlaps: bool,
-}
-
-#[derive(Clone, Debug)]
-pub struct ScoredDeclaration {
- /// identifier used by the local reference
- pub identifier: Identifier,
- pub declaration: Declaration,
- pub components: DeclarationScoreComponents,
-}
-
-#[derive(EnumIter, Clone, Copy, PartialEq, Eq, Hash, Debug)]
-pub enum DeclarationStyle {
- Signature,
- Declaration,
-}
-
-#[derive(Clone, Debug, Serialize, Default)]
-pub struct DeclarationScores {
- pub signature: f32,
- pub declaration: f32,
- pub retrieval: f32,
-}
-
-impl ScoredDeclaration {
- /// Returns the score for this declaration with the specified style.
- pub fn score(&self, style: DeclarationStyle) -> f32 {
- // TODO: handle truncation
-
- // Score related to how likely this is the correct declaration, range 0 to 1
- let retrieval = self.retrieval_score();
-
- // Score related to the distance between the reference and cursor, range 0 to 1
- let distance_score = if self.components.is_referenced_nearby {
- 1.0 / (1.0 + self.components.reference_line_distance as f32 / 10.0).powf(2.0)
- } else {
- // same score as ~14 lines away, rationale is to not overly penalize references from parent signatures
- 0.5
- };
-
- // For now instead of linear combination, the scores are just multiplied together.
- let combined_score = 10.0 * retrieval * distance_score;
-
- match style {
- DeclarationStyle::Signature => {
- combined_score * self.components.excerpt_vs_signature_weighted_overlap
- }
- DeclarationStyle::Declaration => {
- 2.0 * combined_score * self.components.excerpt_vs_item_weighted_overlap
- }
- }
- }
-
- pub fn retrieval_score(&self) -> f32 {
- let mut score = if self.components.is_same_file {
- 10.0 / self.components.same_file_declaration_count as f32
- } else if self.components.path_import_match_count > 0 {
- 3.0
- } else if self.components.wildcard_path_import_match_count > 0 {
- 1.0
- } else if self.components.normalized_import_similarity > 0.0 {
- self.components.normalized_import_similarity
- } else if self.components.normalized_wildcard_import_similarity > 0.0 {
- 0.5 * self.components.normalized_wildcard_import_similarity
- } else {
- 1.0 / self.components.declaration_count as f32
- };
- score *= 1. + self.components.included_by_others as f32 / 2.;
- score *= 1. + self.components.includes_others as f32 / 4.;
- score
- }
-
- pub fn size(&self, style: DeclarationStyle) -> usize {
- match &self.declaration {
- Declaration::File { declaration, .. } => match style {
- DeclarationStyle::Signature => declaration.signature_range.len(),
- DeclarationStyle::Declaration => declaration.text.len(),
- },
- Declaration::Buffer { declaration, .. } => match style {
- DeclarationStyle::Signature => declaration.signature_range.len(),
- DeclarationStyle::Declaration => declaration.item_range.len(),
- },
- }
- }
-
- pub fn score_density(&self, style: DeclarationStyle) -> f32 {
- self.score(style) / self.size(style) as f32
- }
-}
-
-pub fn scored_declarations(
- options: &EditPredictionScoreOptions,
- index: &SyntaxIndexState,
- excerpt: &EditPredictionExcerpt,
- excerpt_occurrences: &Occurrences,
- adjacent_occurrences: &Occurrences,
- imports: &Imports,
- identifier_to_references: HashMap<Identifier, Vec<Reference>>,
- cursor_offset: usize,
- current_buffer: &BufferSnapshot,
-) -> Vec<ScoredDeclaration> {
- let cursor_point = cursor_offset.to_point(¤t_buffer);
-
- let mut wildcard_import_occurrences = Vec::new();
- let mut wildcard_import_paths = Vec::new();
- for wildcard_import in imports.wildcard_modules.iter() {
- match wildcard_import {
- Module::Namespace(namespace) => {
- wildcard_import_occurrences.push(namespace.occurrences())
- }
- Module::SourceExact(path) => wildcard_import_paths.push(path),
- Module::SourceFuzzy(path) => {
- wildcard_import_occurrences.push(Occurrences::from_path(&path))
- }
- }
- }
-
- let mut scored_declarations = Vec::new();
- let mut project_entry_id_to_outline_ranges: HashMap<ProjectEntryId, Vec<Range<usize>>> =
- HashMap::default();
- for (identifier, references) in identifier_to_references {
- let mut import_occurrences = Vec::new();
- let mut import_paths = Vec::new();
- let mut found_external_identifier: Option<&Identifier> = None;
-
- if let Some(imports) = imports.identifier_to_imports.get(&identifier) {
- // only use alias when it's the only import, could be generalized if some language
- // has overlapping aliases
- //
- // TODO: when an aliased declaration is included in the prompt, should include the
- // aliasing in the prompt.
- //
- // TODO: For SourceFuzzy consider having componentwise comparison that pays
- // attention to ordering.
- if let [
- Import::Alias {
- module,
- external_identifier,
- },
- ] = imports.as_slice()
- {
- match module {
- Module::Namespace(namespace) => {
- import_occurrences.push(namespace.occurrences())
- }
- Module::SourceExact(path) => import_paths.push(path),
- Module::SourceFuzzy(path) => {
- import_occurrences.push(Occurrences::from_path(&path))
- }
- }
- found_external_identifier = Some(&external_identifier);
- } else {
- for import in imports {
- match import {
- Import::Direct { module } => match module {
- Module::Namespace(namespace) => {
- import_occurrences.push(namespace.occurrences())
- }
- Module::SourceExact(path) => import_paths.push(path),
- Module::SourceFuzzy(path) => {
- import_occurrences.push(Occurrences::from_path(&path))
- }
- },
- Import::Alias { .. } => {}
- }
- }
- }
- }
-
- let identifier_to_lookup = found_external_identifier.unwrap_or(&identifier);
- // TODO: update this to be able to return more declarations? Especially if there is the
- // ability to quickly filter a large list (based on imports)
- let identifier_declarations = index
- .declarations_for_identifier::<MAX_IDENTIFIER_DECLARATION_COUNT>(&identifier_to_lookup);
- let declaration_count = identifier_declarations.len();
-
- if declaration_count == 0 {
- continue;
- }
-
- // TODO: option to filter out other candidates when same file / import match
- let mut checked_declarations = Vec::with_capacity(declaration_count);
- for (declaration_id, declaration) in identifier_declarations {
- match declaration {
- Declaration::Buffer {
- buffer_id,
- declaration: buffer_declaration,
- ..
- } => {
- if buffer_id == ¤t_buffer.remote_id() {
- let already_included_in_prompt =
- range_intersection(&buffer_declaration.item_range, &excerpt.range)
- .is_some()
- || excerpt
- .parent_declarations
- .iter()
- .any(|(excerpt_parent, _)| excerpt_parent == &declaration_id);
- if !options.omit_excerpt_overlaps || !already_included_in_prompt {
- let declaration_line = buffer_declaration
- .item_range
- .start
- .to_point(current_buffer)
- .row;
- let declaration_line_distance =
- (cursor_point.row as i32 - declaration_line as i32).unsigned_abs();
- checked_declarations.push(CheckedDeclaration {
- declaration,
- same_file_line_distance: Some(declaration_line_distance),
- path_import_match_count: 0,
- wildcard_path_import_match_count: 0,
- });
- }
- continue;
- } else {
- }
- }
- Declaration::File { .. } => {}
- }
- let declaration_path = declaration.cached_path();
- let path_import_match_count = import_paths
- .iter()
- .filter(|import_path| {
- declaration_path_matches_import(&declaration_path, import_path)
- })
- .count();
- let wildcard_path_import_match_count = wildcard_import_paths
- .iter()
- .filter(|import_path| {
- declaration_path_matches_import(&declaration_path, import_path)
- })
- .count();
- checked_declarations.push(CheckedDeclaration {
- declaration,
- same_file_line_distance: None,
- path_import_match_count,
- wildcard_path_import_match_count,
- });
- }
-
- let mut max_import_similarity = 0.0;
- let mut max_wildcard_import_similarity = 0.0;
-
- let mut scored_declarations_for_identifier = Vec::with_capacity(checked_declarations.len());
- for checked_declaration in checked_declarations {
- let same_file_declaration_count =
- index.file_declaration_count(checked_declaration.declaration);
-
- let declaration = score_declaration(
- &identifier,
- &references,
- checked_declaration,
- same_file_declaration_count,
- declaration_count,
- &excerpt_occurrences,
- &adjacent_occurrences,
- &import_occurrences,
- &wildcard_import_occurrences,
- cursor_point,
- current_buffer,
- );
-
- if declaration.components.import_similarity > max_import_similarity {
- max_import_similarity = declaration.components.import_similarity;
- }
-
- if declaration.components.wildcard_import_similarity > max_wildcard_import_similarity {
- max_wildcard_import_similarity = declaration.components.wildcard_import_similarity;
- }
-
- project_entry_id_to_outline_ranges
- .entry(declaration.declaration.project_entry_id())
- .or_default()
- .push(declaration.declaration.item_range());
- scored_declarations_for_identifier.push(declaration);
- }
-
- if max_import_similarity > 0.0 || max_wildcard_import_similarity > 0.0 {
- for declaration in scored_declarations_for_identifier.iter_mut() {
- if max_import_similarity > 0.0 {
- declaration.components.max_import_similarity = max_import_similarity;
- declaration.components.normalized_import_similarity =
- declaration.components.import_similarity / max_import_similarity;
- }
- if max_wildcard_import_similarity > 0.0 {
- declaration.components.normalized_wildcard_import_similarity =
- declaration.components.wildcard_import_similarity
- / max_wildcard_import_similarity;
- }
- }
- }
-
- scored_declarations.extend(scored_declarations_for_identifier);
- }
-
- // TODO: Inform this via import / retrieval scores of outline items
- // TODO: Consider using a sweepline
- for scored_declaration in scored_declarations.iter_mut() {
- let project_entry_id = scored_declaration.declaration.project_entry_id();
- let Some(ranges) = project_entry_id_to_outline_ranges.get(&project_entry_id) else {
- continue;
- };
- for range in ranges {
- if range.contains_inclusive(&scored_declaration.declaration.item_range()) {
- scored_declaration.components.included_by_others += 1
- } else if scored_declaration
- .declaration
- .item_range()
- .contains_inclusive(range)
- {
- scored_declaration.components.includes_others += 1
- }
- }
- }
-
- scored_declarations.sort_unstable_by_key(|declaration| {
- Reverse(OrderedFloat(
- declaration.score(DeclarationStyle::Declaration),
- ))
- });
-
- scored_declarations
-}
-
-struct CheckedDeclaration<'a> {
- declaration: &'a Declaration,
- same_file_line_distance: Option<u32>,
- path_import_match_count: usize,
- wildcard_path_import_match_count: usize,
-}
-
-fn declaration_path_matches_import(
- declaration_path: &CachedDeclarationPath,
- import_path: &Arc<Path>,
-) -> bool {
- if import_path.is_absolute() {
- declaration_path.equals_absolute_path(import_path)
- } else {
- declaration_path.ends_with_posix_path(import_path)
- }
-}
-
-fn range_intersection<T: Ord + Clone>(a: &Range<T>, b: &Range<T>) -> Option<Range<T>> {
- let start = a.start.clone().max(b.start.clone());
- let end = a.end.clone().min(b.end.clone());
- if start < end {
- Some(Range { start, end })
- } else {
- None
- }
-}
-
-fn score_declaration(
- identifier: &Identifier,
- references: &[Reference],
- checked_declaration: CheckedDeclaration,
- same_file_declaration_count: usize,
- declaration_count: usize,
- excerpt_occurrences: &Occurrences,
- adjacent_occurrences: &Occurrences,
- import_occurrences: &[Occurrences],
- wildcard_import_occurrences: &[Occurrences],
- cursor: Point,
- current_buffer: &BufferSnapshot,
-) -> ScoredDeclaration {
- let CheckedDeclaration {
- declaration,
- same_file_line_distance,
- path_import_match_count,
- wildcard_path_import_match_count,
- } = checked_declaration;
-
- let is_referenced_nearby = references
- .iter()
- .any(|r| r.region == ReferenceRegion::Nearby);
- let is_referenced_in_breadcrumb = references
- .iter()
- .any(|r| r.region == ReferenceRegion::Breadcrumb);
- let reference_count = references.len();
- let reference_line_distance = references
- .iter()
- .map(|r| {
- let reference_line = r.range.start.to_point(current_buffer).row as i32;
- (cursor.row as i32 - reference_line).unsigned_abs()
- })
- .min()
- .unwrap();
-
- let is_same_file = same_file_line_distance.is_some();
- let declaration_line_distance = same_file_line_distance.unwrap_or(u32::MAX);
-
- let item_source_occurrences = Occurrences::within_string(&declaration.item_text().0);
- let item_signature_occurrences = Occurrences::within_string(&declaration.signature_text().0);
- let excerpt_vs_item_jaccard = jaccard_similarity(excerpt_occurrences, &item_source_occurrences);
- let excerpt_vs_signature_jaccard =
- jaccard_similarity(excerpt_occurrences, &item_signature_occurrences);
- let adjacent_vs_item_jaccard =
- jaccard_similarity(adjacent_occurrences, &item_source_occurrences);
- let adjacent_vs_signature_jaccard =
- jaccard_similarity(adjacent_occurrences, &item_signature_occurrences);
-
- let excerpt_vs_item_weighted_overlap =
- weighted_overlap_coefficient(excerpt_occurrences, &item_source_occurrences);
- let excerpt_vs_signature_weighted_overlap =
- weighted_overlap_coefficient(excerpt_occurrences, &item_signature_occurrences);
- let adjacent_vs_item_weighted_overlap =
- weighted_overlap_coefficient(adjacent_occurrences, &item_source_occurrences);
- let adjacent_vs_signature_weighted_overlap =
- weighted_overlap_coefficient(adjacent_occurrences, &item_signature_occurrences);
-
- let mut import_similarity = 0f32;
- let mut wildcard_import_similarity = 0f32;
- if !import_occurrences.is_empty() || !wildcard_import_occurrences.is_empty() {
- let cached_path = declaration.cached_path();
- let path_occurrences = Occurrences::from_worktree_path(
- cached_path
- .worktree_abs_path
- .file_name()
- .map(|f| f.to_string_lossy()),
- &cached_path.rel_path,
- );
- import_similarity = import_occurrences
- .iter()
- .map(|namespace_occurrences| {
- OrderedFloat(jaccard_similarity(namespace_occurrences, &path_occurrences))
- })
- .max()
- .map(|similarity| similarity.into_inner())
- .unwrap_or_default();
-
- // TODO: Consider something other than max
- wildcard_import_similarity = wildcard_import_occurrences
- .iter()
- .map(|namespace_occurrences| {
- OrderedFloat(jaccard_similarity(namespace_occurrences, &path_occurrences))
- })
- .max()
- .map(|similarity| similarity.into_inner())
- .unwrap_or_default();
- }
-
- // TODO: Consider adding declaration_file_count
- let score_components = DeclarationScoreComponents {
- is_same_file,
- is_referenced_nearby,
- is_referenced_in_breadcrumb,
- reference_line_distance,
- declaration_line_distance,
- reference_count,
- same_file_declaration_count,
- declaration_count,
- excerpt_vs_item_jaccard,
- excerpt_vs_signature_jaccard,
- adjacent_vs_item_jaccard,
- adjacent_vs_signature_jaccard,
- excerpt_vs_item_weighted_overlap,
- excerpt_vs_signature_weighted_overlap,
- adjacent_vs_item_weighted_overlap,
- adjacent_vs_signature_weighted_overlap,
- path_import_match_count,
- wildcard_path_import_match_count,
- import_similarity,
- max_import_similarity: 0.0,
- normalized_import_similarity: 0.0,
- wildcard_import_similarity,
- normalized_wildcard_import_similarity: 0.0,
- included_by_others: 0,
- includes_others: 0,
- };
-
- ScoredDeclaration {
- identifier: identifier.clone(),
- declaration: declaration.clone(),
- components: score_components,
- }
-}
-
-#[cfg(test)]
-mod test {
- use super::*;
-
- #[test]
- fn test_declaration_path_matches() {
- let declaration_path =
- CachedDeclarationPath::new_for_test("/home/user/project", "src/maths.ts");
-
- assert!(declaration_path_matches_import(
- &declaration_path,
- &Path::new("maths.ts").into()
- ));
-
- assert!(declaration_path_matches_import(
- &declaration_path,
- &Path::new("project/src/maths.ts").into()
- ));
-
- assert!(declaration_path_matches_import(
- &declaration_path,
- &Path::new("user/project/src/maths.ts").into()
- ));
-
- assert!(declaration_path_matches_import(
- &declaration_path,
- &Path::new("/home/user/project/src/maths.ts").into()
- ));
-
- assert!(!declaration_path_matches_import(
- &declaration_path,
- &Path::new("other.ts").into()
- ));
-
- assert!(!declaration_path_matches_import(
- &declaration_path,
- &Path::new("/home/user/project/src/other.ts").into()
- ));
- }
-}
@@ -1,335 +1,474 @@
-mod declaration;
-mod declaration_scoring;
+use crate::assemble_excerpts::assemble_excerpts;
+use anyhow::Result;
+use collections::HashMap;
+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};
+
+mod assemble_excerpts;
+#[cfg(test)]
+mod edit_prediction_context_tests;
mod excerpt;
-mod imports;
-mod outline;
-mod reference;
-mod syntax_index;
-pub mod text_similarity;
+#[cfg(test)]
+mod fake_definition_lsp;
-use std::{path::Path, sync::Arc};
+pub use cloud_llm_client::predict_edits_v3::Line;
+pub use excerpt::{EditPredictionExcerpt, EditPredictionExcerptOptions, EditPredictionExcerptText};
+pub use zeta_prompt::{RelatedExcerpt, RelatedFile};
-use cloud_llm_client::predict_edits_v3;
-use collections::HashMap;
-use gpui::{App, AppContext as _, Entity, Task};
-use language::BufferSnapshot;
-use text::{Point, ToOffset as _};
-
-pub use declaration::*;
-pub use declaration_scoring::*;
-pub use excerpt::*;
-pub use imports::*;
-pub use reference::*;
-pub use syntax_index::*;
-
-pub use predict_edits_v3::Line;
-
-#[derive(Clone, Debug, PartialEq)]
-pub struct EditPredictionContextOptions {
- pub use_imports: bool,
- pub excerpt: EditPredictionExcerptOptions,
- pub score: EditPredictionScoreOptions,
- pub max_retrieved_declarations: u8,
+const IDENTIFIER_LINE_COUNT: u32 = 3;
+
+pub struct RelatedExcerptStore {
+ project: WeakEntity<Project>,
+ related_files: Arc<[RelatedFile]>,
+ related_file_buffers: Vec<Entity<Buffer>>,
+ cache: HashMap<Identifier, Arc<CacheEntry>>,
+ update_tx: mpsc::UnboundedSender<(Entity<Buffer>, Anchor)>,
+ identifier_line_count: u32,
+}
+
+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<Anchor>,
+}
+
+enum DefinitionTask {
+ CacheHit(Arc<CacheEntry>),
+ CacheMiss(Task<Result<Option<Vec<LocationLink>>>>),
+}
+
+#[derive(Debug)]
+struct CacheEntry {
+ definitions: SmallVec<[CachedDefinition; 1]>,
}
#[derive(Clone, Debug)]
-pub struct EditPredictionContext {
- pub excerpt: EditPredictionExcerpt,
- pub excerpt_text: EditPredictionExcerptText,
- pub cursor_point: Point,
- pub declarations: Vec<ScoredDeclaration>,
+struct CachedDefinition {
+ path: ProjectPath,
+ buffer: Entity<Buffer>,
+ anchor_range: Range<Anchor>,
}
-impl EditPredictionContext {
- pub fn gather_context_in_background(
- cursor_point: Point,
- buffer: BufferSnapshot,
- options: EditPredictionContextOptions,
- syntax_index: Option<Entity<SyntaxIndex>>,
- cx: &mut App,
- ) -> Task<Option<Self>> {
- let parent_abs_path = project::File::from_dyn(buffer.file()).and_then(|f| {
- let mut path = f.worktree.read(cx).absolutize(&f.path);
- if path.pop() { Some(path) } else { None }
- });
+const DEBOUNCE_DURATION: Duration = Duration::from_millis(100);
+
+impl EventEmitter<RelatedExcerptStoreEvent> for RelatedExcerptStore {}
+
+impl RelatedExcerptStore {
+ pub fn new(project: &Entity<Project>, cx: &mut Context<Self>) -> Self {
+ let (update_tx, mut update_rx) = mpsc::unbounded::<(Entity<Buffer>, 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,
+ }
+ }
- if let Some(syntax_index) = syntax_index {
- let index_state =
- syntax_index.read_with(cx, |index, _cx| Arc::downgrade(index.state()));
- cx.background_spawn(async move {
- let parent_abs_path = parent_abs_path.as_deref();
- let index_state = index_state.upgrade()?;
- let index_state = index_state.lock().await;
- Self::gather_context(
- cursor_point,
- &buffer,
- parent_abs_path,
- &options,
- Some(&index_state),
- )
- })
- } else {
- cx.background_spawn(async move {
- let parent_abs_path = parent_abs_path.as_deref();
- Self::gather_context(cursor_point, &buffer, parent_abs_path, &options, None)
- })
+ 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,
- parent_abs_path: Option<&Path>,
- options: &EditPredictionContextOptions,
- index_state: Option<&SyntaxIndexState>,
- ) -> Option<Self> {
- let imports = if options.use_imports {
- Imports::gather(&buffer, parent_abs_path)
- } else {
- Imports::default()
- };
- Self::gather_context_with_references_fn(
- cursor_point,
- buffer,
- &imports,
- options,
- index_state,
- references_in_excerpt,
- )
+ pub fn 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,
- imports: &Imports,
- options: &EditPredictionContextOptions,
- index_state: Option<&SyntaxIndexState>,
- get_references: impl FnOnce(
- &EditPredictionExcerpt,
- &EditPredictionExcerptText,
- &BufferSnapshot,
- ) -> HashMap<Identifier, Vec<Reference>>,
- ) -> Option<Self> {
- let excerpt = EditPredictionExcerpt::select_from_buffer(
- cursor_point,
- buffer,
- &options.excerpt,
- index_state,
- )?;
- let excerpt_text = excerpt.text(buffer);
-
- let declarations = if options.max_retrieved_declarations > 0
- && let Some(index_state) = index_state
- {
- let excerpt_occurrences =
- text_similarity::Occurrences::within_string(&excerpt_text.body);
-
- let adjacent_start = Point::new(cursor_point.row.saturating_sub(2), 0);
- let adjacent_end = Point::new(cursor_point.row + 1, 0);
- let adjacent_occurrences = text_similarity::Occurrences::within_string(
- &buffer
- .text_for_range(adjacent_start..adjacent_end)
- .collect::<String>(),
- );
+ pub fn refresh(&mut self, buffer: Entity<Buffer>, position: Anchor, _: &mut Context<Self>) {
+ self.update_tx.unbounded_send((buffer, position)).ok();
+ }
- let cursor_offset_in_file = cursor_point.to_offset(buffer);
+ pub fn related_files(&self) -> Arc<[RelatedFile]> {
+ self.related_files.clone()
+ }
- let references = get_references(&excerpt, &excerpt_text, buffer);
+ pub fn related_files_with_buffers(
+ &self,
+ ) -> impl Iterator<Item = (RelatedFile, Entity<Buffer>)> {
+ self.related_files
+ .iter()
+ .cloned()
+ .zip(self.related_file_buffers.iter().cloned())
+ }
- let mut declarations = scored_declarations(
- &options.score,
- &index_state,
- &excerpt,
- &excerpt_occurrences,
- &adjacent_occurrences,
- &imports,
- references,
- cursor_offset_in_file,
- buffer,
- );
- // TODO [zeta2] if we need this when we ship, we should probably do it in a smarter way
- declarations.truncate(options.max_retrieved_declarations as usize);
- declarations
- } else {
- vec![]
+ pub fn set_related_files(&mut self, files: Vec<RelatedFile>) {
+ self.related_files = files.into();
+ }
+
+ async fn fetch_excerpts(
+ this: WeakEntity<Self>,
+ buffer: Entity<Buffer>,
+ 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,
+ )
+ })?;
+ let Some(project) = project else {
+ return Ok(());
};
- Some(Self {
- excerpt,
- excerpt_text,
- cursor_point,
- declarations,
- })
- }
-}
+ let file = snapshot.file().cloned();
+ if let Some(file) = &file {
+ log::debug!("retrieving_context buffer:{}", file.path().as_unix_str());
+ }
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::sync::Arc;
-
- use gpui::{Entity, TestAppContext};
- use indoc::indoc;
- use language::{Language, LanguageConfig, LanguageId, LanguageMatcher, tree_sitter_rust};
- use project::{FakeFs, Project};
- use serde_json::json;
- use settings::SettingsStore;
- use util::path;
-
- use crate::{EditPredictionExcerptOptions, SyntaxIndex};
-
- #[gpui::test]
- async fn test_call_site(cx: &mut TestAppContext) {
- let (project, index, _rust_lang_id) = init_test(cx).await;
-
- let buffer = project
- .update(cx, |project, cx| {
- let project_path = project.find_project_path("c.rs", cx).unwrap();
- project.open_buffer(project_path, cx)
- })
- .await
- .unwrap();
-
- cx.run_until_parked();
-
- // first process_data call site
- let cursor_point = language::Point::new(8, 21);
- let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
-
- let context = cx
- .update(|cx| {
- EditPredictionContext::gather_context_in_background(
- cursor_point,
- buffer_snapshot,
- EditPredictionContextOptions {
- use_imports: true,
- excerpt: EditPredictionExcerptOptions {
- max_bytes: 60,
- min_bytes: 10,
- target_before_cursor_over_total_bytes: 0.5,
- },
- score: EditPredictionScoreOptions {
- omit_excerpt_overlaps: true,
- },
- max_retrieved_declarations: u8::MAX,
- },
- Some(index.clone()),
- cx,
- )
+ this.update(cx, |_, cx| {
+ cx.emit(RelatedExcerptStoreEvent::StartedRefresh);
+ })?;
+
+ let identifiers = cx
+ .background_spawn(async move {
+ identifiers_for_position(&snapshot, position, identifier_line_count)
})
- .await
- .unwrap();
+ .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::<Vec<_>>()
+ })?;
+
+ 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 mut snippet_identifiers = context
- .declarations
- .iter()
- .map(|snippet| snippet.identifier.name.as_ref())
- .collect::<Vec<_>>();
- snippet_identifiers.sort();
- assert_eq!(snippet_identifiers, vec!["main", "process_data"]);
- drop(buffer);
- }
+ let (new_cache, related_files, related_file_buffers) =
+ rebuild_related_files(&project, new_cache, cx).await?;
- async fn init_test(
- cx: &mut TestAppContext,
- ) -> (Entity<Project>, Entity<SyntaxIndex>, LanguageId) {
- cx.update(|cx| {
- let settings_store = SettingsStore::test(cx);
- cx.set_global(settings_store);
- });
+ if let Some(file) = &file {
+ log::debug!(
+ "finished retrieving context buffer:{}, latency:{:?}",
+ file.path().as_unix_str(),
+ start_time.elapsed()
+ );
+ }
- 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);
- }
+ 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(())
+ }
+}
- fn add(a: i32, b: i32) -> i32 {
- a + b
- }
- "#},
- "b.rs": indoc! {"
- pub struct Config {
- pub name: String,
- pub value: i32,
+async fn rebuild_related_files(
+ project: &Entity<Project>,
+ new_entries: HashMap<Identifier, Arc<CacheEntry>>,
+ cx: &mut AsyncApp,
+) -> Result<(
+ HashMap<Identifier, Arc<CacheEntry>>,
+ Vec<RelatedFile>,
+ Vec<Entity<Buffer>>,
+)> {
+ 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());
}
+ })?;
+ }
+ }
+ }
- 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<String> = std::env::args().collect();
- let data: Vec<i32> = args[1..]
- .iter()
- .filter_map(|s| s.parse().ok())
- .collect();
- let result = process_data(data);
- println!("{:?}", result);
- }
+ Ok(cx
+ .background_spawn(async move {
+ let mut files = Vec::new();
+ let mut ranges_by_buffer = HashMap::<_, Vec<Range<Point>>>::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,
+ },
+ ));
+ }
- fn process_data(data: Vec<i32>) -> HashMap<i32, usize> {
- let mut counts = HashMap::new();
- for value in data {
- *counts.entry(value).or_insert(0) += 1;
- }
- counts
- }
+ files.sort_by_key(|(_, file)| file.path.clone());
+ let (related_buffers, related_files) = files.into_iter().unzip();
- #[cfg(test)]
- mod tests {
- use super::*;
+ (new_entries, related_files, related_buffers)
+ })
+ .await)
+}
- #[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)
+const MAX_TARGET_LEN: usize = 128;
+
+fn process_definition(
+ location: LocationLink,
+ project: &Entity<Project>,
+ cx: &mut App,
+) -> Option<CachedDefinition> {
+ 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;
}
- 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()
+ // 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<Identifier> {
+ 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)
+ });
+
+ for range in ranges {
+ captures.set_byte_range(range.start..outer_range.end);
+
+ 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();
+
+ 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);
+ }
+
+ captures.advance();
+ }
+ }
+
+ identifiers
}
@@ -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<Person>,
+ address: Address,
+ }"}],
+ ),
+ (
+ "root/src/main.rs",
+ &[
+ indoc! {"
+ pub struct Session {
+ company: Arc<Company>,
+ }
+
+ impl Session {
+ pub fn set_company(&mut self, company: Arc<Company>) {"},
+ 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<Foo> {
+ 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<Foo> {
+ …
+ }
+
+ fn sync(&self);
+ }
+ "#},
+ ),
+ (
+ indoc! {r#"
+ trait «Something» {
+ fn method1(&self, id: usize) -> Foo;
+
+ fn method2(&self, ids: &[usize]) -> Vec<Foo> {
+ 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<Foo> {
+ …
+ }
+
+ 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<Range<Point>> = 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<Project>,
+ cx: &mut TestAppContext,
+) -> UnboundedReceiver<FakeLanguageServer> {
+ 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<Person>,
+ 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<Company>,
+ }
+
+ impl Session {
+ pub fn set_company(&mut self, company: Arc<Company>) {
+ 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::<Vec<_>>();
+ (file.path.to_str().unwrap(), excerpts)
+ })
+ .collect::<Vec<_>>();
+ let expected_excerpts = expected_files
+ .iter()
+ .map(|(path, texts)| {
+ (
+ *path,
+ texts
+ .iter()
+ .map(|line| line.to_string())
+ .collect::<Vec<_>>(),
+ )
+ })
+ .collect::<Vec<_>>();
+ 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::<String>()
+ .trim()
+ .to_string()
+ })
+ })
+ .collect::<Vec<String>>();
+
+ 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
+}
@@ -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, Line, declaration::DeclarationId, syntax_index::SyntaxIndexState};
-
// TODO:
//
// - Test parent signatures
@@ -31,19 +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<usize>,
pub line_range: Range<Line>,
- pub parent_declarations: Vec<(DeclarationId, Range<usize>)>,
pub size: usize,
}
#[derive(Debug, Clone)]
pub struct EditPredictionExcerptText {
pub body: String,
- pub parent_signatures: Vec<String>,
pub language_id: Option<LanguageId>,
}
@@ -52,17 +47,8 @@ impl EditPredictionExcerpt {
let body = buffer
.text_for_range(self.range.clone())
.collect::<String>();
- let parent_signatures = self
- .parent_declarations
- .iter()
- .map(|(_, range)| buffer.text_for_range(range.clone()).collect::<String>())
- .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
@@ -79,7 +65,6 @@ impl EditPredictionExcerpt {
query_point: Point,
buffer: &BufferSnapshot,
options: &EditPredictionExcerptOptions,
- syntax_index: Option<&SyntaxIndexState>,
) -> Option<Self> {
if buffer.len() <= options.max_bytes {
log::debug!(
@@ -89,11 +74,7 @@ impl EditPredictionExcerpt {
);
let offset_range = 0..buffer.len();
let line_range = Line(0)..Line(buffer.max_point().row);
- return Some(EditPredictionExcerpt::new(
- offset_range,
- line_range,
- Vec::new(),
- ));
+ return Some(EditPredictionExcerpt::new(offset_range, line_range));
}
let query_offset = query_point.to_offset(buffer);
@@ -104,19 +85,10 @@ impl EditPredictionExcerpt {
return None;
}
- let parent_declarations = if let Some(syntax_index) = syntax_index {
- syntax_index
- .buffer_declarations_containing_range(buffer.remote_id(), query_range.clone())
- .collect()
- } else {
- Vec::new()
- };
-
let excerpt_selector = ExcerptSelector {
query_offset,
query_range,
query_line_range: Line(query_line_range.start)..Line(query_line_range.end),
- parent_declarations: &parent_declarations,
buffer,
options,
};
@@ -139,20 +111,10 @@ impl EditPredictionExcerpt {
excerpt_selector.select_lines()
}
- fn new(
- range: Range<usize>,
- line_range: Range<Line>,
- parent_declarations: Vec<(DeclarationId, Range<usize>)>,
- ) -> Self {
- let size = range.len()
- + parent_declarations
- .iter()
- .map(|(_, range)| range.len())
- .sum::<usize>();
+ fn new(range: Range<usize>, line_range: Range<Line>) -> Self {
Self {
+ size: range.len(),
range,
- parent_declarations,
- size,
line_range,
}
}
@@ -162,14 +124,7 @@ impl EditPredictionExcerpt {
// this is an issue because parent_signature_ranges may be incorrect
log::error!("bug: with_expanded_range called with disjoint range");
}
- let mut parent_declarations = Vec::with_capacity(self.parent_declarations.len());
- for (declaration_id, range) in &self.parent_declarations {
- if !range.contains_inclusive(&new_range) {
- break;
- }
- parent_declarations.push((*declaration_id, range.clone()));
- }
- Self::new(new_range, new_line_range, parent_declarations)
+ Self::new(new_range, new_line_range)
}
fn parent_signatures_size(&self) -> usize {
@@ -181,7 +136,6 @@ struct ExcerptSelector<'a> {
query_offset: usize,
query_range: Range<usize>,
query_line_range: Range<Line>,
- parent_declarations: &'a [(DeclarationId, &'a BufferDeclaration)],
buffer: &'a BufferSnapshot,
options: &'a EditPredictionExcerptOptions,
}
@@ -409,13 +363,7 @@ impl<'a> ExcerptSelector<'a> {
}
fn make_excerpt(&self, range: Range<usize>, line_range: Range<Line>) -> EditPredictionExcerpt {
- let parent_declarations = self
- .parent_declarations
- .iter()
- .filter(|(_, declaration)| declaration.item_range.contains_inclusive(&range))
- .map(|(id, declaration)| (*id, declaration.signature_range.clone()))
- .collect();
- EditPredictionExcerpt::new(range, line_range, parent_declarations)
+ EditPredictionExcerpt::new(range, line_range)
}
/// Returns `true` if the `forward` excerpt is a better choice than the `backward` excerpt.
@@ -471,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<usize>) {
let (text, offsets) = marked_text_offsets_by(text, vec!['ˇ', '«', '»']);
(text, offsets[&'ˇ'][0], offsets[&'«'][0]..offsets[&'»'][0])
@@ -506,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)
@@ -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<LanguageRegistry>,
+ language: Arc<Language>,
+ fs: Arc<dyn Fs>,
+) -> UnboundedReceiver<FakeLanguageServer> {
+ 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::<lsp::notification::DidOpenTextDocument, _>({
+ let index = index.clone();
+ move |params, _cx| {
+ index
+ .lock()
+ .open_buffer(params.text_document.uri, ¶ms.text_document.text);
+ }
+ });
+
+ server.handle_notification::<lsp::notification::DidCloseTextDocument, _>({
+ 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::<lsp::notification::DidChangeWatchedFiles, _>({
+ 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::<lsp::notification::DidChangeTextDocument, _>({
+ 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::<lsp::notification::DidChangeWorkspaceFolders, _>(
+ {
+ 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::<lsp::request::GotoDefinition, _, _>({
+ 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<Language>,
+ definitions: HashMap<String, Vec<lsp::Location>>,
+ files: HashMap<Uri, FileEntry>,
+}
+
+#[derive(Debug)]
+struct FileEntry {
+ contents: String,
+ is_open_in_buffer: bool,
+}
+
+impl DefinitionIndex {
+ fn new(language: Arc<Language>) -> 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<lsp::GotoDefinitionResponse> {
+ 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<usize>)> {
+ 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<Range<usize>> = 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<usize>) -> 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())
+}
@@ -1,1319 +0,0 @@
-use collections::HashMap;
-use language::BufferSnapshot;
-use language::ImportsConfig;
-use language::Language;
-use std::ops::Deref;
-use std::path::Path;
-use std::sync::Arc;
-use std::{borrow::Cow, ops::Range};
-use text::OffsetRangeExt as _;
-use util::RangeExt;
-use util::paths::PathStyle;
-
-use crate::Identifier;
-use crate::text_similarity::Occurrences;
-
-// TODO: Write documentation for extension authors. The @import capture must match before or in the
-// same pattern as all all captures it contains
-
-// Future improvements to consider:
-//
-// * Distinguish absolute vs relative paths in captures. `#include "maths.h"` is relative whereas
-// `#include <maths.h>` is not.
-//
-// * Provide the name used when importing whole modules (see tests with "named_module" in the name).
-// To be useful, will require parsing of identifier qualification.
-//
-// * Scoping for imports that aren't at the top level
-//
-// * Only scan a prefix of the file, when possible. This could look like having query matches that
-// indicate it reached a declaration that is not allowed in the import section.
-//
-// * Support directly parsing to occurrences instead of storing namespaces / paths. Types should be
-// generic on this, so that tests etc can still use strings. Could do similar in syntax index.
-//
-// * Distinguish different types of namespaces when known. E.g. "name.type" capture. Once capture
-// names are more open-ended like this may make sense to build and cache a jump table (direct
-// dispatch from capture index).
-//
-// * There are a few "Language specific:" comments on behavior that gets applied to all languages.
-// Would be cleaner to be conditional on the language or otherwise configured.
-
-#[derive(Debug, Clone, Default)]
-pub struct Imports {
- pub identifier_to_imports: HashMap<Identifier, Vec<Import>>,
- pub wildcard_modules: Vec<Module>,
-}
-
-#[derive(Debug, Clone)]
-pub enum Import {
- Direct {
- module: Module,
- },
- Alias {
- module: Module,
- external_identifier: Identifier,
- },
-}
-
-#[derive(Debug, Clone)]
-pub enum Module {
- SourceExact(Arc<Path>),
- SourceFuzzy(Arc<Path>),
- Namespace(Namespace),
-}
-
-impl Module {
- fn empty() -> Self {
- Module::Namespace(Namespace::default())
- }
-
- fn push_range(
- &mut self,
- range: &ModuleRange,
- snapshot: &BufferSnapshot,
- language: &Language,
- parent_abs_path: Option<&Path>,
- ) -> usize {
- if range.is_empty() {
- return 0;
- }
-
- match range {
- ModuleRange::Source(range) => {
- if let Self::Namespace(namespace) = self
- && namespace.0.is_empty()
- {
- let path = snapshot.text_for_range(range.clone()).collect::<Cow<str>>();
-
- let path = if let Some(strip_regex) =
- language.config().import_path_strip_regex.as_ref()
- {
- strip_regex.replace_all(&path, "")
- } else {
- path
- };
-
- let path = Path::new(path.as_ref());
- if (path.starts_with(".") || path.starts_with(".."))
- && let Some(parent_abs_path) = parent_abs_path
- && let Ok(abs_path) =
- util::paths::normalize_lexically(&parent_abs_path.join(path))
- {
- *self = Self::SourceExact(abs_path.into());
- } else {
- *self = Self::SourceFuzzy(path.into());
- };
- } else if matches!(self, Self::SourceExact(_))
- || matches!(self, Self::SourceFuzzy(_))
- {
- log::warn!("bug in imports query: encountered multiple @source matches");
- } else {
- log::warn!(
- "bug in imports query: encountered both @namespace and @source match"
- );
- }
- }
- ModuleRange::Namespace(range) => {
- if let Self::Namespace(namespace) = self {
- let segment = range_text(snapshot, range);
- if language.config().ignored_import_segments.contains(&segment) {
- return 0;
- } else {
- namespace.0.push(segment);
- return 1;
- }
- } else {
- log::warn!(
- "bug in imports query: encountered both @namespace and @source match"
- );
- }
- }
- }
- 0
- }
-}
-
-#[derive(Debug, Clone)]
-enum ModuleRange {
- Source(Range<usize>),
- Namespace(Range<usize>),
-}
-
-impl Deref for ModuleRange {
- type Target = Range<usize>;
-
- fn deref(&self) -> &Self::Target {
- match self {
- ModuleRange::Source(range) => range,
- ModuleRange::Namespace(range) => range,
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Default)]
-pub struct Namespace(pub Vec<Arc<str>>);
-
-impl Namespace {
- pub fn occurrences(&self) -> Occurrences {
- Occurrences::from_identifiers(&self.0)
- }
-}
-
-impl Imports {
- pub fn gather(snapshot: &BufferSnapshot, parent_abs_path: Option<&Path>) -> Self {
- // Query to match different import patterns
- let mut matches = snapshot
- .syntax
- .matches(0..snapshot.len(), &snapshot.text, |grammar| {
- grammar.imports_config().map(|imports| &imports.query)
- });
-
- let mut detached_nodes: Vec<DetachedNode> = Vec::new();
- let mut identifier_to_imports = HashMap::default();
- let mut wildcard_modules = Vec::new();
- let mut import_range = None;
-
- while let Some(query_match) = matches.peek() {
- let ImportsConfig {
- query: _,
- import_ix,
- name_ix,
- namespace_ix,
- source_ix,
- list_ix,
- wildcard_ix,
- alias_ix,
- } = matches.grammars()[query_match.grammar_index]
- .imports_config()
- .unwrap();
-
- let mut new_import_range = None;
- let mut alias_range = None;
- let mut modules = Vec::new();
- let mut content: Option<(Range<usize>, ContentKind)> = None;
- for capture in query_match.captures {
- let capture_range = capture.node.byte_range();
-
- if capture.index == *import_ix {
- new_import_range = Some(capture_range);
- } else if Some(capture.index) == *namespace_ix {
- modules.push(ModuleRange::Namespace(capture_range));
- } else if Some(capture.index) == *source_ix {
- modules.push(ModuleRange::Source(capture_range));
- } else if Some(capture.index) == *alias_ix {
- alias_range = Some(capture_range);
- } else {
- let mut found_content = None;
- if Some(capture.index) == *name_ix {
- found_content = Some((capture_range, ContentKind::Name));
- } else if Some(capture.index) == *list_ix {
- found_content = Some((capture_range, ContentKind::List));
- } else if Some(capture.index) == *wildcard_ix {
- found_content = Some((capture_range, ContentKind::Wildcard));
- }
- if let Some((found_content_range, found_kind)) = found_content {
- if let Some((_, old_kind)) = content {
- let point = found_content_range.to_point(snapshot);
- log::warn!(
- "bug in {} imports query: unexpected multiple captures of {} and {} ({}:{}:{})",
- query_match.language.name(),
- old_kind.capture_name(),
- found_kind.capture_name(),
- snapshot
- .file()
- .map(|p| p.path().display(PathStyle::Posix))
- .unwrap_or_default(),
- point.start.row + 1,
- point.start.column + 1
- );
- }
- content = Some((found_content_range, found_kind));
- }
- }
- }
-
- if let Some(new_import_range) = new_import_range {
- log::trace!("starting new import {:?}", new_import_range);
- Self::gather_from_import_statement(
- &detached_nodes,
- &snapshot,
- parent_abs_path,
- &mut identifier_to_imports,
- &mut wildcard_modules,
- );
- detached_nodes.clear();
- import_range = Some(new_import_range.clone());
- }
-
- if let Some((content, content_kind)) = content {
- if import_range
- .as_ref()
- .is_some_and(|import_range| import_range.contains_inclusive(&content))
- {
- detached_nodes.push(DetachedNode {
- modules,
- content: content.clone(),
- content_kind,
- alias: alias_range.unwrap_or(0..0),
- language: query_match.language.clone(),
- });
- } else {
- log::trace!(
- "filtered out match not inside import range: {content_kind:?} at {content:?}"
- );
- }
- }
-
- matches.advance();
- }
-
- Self::gather_from_import_statement(
- &detached_nodes,
- &snapshot,
- parent_abs_path,
- &mut identifier_to_imports,
- &mut wildcard_modules,
- );
-
- Imports {
- identifier_to_imports,
- wildcard_modules,
- }
- }
-
- fn gather_from_import_statement(
- detached_nodes: &[DetachedNode],
- snapshot: &BufferSnapshot,
- parent_abs_path: Option<&Path>,
- identifier_to_imports: &mut HashMap<Identifier, Vec<Import>>,
- wildcard_modules: &mut Vec<Module>,
- ) {
- let mut trees = Vec::new();
-
- for detached_node in detached_nodes {
- if let Some(node) = Self::attach_node(detached_node.into(), &mut trees) {
- trees.push(node);
- }
- log::trace!(
- "Attached node to tree\n{:#?}\nAttach result:\n{:#?}",
- detached_node,
- trees
- .iter()
- .map(|tree| tree.debug(snapshot))
- .collect::<Vec<_>>()
- );
- }
-
- for tree in &trees {
- let mut module = Module::empty();
- Self::gather_from_tree(
- tree,
- snapshot,
- parent_abs_path,
- &mut module,
- identifier_to_imports,
- wildcard_modules,
- );
- }
- }
-
- fn attach_node(mut node: ImportTree, trees: &mut Vec<ImportTree>) -> Option<ImportTree> {
- let mut tree_index = 0;
- while tree_index < trees.len() {
- let tree = &mut trees[tree_index];
- if !node.content.is_empty() && node.content == tree.content {
- // multiple matches can apply to the same name/list/wildcard. This keeps the queries
- // simpler by combining info from these matches.
- if tree.module.is_empty() {
- tree.module = node.module;
- tree.module_children = node.module_children;
- }
- if tree.alias.is_empty() {
- tree.alias = node.alias;
- }
- return None;
- } else if !node.module.is_empty() && node.module.contains_inclusive(&tree.range()) {
- node.module_children.push(trees.remove(tree_index));
- continue;
- } else if !node.content.is_empty() && node.content.contains_inclusive(&tree.content) {
- node.content_children.push(trees.remove(tree_index));
- continue;
- } else if !tree.content.is_empty() && tree.content.contains_inclusive(&node.content) {
- if let Some(node) = Self::attach_node(node, &mut tree.content_children) {
- tree.content_children.push(node);
- }
- return None;
- }
- tree_index += 1;
- }
- Some(node)
- }
-
- fn gather_from_tree(
- tree: &ImportTree,
- snapshot: &BufferSnapshot,
- parent_abs_path: Option<&Path>,
- current_module: &mut Module,
- identifier_to_imports: &mut HashMap<Identifier, Vec<Import>>,
- wildcard_modules: &mut Vec<Module>,
- ) {
- let mut pop_count = 0;
-
- if tree.module_children.is_empty() {
- pop_count +=
- current_module.push_range(&tree.module, snapshot, &tree.language, parent_abs_path);
- } else {
- for child in &tree.module_children {
- pop_count += Self::extend_namespace_from_tree(
- child,
- snapshot,
- parent_abs_path,
- current_module,
- );
- }
- };
-
- if tree.content_children.is_empty() && !tree.content.is_empty() {
- match tree.content_kind {
- ContentKind::Name | ContentKind::List => {
- if tree.alias.is_empty() {
- identifier_to_imports
- .entry(Identifier {
- language_id: tree.language.id(),
- name: range_text(snapshot, &tree.content),
- })
- .or_default()
- .push(Import::Direct {
- module: current_module.clone(),
- });
- } else {
- let alias_name: Arc<str> = range_text(snapshot, &tree.alias);
- let external_name = range_text(snapshot, &tree.content);
- // Language specific: skip "_" aliases for Rust
- if alias_name.as_ref() != "_" {
- identifier_to_imports
- .entry(Identifier {
- language_id: tree.language.id(),
- name: alias_name,
- })
- .or_default()
- .push(Import::Alias {
- module: current_module.clone(),
- external_identifier: Identifier {
- language_id: tree.language.id(),
- name: external_name,
- },
- });
- }
- }
- }
- ContentKind::Wildcard => wildcard_modules.push(current_module.clone()),
- }
- } else {
- for child in &tree.content_children {
- Self::gather_from_tree(
- child,
- snapshot,
- parent_abs_path,
- current_module,
- identifier_to_imports,
- wildcard_modules,
- );
- }
- }
-
- if pop_count > 0 {
- match current_module {
- Module::SourceExact(_) | Module::SourceFuzzy(_) => {
- log::warn!(
- "bug in imports query: encountered both @namespace and @source match"
- );
- }
- Module::Namespace(namespace) => {
- namespace.0.drain(namespace.0.len() - pop_count..);
- }
- }
- }
- }
-
- fn extend_namespace_from_tree(
- tree: &ImportTree,
- snapshot: &BufferSnapshot,
- parent_abs_path: Option<&Path>,
- module: &mut Module,
- ) -> usize {
- let mut pop_count = 0;
- if tree.module_children.is_empty() {
- pop_count += module.push_range(&tree.module, snapshot, &tree.language, parent_abs_path);
- } else {
- for child in &tree.module_children {
- pop_count +=
- Self::extend_namespace_from_tree(child, snapshot, parent_abs_path, module);
- }
- }
- if tree.content_children.is_empty() {
- pop_count += module.push_range(
- &ModuleRange::Namespace(tree.content.clone()),
- snapshot,
- &tree.language,
- parent_abs_path,
- );
- } else {
- for child in &tree.content_children {
- pop_count +=
- Self::extend_namespace_from_tree(child, snapshot, parent_abs_path, module);
- }
- }
- pop_count
- }
-}
-
-fn range_text(snapshot: &BufferSnapshot, range: &Range<usize>) -> Arc<str> {
- snapshot
- .text_for_range(range.clone())
- .collect::<Cow<str>>()
- .into()
-}
-
-#[derive(Debug)]
-struct DetachedNode {
- modules: Vec<ModuleRange>,
- content: Range<usize>,
- content_kind: ContentKind,
- alias: Range<usize>,
- language: Arc<Language>,
-}
-
-#[derive(Debug, Clone, Copy)]
-enum ContentKind {
- Name,
- Wildcard,
- List,
-}
-
-impl ContentKind {
- fn capture_name(&self) -> &'static str {
- match self {
- ContentKind::Name => "name",
- ContentKind::Wildcard => "wildcard",
- ContentKind::List => "list",
- }
- }
-}
-
-#[derive(Debug)]
-struct ImportTree {
- module: ModuleRange,
- /// When non-empty, provides namespace / source info which should be used instead of `module`.
- module_children: Vec<ImportTree>,
- content: Range<usize>,
- /// When non-empty, provides content which should be used instead of `content`.
- content_children: Vec<ImportTree>,
- content_kind: ContentKind,
- alias: Range<usize>,
- language: Arc<Language>,
-}
-
-impl ImportTree {
- fn range(&self) -> Range<usize> {
- self.module.start.min(self.content.start)..self.module.end.max(self.content.end)
- }
-
- #[allow(dead_code)]
- fn debug<'a>(&'a self, snapshot: &'a BufferSnapshot) -> ImportTreeDebug<'a> {
- ImportTreeDebug {
- tree: self,
- snapshot,
- }
- }
-
- fn from_module_range(module: &ModuleRange, language: Arc<Language>) -> Self {
- ImportTree {
- module: module.clone(),
- module_children: Vec::new(),
- content: 0..0,
- content_children: Vec::new(),
- content_kind: ContentKind::Name,
- alias: 0..0,
- language,
- }
- }
-}
-
-impl From<&DetachedNode> for ImportTree {
- fn from(value: &DetachedNode) -> Self {
- let module;
- let module_children;
- match value.modules.len() {
- 0 => {
- module = ModuleRange::Namespace(0..0);
- module_children = Vec::new();
- }
- 1 => {
- module = value.modules[0].clone();
- module_children = Vec::new();
- }
- _ => {
- module = ModuleRange::Namespace(
- value.modules.first().unwrap().start..value.modules.last().unwrap().end,
- );
- module_children = value
- .modules
- .iter()
- .map(|module| ImportTree::from_module_range(module, value.language.clone()))
- .collect();
- }
- }
-
- ImportTree {
- module,
- module_children,
- content: value.content.clone(),
- content_children: Vec::new(),
- content_kind: value.content_kind,
- alias: value.alias.clone(),
- language: value.language.clone(),
- }
- }
-}
-
-struct ImportTreeDebug<'a> {
- tree: &'a ImportTree,
- snapshot: &'a BufferSnapshot,
-}
-
-impl std::fmt::Debug for ImportTreeDebug<'_> {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.debug_struct("ImportTree")
- .field("module_range", &self.tree.module)
- .field("module_text", &range_text(self.snapshot, &self.tree.module))
- .field(
- "module_children",
- &self
- .tree
- .module_children
- .iter()
- .map(|child| child.debug(&self.snapshot))
- .collect::<Vec<Self>>(),
- )
- .field("content_range", &self.tree.content)
- .field(
- "content_text",
- &range_text(self.snapshot, &self.tree.content),
- )
- .field(
- "content_children",
- &self
- .tree
- .content_children
- .iter()
- .map(|child| child.debug(&self.snapshot))
- .collect::<Vec<Self>>(),
- )
- .field("content_kind", &self.tree.content_kind)
- .field("alias_range", &self.tree.alias)
- .field("alias_text", &range_text(self.snapshot, &self.tree.alias))
- .finish()
- }
-}
-
-#[cfg(test)]
-mod test {
- use std::path::PathBuf;
- use std::sync::{Arc, LazyLock};
-
- use super::*;
- use collections::HashSet;
- use gpui::{TestAppContext, prelude::*};
- use indoc::indoc;
- use language::{
- Buffer, Language, LanguageConfig, tree_sitter_python, tree_sitter_rust,
- tree_sitter_typescript,
- };
- use regex::Regex;
-
- #[gpui::test]
- fn test_rust_simple(cx: &mut TestAppContext) {
- check_imports(
- &RUST,
- "use std::collections::HashMap;",
- &[&["std", "collections", "HashMap"]],
- cx,
- );
-
- check_imports(
- &RUST,
- "pub use std::collections::HashMap;",
- &[&["std", "collections", "HashMap"]],
- cx,
- );
-
- check_imports(
- &RUST,
- "use std::collections::{HashMap, HashSet};",
- &[
- &["std", "collections", "HashMap"],
- &["std", "collections", "HashSet"],
- ],
- cx,
- );
- }
-
- #[gpui::test]
- fn test_rust_nested(cx: &mut TestAppContext) {
- check_imports(
- &RUST,
- "use std::{any::TypeId, collections::{HashMap, HashSet}};",
- &[
- &["std", "any", "TypeId"],
- &["std", "collections", "HashMap"],
- &["std", "collections", "HashSet"],
- ],
- cx,
- );
-
- check_imports(
- &RUST,
- "use a::b::c::{d::e::F, g::h::I};",
- &[
- &["a", "b", "c", "d", "e", "F"],
- &["a", "b", "c", "g", "h", "I"],
- ],
- cx,
- );
- }
-
- #[gpui::test]
- fn test_rust_multiple_imports(cx: &mut TestAppContext) {
- check_imports(
- &RUST,
- indoc! {"
- use std::collections::HashMap;
- use std::any::{TypeId, Any};
- "},
- &[
- &["std", "collections", "HashMap"],
- &["std", "any", "TypeId"],
- &["std", "any", "Any"],
- ],
- cx,
- );
-
- check_imports(
- &RUST,
- indoc! {"
- use std::collections::HashSet;
-
- fn main() {
- let unqualified = HashSet::new();
- let qualified = std::collections::HashMap::new();
- }
-
- use std::any::TypeId;
- "},
- &[
- &["std", "collections", "HashSet"],
- &["std", "any", "TypeId"],
- ],
- cx,
- );
- }
-
- #[gpui::test]
- fn test_rust_wildcard(cx: &mut TestAppContext) {
- check_imports(&RUST, "use prelude::*;", &[&["prelude", "WILDCARD"]], cx);
-
- check_imports(
- &RUST,
- "use zed::prelude::*;",
- &[&["zed", "prelude", "WILDCARD"]],
- cx,
- );
-
- check_imports(&RUST, "use prelude::{*};", &[&["prelude", "WILDCARD"]], cx);
-
- check_imports(
- &RUST,
- "use prelude::{File, *};",
- &[&["prelude", "File"], &["prelude", "WILDCARD"]],
- cx,
- );
-
- check_imports(
- &RUST,
- "use zed::{App, prelude::*};",
- &[&["zed", "App"], &["zed", "prelude", "WILDCARD"]],
- cx,
- );
- }
-
- #[gpui::test]
- fn test_rust_alias(cx: &mut TestAppContext) {
- check_imports(
- &RUST,
- "use std::io::Result as IoResult;",
- &[&["std", "io", "Result AS IoResult"]],
- cx,
- );
- }
-
- #[gpui::test]
- fn test_rust_crate_and_super(cx: &mut TestAppContext) {
- check_imports(&RUST, "use crate::a::b::c;", &[&["a", "b", "c"]], cx);
- check_imports(&RUST, "use super::a::b::c;", &[&["a", "b", "c"]], cx);
- // TODO: Consider stripping leading "::". Not done for now because for the text similarity matching usecase this
- // is fine.
- check_imports(&RUST, "use ::a::b::c;", &[&["::a", "b", "c"]], cx);
- }
-
- #[gpui::test]
- fn test_typescript_imports(cx: &mut TestAppContext) {
- let parent_abs_path = PathBuf::from("/home/user/project");
-
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &TYPESCRIPT,
- r#"import "./maths.js";"#,
- &[&["SOURCE /home/user/project/maths", "WILDCARD"]],
- cx,
- );
-
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &TYPESCRIPT,
- r#"import "../maths.js";"#,
- &[&["SOURCE /home/user/maths", "WILDCARD"]],
- cx,
- );
-
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &TYPESCRIPT,
- r#"import RandomNumberGenerator, { pi as π } from "./maths.js";"#,
- &[
- &["SOURCE /home/user/project/maths", "RandomNumberGenerator"],
- &["SOURCE /home/user/project/maths", "pi AS π"],
- ],
- cx,
- );
-
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &TYPESCRIPT,
- r#"import { pi, phi, absolute } from "./maths.js";"#,
- &[
- &["SOURCE /home/user/project/maths", "pi"],
- &["SOURCE /home/user/project/maths", "phi"],
- &["SOURCE /home/user/project/maths", "absolute"],
- ],
- cx,
- );
-
- // index.js is removed by import_path_strip_regex
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &TYPESCRIPT,
- r#"import { pi, phi, absolute } from "./maths/index.js";"#,
- &[
- &["SOURCE /home/user/project/maths", "pi"],
- &["SOURCE /home/user/project/maths", "phi"],
- &["SOURCE /home/user/project/maths", "absolute"],
- ],
- cx,
- );
-
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &TYPESCRIPT,
- r#"import type { SomeThing } from "./some-module.js";"#,
- &[&["SOURCE /home/user/project/some-module", "SomeThing"]],
- cx,
- );
-
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &TYPESCRIPT,
- r#"import { type SomeThing, OtherThing } from "./some-module.js";"#,
- &[
- &["SOURCE /home/user/project/some-module", "SomeThing"],
- &["SOURCE /home/user/project/some-module", "OtherThing"],
- ],
- cx,
- );
-
- // index.js is removed by import_path_strip_regex
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &TYPESCRIPT,
- r#"import { type SomeThing, OtherThing } from "./some-module/index.js";"#,
- &[
- &["SOURCE /home/user/project/some-module", "SomeThing"],
- &["SOURCE /home/user/project/some-module", "OtherThing"],
- ],
- cx,
- );
-
- // fuzzy paths
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &TYPESCRIPT,
- r#"import { type SomeThing, OtherThing } from "@my-app/some-module.js";"#,
- &[
- &["SOURCE FUZZY @my-app/some-module", "SomeThing"],
- &["SOURCE FUZZY @my-app/some-module", "OtherThing"],
- ],
- cx,
- );
- }
-
- #[gpui::test]
- fn test_typescript_named_module_imports(cx: &mut TestAppContext) {
- let parent_abs_path = PathBuf::from("/home/user/project");
-
- // TODO: These should provide the name that the module is bound to.
- // For now instead these are treated as unqualified wildcard imports.
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &TYPESCRIPT,
- r#"import * as math from "./maths.js";"#,
- // &[&["/home/user/project/maths.js", "WILDCARD AS math"]],
- &[&["SOURCE /home/user/project/maths", "WILDCARD"]],
- cx,
- );
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &TYPESCRIPT,
- r#"import math = require("./maths");"#,
- // &[&["/home/user/project/maths", "WILDCARD AS math"]],
- &[&["SOURCE /home/user/project/maths", "WILDCARD"]],
- cx,
- );
- }
-
- #[gpui::test]
- fn test_python_imports(cx: &mut TestAppContext) {
- check_imports(&PYTHON, "from math import pi", &[&["math", "pi"]], cx);
-
- check_imports(
- &PYTHON,
- "from math import pi, sin, cos",
- &[&["math", "pi"], &["math", "sin"], &["math", "cos"]],
- cx,
- );
-
- check_imports(&PYTHON, "from math import *", &[&["math", "WILDCARD"]], cx);
-
- check_imports(
- &PYTHON,
- "from math import foo.bar.baz",
- &[&["math", "foo", "bar", "baz"]],
- cx,
- );
-
- check_imports(
- &PYTHON,
- "from math import pi as PI",
- &[&["math", "pi AS PI"]],
- cx,
- );
-
- check_imports(
- &PYTHON,
- "from serializers.json import JsonSerializer",
- &[&["serializers", "json", "JsonSerializer"]],
- cx,
- );
-
- check_imports(
- &PYTHON,
- "from custom.serializers import json, xml, yaml",
- &[
- &["custom", "serializers", "json"],
- &["custom", "serializers", "xml"],
- &["custom", "serializers", "yaml"],
- ],
- cx,
- );
- }
-
- #[gpui::test]
- fn test_python_named_module_imports(cx: &mut TestAppContext) {
- // TODO: These should provide the name that the module is bound to.
- // For now instead these are treated as unqualified wildcard imports.
- //
- // check_imports(&PYTHON, "import math", &[&["math", "WILDCARD as math"]], cx);
- // check_imports(&PYTHON, "import math as maths", &[&["math", "WILDCARD AS maths"]], cx);
- //
- // Something like:
- //
- // (import_statement
- // name: [
- // (dotted_name
- // (identifier)* @namespace
- // (identifier) @name.module .)
- // (aliased_import
- // name: (dotted_name
- // ((identifier) ".")* @namespace
- // (identifier) @name.module .)
- // alias: (identifier) @alias)
- // ]) @import
-
- check_imports(&PYTHON, "import math", &[&["math", "WILDCARD"]], cx);
-
- check_imports(
- &PYTHON,
- "import math as maths",
- &[&["math", "WILDCARD"]],
- cx,
- );
-
- check_imports(&PYTHON, "import a.b.c", &[&["a", "b", "c", "WILDCARD"]], cx);
-
- check_imports(
- &PYTHON,
- "import a.b.c as d",
- &[&["a", "b", "c", "WILDCARD"]],
- cx,
- );
- }
-
- #[gpui::test]
- fn test_python_package_relative_imports(cx: &mut TestAppContext) {
- // TODO: These should provide info about the dir they are relative to, to provide more
- // precise resolution. Instead, fuzzy matching is used as usual.
-
- check_imports(&PYTHON, "from . import math", &[&["math"]], cx);
-
- check_imports(&PYTHON, "from .a import math", &[&["a", "math"]], cx);
-
- check_imports(
- &PYTHON,
- "from ..a.b import math",
- &[&["a", "b", "math"]],
- cx,
- );
-
- check_imports(
- &PYTHON,
- "from ..a.b import *",
- &[&["a", "b", "WILDCARD"]],
- cx,
- );
- }
-
- #[gpui::test]
- fn test_c_imports(cx: &mut TestAppContext) {
- let parent_abs_path = PathBuf::from("/home/user/project");
-
- // TODO: Distinguish that these are not relative to current path
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &C,
- r#"#include <math.h>"#,
- &[&["SOURCE FUZZY math.h", "WILDCARD"]],
- cx,
- );
-
- // TODO: These should be treated as relative, but don't start with ./ or ../
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &C,
- r#"#include "math.h""#,
- &[&["SOURCE FUZZY math.h", "WILDCARD"]],
- cx,
- );
- }
-
- #[gpui::test]
- fn test_cpp_imports(cx: &mut TestAppContext) {
- let parent_abs_path = PathBuf::from("/home/user/project");
-
- // TODO: Distinguish that these are not relative to current path
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &CPP,
- r#"#include <math.h>"#,
- &[&["SOURCE FUZZY math.h", "WILDCARD"]],
- cx,
- );
-
- // TODO: These should be treated as relative, but don't start with ./ or ../
- check_imports_with_file_abs_path(
- Some(&parent_abs_path),
- &CPP,
- r#"#include "math.h""#,
- &[&["SOURCE FUZZY math.h", "WILDCARD"]],
- cx,
- );
- }
-
- #[gpui::test]
- fn test_go_imports(cx: &mut TestAppContext) {
- check_imports(
- &GO,
- r#"import . "lib/math""#,
- &[&["lib/math", "WILDCARD"]],
- cx,
- );
-
- // not included, these are only for side-effects
- check_imports(&GO, r#"import _ "lib/math""#, &[], cx);
- }
-
- #[gpui::test]
- fn test_go_named_module_imports(cx: &mut TestAppContext) {
- // TODO: These should provide the name that the module is bound to.
- // For now instead these are treated as unqualified wildcard imports.
-
- check_imports(
- &GO,
- r#"import "lib/math""#,
- &[&["lib/math", "WILDCARD"]],
- cx,
- );
- check_imports(
- &GO,
- r#"import m "lib/math""#,
- &[&["lib/math", "WILDCARD"]],
- cx,
- );
- }
-
- #[track_caller]
- fn check_imports(
- language: &Arc<Language>,
- source: &str,
- expected: &[&[&str]],
- cx: &mut TestAppContext,
- ) {
- check_imports_with_file_abs_path(None, language, source, expected, cx);
- }
-
- #[track_caller]
- fn check_imports_with_file_abs_path(
- parent_abs_path: Option<&Path>,
- language: &Arc<Language>,
- source: &str,
- expected: &[&[&str]],
- cx: &mut TestAppContext,
- ) {
- let buffer = cx.new(|cx| {
- let mut buffer = Buffer::local(source, cx);
- buffer.set_language(Some(language.clone()), cx);
- buffer
- });
- cx.run_until_parked();
-
- let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
-
- let imports = Imports::gather(&snapshot, parent_abs_path);
- let mut actual_symbols = imports
- .identifier_to_imports
- .iter()
- .flat_map(|(identifier, imports)| {
- imports
- .iter()
- .map(|import| import.to_identifier_parts(identifier.name.as_ref()))
- })
- .chain(
- imports
- .wildcard_modules
- .iter()
- .map(|module| module.to_identifier_parts("WILDCARD")),
- )
- .collect::<Vec<_>>();
- let mut expected_symbols = expected
- .iter()
- .map(|expected| expected.iter().map(|s| s.to_string()).collect::<Vec<_>>())
- .collect::<Vec<_>>();
- actual_symbols.sort();
- expected_symbols.sort();
- if actual_symbols != expected_symbols {
- let top_layer = snapshot.syntax_layers().next().unwrap();
- panic!(
- "Expected imports: {:?}\n\
- Actual imports: {:?}\n\
- Tree:\n{}",
- expected_symbols,
- actual_symbols,
- tree_to_string(&top_layer.node()),
- );
- }
- }
-
- fn tree_to_string(node: &tree_sitter::Node) -> String {
- let mut cursor = node.walk();
- let mut result = String::new();
- let mut depth = 0;
- 'outer: loop {
- result.push_str(&" ".repeat(depth));
- if let Some(field_name) = cursor.field_name() {
- result.push_str(field_name);
- result.push_str(": ");
- }
- if cursor.node().is_named() {
- result.push_str(cursor.node().kind());
- } else {
- result.push('"');
- result.push_str(cursor.node().kind());
- result.push('"');
- }
- result.push('\n');
-
- if cursor.goto_first_child() {
- depth += 1;
- continue;
- }
- if cursor.goto_next_sibling() {
- continue;
- }
- while cursor.goto_parent() {
- depth -= 1;
- if cursor.goto_next_sibling() {
- continue 'outer;
- }
- }
- break;
- }
- result
- }
-
- static RUST: LazyLock<Arc<Language>> = LazyLock::new(|| {
- Arc::new(
- Language::new(
- LanguageConfig {
- name: "Rust".into(),
- ignored_import_segments: HashSet::from_iter(["crate".into(), "super".into()]),
- import_path_strip_regex: Some(Regex::new("/(lib|mod)\\.rs$").unwrap()),
- ..Default::default()
- },
- Some(tree_sitter_rust::LANGUAGE.into()),
- )
- .with_imports_query(include_str!("../../languages/src/rust/imports.scm"))
- .unwrap(),
- )
- });
-
- static TYPESCRIPT: LazyLock<Arc<Language>> = LazyLock::new(|| {
- Arc::new(
- Language::new(
- LanguageConfig {
- name: "TypeScript".into(),
- import_path_strip_regex: Some(Regex::new("(?:/index)?\\.[jt]s$").unwrap()),
- ..Default::default()
- },
- Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
- )
- .with_imports_query(include_str!("../../languages/src/typescript/imports.scm"))
- .unwrap(),
- )
- });
-
- static PYTHON: LazyLock<Arc<Language>> = LazyLock::new(|| {
- Arc::new(
- Language::new(
- LanguageConfig {
- name: "Python".into(),
- import_path_strip_regex: Some(Regex::new("/__init__\\.py$").unwrap()),
- ..Default::default()
- },
- Some(tree_sitter_python::LANGUAGE.into()),
- )
- .with_imports_query(include_str!("../../languages/src/python/imports.scm"))
- .unwrap(),
- )
- });
-
- // TODO: Ideally should use actual language configurations
- static C: LazyLock<Arc<Language>> = LazyLock::new(|| {
- Arc::new(
- Language::new(
- LanguageConfig {
- name: "C".into(),
- import_path_strip_regex: Some(Regex::new("^<|>$").unwrap()),
- ..Default::default()
- },
- Some(tree_sitter_c::LANGUAGE.into()),
- )
- .with_imports_query(include_str!("../../languages/src/c/imports.scm"))
- .unwrap(),
- )
- });
-
- static CPP: LazyLock<Arc<Language>> = LazyLock::new(|| {
- Arc::new(
- Language::new(
- LanguageConfig {
- name: "C++".into(),
- import_path_strip_regex: Some(Regex::new("^<|>$").unwrap()),
- ..Default::default()
- },
- Some(tree_sitter_cpp::LANGUAGE.into()),
- )
- .with_imports_query(include_str!("../../languages/src/cpp/imports.scm"))
- .unwrap(),
- )
- });
-
- static GO: LazyLock<Arc<Language>> = LazyLock::new(|| {
- Arc::new(
- Language::new(
- LanguageConfig {
- name: "Go".into(),
- ..Default::default()
- },
- Some(tree_sitter_go::LANGUAGE.into()),
- )
- .with_imports_query(include_str!("../../languages/src/go/imports.scm"))
- .unwrap(),
- )
- });
-
- impl Import {
- fn to_identifier_parts(&self, identifier: &str) -> Vec<String> {
- match self {
- Import::Direct { module } => module.to_identifier_parts(identifier),
- Import::Alias {
- module,
- external_identifier: external_name,
- } => {
- module.to_identifier_parts(&format!("{} AS {}", external_name.name, identifier))
- }
- }
- }
- }
-
- impl Module {
- fn to_identifier_parts(&self, identifier: &str) -> Vec<String> {
- match self {
- Self::Namespace(namespace) => namespace.to_identifier_parts(identifier),
- Self::SourceExact(path) => {
- vec![
- format!("SOURCE {}", path.display().to_string().replace("\\", "/")),
- identifier.to_string(),
- ]
- }
- Self::SourceFuzzy(path) => {
- vec![
- format!(
- "SOURCE FUZZY {}",
- path.display().to_string().replace("\\", "/")
- ),
- identifier.to_string(),
- ]
- }
- }
- }
- }
-
- impl Namespace {
- fn to_identifier_parts(&self, identifier: &str) -> Vec<String> {
- self.0
- .iter()
- .map(|chunk| chunk.to_string())
- .chain(std::iter::once(identifier.to_string()))
- .collect::<Vec<_>>()
- }
- }
-}
@@ -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<usize>,
- pub identifier: Identifier,
- pub item_range: Range<usize>,
- pub signature_range: Range<usize>,
-}
-
-pub fn declarations_in_buffer(buffer: &BufferSnapshot) -> Vec<OutlineDeclaration> {
- declarations_overlapping_range(0..buffer.len(), buffer)
-}
-
-pub fn declarations_overlapping_range(
- range: Range<usize>,
- buffer: &BufferSnapshot,
-) -> Vec<OutlineDeclaration> {
- let mut declarations = OutlineIterator::new(range, buffer).collect::<Vec<_>>();
- declarations.sort_unstable_by_key(|item| (item.item_range.start, Reverse(item.item_range.end)));
-
- let mut parent_stack: Vec<(usize, Range<usize>)> = 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<usize>, 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<Self::Item> {
- 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<usize>| {
- 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::<String>()
- .into();
-
- return Some(OutlineDeclaration {
- identifier: Identifier { name, language_id },
- item_range: item_range,
- signature_range: signature_start..signature_end,
- parent_index: None,
- });
- }
- }
- None
- }
-}
@@ -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<usize>,
- 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<Identifier, Vec<Reference>> {
- 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<Identifier, Vec<Reference>> = 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<usize>,
- range_text: &str,
- reference_region: ReferenceRegion,
- buffer: &BufferSnapshot,
-) -> Vec<Reference> {
- 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()
- }
-}
@@ -1,1069 +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::CachedDeclarationPath;
-use crate::declaration::{
- BufferDeclaration, Declaration, DeclarationId, FileDeclaration, Identifier,
-};
-use crate::outline::declarations_in_buffer;
-
-// TODO
-//
-// * Also queue / debounce buffer changes. A challenge for this is that use of
-// `buffer_declarations_containing_range` assumes that the index is always immediately up to date.
-//
-// * Add a per language configuration for skipping indexing.
-//
-// * Handle tsx / ts / js referencing each-other
-
-// Potential future improvements:
-//
-// * Prevent indexing of a large file from blocking the queue.
-//
-// * Send multiple selected excerpt ranges. Challenge is that excerpt ranges influence which
-// references are present and their scores.
-//
-// * Include single-file worktrees / non visible worktrees? E.g. go to definition that resolves to a
-// file in a build dependency. Should not be editable in that case - but how to distinguish the case
-// where it should be editable?
-
-// Potential future optimizations:
-//
-// * Index files on multiple threads in Zed (currently only parallel for the CLI). Adding some kind
-// of priority system to the background executor could help - it's single threaded for now to avoid
-// interfering with other work.
-//
-// * Parse files directly instead of loading into a Rope.
-//
-// - This would allow the task handling dirty_files to be done entirely on the background executor.
-//
-// - Make SyntaxMap generic to handle embedded languages? Will also need to find line boundaries,
-// but that can be done by scanning characters in the flat representation.
-//
-// * Use something similar to slotmap without key versions.
-//
-// * Concurrent slotmap
-
-pub struct SyntaxIndex {
- state: Arc<Mutex<SyntaxIndexState>>,
- project: WeakEntity<Project>,
- initial_file_indexing_done_rx: postage::watch::Receiver<bool>,
- _file_indexing_task: Option<Task<()>>,
-}
-
-pub struct SyntaxIndexState {
- declarations: SlotMap<DeclarationId, Declaration>,
- identifiers: HashMap<Identifier, HashSet<DeclarationId>>,
- files: HashMap<ProjectEntryId, FileState>,
- buffers: HashMap<BufferId, BufferState>,
- dirty_files: HashMap<ProjectEntryId, ProjectPath>,
- dirty_files_tx: mpsc::Sender<()>,
-}
-
-#[derive(Debug, Default)]
-struct FileState {
- declarations: Vec<DeclarationId>,
-}
-
-#[derive(Default)]
-struct BufferState {
- declarations: Vec<DeclarationId>,
- task: Option<Task<()>>,
-}
-
-impl SyntaxIndex {
- pub fn new(
- project: &Entity<Project>,
- file_indexing_parallelism: usize,
- cx: &mut Context<Self>,
- ) -> Self {
- assert!(file_indexing_parallelism > 0);
- let (dirty_files_tx, mut dirty_files_rx) = mpsc::channel::<()>(1);
- let (mut initial_file_indexing_done_tx, initial_file_indexing_done_rx) =
- postage::watch::channel();
-
- let initial_state = SyntaxIndexState {
- declarations: SlotMap::default(),
- identifiers: HashMap::default(),
- files: HashMap::default(),
- buffers: HashMap::default(),
- dirty_files: HashMap::default(),
- dirty_files_tx,
- };
- let mut this = Self {
- project: project.downgrade(),
- state: Arc::new(Mutex::new(initial_state)),
- initial_file_indexing_done_rx,
- _file_indexing_task: None,
- };
-
- let worktree_store = project.read(cx).worktree_store();
- let initial_worktree_snapshots = worktree_store
- .read(cx)
- .worktrees()
- .map(|w| w.read(cx).snapshot())
- .collect::<Vec<_>>();
- this._file_indexing_task = Some(cx.spawn(async move |this, cx| {
- let snapshots_file_count = initial_worktree_snapshots
- .iter()
- .map(|worktree| worktree.file_count())
- .sum::<usize>();
- if snapshots_file_count > 0 {
- let chunk_size = snapshots_file_count.div_ceil(file_indexing_parallelism);
- let chunk_count = snapshots_file_count.div_ceil(chunk_size);
- let file_chunks = initial_worktree_snapshots
- .iter()
- .flat_map(|worktree| {
- let worktree_id = worktree.id();
- worktree.files(false, 0).map(move |entry| {
- (
- entry.id,
- ProjectPath {
- worktree_id,
- path: entry.path.clone(),
- },
- )
- })
- })
- .chunks(chunk_size);
-
- let mut tasks = Vec::with_capacity(chunk_count);
- for chunk in file_chunks.into_iter() {
- tasks.push(Self::update_dirty_files(
- &this,
- chunk.into_iter().collect(),
- cx.clone(),
- ));
- }
- futures::future::join_all(tasks).await;
- log::info!("Finished initial file indexing");
- }
-
- *initial_file_indexing_done_tx.borrow_mut() = true;
-
- let Ok(state) = this.read_with(cx, |this, _cx| Arc::downgrade(&this.state)) else {
- return;
- };
- while dirty_files_rx.next().await.is_some() {
- let Some(state) = state.upgrade() else {
- return;
- };
- let mut state = state.lock().await;
- let was_underused = state.dirty_files.capacity() > 255
- && state.dirty_files.len() * 8 < state.dirty_files.capacity();
- let dirty_files = state.dirty_files.drain().collect::<Vec<_>>();
- 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::<Vec<_>>() {
- this.register_buffer(&buffer, cx);
- }
- cx.subscribe(&buffer_store, Self::handle_buffer_store_event)
- .detach();
-
- this
- }
-
- async fn update_dirty_files(
- this: &WeakEntity<Self>,
- 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<Result<()>> {
- 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<Vec<ProjectPath>> {
- 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<WorktreeStore>,
- event: &WorktreeStoreEvent,
- cx: &mut Context<Self>,
- ) {
- 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<BufferStore>,
- event: &BufferStoreEvent,
- cx: &mut Context<Self>,
- ) {
- use BufferStoreEvent::*;
- match event {
- BufferAdded(buffer) => self.register_buffer(buffer, cx),
- BufferOpened { .. }
- | BufferChangedFilePath { .. }
- | BufferDropped { .. }
- | SharedBufferClosed { .. } => {}
- }
- }
-
- pub fn state(&self) -> &Arc<Mutex<SyntaxIndexState>> {
- &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<Buffer>, cx: &mut Context<Self>) {
- 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<Buffer>,
- event: &BufferEvent,
- cx: &mut Context<Self>,
- ) {
- match event {
- BufferEvent::Edited |
- // paths are cached and so should be updated
- BufferEvent::FileHandleChanged => self.update_buffer(buffer, cx),
- _ => {}
- }
- }
-
- fn update_buffer(&self, buffer_entity: Entity<Buffer>, cx: &mut Context<Self>) {
- let buffer = buffer_entity.read(cx);
- if buffer.language().is_none() {
- return;
- }
-
- let Some((project_entry_id, cached_path)) = project::File::from_dyn(buffer.file())
- .and_then(|f| {
- let project_entry_id = f.project_entry_id()?;
- let cached_path = CachedDeclarationPath::new(
- f.worktree.read(cx).abs_path(),
- &f.path,
- buffer.language(),
- );
- Some((project_entry_id, cached_path))
- })
- else {
- return;
- };
- let buffer_id = buffer.remote_id();
-
- let mut parse_status = buffer.parse_status();
- let snapshot_task = cx.spawn({
- let weak_buffer = buffer_entity.downgrade();
- async move |_, cx| {
- while *parse_status.borrow() != language::ParseStatus::Idle {
- parse_status.changed().await?;
- }
- weak_buffer.read_with(cx, |buffer, _cx| buffer.snapshot())
- }
- });
-
- let state = Arc::downgrade(&self.state);
- let task = cx.background_spawn(async move {
- // TODO: How to handle errors?
- let Ok(snapshot) = snapshot_task.await else {
- return;
- };
- let rope = snapshot.text.as_rope();
-
- let declarations = declarations_in_buffer(&snapshot)
- .into_iter()
- .map(|item| {
- (
- item.parent_index,
- BufferDeclaration::from_outline(item, &rope),
- )
- })
- .collect::<Vec<_>>();
-
- let Some(state) = state.upgrade() else {
- return;
- };
- let mut state = state.lock().await;
- let state = state.deref_mut();
-
- let buffer_state = state
- .buffers
- .entry(buffer_id)
- .or_insert_with(Default::default);
-
- SyntaxIndexState::remove_buffer_declarations(
- &buffer_state.declarations,
- &mut state.declarations,
- &mut state.identifiers,
- );
-
- let mut new_ids = Vec::with_capacity(declarations.len());
- state.declarations.reserve(declarations.len());
- for (parent_index, mut declaration) in declarations {
- declaration.parent =
- parent_index.and_then(|ix| some_or_debug_panic(new_ids.get(ix).copied()));
-
- let identifier = declaration.identifier.clone();
- let declaration_id = state.declarations.insert(Declaration::Buffer {
- rope: rope.clone(),
- buffer_id,
- declaration,
- project_entry_id,
- cached_path: cached_path.clone(),
- });
- new_ids.push(declaration_id);
-
- state
- .identifiers
- .entry(identifier)
- .or_default()
- .insert(declaration_id);
- }
-
- buffer_state.declarations = new_ids;
- });
-
- self.with_state(cx, move |state| {
- state
- .buffers
- .entry(buffer_id)
- .or_insert_with(Default::default)
- .task = Some(task)
- });
- }
-
- fn update_file(
- &mut self,
- entry_id: ProjectEntryId,
- project_path: ProjectPath,
- cx: &mut Context<Self>,
- ) -> Task<()> {
- let Some(project) = self.project.upgrade() else {
- return Task::ready(());
- };
- let project = project.read(cx);
-
- let language_registry = project.languages();
- let Some(available_language) =
- language_registry.language_for_file_path(project_path.path.as_std_path())
- else {
- return Task::ready(());
- };
- let language = if let Some(Ok(Ok(language))) = language_registry
- .load_language(&available_language)
- .now_or_never()
- {
- if language
- .grammar()
- .is_none_or(|grammar| grammar.outline_config.is_none())
- {
- return Task::ready(());
- }
- future::Either::Left(async { Ok(language) })
- } else {
- let language_registry = language_registry.clone();
- future::Either::Right(async move {
- anyhow::Ok(
- language_registry
- .load_language(&available_language)
- .await??,
- )
- })
- };
-
- let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx) else {
- return Task::ready(());
- };
-
- let snapshot_task = worktree.update(cx, |worktree, cx| {
- let load_task = worktree.load_file(&project_path.path, cx);
- let worktree_abs_path = worktree.abs_path();
- cx.spawn(async move |_this, cx| {
- let loaded_file = load_task.await?;
- let language = language.await?;
-
- let buffer = cx.new(|cx| {
- let mut buffer = Buffer::local(loaded_file.text, cx);
- buffer.set_language(Some(language.clone()), cx);
- buffer
- })?;
-
- let mut parse_status = buffer.read_with(cx, |buffer, _| buffer.parse_status())?;
- while *parse_status.borrow() != language::ParseStatus::Idle {
- parse_status.changed().await?;
- }
-
- let cached_path = CachedDeclarationPath::new(
- worktree_abs_path,
- &project_path.path,
- Some(&language),
- );
-
- let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
-
- anyhow::Ok((snapshot, cached_path))
- })
- });
-
- let state = Arc::downgrade(&self.state);
- cx.background_spawn(async move {
- // TODO: How to handle errors?
- let Ok((snapshot, cached_path)) = snapshot_task.await else {
- return;
- };
- let rope = snapshot.as_rope();
- let declarations = declarations_in_buffer(&snapshot)
- .into_iter()
- .map(|item| (item.parent_index, FileDeclaration::from_outline(item, rope)))
- .collect::<Vec<_>>();
-
- let Some(state) = state.upgrade() else {
- return;
- };
- let mut state = state.lock().await;
- let state = state.deref_mut();
-
- let file_state = state.files.entry(entry_id).or_insert_with(Default::default);
- for old_declaration_id in &file_state.declarations {
- let Some(declaration) = state.declarations.remove(*old_declaration_id) else {
- debug_panic!("declaration not found");
- continue;
- };
- if let Some(identifier_declarations) =
- state.identifiers.get_mut(declaration.identifier())
- {
- identifier_declarations.remove(old_declaration_id);
- }
- }
-
- let mut new_ids = Vec::with_capacity(declarations.len());
- state.declarations.reserve(declarations.len());
- for (parent_index, mut declaration) in declarations {
- declaration.parent =
- parent_index.and_then(|ix| some_or_debug_panic(new_ids.get(ix).copied()));
-
- let identifier = declaration.identifier.clone();
- let declaration_id = state.declarations.insert(Declaration::File {
- project_entry_id: entry_id,
- declaration,
- cached_path: cached_path.clone(),
- });
- new_ids.push(declaration_id);
-
- state
- .identifiers
- .entry(identifier)
- .or_default()
- .insert(declaration_id);
- }
- file_state.declarations = new_ids;
- })
- }
-}
-
-impl SyntaxIndexState {
- pub fn declaration(&self, id: DeclarationId) -> Option<&Declaration> {
- self.declarations.get(id)
- }
-
- /// Returns declarations for the identifier. If the limit is exceeded, returns an empty vector.
- ///
- /// TODO: Consider doing some pre-ranking and instead truncating when N is exceeded.
- pub fn declarations_for_identifier<const N: usize>(
- &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<usize>,
- ) -> impl Iterator<Item = (DeclarationId, &BufferDeclaration)> {
- 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<DeclarationId, Declaration>,
- identifiers: &mut HashMap<Identifier, HashSet<DeclarationId>>,
- ) {
- for old_declaration_id in old_declaration_ids {
- let Some(declaration) = declarations.remove(*old_declaration_id) else {
- debug_panic!("declaration not found");
- continue;
- };
- if let Some(identifier_declarations) = identifiers.get_mut(declaration.identifier()) {
- identifier_declarations.remove(old_declaration_id);
- }
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::sync::Arc;
-
- use gpui::TestAppContext;
- use indoc::indoc;
- use language::{Language, LanguageConfig, LanguageId, LanguageMatcher, tree_sitter_rust};
- use project::{FakeFs, Project};
- use serde_json::json;
- use settings::SettingsStore;
- use text::OffsetRangeExt as _;
- use util::{path, rel_path::rel_path};
-
- use crate::syntax_index::SyntaxIndex;
-
- #[gpui::test]
- async fn test_unopen_indexed_files(cx: &mut TestAppContext) {
- let (project, index, rust_lang_id) = init_test(cx).await;
- let main = Identifier {
- name: "main".into(),
- language_id: rust_lang_id,
- };
-
- let index_state = index.read_with(cx, |index, _cx| index.state().clone());
- let index_state = index_state.lock().await;
- cx.update(|cx| {
- let decls = index_state.declarations_for_identifier::<8>(&main);
- assert_eq!(decls.len(), 2);
-
- let decl = expect_file_decl("a.rs", &decls[0].1, &project, cx);
- assert_eq!(decl.identifier, main);
- assert_eq!(decl.item_range, 0..98);
-
- let decl = expect_file_decl("c.rs", &decls[1].1, &project, cx);
- assert_eq!(decl.identifier, main.clone());
- assert_eq!(decl.item_range, 32..280);
- });
- }
-
- #[gpui::test]
- async fn test_parents_in_file(cx: &mut TestAppContext) {
- let (project, index, rust_lang_id) = init_test(cx).await;
- let test_process_data = Identifier {
- name: "test_process_data".into(),
- language_id: rust_lang_id,
- };
-
- let index_state = index.read_with(cx, |index, _cx| index.state().clone());
- let index_state = index_state.lock().await;
- cx.update(|cx| {
- let decls = index_state.declarations_for_identifier::<8>(&test_process_data);
- assert_eq!(decls.len(), 1);
-
- let decl = expect_file_decl("c.rs", &decls[0].1, &project, cx);
- assert_eq!(decl.identifier, test_process_data);
-
- let parent_id = decl.parent.unwrap();
- let parent = index_state.declaration(parent_id).unwrap();
- let parent_decl = expect_file_decl("c.rs", &parent, &project, cx);
- assert_eq!(
- parent_decl.identifier,
- Identifier {
- name: "tests".into(),
- language_id: rust_lang_id
- }
- );
- assert_eq!(parent_decl.parent, None);
- });
- }
-
- #[gpui::test]
- async fn test_parents_in_buffer(cx: &mut TestAppContext) {
- let (project, index, rust_lang_id) = init_test(cx).await;
- let test_process_data = Identifier {
- name: "test_process_data".into(),
- language_id: rust_lang_id,
- };
-
- let buffer = project
- .update(cx, |project, cx| {
- let project_path = project.find_project_path("c.rs", cx).unwrap();
- project.open_buffer(project_path, cx)
- })
- .await
- .unwrap();
-
- cx.run_until_parked();
-
- let index_state = index.read_with(cx, |index, _cx| index.state().clone());
- let index_state = index_state.lock().await;
- cx.update(|cx| {
- let decls = index_state.declarations_for_identifier::<8>(&test_process_data);
- assert_eq!(decls.len(), 1);
-
- let decl = expect_buffer_decl("c.rs", &decls[0].1, &project, cx);
- assert_eq!(decl.identifier, test_process_data);
-
- let parent_id = decl.parent.unwrap();
- let parent = index_state.declaration(parent_id).unwrap();
- let parent_decl = expect_buffer_decl("c.rs", &parent, &project, cx);
- assert_eq!(
- parent_decl.identifier,
- Identifier {
- name: "tests".into(),
- language_id: rust_lang_id
- }
- );
- assert_eq!(parent_decl.parent, None);
- });
-
- drop(buffer);
- }
-
- #[gpui::test]
- async fn test_declarations_limit(cx: &mut TestAppContext) {
- let (_, index, rust_lang_id) = init_test(cx).await;
-
- let index_state = index.read_with(cx, |index, _cx| index.state().clone());
- let index_state = index_state.lock().await;
- let decls = index_state.declarations_for_identifier::<1>(&Identifier {
- name: "main".into(),
- language_id: rust_lang_id,
- });
- assert_eq!(decls.len(), 0);
- }
-
- #[gpui::test]
- async fn test_buffer_shadow(cx: &mut TestAppContext) {
- let (project, index, rust_lang_id) = init_test(cx).await;
-
- let main = Identifier {
- name: "main".into(),
- language_id: rust_lang_id,
- };
-
- let buffer = project
- .update(cx, |project, cx| {
- let project_path = project.find_project_path("c.rs", cx).unwrap();
- project.open_buffer(project_path, cx)
- })
- .await
- .unwrap();
-
- cx.run_until_parked();
-
- let index_state_arc = index.read_with(cx, |index, _cx| index.state().clone());
- {
- let index_state = index_state_arc.lock().await;
-
- cx.update(|cx| {
- let decls = index_state.declarations_for_identifier::<8>(&main);
- assert_eq!(decls.len(), 2);
- let decl = expect_buffer_decl("c.rs", &decls[0].1, &project, cx);
- assert_eq!(decl.identifier, main);
- assert_eq!(decl.item_range.to_offset(&buffer.read(cx)), 32..280);
-
- expect_file_decl("a.rs", &decls[1].1, &project, cx);
- });
- }
-
- // Drop the buffer and wait for release
- cx.update(|_| {
- drop(buffer);
- });
- cx.run_until_parked();
-
- let index_state = index_state_arc.lock().await;
-
- cx.update(|cx| {
- let decls = index_state.declarations_for_identifier::<8>(&main);
- assert_eq!(decls.len(), 2);
- expect_file_decl("a.rs", &decls[0].1, &project, cx);
- expect_file_decl("c.rs", &decls[1].1, &project, cx);
- });
- }
-
- fn expect_buffer_decl<'a>(
- path: &str,
- declaration: &'a Declaration,
- project: &Entity<Project>,
- 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<Project>,
- 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<Project>, Entity<SyntaxIndex>, LanguageId) {
- cx.update(|cx| {
- let settings_store = SettingsStore::test(cx);
- cx.set_global(settings_store);
- });
-
- 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<String> = std::env::args().collect();
- let data: Vec<i32> = args[1..]
- .iter()
- .filter_map(|s| s.parse().ok())
- .collect();
- let result = process_data(data);
- println!("{:?}", result);
- }
-
- fn process_data(data: Vec<i32>) -> HashMap<i32, usize> {
- 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()
- }
-}
@@ -1,314 +0,0 @@
-use hashbrown::HashTable;
-use regex::Regex;
-use std::{
- borrow::Cow,
- hash::{Hash, Hasher as _},
- path::Path,
- sync::LazyLock,
-};
-use util::rel_path::RelPath;
-
-use crate::reference::Reference;
-
-// TODO: Consider implementing sliding window similarity matching like
-// https://github.com/sourcegraph/cody-public-snapshot/blob/8e20ac6c1460c08b0db581c0204658112a246eda/vscode/src/completions/context/retrievers/jaccard-similarity/bestJaccardMatch.ts
-//
-// That implementation could actually be more efficient - no need to track words in the window that
-// are not in the query.
-
-// TODO: Consider a flat sorted Vec<(String, usize)> representation. Intersection can just walk the
-// two in parallel.
-
-static IDENTIFIER_REGEX: LazyLock<Regex> = 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<OccurrenceEntry>,
- total_count: usize,
-}
-
-#[derive(Debug)]
-struct OccurrenceEntry {
- hash: u64,
- count: usize,
-}
-
-impl Occurrences {
- pub fn within_string(text: &str) -> Self {
- Self::from_identifiers(IDENTIFIER_REGEX.find_iter(text).map(|mat| mat.as_str()))
- }
-
- #[allow(dead_code)]
- pub fn within_references(references: &[Reference]) -> Self {
- Self::from_identifiers(
- references
- .iter()
- .map(|reference| reference.identifier.name.as_ref()),
- )
- }
-
- pub fn from_identifiers(identifiers: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
- let mut this = Self::default();
- // TODO: Score matches that match case higher?
- //
- // TODO: Also include unsplit identifier?
- for identifier in identifiers {
- for identifier_part in split_identifier(identifier.as_ref()) {
- this.add_hash(fx_hash(&identifier_part.to_lowercase()));
- }
- }
- this
- }
-
- pub fn from_worktree_path(worktree_name: Option<Cow<'_, str>>, rel_path: &RelPath) -> Self {
- if let Some(worktree_name) = worktree_name {
- Self::from_identifiers(
- std::iter::once(worktree_name)
- .chain(iter_path_without_extension(rel_path.as_std_path())),
- )
- } else {
- Self::from_path(rel_path.as_std_path())
- }
- }
-
- pub fn from_path(path: &Path) -> Self {
- Self::from_identifiers(iter_path_without_extension(path))
- }
-
- fn add_hash(&mut self, hash: u64) {
- self.table
- .entry(
- hash,
- |entry: &OccurrenceEntry| entry.hash == hash,
- |entry| entry.hash,
- )
- .and_modify(|entry| entry.count += 1)
- .or_insert(OccurrenceEntry { hash, count: 1 });
- self.total_count += 1;
- }
-
- fn contains_hash(&self, hash: u64) -> bool {
- self.get_count(hash) != 0
- }
-
- fn get_count(&self, hash: u64) -> usize {
- self.table
- .find(hash, |entry| entry.hash == hash)
- .map(|entry| entry.count)
- .unwrap_or(0)
- }
-}
-
-fn iter_path_without_extension(path: &Path) -> impl Iterator<Item = Cow<'_, str>> {
- let last_component: Option<Cow<'_, str>> = path.file_stem().map(|stem| stem.to_string_lossy());
- let mut path_components = path.components();
- path_components.next_back();
- path_components
- .map(|component| component.as_os_str().to_string_lossy())
- .chain(last_component)
-}
-
-pub fn fx_hash<T: Hash + ?Sized>(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<char> = 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<OutlineItem> {",
- );
-
- // 6 overlaps: "outline", "items", "query", "language", "tree", "source"
- // 7 non-overlaps: "let", "mut", "pub", "fn", "vec", "item", "str"
- assert_eq!(jaccard_similarity(&set_a, &set_b), 6.0 / (6.0 + 7.0));
-
- // Numerator is one more than before due to both having 2 "outline".
- // Denominator is the same except for 3 more due to the non-overlapping duplicates
- assert_eq!(
- weighted_jaccard_similarity(&set_a, &set_b),
- 7.0 / (7.0 + 7.0 + 3.0)
- );
-
- // Numerator is the same as jaccard_similarity. Denominator is the size of the smaller set, 8.
- assert_eq!(overlap_coefficient(&set_a, &set_b), 6.0 / 8.0);
-
- // Numerator is the same as weighted_jaccard_similarity. Denominator is the total weight of
- // the smaller set, 10.
- assert_eq!(weighted_overlap_coefficient(&set_a, &set_b), 7.0 / 10.0);
- }
-
- #[test]
- fn test_iter_path_without_extension() {
- let mut iter = iter_path_without_extension(Path::new(""));
- assert_eq!(iter.next(), None);
-
- let iter = iter_path_without_extension(Path::new("foo"));
- assert_eq!(iter.collect::<Vec<_>>(), ["foo"]);
-
- let iter = iter_path_without_extension(Path::new("foo/bar.txt"));
- assert_eq!(iter.collect::<Vec<_>>(), ["foo", "bar"]);
-
- let iter = iter_path_without_extension(Path::new("foo/bar/baz.txt"));
- assert_eq!(iter.collect::<Vec<_>>(), ["foo", "bar", "baz"]);
- }
-}
@@ -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
@@ -0,0 +1,278 @@
+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<SharedString>,
+ edits: Vec<(Range<language::Anchor>, Arc<str>)>,
+ edit_preview: Option<language::EditPreview>,
+ },
+ /// Jump to a different file from the one that requested the prediction
+ Jump {
+ id: Option<SharedString>,
+ 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<EditPredictionUsage> {
+ None
+ }
+
+ fn toggle_data_collection(&mut self, _cx: &mut App) {}
+ fn is_enabled(
+ &self,
+ buffer: &Entity<Buffer>,
+ cursor_position: language::Anchor,
+ cx: &App,
+ ) -> bool;
+ fn is_refreshing(&self, cx: &App) -> bool;
+ fn refresh(
+ &mut self,
+ buffer: Entity<Buffer>,
+ cursor_position: language::Anchor,
+ debounce: bool,
+ cx: &mut Context<Self>,
+ );
+ fn accept(&mut self, cx: &mut Context<Self>);
+ fn discard(&mut self, cx: &mut Context<Self>);
+ fn did_show(&mut self, _cx: &mut Context<Self>) {}
+ fn suggest(
+ &mut self,
+ buffer: &Entity<Buffer>,
+ cursor_position: language::Anchor,
+ cx: &mut Context<Self>,
+ ) -> Option<EditPrediction>;
+}
+
+pub trait EditPredictionDelegateHandle {
+ fn name(&self) -> &'static str;
+ fn display_name(&self) -> &'static str;
+ fn is_enabled(
+ &self,
+ buffer: &Entity<Buffer>,
+ 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<EditPredictionUsage>;
+ fn toggle_data_collection(&self, cx: &mut App);
+ fn is_refreshing(&self, cx: &App) -> bool;
+ fn refresh(
+ &self,
+ buffer: Entity<Buffer>,
+ cursor_position: language::Anchor,
+ debounce: bool,
+ 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<Buffer>,
+ cursor_position: language::Anchor,
+ cx: &mut App,
+ ) -> Option<EditPrediction>;
+}
+
+impl<T> EditPredictionDelegateHandle for Entity<T>
+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<EditPredictionUsage> {
+ 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<Buffer>,
+ 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<Buffer>,
+ cursor_position: language::Anchor,
+ debounce: bool,
+ cx: &mut App,
+ ) {
+ self.update(cx, |this, cx| {
+ this.refresh(buffer, cursor_position, debounce, 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<Buffer>,
+ cursor_position: language::Anchor,
+ cx: &mut App,
+ ) -> Option<EditPrediction> {
+ 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<Anchor>, Arc<str>)],
+) -> Option<Vec<(Range<Anchor>, Arc<str>)>> {
+ let mut edits = Vec::new();
+
+ let mut model_edits = current_edits.iter().peekable();
+ for user_edit in new_snapshot.edits_since::<usize>(&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::<String>();
+
+ 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) }
+}
@@ -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
+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"] }
@@ -1,15 +1,21 @@
use anyhow::Result;
use client::{Client, UserStore, zed_urls};
use cloud_llm_client::UsageLimit;
-use codestral::CodestralCompletionProvider;
+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::{
@@ -18,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,
@@ -28,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,
@@ -43,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;
@@ -55,7 +71,7 @@ pub struct EditPredictionButton {
editor_focus_handle: Option<FocusHandle>,
language: Option<Arc<Language>>,
file: Option<Arc<dyn File>>,
- edit_prediction_provider: Option<Arc<dyn edit_prediction::EditPredictionProviderHandle>>,
+ edit_prediction_provider: Option<Arc<dyn EditPredictionDelegateHandle>>,
fs: Arc<dyn Fs>,
user_store: Entity<UserStore>,
popover_menu_handle: PopoverMenuHandle<ContextMenu>,
@@ -78,8 +94,6 @@ impl Render for EditPredictionButton {
let all_language_settings = all_language_settings(None, cx);
match all_language_settings.edit_predictions.provider {
- EditPredictionProvider::None => div().hidden(),
-
EditPredictionProvider::Copilot => {
let Some(copilot) = Copilot::global(cx) else {
return div().hidden();
@@ -128,20 +142,21 @@ impl Render for EditPredictionButton {
}),
);
}
- 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(
@@ -182,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(
@@ -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)
@@ -231,45 +248,22 @@ impl Render for EditPredictionButton {
EditPredictionProvider::Codestral => {
let enabled = self.editor_enabled.unwrap_or(true);
- let has_api_key = CodestralCompletionProvider::has_api_key(cx);
- let fs = self.fs.clone();
- let this = cx.entity();
+ let has_api_key = CodestralEditPredictionDelegate::has_api_key(cx);
+ let this = cx.weak_entity();
+
+ let tooltip_meta = if has_api_key {
+ "Powered by Codestral"
+ } else {
+ "Missing API key for Codestral"
+ };
div().child(
PopoverMenu::new("codestral")
.menu(move |window, cx| {
- if has_api_key {
- Some(this.update(cx, |this, cx| {
- this.build_codestral_context_menu(window, cx)
- }))
- } else {
- Some(ContextMenu::build(window, cx, |menu, _, _| {
- let fs = fs.clone();
-
- menu.entry(
- "Configure Codestral API Key",
- None,
- move |window, cx| {
- window.dispatch_action(
- zed_actions::agent::OpenSettings.boxed_clone(),
- cx,
- );
- },
- )
- .separator()
- .entry(
- "Use Zed AI instead",
- None,
- move |_, cx| {
- set_completion_provider(
- fs.clone(),
- cx,
- EditPredictionProvider::Zed,
- )
- },
- )
- }))
- }
+ this.update(cx, |this, cx| {
+ this.build_codestral_context_menu(window, cx)
+ })
+ .ok()
})
.anchor(Corner::BottomRight)
.trigger_with_tooltip(
@@ -287,30 +281,69 @@ impl Render for EditPredictionButton {
cx.theme().colors().status_bar_background,
))
}),
- move |_window, cx| Tooltip::for_action("Codestral", &ToggleMenu, cx),
+ move |_window, cx| {
+ Tooltip::with_meta(
+ "Edit Prediction",
+ Some(&ToggleMenu),
+ tooltip_meta,
+ cx,
+ )
+ },
)
.with_handle(self.popover_menu_handle.clone()),
)
}
-
- EditPredictionProvider::Zed => {
+ provider @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed) => {
let enabled = self.editor_enabled.unwrap_or(true);
- let zeta_icon = if enabled {
- IconName::ZedPredict
- } else {
- IconName::ZedPredictDisabled
+ 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 zeta::should_show_upsell_modal() {
+ 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))
@@ -341,50 +374,73 @@ impl Render for EditPredictionButton {
}
let show_editor_predictions = self.editor_show_predictions;
+ let user = self.user_store.read(cx).current_user();
+
+ 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", zeta_icon)
+ 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| {
+ let user = user.clone();
+
element.tooltip(move |_window, cx| {
- if enabled {
+ let description = if enabled {
if show_editor_predictions {
- Tooltip::for_action("Edit Prediction", &ToggleMenu, cx)
+ tooltip_meta
+ } else if user.is_none() {
+ "Sign In To Use"
} else {
- Tooltip::with_meta(
- "Edit Prediction",
- Some(&ToggleMenu),
- "Hidden For This File",
- cx,
- )
+ "Hidden For This File"
}
} else {
- Tooltip::with_meta(
- "Edit Prediction",
- Some(&ToggleMenu),
- "Disabled For This File",
- cx,
- )
- }
+ "Disabled For This File"
+ };
+
+ Tooltip::with_meta(
+ "Edit Prediction",
+ Some(&ToggleMenu),
+ description,
+ cx,
+ )
})
});
let this = cx.weak_entity();
- let mut popover_menu = PopoverMenu::new("zeta")
- .menu(move |window, cx| {
- 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());
@@ -410,6 +466,8 @@ impl Render for EditPredictionButton {
div().child(popover_menu.into_any_element())
}
+
+ EditPredictionProvider::None => div().hidden(),
}
}
}
@@ -429,7 +487,22 @@ impl EditPredictionButton {
cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
.detach();
- CodestralCompletionProvider::ensure_api_key_loaded(client.http_client(), cx);
+ cx.observe_global::<EditPredictionStore>(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,
@@ -445,11 +518,17 @@ impl EditPredictionButton {
}
}
- fn get_available_providers(&self, cx: &App) -> Vec<EditPredictionProvider> {
+ fn get_available_providers(&self, cx: &mut App) -> Vec<EditPredictionProvider> {
let mut providers = Vec::new();
providers.push(EditPredictionProvider::Zed);
+ if cx.has_flag::<Zeta2FeatureFlag>() {
+ 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);
@@ -464,10 +543,30 @@ impl EditPredictionButton {
}
}
- if CodestralCompletionProvider::has_api_key(cx) {
+ if CodestralEditPredictionDelegate::has_api_key(cx) {
providers.push(EditPredictionProvider::Codestral);
}
+ if cx.has_flag::<SweepFeatureFlag>()
+ && 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::<MercuryFeatureFlag>()
+ && edit_prediction::mercury::mercury_api_token(cx)
+ .read(cx)
+ .has_key()
+ {
+ providers.push(EditPredictionProvider::Experimental(
+ EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
+ ));
+ }
+
providers
}
@@ -475,53 +574,48 @@ impl EditPredictionButton {
&self,
mut menu: ContextMenu,
current_provider: EditPredictionProvider,
- cx: &App,
+ cx: &mut App,
) -> ContextMenu {
let available_providers = self.get_available_providers(cx);
- let other_providers: Vec<_> = available_providers
+ let providers: Vec<_> = available_providers
.into_iter()
- .filter(|p| *p != current_provider && *p != EditPredictionProvider::None)
+ .filter(|p| *p != EditPredictionProvider::None)
.collect();
- if !other_providers.is_empty() {
- menu = menu.separator().header("Switch Providers");
+ if !providers.is_empty() {
+ menu = menu.separator().header("Providers");
- for provider in other_providers {
+ for provider in providers {
+ let is_current = provider == current_provider;
let fs = self.fs.clone();
- menu = match provider {
- EditPredictionProvider::Zed => menu.item(
- ContextMenuEntry::new("Zed AI")
- .documentation_aside(
- DocumentationSide::Left,
- DocumentationEdge::Top,
- |_| {
- Label::new("Zed's edit prediction is powered by Zeta, an open-source, dataset mode.")
- .into_any_element()
- },
- )
- .handler(move |_, cx| {
- set_completion_provider(fs.clone(), cx, provider);
- }),
- ),
- EditPredictionProvider::Copilot => {
- menu.entry("GitHub Copilot", None, move |_, cx| {
- set_completion_provider(fs.clone(), cx, provider);
- })
- }
- EditPredictionProvider::Supermaven => {
- menu.entry("Supermaven", None, move |_, cx| {
- set_completion_provider(fs.clone(), cx, provider);
- })
+ 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;
}
- EditPredictionProvider::Codestral => {
- menu.entry("Codestral", None, move |_, cx| {
- set_completion_provider(fs.clone(), cx, provider);
- })
- }
- EditPredictionProvider::None => continue,
};
+
+ menu = menu.item(
+ ContextMenuEntry::new(name)
+ .toggleable(IconPosition::Start, is_current)
+ .handler(move |_, cx| {
+ set_completion_provider(fs.clone(), cx, provider);
+ }),
+ )
}
}
@@ -626,14 +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
- | EditPredictionProvider::Codestral
- ) {
- menu = menu
+ menu = menu
.separator()
.header("Display Modes")
.item(
@@ -662,104 +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 !enabled {
- telemetry::event!(
- "Data Collection Enabled",
- source = "Edit Prediction Status Menu"
- );
- } else {
- telemetry::event!(
- "Data Collection Disabled",
- source = "Edit Prediction Status Menu"
- );
- }
- })
- );
+ 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 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),
+ );
+ }
}
}
}
@@ -819,8 +913,14 @@ impl EditPredictionButton {
)
.context(editor_focus_handle)
.when(
- cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>(),
- |this| this.action("Rate Completions", RateCompletions.boxed_clone()),
+ cx.has_flag::<PredictEditsRatePredictionsFeatureFlag>(),
+ |this| {
+ this.action(
+ "Capture Edit Prediction Example",
+ CaptureExample.boxed_clone(),
+ )
+ .action("Rate Predictions", RatePredictions.boxed_clone())
+ },
);
}
@@ -832,6 +932,16 @@ impl EditPredictionButton {
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<ContextMenu> {
+ 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| {
let menu = self.build_language_settings_menu(menu, window, cx);
let menu =
@@ -840,10 +950,7 @@ impl EditPredictionButton {
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())
})
@@ -874,15 +981,13 @@ impl EditPredictionButton {
let menu =
self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
- menu.separator()
- .entry("Configure Codestral API Key", None, move |window, cx| {
- window.dispatch_action(zed_actions::agent::OpenSettings.boxed_clone(), cx);
- })
+ menu
})
}
- fn build_zeta_context_menu(
+ fn build_edit_prediction_context_menu(
&self,
+ provider: EditPredictionProvider,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<ContextMenu> {
@@ -969,8 +1074,97 @@ impl EditPredictionButton {
.separator();
}
- let menu = self.build_language_settings_menu(menu, window, cx);
- let menu = self.add_provider_switching_section(menu, EditPredictionProvider::Zed, cx);
+ menu = self.build_language_settings_menu(menu, window, cx);
+
+ if cx.has_flag::<Zeta2FeatureFlag>() {
+ 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<Self>,
+ ) -> Entity<ContextMenu> {
+ 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
})
@@ -1083,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();
@@ -1098,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,
@@ -1172,3 +1372,166 @@ fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, 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");
+ }
+}
@@ -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<Project>,
+ store: Entity<EditPredictionStore>,
+ runs: VecDeque<RetrievalRun>,
+ current_ix: usize,
+ _update_task: Task<Result<()>>,
+}
+
+#[derive(Debug)]
+struct RetrievalRun {
+ editor: Entity<Editor>,
+ started_at: Instant,
+ metadata: Vec<(&'static str, SharedString)>,
+ finished_at: Option<Instant>,
+}
+
+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<Project>,
+ client: &Arc<Client>,
+ user_store: &Entity<UserStore>,
+ window: &mut gpui::Window,
+ cx: &mut Context<Self>,
+ ) -> 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<Self>,
+ ) {
+ 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<Self>,
+ ) {
+ 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<Self>,
+ ) {
+ 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::<Vec<_>>();
+ 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>,
+ ) {
+ 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>,
+ ) {
+ 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<Self>,
+ _: &'a App,
+ ) -> Option<gpui::AnyEntity> {
+ if type_id == TypeId::of::<Self>() {
+ Some(self_handle.clone().into())
+ } else if type_id == TypeId::of::<Editor>() {
+ 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<Self>) -> 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))
+ }
+ })
+ }
+}
@@ -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::<PredictEditsRatePredictionsFeatureFlag>() {
+ RatePredictionsModal::toggle(workspace, window, cx);
+ }
+ });
+
+ workspace.register_action(capture_edit_prediction_example);
+ workspace.register_action_renderer(|div, _, _, cx| {
+ let has_flag = cx.has_flag::<Zeta2FeatureFlag>();
+ 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::<RatePredictions>()];
+ let reset_onboarding_action_types = [TypeId::of::<ResetOnboarding>()];
+ let all_action_types = [
+ TypeId::of::<RatePredictions>(),
+ TypeId::of::<CaptureExample>(),
+ TypeId::of::<edit_prediction::ResetOnboarding>(),
+ zed_actions::OpenZedPredictOnboarding.type_id(),
+ TypeId::of::<edit_prediction::ClearHistory>(),
+ TypeId::of::<rate_prediction_modal::ThumbsUpActivePrediction>(),
+ TypeId::of::<rate_prediction_modal::ThumbsDownActivePrediction>(),
+ TypeId::of::<rate_prediction_modal::NextEdit>(),
+ TypeId::of::<rate_prediction_modal::PreviousEdit>(),
+ ];
+
+ 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::<SettingsStore>(move |cx| {
+ let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
+ let has_feature_flag = cx.has_flag::<PredictEditsRatePredictionsFeatureFlag>();
+
+ 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::<PredictEditsRatePredictionsFeatureFlag, _>(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<Workspace>,
+) {
+ 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::<Editor>(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<Path> = 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::<String>();
+ 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);
+}
@@ -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<EditPredictionStore>,
+ language_registry: Arc<LanguageRegistry>,
+ active_prediction: Option<ActivePrediction>,
+ selected_index: usize,
+ diff_editor: Entity<Editor>,
+ focus_handle: FocusHandle,
+ _subscription: gpui::Subscription,
+ current_view: RatePredictionView,
+}
+
+struct ActivePrediction {
+ prediction: EditPrediction,
+ feedback_editor: Entity<Editor>,
+ formatted_inputs: Entity<Markdown>,
+}
+
+#[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<Workspace>) {
+ 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<EditPredictionStore>,
+ language_registry: Arc<LanguageRegistry>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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<Self>) {
+ cx.emit(DismissEvent);
+ }
+
+ fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context<Self>) {
+ 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>,
+ ) {
+ self.selected_index = self.selected_index.saturating_sub(1);
+ cx.notify();
+ }
+
+ fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context<Self>) {
+ 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<Self>) {
+ 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>) {
+ self.selected_index = 0;
+ cx.notify();
+ }
+
+ fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+ 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>,
+ ) {
+ 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<Self>,
+ ) {
+ 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<Self>,
+ ) {
+ cx.focus_self(window);
+ cx.notify();
+ }
+
+ fn preview_completion(
+ &mut self,
+ _: &PreviewPrediction,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ 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<Self>) {
+ 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<EditPrediction>,
+ focus: bool,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ // 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), 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::<BufferDiff>(|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{}<CURSOR>{}\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<Self>) -> 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<Self>) -> Option<gpui::Stateful<Div>> {
+ 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<Self>,
+ ) -> Option<gpui::Stateful<Div>> {
+ 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<Self>,
+ ) -> Option<impl IntoElement> {
+ 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<Self>) -> impl Iterator<Item = ListItem> {
+ 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<Self>) -> 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<DismissEvent> 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)
+ }
+}
@@ -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
@@ -83,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 }
@@ -93,6 +96,7 @@ uuid.workspace = true
vim_mode_setting.workspace = true
workspace.workspace = true
zed_actions.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"] }
@@ -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);
});
},
);
@@ -29,7 +29,7 @@ fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &TestAppContext
);
editor
});
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
editor
});
@@ -72,7 +72,7 @@ fn open_editor_with_one_long_line(bencher: &mut Bencher<'_>, args: &(String, Tes
editor.set_style(editor::EditorStyle::default(), window, cx);
editor
});
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
editor
});
});
@@ -100,7 +100,7 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) {
editor.set_style(editor::EditorStyle::default(), window, cx);
editor
});
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
editor
});
@@ -123,7 +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);
+ // release_channel::init(semver::Version::new(0,0,0), cx);
editor::init(cx);
});
@@ -327,6 +327,23 @@ pub struct AddSelectionBelow {
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<String>,
+ /// Name if using a named snippet
+ pub name: Option<String>,
+
+ /// Snippet body, if not using a named snippet
+ // todo(andrew): use `ListOrDirect` or similar for multiline snippet body
+ pub snippet: Option<String>,
+}
+
actions!(
debugger,
[
@@ -353,7 +370,8 @@ actions!(
AcceptEditPrediction,
/// Accepts a partial edit prediction.
#[action(deprecated_aliases = ["editor::AcceptPartialCopilotSuggestion"])]
- AcceptPartialEditPrediction,
+ AcceptNextWordEditPrediction,
+ AcceptNextLineEditPrediction,
/// Applies all diff hunks in the editor.
ApplyAllDiffHunks,
/// Applies the diff hunk at the current position.
@@ -453,8 +471,6 @@ actions!(
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.
@@ -665,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.
@@ -827,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,
+ }
+ }
+}
@@ -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>) -> Self {
+ pub fn new(
+ blink_interval: Duration,
+ blink_enabled_in_settings: fn(&App) -> bool,
+ cx: &mut Context<Self>,
+ ) -> Self {
// Make sure we blink the cursors if the setting is re-enabled
cx.observe_global::<SettingsStore>(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<Self>) {
- 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<Self>) {
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>) {
self.visible = false;
self.enabled = false;
@@ -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<Editor>) {
+ 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::<Range<Anchor>>::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::<ColorizedBracketsHighlight>(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::<ColorizedBracketsHighlight>(
+ 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<T>(a: HashMap<String, Vec<Option<T>>>) -> usize
+where
+ T: Future<Output = HashMap<String, Vec<Option<Box<()>>>>>,
+{
+ 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«1<T>1»«1(a: HashMap«2<String, Vec«3<Option«4<T>4»>3»>2»)1» -> usize
+where
+ T: Future«1<Output = HashMap«2<String, Vec«3<Option«4<Box«5<«6()6»>5»>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<Option<&'a T>>,
+}
+
+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«2<Option«3<&'a T>3»>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("Option<Foo<'_, ()", 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«2<Option«3<&'a T>3»>2»,
+}1»
+
+fn process_data«1()1» «1{
+ let map: Result<Option<Foo<'_, «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)
+"#},
+ &bracket_colors_markup(&mut cx),
+ );
+
+ 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«2<Option«3<&'a T>3»>2»,
+}1»
+
+fn process_data«1()1» «1{
+ let map: Result<Option<Foo«2<'_, «3()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)
+"#},
+ &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«2<Option«3<&'a T>3»>2»,
+}1»
+
+fn process_data«1()1» «1{
+ let map: Result<Option«2<Foo«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)
+"#},
+ &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«2<Option«3<&'a T>3»>2»,
+}1»
+
+fn process_data«1()1» «1{
+ let map: Result«2<Option«3<Foo«4<'_, «5()5»>4»>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<Vec<()>> = None;
+ }
+"#},
+ indoc! {r#"
+ fn process_data_2() {
+ let map: Option<Vec<()>> = 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«3<Vec«4<«5()5»>4»>3» = None;
+ }2»
+"#},
+ indoc! {r#"
+ fn process_data_2() {
+ let map: Option<Vec<()>> = 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«3<Vec«4<«5()5»>4»>3» = None;
+ }2»
+"#},
+ indoc! {r#"
+ fn process_data_2«2()2» «2{
+ let map: Option«3<Vec«4<«5()5»>4»>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<Vec<()>> = None;
+ }
+"#},
+ indoc! {r#"
+ fn process_data_2«2()2» «2{
+ let map: Option«3<Vec«4<«5()5»>4»>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«3<Vec«4<«5()5»>4»>3» = None;
+ }2»
+"#},
+ indoc! {r#"
+ fn process_data_2«2()2» «2{
+ let map: Option«3<Vec«4<«5()5»>4»>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<Vec<()>> = None;
+ }
+"#},
+ r#" fn process_data_2() {
+ let map: Option<Vec<()>> = 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«3<Vec«4<«5()5»>4»>3» = None;
+ }2»
+"#},
+ r#" fn process_data_2() {
+ let map: Option<Vec<()>> = 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<TextRange>,
+ config: &InlayHintsConfig,
+ ) -> Vec<InlayHint> {
+ 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<Vec<SmolStr>>,
+ extern_block_parent: Option<ast::ExternBlock>,
+ }
+
+ pub(crate) fn inlay_hints_resolve(
+ db: &RootDatabase,
+ file_id: FileId,
+ resolve_range: TextRange,
+ hash: u64,
+ config: &InlayHintsConfig,
+ hasher: impl Fn(&InlayHint) -> u64,
+ ) -> Option<InlayHint> {
+ 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<SyntaxNode>) -> Option<SyntaxNode> {
+ 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<InlayHint>,
+ 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::<ColorizedBracketsHighlight>()
+ });
+
+ 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::<ColorizedBracketsHighlight>()
+ });
+ 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::<ColorizedBracketsHighlight>()
+ });
+ 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<Point> = 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::<String>(),
+ 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::<String>(),
+ 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<Vec<()>> = None;
+ // a
+ // b
+ // c
+ }
+ "#},
+ indoc! {r#"
+ fn process_data_2() {
+ let other_map: Option<Vec<()>> = 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«3<Vec«4<«5()5»>4»>3» = None;
+ // a
+ // b
+
+
+ fn process_data_2«2()2» «2{
+ let other_map: Option«3<Vec«4<«5()5»>4»>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«3<Vec«4<«5()5»>4»>3» = None;
+ // a
+ // b
+
+
+ fn process_data_2«2()2» «2{
+ let other_map: Option«3<Vec«4<«5()5»>4»>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«1<Vec«2<«1()1»>2»>1» = None;
+ // a
+ // b
+
+
+ fn process_data_2«2()2» «2{
+ let other_map: Option«1<Vec«2<«1()1»>2»>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::<ColorizedBracketsHighlight>();
+ 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
+ }
+}
@@ -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<F>(
target: &str,
completions: &Vec<Completion>,
@@ -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<CompletionItemKind>,
) -> 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,
}
}
}
@@ -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::{
@@ -36,18 +36,21 @@ use util::ResultExt;
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 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.
@@ -203,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 {
@@ -220,7 +230,9 @@ pub struct CompletionsMenu {
pub is_incomplete: bool,
pub buffer: Entity<Buffer>,
pub completions: Rc<RefCell<Box<[Completion]>>>,
- match_candidates: Arc<[StringMatchCandidate]>,
+ /// String match candidate for each completion, grouped by `match_start`.
+ match_candidates: Arc<[(Option<text::Anchor>, Vec<StringMatchCandidate>)]>,
+ /// Entries displayed in the menu, which is a filtered and sorted subset of `match_candidates`.
pub entries: Rc<RefCell<Box<[StringMatch]>>>,
pub selected_item: usize,
filter_task: Task<()>,
@@ -298,6 +310,7 @@ impl CompletionsMenu {
is_incomplete: bool,
buffer: Entity<Buffer>,
completions: Box<[Completion]>,
+ scroll_handle: Option<UniformListScrollHandle>,
display_options: CompletionDisplayOptions,
snippet_sort_order: SnippetSortOrder,
language_registry: Option<Arc<LanguageRegistry>>,
@@ -308,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 {
@@ -325,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(),
@@ -347,6 +362,7 @@ impl CompletionsMenu {
choices: &Vec<String>,
selection: Range<Anchor>,
buffer: Entity<Buffer>,
+ scroll_handle: Option<UniformListScrollHandle>,
snippet_sort_order: SnippetSortOrder,
) -> Self {
let completions = choices
@@ -355,6 +371,8 @@ impl CompletionsMenu {
replace_range: selection.start.text_anchor..selection.end.text_anchor,
new_text: choice.to_string(),
label: CodeLabel::plain(choice.to_string(), None),
+ match_start: None,
+ snippet_deduplication_key: None,
icon_path: None,
documentation: None,
confirm: None,
@@ -363,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()
@@ -392,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,
@@ -497,7 +518,7 @@ impl CompletionsMenu {
cx: &mut Context<Editor>,
) {
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() {
@@ -823,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())
@@ -888,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::<AnyElement>(start_slot)
- .child(h_flex().overflow_hidden().child(completion_label))
- .end_slot::<Label>(documentation_label),
- )
+ div()
+ .min_w(COMPLETION_MENU_MIN_WIDTH)
+ .max_w(COMPLETION_MENU_MAX_WIDTH)
+ .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::<AnyElement>(start_slot)
+ .child(h_flex().overflow_hidden().child(completion_label))
+ .end_slot::<Label>(documentation_label),
+ )
})
.collect()
}),
)
.occlude()
.max_h(max_height_in_lines as f32 * window.line_height())
- .track_scroll(self.scroll_handle.clone())
+ .track_scroll(&self.scroll_handle)
.with_sizing_behavior(ListSizingBehavior::Infer)
.map(|this| {
if self.display_options.dynamic_width {
@@ -929,7 +964,7 @@ impl CompletionsMenu {
div().child(list).custom_scrollbars(
Scrollbars::for_settings::<CompletionMenuScrollBarSetting>()
.show_along(ScrollAxes::Vertical)
- .tracked_scroll_handle(self.scroll_handle.clone()),
+ .tracked_scroll_handle(&self.scroll_handle),
window,
cx,
),
@@ -948,7 +983,7 @@ impl CompletionsMenu {
}
let mat = &self.entries.borrow()[self.selected_item];
- let completions = self.completions.borrow_mut();
+ let completions = self.completions.borrow();
let multiline_docs = match completions[mat.candidate_id].documentation.as_ref() {
Some(CompletionDocumentation::MultiLinePlainText(text)) => div().child(text.clone()),
Some(CompletionDocumentation::SingleLineAndMultiLinePlainText {
@@ -1026,57 +1061,74 @@ impl CompletionsMenu {
pub fn filter(
&mut self,
- query: Option<Arc<String>>,
+ query: Arc<String>,
+ query_end: text::Anchor,
+ buffer: &Entity<Buffer>,
provider: Option<Rc<dyn CompletionProvider>>,
window: &mut Window,
cx: &mut Context<Editor>,
) {
self.cancel_filter.store(true, Ordering::Relaxed);
- if let Some(query) = query {
- self.cancel_filter = Arc::new(AtomicBool::new(false));
- let matches = self.do_async_filtering(query, cx);
- let id = self.id;
- self.filter_task = cx.spawn_in(window, async move |editor, cx| {
- let matches = matches.await;
- editor
- .update_in(cx, |editor, window, cx| {
- editor.with_completions_menu_matching_id(id, |this| {
- if let Some(this) = this {
- this.set_filter_results(matches, provider, window, cx);
- }
- });
- })
- .ok();
- });
- } else {
- self.filter_task = Task::ready(());
- let matches = self.unfiltered_matches();
- self.set_filter_results(matches, provider, window, cx);
- }
+ self.cancel_filter = Arc::new(AtomicBool::new(false));
+ let matches = self.do_async_filtering(query, query_end, buffer, cx);
+ let id = self.id;
+ self.filter_task = cx.spawn_in(window, async move |editor, cx| {
+ let matches = matches.await;
+ editor
+ .update_in(cx, |editor, window, cx| {
+ editor.with_completions_menu_matching_id(id, |this| {
+ if let Some(this) = this {
+ this.set_filter_results(matches, provider, window, cx);
+ }
+ });
+ })
+ .ok();
+ });
}
pub fn do_async_filtering(
&self,
query: Arc<String>,
+ query_end: text::Anchor,
+ buffer: &Entity<Buffer>,
cx: &Context<Editor>,
) -> Task<Vec<StringMatch>> {
- let matches_task = cx.background_spawn({
- let query = query.clone();
- let match_candidates = self.match_candidates.clone();
- let cancel_filter = self.cancel_filter.clone();
- let background_executor = cx.background_executor().clone();
- async move {
- fuzzy::match_strings(
- &match_candidates,
- &query,
- query.chars().any(|c| c.is_uppercase()),
- false,
- 1000,
- &cancel_filter,
- background_executor,
- )
- .await
+ let buffer_snapshot = buffer.read(cx).snapshot();
+ let background_executor = cx.background_executor().clone();
+ let match_candidates = self.match_candidates.clone();
+ let cancel_filter = self.cancel_filter.clone();
+ let default_query = query.clone();
+
+ let matches_task = cx.background_spawn(async move {
+ let queries_and_candidates = match_candidates
+ .iter()
+ .map(|(query_start, candidates)| {
+ let query_for_batch = match query_start {
+ Some(start) => {
+ Arc::new(buffer_snapshot.text_for_range(*start..query_end).collect())
+ }
+ None => default_query.clone(),
+ };
+ (query_for_batch, candidates)
+ })
+ .collect_vec();
+
+ let mut results = vec![];
+ for (query, match_candidates) in queries_and_candidates {
+ results.extend(
+ fuzzy::match_strings(
+ &match_candidates,
+ &query,
+ query.chars().any(|c| c.is_uppercase()),
+ false,
+ 1000,
+ &cancel_filter,
+ background_executor.clone(),
+ )
+ .await,
+ );
}
+ results
});
let completions = self.completions.clone();
@@ -1085,45 +1137,31 @@ impl CompletionsMenu {
cx.foreground_executor().spawn(async move {
let mut matches = matches_task.await;
+ let completions_ref = completions.borrow();
+
if sort_completions {
matches = Self::sort_string_matches(
matches,
- Some(&query),
+ Some(&query), // used for non-snippets only
snippet_sort_order,
- completions.borrow().as_ref(),
+ &completions_ref,
);
}
+ // Remove duplicate snippet prefixes (e.g., "cool code" will match
+ // the text "c c" in two places; we should only show the longer one)
+ let mut snippets_seen = HashSet::<(usize, usize)>::default();
+ matches.retain(|result| {
+ match completions_ref[result.candidate_id].snippet_deduplication_key {
+ Some(key) => snippets_seen.insert(key),
+ None => true,
+ }
+ });
+
matches
})
}
- /// Like `do_async_filtering` but there is no filter query, so no need to spawn tasks.
- pub fn unfiltered_matches(&self) -> Vec<StringMatch> {
- let mut matches = self
- .match_candidates
- .iter()
- .enumerate()
- .map(|(candidate_id, candidate)| StringMatch {
- candidate_id,
- score: Default::default(),
- positions: Default::default(),
- string: candidate.string.clone(),
- })
- .collect();
-
- if self.sort_completions {
- matches = Self::sort_string_matches(
- matches,
- None,
- self.snippet_sort_order,
- self.completions.borrow().as_ref(),
- );
- }
-
- matches
- }
-
pub fn set_filter_results(
&mut self,
matches: Vec<StringMatch>,
@@ -1166,28 +1204,13 @@ impl CompletionsMenu {
.and_then(|c| c.to_lowercase().next());
if snippet_sort_order == SnippetSortOrder::None {
- matches.retain(|string_match| {
- let completion = &completions[string_match.candidate_id];
-
- let is_snippet = matches!(
- &completion.source,
- CompletionSource::Lsp { lsp_completion, .. }
- if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
- );
-
- !is_snippet
- });
+ matches
+ .retain(|string_match| !completions[string_match.candidate_id].is_snippet_kind());
}
matches.sort_unstable_by_key(|string_match| {
let completion = &completions[string_match.candidate_id];
- let is_snippet = matches!(
- &completion.source,
- CompletionSource::Lsp { lsp_completion, .. }
- if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
- );
-
let sort_text = match &completion.source {
CompletionSource::Lsp { lsp_completion, .. } => lsp_completion.sort_text.as_deref(),
CompletionSource::Dap { sort_text } => Some(sort_text.as_str()),
@@ -1199,14 +1222,17 @@ impl CompletionsMenu {
let score = string_match.score;
let sort_score = Reverse(OrderedFloat(score));
- let query_start_doesnt_match_split_words = query_start_lower
- .map(|query_char| {
- !split_words(&string_match.string).any(|word| {
- word.chars().next().and_then(|c| c.to_lowercase().next())
- == Some(query_char)
+ // Snippets do their own first-letter matching logic elsewhere.
+ let is_snippet = completion.is_snippet_kind();
+ let query_start_doesnt_match_split_words = !is_snippet
+ && query_start_lower
+ .map(|query_char| {
+ !split_words(&string_match.string).any(|word| {
+ word.chars().next().and_then(|c| c.to_lowercase().next())
+ == Some(query_char)
+ })
})
- })
- .unwrap_or(false);
+ .unwrap_or(false);
if query_start_doesnt_match_split_words {
MatchTier::OtherMatch { sort_score }
@@ -1218,6 +1244,7 @@ impl CompletionsMenu {
SnippetSortOrder::None => Reverse(0),
};
let sort_positions = string_match.positions.clone();
+ // This exact matching won't work for multi-word snippets, but it's fine
let sort_exact = Reverse(if Some(completion.label.filter_text()) == query {
1
} else {
@@ -1588,7 +1615,7 @@ impl CodeActionsMenu {
)
.occlude()
.max_h(max_height_in_lines as f32 * window.line_height())
- .track_scroll(self.scroll_handle.clone())
+ .track_scroll(&self.scroll_handle)
.with_width_from_item(
self.actions
.iter()
@@ -14,8 +14,57 @@
//! - [`DisplayMap`] that adds background highlights to the regions of text.
//! Each one of those builds on top of preceding map.
//!
+//! ## Structure of the display map layers
+//!
+//! Each layer in the map (and the multibuffer itself to some extent) has a few
+//! structures that are used to implement the public API available to the layer
+//! above:
+//! - a `Transform` type - this represents a region of text that the layer in
+//! question is "managing", that it transforms into a more "processed" text
+//! for the layer above. For example, the inlay map has an `enum Transform`
+//! that has two variants:
+//! - `Isomorphic`, representing a region of text that has no inlay hints (i.e.
+//! is passed through the map transparently)
+//! - `Inlay`, representing a location where an inlay hint is to be inserted.
+//! - a `TransformSummary` type, which is usually a struct with two fields:
+//! [`input: TextSummary`][`TextSummary`] and [`output: TextSummary`][`TextSummary`]. Here,
+//! `input` corresponds to "text in the layer below", and `output` corresponds to the text
+//! exposed to the layer above. So in the inlay map case, a `Transform::Isomorphic`'s summary is
+//! just `input = output = summary`, where `summary` is the [`TextSummary`] stored in that
+//! variant. Conversely, a `Transform::Inlay` always has an empty `input` summary, because it's
+//! not "replacing" any text that exists on disk. The `output` is the summary of the inlay text
+//! to be injected. - Various newtype wrappers for co-ordinate spaces (e.g. [`WrapRow`]
+//! represents a row index, after soft-wrapping (and all lower layers)).
+//! - A `Snapshot` type (e.g. [`InlaySnapshot`]) that captures the state of a layer at a specific
+//! point in time.
+//! - various APIs which drill through the layers below to work with the underlying text. Notably:
+//! - `fn text_summary_for_offset()` returns a [`TextSummary`] for the range in the co-ordinate
+//! space that the map in question is responsible for.
+//! - `fn <A>_point_to_<B>_point()` converts a point in co-ordinate space `A` into co-ordinate
+//! space `B`.
+//! - A [`RowInfo`] iterator (e.g. [`InlayBufferRows`]) and a [`Chunk`] iterator
+//! (e.g. [`InlayChunks`])
+//! - A `sync` function (e.g. [`InlayMap::sync`]) that takes a snapshot and list of [`Edit<T>`]s,
+//! and returns a new snapshot and a list of transformed [`Edit<S>`]s. Note that the generic
+//! parameter on `Edit` changes, since these methods take in edits in the co-ordinate space of
+//! the lower layer, and return edits in their own co-ordinate space. The term "edit" is
+//! slightly misleading, since an [`Edit<T>`] doesn't tell you what changed - rather it can be
+//! thought of as a "region to invalidate". In theory, it would be correct to always use a
+//! single edit that covers the entire range. However, this would lead to lots of unnecessary
+//! recalculation.
+//!
+//! See the docs for the [`inlay_map`] module for a more in-depth explanation of how a single layer
+//! works.
+//!
//! [Editor]: crate::Editor
//! [EditorElement]: crate::element::EditorElement
+//! [`TextSummary`]: multi_buffer::MBTextSummary
+//! [`WrapRow`]: wrap_map::WrapRow
+//! [`InlayBufferRows`]: inlay_map::InlayBufferRows
+//! [`InlayChunks`]: inlay_map::InlayChunks
+//! [`Edit<T>`]: text::Edit
+//! [`Edit<S>`]: text::Edit
+//! [`Chunk`]: language::Chunk
#[macro_use]
mod dimensions;
@@ -44,12 +93,10 @@ pub use invisibles::{is_invisible, replacement};
use collections::{HashMap, HashSet};
use gpui::{App, Context, Entity, Font, HighlightStyle, LineLayout, Pixels, UnderlineStyle};
-use language::{
- OffsetUtf16, Point, Subscription as BufferSubscription, language_settings::language_settings,
-};
+use language::{Point, Subscription as BufferSubscription, language_settings::language_settings};
use multi_buffer::{
- Anchor, AnchorRangeExt, MultiBuffer, MultiBufferPoint, MultiBufferRow, MultiBufferSnapshot,
- RowInfo, ToOffset, ToPoint,
+ Anchor, AnchorRangeExt, MultiBuffer, MultiBufferOffset, MultiBufferOffsetUtf16,
+ MultiBufferPoint, MultiBufferRow, MultiBufferSnapshot, RowInfo, ToOffset, ToPoint,
};
use project::InlayId;
use project::project_settings::DiagnosticSeverity;
@@ -58,6 +105,7 @@ use sum_tree::{Bias, TreeMap};
use text::{BufferId, LineIndent};
use ui::{SharedString, px};
use unicode_segmentation::UnicodeSegmentation;
+use ztracing::instrument;
use std::{
any::TypeId,
@@ -104,7 +152,7 @@ type InlayHighlights = TreeMap<TypeId, TreeMap<InlayId, (HighlightStyle, InlayHi
pub struct DisplayMap {
/// The buffer that we are displaying.
buffer: Entity<MultiBuffer>,
- buffer_subscription: BufferSubscription,
+ buffer_subscription: BufferSubscription<MultiBufferOffset>,
/// Decides where the [`Inlay`]s should be displayed.
inlay_map: InlayMap,
/// Decides where the fold indicators should be and tracks parts of a source file that are currently folded.
@@ -170,6 +218,7 @@ impl DisplayMap {
}
}
+ #[instrument(skip_all)]
pub fn snapshot(&mut self, cx: &mut Context<Self>) -> DisplaySnapshot {
let tab_size = Self::tab_size(&self.buffer, cx);
@@ -183,6 +232,8 @@ impl DisplayMap {
.update(cx, |map, cx| map.sync(tab_snapshot, edits, cx));
let block_snapshot = self.block_map.read(wrap_snapshot, edits).snapshot;
+ // todo word diff here?
+
DisplaySnapshot {
block_snapshot,
diagnostics_max_severity: self.diagnostics_max_severity,
@@ -195,10 +246,11 @@ impl DisplayMap {
}
}
+ #[instrument(skip_all)]
pub fn set_state(&mut self, other: &DisplaySnapshot, cx: &mut Context<Self>) {
self.fold(
other
- .folds_in_range(0..other.buffer_snapshot().len())
+ .folds_in_range(MultiBufferOffset(0)..other.buffer_snapshot().len())
.map(|fold| {
Crease::simple(
fold.range.to_offset(other.buffer_snapshot()),
@@ -211,6 +263,7 @@ impl DisplayMap {
}
/// Creates folds for the given creases.
+ #[instrument(skip_all)]
pub fn fold<T: Clone + ToOffset>(&mut self, creases: Vec<Crease<T>>, cx: &mut Context<Self>) {
let buffer_snapshot = self.buffer.read(cx).snapshot(cx);
let edits = self.buffer_subscription.consume().into_inner();
@@ -279,6 +332,7 @@ impl DisplayMap {
}
/// Removes any folds with the given ranges.
+ #[instrument(skip_all)]
pub fn remove_folds_with_type<T: ToOffset>(
&mut self,
ranges: impl IntoIterator<Item = Range<T>>,
@@ -304,6 +358,7 @@ impl DisplayMap {
}
/// Removes any folds whose ranges intersect any of the given ranges.
+ #[instrument(skip_all)]
pub fn unfold_intersecting<T: ToOffset>(
&mut self,
ranges: impl IntoIterator<Item = Range<T>>,
@@ -335,6 +390,7 @@ impl DisplayMap {
block_map.remove_intersecting_replace_blocks(offset_ranges, inclusive);
}
+ #[instrument(skip_all)]
pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context<Self>) {
let snapshot = self.buffer.read(cx).snapshot(cx);
let edits = self.buffer_subscription.consume().into_inner();
@@ -349,6 +405,7 @@ impl DisplayMap {
block_map.disable_header_for_buffer(buffer_id)
}
+ #[instrument(skip_all)]
pub fn fold_buffers(
&mut self,
buffer_ids: impl IntoIterator<Item = language::BufferId>,
@@ -367,6 +424,7 @@ impl DisplayMap {
block_map.fold_buffers(buffer_ids, self.buffer.read(cx), cx)
}
+ #[instrument(skip_all)]
pub fn unfold_buffers(
&mut self,
buffer_ids: impl IntoIterator<Item = language::BufferId>,
@@ -385,14 +443,17 @@ impl DisplayMap {
block_map.unfold_buffers(buffer_ids, self.buffer.read(cx), cx)
}
+ #[instrument(skip_all)]
pub(crate) fn is_buffer_folded(&self, buffer_id: language::BufferId) -> bool {
self.block_map.folded_buffers.contains(&buffer_id)
}
+ #[instrument(skip_all)]
pub(crate) fn folded_buffers(&self) -> &HashSet<BufferId> {
&self.block_map.folded_buffers
}
+ #[instrument(skip_all)]
pub fn insert_creases(
&mut self,
creases: impl IntoIterator<Item = Crease<Anchor>>,
@@ -402,6 +463,7 @@ impl DisplayMap {
self.crease_map.insert(creases, &snapshot)
}
+ #[instrument(skip_all)]
pub fn remove_creases(
&mut self,
crease_ids: impl IntoIterator<Item = CreaseId>,
@@ -411,6 +473,7 @@ impl DisplayMap {
self.crease_map.remove(crease_ids, &snapshot)
}
+ #[instrument(skip_all)]
pub fn insert_blocks(
&mut self,
blocks: impl IntoIterator<Item = BlockProperties<Anchor>>,
@@ -429,6 +492,7 @@ impl DisplayMap {
block_map.insert(blocks)
}
+ #[instrument(skip_all)]
pub fn resize_blocks(&mut self, heights: HashMap<CustomBlockId, u32>, cx: &mut Context<Self>) {
let snapshot = self.buffer.read(cx).snapshot(cx);
let edits = self.buffer_subscription.consume().into_inner();
@@ -443,10 +507,12 @@ impl DisplayMap {
block_map.resize(heights);
}
+ #[instrument(skip_all)]
pub fn replace_blocks(&mut self, renderers: HashMap<CustomBlockId, RenderBlock>) {
self.block_map.replace_blocks(renderers);
}
+ #[instrument(skip_all)]
pub fn remove_blocks(&mut self, ids: HashSet<CustomBlockId>, cx: &mut Context<Self>) {
let snapshot = self.buffer.read(cx).snapshot(cx);
let edits = self.buffer_subscription.consume().into_inner();
@@ -461,6 +527,7 @@ impl DisplayMap {
block_map.remove(ids);
}
+ #[instrument(skip_all)]
pub fn row_for_block(
&mut self,
block_id: CustomBlockId,
@@ -480,15 +547,35 @@ impl DisplayMap {
Some(DisplayRow(block_row.0))
}
+ #[instrument(skip_all)]
pub fn highlight_text(
&mut self,
key: HighlightKey,
ranges: Vec<Range<Anchor>>,
style: HighlightStyle,
+ merge: bool,
+ cx: &App,
) {
- self.text_highlights.insert(key, Arc::new((style, ranges)));
+ let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx);
+ let to_insert = match self.text_highlights.remove(&key).filter(|_| merge) {
+ Some(previous) => {
+ let mut merged_ranges = previous.1.clone();
+ for new_range in ranges {
+ let i = merged_ranges
+ .binary_search_by(|probe| {
+ probe.start.cmp(&new_range.start, &multi_buffer_snapshot)
+ })
+ .unwrap_or_else(|i| i);
+ merged_ranges.insert(i, new_range);
+ }
+ Arc::new((style, merged_ranges))
+ }
+ None => Arc::new((style, ranges)),
+ };
+ self.text_highlights.insert(key, to_insert);
}
+ #[instrument(skip_all)]
pub(crate) fn highlight_inlays(
&mut self,
type_id: TypeId,
@@ -508,6 +595,7 @@ impl DisplayMap {
}
}
+ #[instrument(skip_all)]
pub fn text_highlights(&self, type_id: TypeId) -> Option<(HighlightStyle, &[Range<Anchor>])> {
let highlights = self.text_highlights.get(&HighlightKey::Type(type_id))?;
Some((highlights.0, &highlights.1))
@@ -520,11 +608,21 @@ impl DisplayMap {
self.text_highlights.values()
}
+ #[instrument(skip_all)]
pub fn clear_highlights(&mut self, type_id: TypeId) -> bool {
let mut cleared = self
.text_highlights
.remove(&HighlightKey::Type(type_id))
.is_some();
+ self.text_highlights.retain(|key, _| {
+ let retain = if let HighlightKey::TypePlus(key_type_id, _) = key {
+ key_type_id != &type_id
+ } else {
+ true
+ };
+ cleared |= !retain;
+ retain
+ });
cleared |= self.inlay_highlights.remove(&type_id).is_some();
cleared
}
@@ -539,6 +637,7 @@ impl DisplayMap {
.update(cx, |map, cx| map.set_wrap_width(width, cx))
}
+ #[instrument(skip_all)]
pub fn update_fold_widths(
&mut self,
widths: impl IntoIterator<Item = (ChunkRendererId, Pixels)>,
@@ -570,6 +669,7 @@ impl DisplayMap {
self.inlay_map.current_inlays()
}
+ #[instrument(skip_all)]
pub(crate) fn splice_inlays(
&mut self,
to_remove: &[InlayId],
@@ -599,6 +699,7 @@ impl DisplayMap {
self.block_map.read(snapshot, edits);
}
+ #[instrument(skip_all)]
fn tab_size(buffer: &Entity<MultiBuffer>, cx: &App) -> NonZeroU32 {
let buffer = buffer.read(cx).as_singleton().map(|buffer| buffer.read(cx));
let language = buffer
@@ -648,6 +749,7 @@ pub struct HighlightedChunk<'a> {
}
impl<'a> HighlightedChunk<'a> {
+ #[instrument(skip_all)]
fn highlight_invisibles(
self,
editor_style: &'a EditorStyle,
@@ -794,7 +896,7 @@ impl DisplaySnapshot {
}
pub fn is_empty(&self) -> bool {
- self.buffer_snapshot().len() == 0
+ self.buffer_snapshot().len() == MultiBufferOffset(0)
}
pub fn row_infos(&self, start_row: DisplayRow) -> impl Iterator<Item = RowInfo> + '_ {
@@ -805,6 +907,7 @@ impl DisplaySnapshot {
self.buffer_snapshot().widest_line_number()
}
+ #[instrument(skip_all)]
pub fn prev_line_boundary(&self, mut point: MultiBufferPoint) -> (Point, DisplayPoint) {
loop {
let mut inlay_point = self.inlay_snapshot().to_inlay_point(point);
@@ -823,6 +926,7 @@ impl DisplaySnapshot {
}
}
+ #[instrument(skip_all)]
pub fn next_line_boundary(
&self,
mut point: MultiBufferPoint,
@@ -861,10 +965,11 @@ impl DisplaySnapshot {
new_start..new_end
}
+ #[instrument(skip_all)]
pub fn point_to_display_point(&self, point: MultiBufferPoint, bias: Bias) -> DisplayPoint {
let inlay_point = self.inlay_snapshot().to_inlay_point(point);
let fold_point = self.fold_snapshot().to_fold_point(inlay_point, bias);
- let tab_point = self.tab_snapshot().to_tab_point(fold_point);
+ let tab_point = self.tab_snapshot().fold_point_to_tab_point(fold_point);
let wrap_point = self.wrap_snapshot().tab_point_to_wrap_point(tab_point);
let block_point = self.block_snapshot.to_block_point(wrap_point);
DisplayPoint(block_point)
@@ -890,23 +995,31 @@ impl DisplaySnapshot {
.anchor_at(point.to_offset(self, bias), bias)
}
+ #[instrument(skip_all)]
fn display_point_to_inlay_point(&self, point: DisplayPoint, bias: Bias) -> InlayPoint {
let block_point = point.0;
let wrap_point = self.block_snapshot.to_wrap_point(block_point, bias);
let tab_point = self.wrap_snapshot().to_tab_point(wrap_point);
- let fold_point = self.tab_snapshot().to_fold_point(tab_point, bias).0;
+ let fold_point = self
+ .tab_snapshot()
+ .tab_point_to_fold_point(tab_point, bias)
+ .0;
fold_point.to_inlay_point(self.fold_snapshot())
}
+ #[instrument(skip_all)]
pub fn display_point_to_fold_point(&self, point: DisplayPoint, bias: Bias) -> FoldPoint {
let block_point = point.0;
let wrap_point = self.block_snapshot.to_wrap_point(block_point, bias);
let tab_point = self.wrap_snapshot().to_tab_point(wrap_point);
- self.tab_snapshot().to_fold_point(tab_point, bias).0
+ self.tab_snapshot()
+ .tab_point_to_fold_point(tab_point, bias)
+ .0
}
+ #[instrument(skip_all)]
pub fn fold_point_to_display_point(&self, fold_point: FoldPoint) -> DisplayPoint {
- let tab_point = self.tab_snapshot().to_tab_point(fold_point);
+ let tab_point = self.tab_snapshot().fold_point_to_tab_point(fold_point);
let wrap_point = self.wrap_snapshot().tab_point_to_wrap_point(tab_point);
let block_point = self.block_snapshot.to_block_point(wrap_point);
DisplayPoint(block_point)
@@ -917,6 +1030,7 @@ impl DisplaySnapshot {
}
/// Returns text chunks starting at the given display row until the end of the file
+ #[instrument(skip_all)]
pub fn text_chunks(&self, display_row: DisplayRow) -> impl Iterator<Item = &str> {
self.block_snapshot
.chunks(
@@ -929,6 +1043,7 @@ impl DisplaySnapshot {
}
/// Returns text chunks starting at the end of the given display row in reverse until the start of the file
+ #[instrument(skip_all)]
pub fn reverse_text_chunks(&self, display_row: DisplayRow) -> impl Iterator<Item = &str> {
(0..=display_row.0).rev().flat_map(move |row| {
self.block_snapshot
@@ -945,6 +1060,7 @@ impl DisplaySnapshot {
})
}
+ #[instrument(skip_all)]
pub fn chunks(
&self,
display_rows: Range<DisplayRow>,
@@ -963,6 +1079,7 @@ impl DisplaySnapshot {
)
}
+ #[instrument(skip_all)]
pub fn highlighted_chunks<'a>(
&'a self,
display_rows: Range<DisplayRow>,
@@ -1039,6 +1156,7 @@ impl DisplaySnapshot {
})
}
+ #[instrument(skip_all)]
pub fn layout_row(
&self,
display_row: DisplayRow,
@@ -1097,9 +1215,10 @@ impl DisplaySnapshot {
details: &TextLayoutDetails,
) -> u32 {
let layout_line = self.layout_row(display_row, details);
- layout_line.index_for_x(x) as u32
+ layout_line.closest_index_for_x(x) as u32
}
+ #[instrument(skip_all)]
pub fn grapheme_at(&self, mut point: DisplayPoint) -> Option<SharedString> {
point = DisplayPoint(self.block_snapshot.clip_point(point.0, Bias::Left));
let chars = self
@@ -1133,7 +1252,10 @@ impl DisplaySnapshot {
})
}
- pub fn buffer_chars_at(&self, mut offset: usize) -> impl Iterator<Item = (char, usize)> + '_ {
+ pub fn buffer_chars_at(
+ &self,
+ mut offset: MultiBufferOffset,
+ ) -> impl Iterator<Item = (char, MultiBufferOffset)> + '_ {
self.buffer_snapshot().chars_at(offset).map(move |ch| {
let ret = (ch, offset);
offset += ch.len_utf8();
@@ -1143,8 +1265,8 @@ impl DisplaySnapshot {
pub fn reverse_buffer_chars_at(
&self,
- mut offset: usize,
- ) -> impl Iterator<Item = (char, usize)> + '_ {
+ mut offset: MultiBufferOffset,
+ ) -> impl Iterator<Item = (char, MultiBufferOffset)> + '_ {
self.buffer_snapshot()
.reversed_chars_at(offset)
.map(move |ch| {
@@ -1286,6 +1408,7 @@ impl DisplaySnapshot {
.unwrap_or(false)
}
+ #[instrument(skip_all)]
pub fn crease_for_buffer_row(&self, buffer_row: MultiBufferRow) -> Option<Crease<Point>> {
let start =
MultiBufferPoint::new(buffer_row.0, self.buffer_snapshot().line_len(buffer_row));
@@ -1372,6 +1495,7 @@ impl DisplaySnapshot {
}
#[cfg(any(test, feature = "test-support"))]
+ #[instrument(skip_all)]
pub fn text_highlight_ranges<Tag: ?Sized + 'static>(
&self,
) -> Option<Arc<(HighlightStyle, Vec<Range<Anchor>>)>> {
@@ -1381,6 +1505,34 @@ impl DisplaySnapshot {
.cloned()
}
+ #[cfg(any(test, feature = "test-support"))]
+ #[instrument(skip_all)]
+ pub fn all_text_highlight_ranges<Tag: ?Sized + 'static>(
+ &self,
+ ) -> Vec<(gpui::Hsla, Range<Point>)> {
+ use itertools::Itertools;
+
+ let required_type_id = TypeId::of::<Tag>();
+ self.text_highlights
+ .iter()
+ .filter(|(key, _)| match key {
+ HighlightKey::Type(type_id) => type_id == &required_type_id,
+ HighlightKey::TypePlus(type_id, _) => type_id == &required_type_id,
+ })
+ .map(|(_, value)| value.clone())
+ .flat_map(|ranges| {
+ ranges
+ .1
+ .iter()
+ .flat_map(|range| {
+ Some((ranges.0.color?, range.to_point(self.buffer_snapshot())))
+ })
+ .collect::<Vec<_>>()
+ })
+ .sorted_by_key(|(_, range)| range.start)
+ .collect()
+ }
+
#[allow(unused)]
#[cfg(any(test, feature = "test-support"))]
pub(crate) fn inlay_highlights<Tag: ?Sized + 'static>(
@@ -1404,6 +1556,7 @@ impl DisplaySnapshot {
///
/// This moves by buffer rows instead of display rows, a distinction that is
/// important when soft wrapping is enabled.
+ #[instrument(skip_all)]
pub fn start_of_relative_buffer_row(&self, point: DisplayPoint, times: isize) -> DisplayPoint {
let start = self.display_point_to_fold_point(point, Bias::Left);
let target = start.row() as isize + times;
@@ -1526,23 +1679,26 @@ impl DisplayPoint {
map.display_point_to_point(self, Bias::Left)
}
- pub fn to_offset(self, map: &DisplaySnapshot, bias: Bias) -> usize {
+ pub fn to_offset(self, map: &DisplaySnapshot, bias: Bias) -> MultiBufferOffset {
let wrap_point = map.block_snapshot.to_wrap_point(self.0, bias);
let tab_point = map.wrap_snapshot().to_tab_point(wrap_point);
- let fold_point = map.tab_snapshot().to_fold_point(tab_point, bias).0;
+ let fold_point = map
+ .tab_snapshot()
+ .tab_point_to_fold_point(tab_point, bias)
+ .0;
let inlay_point = fold_point.to_inlay_point(map.fold_snapshot());
map.inlay_snapshot()
.to_buffer_offset(map.inlay_snapshot().to_offset(inlay_point))
}
}
-impl ToDisplayPoint for usize {
+impl ToDisplayPoint for MultiBufferOffset {
fn to_display_point(&self, map: &DisplaySnapshot) -> DisplayPoint {
map.point_to_display_point(self.to_point(map.buffer_snapshot()), Bias::Left)
}
}
-impl ToDisplayPoint for OffsetUtf16 {
+impl ToDisplayPoint for MultiBufferOffsetUtf16 {
fn to_display_point(&self, map: &DisplaySnapshot) -> DisplayPoint {
self.to_offset(map.buffer_snapshot()).to_display_point(map)
}
@@ -1685,7 +1841,7 @@ pub mod tests {
let block_properties = (0..rng.random_range(1..=1))
.map(|_| {
let position = buffer.anchor_after(buffer.clip_offset(
- rng.random_range(0..=buffer.len()),
+ rng.random_range(MultiBufferOffset(0)..=buffer.len()),
Bias::Left,
));
@@ -1727,8 +1883,12 @@ pub mod tests {
for _ in 0..rng.random_range(1..=3) {
buffer.read_with(cx, |buffer, cx| {
let buffer = buffer.read(cx);
- let end = buffer.clip_offset(rng.random_range(0..=buffer.len()), Right);
- let start = buffer.clip_offset(rng.random_range(0..=end), Left);
+ let end = buffer.clip_offset(
+ rng.random_range(MultiBufferOffset(0)..=buffer.len()),
+ Right,
+ );
+ let start = buffer
+ .clip_offset(rng.random_range(MultiBufferOffset(0)..=end), Left);
ranges.push(start..end);
});
}
@@ -1954,7 +2114,7 @@ pub mod tests {
)
);
- let ix = snapshot.buffer_snapshot().text().find("seven").unwrap();
+ let ix = MultiBufferOffset(snapshot.buffer_snapshot().text().find("seven").unwrap());
buffer.update(cx, |buffer, cx| {
buffer.edit([(ix..ix, "and ")], None, cx);
});
@@ -2083,7 +2243,7 @@ pub mod tests {
&[],
vec![Inlay::edit_prediction(
0,
- buffer_snapshot.anchor_after(0),
+ buffer_snapshot.anchor_after(MultiBufferOffset(0)),
"\n",
)],
cx,
@@ -2094,7 +2254,11 @@ pub mod tests {
// Regression test: updating the display map does not crash when a
// block is immediately followed by a multi-line inlay.
buffer.update(cx, |buffer, cx| {
- buffer.edit([(1..1, "b")], None, cx);
+ buffer.edit(
+ [(MultiBufferOffset(1)..MultiBufferOffset(1), "b")],
+ None,
+ cx,
+ );
});
map.update(cx, |m, cx| assert_eq!(m.snapshot(cx).text(), "\n\n\nab"));
}
@@ -2378,6 +2542,8 @@ pub mod tests {
..buffer_snapshot.anchor_after(Point::new(3, 18)),
],
red.into(),
+ false,
+ cx,
);
map.insert_blocks(
[BlockProperties {
@@ -2689,17 +2855,20 @@ pub mod tests {
..Default::default()
};
- map.update(cx, |map, _cx| {
+ map.update(cx, |map, cx| {
map.highlight_text(
HighlightKey::Type(TypeId::of::<MyType>()),
highlighted_ranges
.into_iter()
+ .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end))
.map(|range| {
buffer_snapshot.anchor_before(range.start)
..buffer_snapshot.anchor_before(range.end)
})
.collect(),
style,
+ false,
+ cx,
);
});
@@ -11,8 +11,8 @@ use collections::{Bound, HashMap, HashSet};
use gpui::{AnyElement, App, EntityId, Pixels, Window};
use language::{Patch, Point};
use multi_buffer::{
- Anchor, ExcerptId, ExcerptInfo, MultiBuffer, MultiBufferRow, MultiBufferSnapshot, RowInfo,
- ToOffset, ToPoint as _,
+ Anchor, ExcerptId, ExcerptInfo, MultiBuffer, MultiBufferOffset, MultiBufferRow,
+ MultiBufferSnapshot, RowInfo, ToOffset, ToPoint as _,
};
use parking_lot::Mutex;
use std::{
@@ -164,6 +164,7 @@ impl<T> BlockPlacement<T> {
}
impl BlockPlacement<Anchor> {
+ #[ztracing::instrument(skip_all)]
fn cmp(&self, other: &Self, buffer: &MultiBufferSnapshot) -> Ordering {
self.start()
.cmp(other.start(), buffer)
@@ -171,6 +172,7 @@ impl BlockPlacement<Anchor> {
.then_with(|| self.tie_break().cmp(&other.tie_break()))
}
+ #[ztracing::instrument(skip_all)]
fn to_wrap_row(&self, wrap_snapshot: &WrapSnapshot) -> Option<BlockPlacement<WrapRow>> {
let buffer_snapshot = wrap_snapshot.buffer_snapshot();
match self {
@@ -474,6 +476,7 @@ pub struct BlockRows<'a> {
}
impl BlockMap {
+ #[ztracing::instrument(skip_all)]
pub fn new(
wrap_snapshot: WrapSnapshot,
buffer_header_height: u32,
@@ -503,6 +506,7 @@ impl BlockMap {
map
}
+ #[ztracing::instrument(skip_all)]
pub fn read(&self, wrap_snapshot: WrapSnapshot, edits: WrapPatch) -> BlockMapReader<'_> {
self.sync(&wrap_snapshot, edits);
*self.wrap_snapshot.borrow_mut() = wrap_snapshot.clone();
@@ -518,13 +522,17 @@ impl BlockMap {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn write(&mut self, wrap_snapshot: WrapSnapshot, edits: WrapPatch) -> BlockMapWriter<'_> {
self.sync(&wrap_snapshot, edits);
*self.wrap_snapshot.borrow_mut() = wrap_snapshot;
BlockMapWriter(self)
}
+ #[ztracing::instrument(skip_all, fields(edits = ?edits))]
fn sync(&self, wrap_snapshot: &WrapSnapshot, mut edits: WrapPatch) {
+ let _timer = zlog::time!("BlockMap::sync").warn_if_gt(std::time::Duration::from_millis(50));
+
let buffer = wrap_snapshot.buffer_snapshot();
// Handle changing the last excerpt if it is empty.
@@ -537,7 +545,7 @@ impl BlockMap {
{
let max_point = wrap_snapshot.max_point();
let edit_start = wrap_snapshot.prev_row_boundary(max_point);
- let edit_end = max_point.row() + WrapRow(1);
+ let edit_end = max_point.row() + WrapRow(1); // this is end of file
edits = edits.compose([WrapEdit {
old: edit_start..edit_end,
new: edit_start..edit_end,
@@ -556,7 +564,15 @@ impl BlockMap {
let mut blocks_in_edit = Vec::new();
let mut edits = edits.into_iter().peekable();
+ let mut inlay_point_cursor = wrap_snapshot.inlay_point_cursor();
+ let mut tab_point_cursor = wrap_snapshot.tab_point_cursor();
+ let mut fold_point_cursor = wrap_snapshot.fold_point_cursor();
+ let mut wrap_point_cursor = wrap_snapshot.wrap_point_cursor();
+
while let Some(edit) = edits.next() {
+ let span = ztracing::debug_span!("while edits", edit = ?edit);
+ let _enter = span.enter();
+
let mut old_start = edit.old.start;
let mut new_start = edit.new.start;
@@ -615,6 +631,8 @@ impl BlockMap {
let mut old_end = edit.old.end;
let mut new_end = edit.new.end;
loop {
+ let span = ztracing::debug_span!("decide where edit ends loop");
+ let _enter = span.enter();
// Seek to the transform starting at or after the end of the edit
cursor.seek(&old_end, Bias::Left);
cursor.next();
@@ -686,6 +704,9 @@ impl BlockMap {
last_block_ix = end_block_ix;
debug_assert!(blocks_in_edit.is_empty());
+ // + 8 is chosen arbitrarily to cover some multibuffer headers
+ blocks_in_edit
+ .reserve(end_block_ix - start_block_ix + if buffer.is_singleton() { 0 } else { 8 });
blocks_in_edit.extend(
self.custom_blocks[start_block_ix..end_block_ix]
@@ -694,6 +715,7 @@ impl BlockMap {
let placement = block.placement.to_wrap_row(wrap_snapshot)?;
if let BlockPlacement::Above(row) = placement
&& row < new_start
+ // this will be true more often now
{
return None;
}
@@ -704,7 +726,14 @@ impl BlockMap {
blocks_in_edit.extend(self.header_and_footer_blocks(
buffer,
(start_bound, end_bound),
- wrap_snapshot,
+ |point, bias| {
+ wrap_point_cursor
+ .map(
+ tab_point_cursor
+ .map(fold_point_cursor.map(inlay_point_cursor.map(point), bias)),
+ )
+ .row()
+ },
));
BlockMap::sort_blocks(&mut blocks_in_edit);
@@ -713,6 +742,10 @@ impl BlockMap {
// and then insert the block itself.
let mut just_processed_folded_buffer = false;
for (block_placement, block) in blocks_in_edit.drain(..) {
+ let span =
+ ztracing::debug_span!("for block in edits", block_height = block.height());
+ let _enter = span.enter();
+
let mut summary = TransformSummary {
input_rows: WrapRow(0),
output_rows: BlockRow(block.height()),
@@ -769,6 +802,7 @@ impl BlockMap {
*transforms = new_transforms;
}
+ #[ztracing::instrument(skip_all)]
pub fn replace_blocks(&mut self, mut renderers: HashMap<CustomBlockId, RenderBlock>) {
for block in &mut self.custom_blocks {
if let Some(render) = renderers.remove(&block.id) {
@@ -777,11 +811,13 @@ impl BlockMap {
}
}
+ /// Guarantees that `wrap_row_for` is called with points in increasing order.
+ #[ztracing::instrument(skip_all)]
fn header_and_footer_blocks<'a, R, T>(
&'a self,
buffer: &'a multi_buffer::MultiBufferSnapshot,
range: R,
- wrap_snapshot: &'a WrapSnapshot,
+ mut wrap_row_for: impl 'a + FnMut(Point, Bias) -> WrapRow,
) -> impl Iterator<Item = (BlockPlacement<WrapRow>, Block)> + 'a
where
R: RangeBounds<T>,
@@ -792,9 +828,7 @@ impl BlockMap {
std::iter::from_fn(move || {
loop {
let excerpt_boundary = boundaries.next()?;
- let wrap_row = wrap_snapshot
- .make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left)
- .row();
+ let wrap_row = wrap_row_for(Point::new(excerpt_boundary.row.0, 0), Bias::Left);
let new_buffer_id = match (&excerpt_boundary.prev, &excerpt_boundary.next) {
(None, next) => Some(next.buffer_id),
@@ -826,16 +860,13 @@ impl BlockMap {
boundaries.next();
}
-
- let wrap_end_row = wrap_snapshot
- .make_wrap_point(
- Point::new(
- last_excerpt_end_row.0,
- buffer.line_len(last_excerpt_end_row),
- ),
- Bias::Right,
- )
- .row();
+ let wrap_end_row = wrap_row_for(
+ Point::new(
+ last_excerpt_end_row.0,
+ buffer.line_len(last_excerpt_end_row),
+ ),
+ Bias::Right,
+ );
return Some((
BlockPlacement::Replace(wrap_row..=wrap_end_row),
@@ -869,6 +900,7 @@ impl BlockMap {
})
}
+ #[ztracing::instrument(skip_all)]
fn sort_blocks(blocks: &mut Vec<(BlockPlacement<WrapRow>, Block)>) {
blocks.sort_unstable_by(|(placement_a, block_a), (placement_b, block_b)| {
placement_a
@@ -935,6 +967,7 @@ impl BlockMap {
}
}
+#[ztracing::instrument(skip(tree, wrap_snapshot))]
fn push_isomorphic(tree: &mut SumTree<Transform>, rows: RowDelta, wrap_snapshot: &WrapSnapshot) {
if rows == RowDelta(0) {
return;
@@ -1005,6 +1038,7 @@ impl DerefMut for BlockMapReader<'_> {
}
impl BlockMapReader<'_> {
+ #[ztracing::instrument(skip_all)]
pub fn row_for_block(&self, block_id: CustomBlockId) -> Option<BlockRow> {
let block = self.blocks.iter().find(|block| block.id == block_id)?;
let buffer_row = block
@@ -1043,6 +1077,7 @@ impl BlockMapReader<'_> {
}
impl BlockMapWriter<'_> {
+ #[ztracing::instrument(skip_all)]
pub fn insert(
&mut self,
blocks: impl IntoIterator<Item = BlockProperties<Anchor>>,
@@ -1109,6 +1144,7 @@ impl BlockMapWriter<'_> {
ids
}
+ #[ztracing::instrument(skip_all)]
pub fn resize(&mut self, mut heights: HashMap<CustomBlockId, u32>) {
let wrap_snapshot = &*self.0.wrap_snapshot.borrow();
let buffer = wrap_snapshot.buffer_snapshot();
@@ -1161,6 +1197,7 @@ impl BlockMapWriter<'_> {
self.0.sync(wrap_snapshot, edits);
}
+ #[ztracing::instrument(skip_all)]
pub fn remove(&mut self, block_ids: HashSet<CustomBlockId>) {
let wrap_snapshot = &*self.0.wrap_snapshot.borrow();
let buffer = wrap_snapshot.buffer_snapshot();
@@ -1206,9 +1243,10 @@ impl BlockMapWriter<'_> {
self.0.sync(wrap_snapshot, edits);
}
+ #[ztracing::instrument(skip_all)]
pub fn remove_intersecting_replace_blocks(
&mut self,
- ranges: impl IntoIterator<Item = Range<usize>>,
+ ranges: impl IntoIterator<Item = Range<MultiBufferOffset>>,
inclusive: bool,
) {
let wrap_snapshot = self.0.wrap_snapshot.borrow();
@@ -1228,6 +1266,7 @@ impl BlockMapWriter<'_> {
self.0.buffers_with_disabled_headers.insert(buffer_id);
}
+ #[ztracing::instrument(skip_all)]
pub fn fold_buffers(
&mut self,
buffer_ids: impl IntoIterator<Item = BufferId>,
@@ -1237,6 +1276,7 @@ impl BlockMapWriter<'_> {
self.fold_or_unfold_buffers(true, buffer_ids, multi_buffer, cx);
}
+ #[ztracing::instrument(skip_all)]
pub fn unfold_buffers(
&mut self,
buffer_ids: impl IntoIterator<Item = BufferId>,
@@ -1246,6 +1286,7 @@ impl BlockMapWriter<'_> {
self.fold_or_unfold_buffers(false, buffer_ids, multi_buffer, cx);
}
+ #[ztracing::instrument(skip_all)]
fn fold_or_unfold_buffers(
&mut self,
fold: bool,
@@ -1281,9 +1322,10 @@ impl BlockMapWriter<'_> {
self.0.sync(&wrap_snapshot, edits);
}
+ #[ztracing::instrument(skip_all)]
fn blocks_intersecting_buffer_range(
&self,
- range: Range<usize>,
+ range: Range<MultiBufferOffset>,
inclusive: bool,
) -> &[Arc<CustomBlock>] {
if range.is_empty() && !inclusive {
@@ -1315,6 +1357,7 @@ impl BlockMapWriter<'_> {
impl BlockSnapshot {
#[cfg(test)]
+ #[ztracing::instrument(skip_all)]
pub fn text(&self) -> String {
self.chunks(
BlockRow(0)..self.transforms.summary().output_rows,
@@ -1326,6 +1369,7 @@ impl BlockSnapshot {
.collect()
}
+ #[ztracing::instrument(skip_all)]
pub(crate) fn chunks<'a>(
&'a self,
rows: Range<BlockRow>,
@@ -1367,6 +1411,7 @@ impl BlockSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub(super) fn row_infos(&self, start_row: BlockRow) -> BlockRows<'_> {
let mut cursor = self.transforms.cursor::<Dimensions<BlockRow, WrapRow>>(());
cursor.seek(&start_row, Bias::Right);
@@ -1388,6 +1433,7 @@ impl BlockSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn blocks_in_range(
&self,
rows: Range<BlockRow>,
@@ -1421,6 +1467,7 @@ impl BlockSnapshot {
})
}
+ #[ztracing::instrument(skip_all)]
pub(crate) fn sticky_header_excerpt(&self, position: f64) -> Option<StickyHeaderExcerpt<'_>> {
let top_row = position as u32;
let mut cursor = self.transforms.cursor::<BlockRow>(());
@@ -1444,6 +1491,7 @@ impl BlockSnapshot {
None
}
+ #[ztracing::instrument(skip_all)]
pub fn block_for_id(&self, block_id: BlockId) -> Option<Block> {
let buffer = self.wrap_snapshot.buffer_snapshot();
let wrap_point = match block_id {
@@ -1480,6 +1528,7 @@ impl BlockSnapshot {
None
}
+ #[ztracing::instrument(skip_all)]
pub fn max_point(&self) -> BlockPoint {
let row = self
.transforms
@@ -1489,10 +1538,12 @@ impl BlockSnapshot {
BlockPoint::new(row, self.line_len(row))
}
+ #[ztracing::instrument(skip_all)]
pub fn longest_row(&self) -> BlockRow {
self.transforms.summary().longest_row
}
+ #[ztracing::instrument(skip_all)]
pub fn longest_row_in_range(&self, range: Range<BlockRow>) -> BlockRow {
let mut cursor = self.transforms.cursor::<Dimensions<BlockRow, WrapRow>>(());
cursor.seek(&range.start, Bias::Right);
@@ -1544,6 +1595,7 @@ impl BlockSnapshot {
longest_row
}
+ #[ztracing::instrument(skip_all)]
pub(super) fn line_len(&self, row: BlockRow) -> u32 {
let (start, _, item) =
self.transforms
@@ -1563,11 +1615,13 @@ impl BlockSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub(super) fn is_block_line(&self, row: BlockRow) -> bool {
let (_, _, item) = self.transforms.find::<BlockRow, _>((), &row, Bias::Right);
item.is_some_and(|t| t.block.is_some())
}
+ #[ztracing::instrument(skip_all)]
pub(super) fn is_folded_buffer_header(&self, row: BlockRow) -> bool {
let (_, _, item) = self.transforms.find::<BlockRow, _>((), &row, Bias::Right);
let Some(transform) = item else {
@@ -1576,6 +1630,7 @@ impl BlockSnapshot {
matches!(transform.block, Some(Block::FoldedBuffer { .. }))
}
+ #[ztracing::instrument(skip_all)]
pub(super) fn is_line_replaced(&self, row: MultiBufferRow) -> bool {
let wrap_point = self
.wrap_snapshot
@@ -1591,6 +1646,7 @@ impl BlockSnapshot {
})
}
+ #[ztracing::instrument(skip_all)]
pub fn clip_point(&self, point: BlockPoint, bias: Bias) -> BlockPoint {
let mut cursor = self.transforms.cursor::<Dimensions<BlockRow, WrapRow>>(());
cursor.seek(&BlockRow(point.row), Bias::Right);
@@ -1652,6 +1708,7 @@ impl BlockSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn to_block_point(&self, wrap_point: WrapPoint) -> BlockPoint {
let (start, _, item) = self.transforms.find::<Dimensions<WrapRow, BlockRow>, _>(
(),
@@ -1673,6 +1730,7 @@ impl BlockSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn to_wrap_point(&self, block_point: BlockPoint, bias: Bias) -> WrapPoint {
let (start, end, item) = self.transforms.find::<Dimensions<BlockRow, WrapRow>, _>(
(),
@@ -1708,6 +1766,7 @@ impl BlockSnapshot {
impl BlockChunks<'_> {
/// Go to the next transform
+ #[ztracing::instrument(skip_all)]
fn advance(&mut self) {
self.input_chunk = Chunk::default();
self.transforms.next();
@@ -1748,6 +1807,7 @@ pub struct StickyHeaderExcerpt<'a> {
impl<'a> Iterator for BlockChunks<'a> {
type Item = Chunk<'a>;
+ #[ztracing::instrument(skip_all)]
fn next(&mut self) -> Option<Self::Item> {
if self.output_row >= self.max_output_row {
return None;
@@ -1847,6 +1907,7 @@ impl<'a> Iterator for BlockChunks<'a> {
impl Iterator for BlockRows<'_> {
type Item = RowInfo;
+ #[ztracing::instrument(skip_all)]
fn next(&mut self) -> Option<Self::Item> {
if self.started {
self.output_row.0 += 1;
@@ -1949,14 +2010,17 @@ impl DerefMut for BlockContext<'_, '_> {
}
impl CustomBlock {
+ #[ztracing::instrument(skip_all)]
pub fn render(&self, cx: &mut BlockContext) -> AnyElement {
self.render.lock()(cx)
}
+ #[ztracing::instrument(skip_all)]
pub fn start(&self) -> Anchor {
*self.placement.start()
}
+ #[ztracing::instrument(skip_all)]
pub fn end(&self) -> Anchor {
*self.placement.end()
}
@@ -2976,7 +3040,7 @@ mod tests {
);
}
- #[gpui::test(iterations = 100)]
+ #[gpui::test(iterations = 60)]
fn test_random_blocks(cx: &mut gpui::TestAppContext, mut rng: StdRng) {
cx.update(init_test);
@@ -3043,8 +3107,10 @@ mod tests {
let block_properties = (0..block_count)
.map(|_| {
let buffer = cx.update(|cx| buffer.read(cx).read(cx).clone());
- let offset =
- buffer.clip_offset(rng.random_range(0..=buffer.len()), Bias::Left);
+ let offset = buffer.clip_offset(
+ rng.random_range(MultiBufferOffset(0)..=buffer.len()),
+ Bias::Left,
+ );
let mut min_height = 0;
let placement = match rng.random_range(0..3) {
0 => {
@@ -3241,11 +3307,23 @@ mod tests {
))
}));
+ let mut inlay_point_cursor = wraps_snapshot.inlay_point_cursor();
+ let mut tab_point_cursor = wraps_snapshot.tab_point_cursor();
+ let mut fold_point_cursor = wraps_snapshot.fold_point_cursor();
+ let mut wrap_point_cursor = wraps_snapshot.wrap_point_cursor();
+
// Note that this needs to be synced with the related section in BlockMap::sync
expected_blocks.extend(block_map.header_and_footer_blocks(
&buffer_snapshot,
- 0..,
- &wraps_snapshot,
+ MultiBufferOffset(0)..,
+ |point, bias| {
+ wrap_point_cursor
+ .map(
+ tab_point_cursor
+ .map(fold_point_cursor.map(inlay_point_cursor.map(point), bias)),
+ )
+ .row()
+ },
));
BlockMap::sort_blocks(&mut expected_blocks);
@@ -19,6 +19,7 @@ pub struct CreaseMap {
}
impl CreaseMap {
+ #[ztracing::instrument(skip_all)]
pub fn new(snapshot: &MultiBufferSnapshot) -> Self {
CreaseMap {
snapshot: CreaseSnapshot::new(snapshot),
@@ -40,11 +41,13 @@ impl CreaseSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn creases(&self) -> impl Iterator<Item = (CreaseId, &Crease<Anchor>)> {
self.creases.iter().map(|item| (item.id, &item.crease))
}
/// Returns the first Crease starting on the specified buffer row.
+ #[ztracing::instrument(skip_all)]
pub fn query_row<'a>(
&'a self,
row: MultiBufferRow,
@@ -69,6 +72,7 @@ impl CreaseSnapshot {
None
}
+ #[ztracing::instrument(skip_all)]
pub fn creases_in_range<'a>(
&'a self,
range: Range<MultiBufferRow>,
@@ -95,6 +99,7 @@ impl CreaseSnapshot {
})
}
+ #[ztracing::instrument(skip_all)]
pub fn crease_items_with_offsets(
&self,
snapshot: &MultiBufferSnapshot,
@@ -156,6 +161,7 @@ pub struct CreaseMetadata {
}
impl<T> Crease<T> {
+ #[ztracing::instrument(skip_all)]
pub fn simple(range: Range<T>, placeholder: FoldPlaceholder) -> Self {
Crease::Inline {
range,
@@ -166,6 +172,7 @@ impl<T> Crease<T> {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn block(range: Range<T>, height: u32, style: BlockStyle, render: RenderBlock) -> Self {
Self::Block {
range,
@@ -177,6 +184,7 @@ impl<T> Crease<T> {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn inline<RenderToggle, ToggleElement, RenderTrailer, TrailerElement>(
range: Range<T>,
placeholder: FoldPlaceholder,
@@ -216,6 +224,7 @@ impl<T> Crease<T> {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn with_metadata(self, metadata: CreaseMetadata) -> Self {
match self {
Crease::Inline {
@@ -235,6 +244,7 @@ impl<T> Crease<T> {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn range(&self) -> &Range<T> {
match self {
Crease::Inline { range, .. } => range,
@@ -242,6 +252,7 @@ impl<T> Crease<T> {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn metadata(&self) -> Option<&CreaseMetadata> {
match self {
Self::Inline { metadata, .. } => metadata.as_ref(),
@@ -287,6 +298,7 @@ impl CreaseMap {
self.snapshot.clone()
}
+ #[ztracing::instrument(skip_all)]
pub fn insert(
&mut self,
creases: impl IntoIterator<Item = Crease<Anchor>>,
@@ -312,6 +324,7 @@ impl CreaseMap {
new_ids
}
+ #[ztracing::instrument(skip_all)]
pub fn remove(
&mut self,
ids: impl IntoIterator<Item = CreaseId>,
@@ -379,6 +392,7 @@ impl sum_tree::Summary for ItemSummary {
impl sum_tree::Item for CreaseItem {
type Summary = ItemSummary;
+ #[ztracing::instrument(skip_all)]
fn summary(&self, _cx: &MultiBufferSnapshot) -> Self::Summary {
ItemSummary {
range: self.crease.range().clone(),
@@ -388,12 +402,14 @@ impl sum_tree::Item for CreaseItem {
/// Implements `SeekTarget` for `Range<Anchor>` to enable seeking within a `SumTree` of `CreaseItem`s.
impl SeekTarget<'_, ItemSummary, ItemSummary> for Range<Anchor> {
+ #[ztracing::instrument(skip_all)]
fn cmp(&self, cursor_location: &ItemSummary, snapshot: &MultiBufferSnapshot) -> Ordering {
AnchorRangeExt::cmp(self, &cursor_location.range, snapshot)
}
}
impl SeekTarget<'_, ItemSummary, ItemSummary> for Anchor {
+ #[ztracing::instrument(skip_all)]
fn cmp(&self, other: &ItemSummary, snapshot: &MultiBufferSnapshot) -> Ordering {
self.cmp(&other.range.start, snapshot)
}
@@ -461,6 +477,7 @@ mod test {
}
#[gpui::test]
+ #[ztracing::instrument(skip_all)]
fn test_creases_in_range(cx: &mut App) {
let text = "line1\nline2\nline3\nline4\nline5\nline6\nline7";
let buffer = MultiBuffer::build_simple(text, cx);
@@ -1,7 +1,7 @@
use collections::BTreeMap;
use gpui::HighlightStyle;
use language::Chunk;
-use multi_buffer::{MultiBufferChunks, MultiBufferSnapshot, ToOffset as _};
+use multi_buffer::{MultiBufferChunks, MultiBufferOffset, MultiBufferSnapshot, ToOffset as _};
use std::{
cmp,
iter::{self, Peekable},
@@ -14,7 +14,7 @@ use crate::display_map::{HighlightKey, TextHighlights};
pub struct CustomHighlightsChunks<'a> {
buffer_chunks: MultiBufferChunks<'a>,
buffer_chunk: Option<Chunk<'a>>,
- offset: usize,
+ offset: MultiBufferOffset,
multibuffer_snapshot: &'a MultiBufferSnapshot,
highlight_endpoints: Peekable<vec::IntoIter<HighlightEndpoint>>,
@@ -24,14 +24,15 @@ pub struct CustomHighlightsChunks<'a> {
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
struct HighlightEndpoint {
- offset: usize,
+ offset: MultiBufferOffset,
tag: HighlightKey,
style: Option<HighlightStyle>,
}
impl<'a> CustomHighlightsChunks<'a> {
+ #[ztracing::instrument(skip_all)]
pub fn new(
- range: Range<usize>,
+ range: Range<MultiBufferOffset>,
language_aware: bool,
text_highlights: Option<&'a TextHighlights>,
multibuffer_snapshot: &'a MultiBufferSnapshot,
@@ -40,7 +41,6 @@ impl<'a> CustomHighlightsChunks<'a> {
buffer_chunks: multibuffer_snapshot.chunks(range.clone(), language_aware),
buffer_chunk: None,
offset: range.start,
-
text_highlights,
highlight_endpoints: create_highlight_endpoints(
&range,
@@ -52,7 +52,8 @@ impl<'a> CustomHighlightsChunks<'a> {
}
}
- pub fn seek(&mut self, new_range: Range<usize>) {
+ #[ztracing::instrument(skip_all)]
+ pub fn seek(&mut self, new_range: Range<MultiBufferOffset>) {
self.highlight_endpoints =
create_highlight_endpoints(&new_range, self.text_highlights, self.multibuffer_snapshot);
self.offset = new_range.start;
@@ -63,7 +64,7 @@ impl<'a> CustomHighlightsChunks<'a> {
}
fn create_highlight_endpoints(
- range: &Range<usize>,
+ range: &Range<MultiBufferOffset>,
text_highlights: Option<&TextHighlights>,
buffer: &MultiBufferSnapshot,
) -> iter::Peekable<vec::IntoIter<HighlightEndpoint>> {
@@ -75,22 +76,18 @@ fn create_highlight_endpoints(
let style = text_highlights.0;
let ranges = &text_highlights.1;
- let start_ix = match ranges.binary_search_by(|probe| {
- let cmp = probe.end.cmp(&start, buffer);
- if cmp.is_gt() {
- cmp::Ordering::Greater
- } else {
- cmp::Ordering::Less
- }
- }) {
- Ok(i) | Err(i) => i,
- };
+ let start_ix = ranges
+ .binary_search_by(|probe| probe.end.cmp(&start, buffer).then(cmp::Ordering::Less))
+ .unwrap_or_else(|i| i);
+ let end_ix = ranges[start_ix..]
+ .binary_search_by(|probe| {
+ probe.start.cmp(&end, buffer).then(cmp::Ordering::Greater)
+ })
+ .unwrap_or_else(|i| i);
- for range in &ranges[start_ix..] {
- if range.start.cmp(&end, buffer).is_ge() {
- break;
- }
+ highlight_endpoints.reserve(2 * end_ix);
+ for range in &ranges[start_ix..][..end_ix] {
let start = range.start.to_offset(buffer);
let end = range.end.to_offset(buffer);
if start == end {
@@ -116,8 +113,9 @@ fn create_highlight_endpoints(
impl<'a> Iterator for CustomHighlightsChunks<'a> {
type Item = Chunk<'a>;
+ #[ztracing::instrument(skip_all)]
fn next(&mut self) -> Option<Self::Item> {
- let mut next_highlight_endpoint = usize::MAX;
+ let mut next_highlight_endpoint = MultiBufferOffset(usize::MAX);
while let Some(endpoint) = self.highlight_endpoints.peek().copied() {
if endpoint.offset <= self.offset {
if let Some(style) = endpoint.style {
@@ -224,20 +222,22 @@ mod tests {
let range_count = rng.random_range(1..10);
let text = buffer_snapshot.text();
for _ in 0..range_count {
- if buffer_snapshot.len() == 0 {
+ if buffer_snapshot.len() == MultiBufferOffset(0) {
continue;
}
- let mut start = rng.random_range(0..=buffer_snapshot.len().saturating_sub(10));
+ let mut start = rng.random_range(
+ MultiBufferOffset(0)..=buffer_snapshot.len().saturating_sub_usize(10),
+ );
- while !text.is_char_boundary(start) {
- start = start.saturating_sub(1);
+ while !text.is_char_boundary(start.0) {
+ start = start.saturating_sub_usize(1);
}
- let end_end = buffer_snapshot.len().min(start + 100);
+ let end_end = buffer_snapshot.len().min(start + 100usize);
let mut end = rng.random_range(start..=end_end);
- while !text.is_char_boundary(end) {
- end = end.saturating_sub(1);
+ while !text.is_char_boundary(end.0) {
+ end = end.saturating_sub_usize(1);
}
if start < end {
@@ -253,8 +253,12 @@ mod tests {
}
// Get all chunks and verify their bitmaps
- let chunks =
- CustomHighlightsChunks::new(0..buffer_snapshot.len(), false, None, &buffer_snapshot);
+ let chunks = CustomHighlightsChunks::new(
+ MultiBufferOffset(0)..buffer_snapshot.len(),
+ false,
+ None,
+ &buffer_snapshot,
+ );
for chunk in chunks {
let chunk_text = chunk.text;
@@ -5,16 +5,17 @@ use super::{
inlay_map::{InlayBufferRows, InlayChunks, InlayEdit, InlayOffset, InlayPoint, InlaySnapshot},
};
use gpui::{AnyElement, App, ElementId, HighlightStyle, Pixels, Window};
-use language::{Edit, HighlightId, Point, TextSummary};
+use language::{Edit, HighlightId, Point};
use multi_buffer::{
- Anchor, AnchorRangeExt, MultiBufferRow, MultiBufferSnapshot, RowInfo, ToOffset,
+ Anchor, AnchorRangeExt, MBTextSummary, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot,
+ RowInfo, ToOffset,
};
use project::InlayId;
use std::{
any::TypeId,
cmp::{self, Ordering},
fmt, iter,
- ops::{Add, AddAssign, Deref, DerefMut, Range, Sub},
+ ops::{Add, AddAssign, Deref, DerefMut, Range, Sub, SubAssign},
sync::Arc,
usize,
};
@@ -98,6 +99,7 @@ impl FoldPoint {
&mut self.0.column
}
+ #[ztracing::instrument(skip_all)]
pub fn to_inlay_point(self, snapshot: &FoldSnapshot) -> InlayPoint {
let (start, _, _) = snapshot
.transforms
@@ -106,6 +108,7 @@ impl FoldPoint {
InlayPoint(start.1.0 + overshoot)
}
+ #[ztracing::instrument(skip_all)]
pub fn to_offset(self, snapshot: &FoldSnapshot) -> FoldOffset {
let (start, _, item) = snapshot
.transforms
@@ -137,6 +140,7 @@ impl<'a> sum_tree::Dimension<'a, TransformSummary> for FoldPoint {
pub(crate) struct FoldMapWriter<'a>(&'a mut FoldMap);
impl FoldMapWriter<'_> {
+ #[ztracing::instrument(skip_all)]
pub(crate) fn fold<T: ToOffset>(
&mut self,
ranges: impl IntoIterator<Item = (Range<T>, FoldPlaceholder)>,
@@ -201,6 +205,7 @@ impl FoldMapWriter<'_> {
}
/// Removes any folds with the given ranges.
+ #[ztracing::instrument(skip_all)]
pub(crate) fn remove_folds<T: ToOffset>(
&mut self,
ranges: impl IntoIterator<Item = Range<T>>,
@@ -214,6 +219,7 @@ impl FoldMapWriter<'_> {
}
/// Removes any folds whose ranges intersect the given ranges.
+ #[ztracing::instrument(skip_all)]
pub(crate) fn unfold_intersecting<T: ToOffset>(
&mut self,
ranges: impl IntoIterator<Item = Range<T>>,
@@ -224,6 +230,7 @@ impl FoldMapWriter<'_> {
/// Removes any folds that intersect the given ranges and for which the given predicate
/// returns true.
+ #[ztracing::instrument(skip_all)]
fn remove_folds_with<T: ToOffset>(
&mut self,
ranges: impl IntoIterator<Item = Range<T>>,
@@ -261,7 +268,7 @@ impl FoldMapWriter<'_> {
fold_ixs_to_delete.dedup();
self.0.snapshot.folds = {
- let mut cursor = self.0.snapshot.folds.cursor::<usize>(buffer);
+ let mut cursor = self.0.snapshot.folds.cursor::<MultiBufferOffset>(buffer);
let mut folds = SumTree::new(buffer);
for fold_ix in fold_ixs_to_delete {
folds.append(cursor.slice(&fold_ix, Bias::Right), buffer);
@@ -276,6 +283,7 @@ impl FoldMapWriter<'_> {
(self.0.snapshot.clone(), edits)
}
+ #[ztracing::instrument(skip_all)]
pub(crate) fn update_fold_widths(
&mut self,
new_widths: impl IntoIterator<Item = (ChunkRendererId, Pixels)>,
@@ -325,6 +333,7 @@ pub struct FoldMap {
}
impl FoldMap {
+ #[ztracing::instrument(skip_all)]
pub fn new(inlay_snapshot: InlaySnapshot) -> (Self, FoldSnapshot) {
let this = Self {
snapshot: FoldSnapshot {
@@ -349,6 +358,7 @@ impl FoldMap {
(this, snapshot)
}
+ #[ztracing::instrument(skip_all)]
pub fn read(
&mut self,
inlay_snapshot: InlaySnapshot,
@@ -359,6 +369,7 @@ impl FoldMap {
(self.snapshot.clone(), edits)
}
+ #[ztracing::instrument(skip_all)]
pub(crate) fn write(
&mut self,
inlay_snapshot: InlaySnapshot,
@@ -368,6 +379,7 @@ impl FoldMap {
(FoldMapWriter(self), snapshot, edits)
}
+ #[ztracing::instrument(skip_all)]
fn check_invariants(&self) {
if cfg!(test) {
assert_eq!(
@@ -397,6 +409,7 @@ impl FoldMap {
}
}
+ #[ztracing::instrument(skip_all)]
fn sync(
&mut self,
inlay_snapshot: InlaySnapshot,
@@ -413,7 +426,7 @@ impl FoldMap {
let mut new_transforms = SumTree::<Transform>::default();
let mut cursor = self.snapshot.transforms.cursor::<InlayOffset>(());
- cursor.seek(&InlayOffset(0), Bias::Right);
+ cursor.seek(&InlayOffset(MultiBufferOffset(0)), Bias::Right);
while let Some(mut edit) = inlay_edits_iter.next() {
if let Some(item) = cursor.item()
@@ -436,7 +449,7 @@ impl FoldMap {
cursor.seek(&edit.old.end, Bias::Right);
cursor.next();
- let mut delta = edit.new_len().0 as isize - edit.old_len().0 as isize;
+ let mut delta = edit.new_len() as isize - edit.old_len() as isize;
loop {
edit.old.end = *cursor.start();
@@ -446,7 +459,7 @@ impl FoldMap {
}
let next_edit = inlay_edits_iter.next().unwrap();
- delta += next_edit.new_len().0 as isize - next_edit.old_len().0 as isize;
+ delta += next_edit.new_len() as isize - next_edit.old_len() as isize;
if next_edit.old.end >= edit.old.end {
edit.old.end = next_edit.old.end;
@@ -458,8 +471,9 @@ impl FoldMap {
}
}
- edit.new.end =
- InlayOffset(((edit.new.start + edit.old_len()).0 as isize + delta) as usize);
+ edit.new.end = InlayOffset(MultiBufferOffset(
+ ((edit.new.start + edit.old_len()).0.0 as isize + delta) as usize,
+ ));
let anchor = inlay_snapshot
.buffer
@@ -522,7 +536,7 @@ impl FoldMap {
new_transforms.push(
Transform {
summary: TransformSummary {
- output: TextSummary::from(ELLIPSIS),
+ output: MBTextSummary::from(ELLIPSIS),
input: inlay_snapshot
.text_summary_for_range(fold_range.start..fold_range.end),
},
@@ -579,7 +593,7 @@ impl FoldMap {
edit.old.start = old_transforms.start().0;
}
let old_start =
- old_transforms.start().1.0 + (edit.old.start - old_transforms.start().0).0;
+ old_transforms.start().1.0 + (edit.old.start - old_transforms.start().0);
old_transforms.seek_forward(&edit.old.end, Bias::Right);
if old_transforms.item().is_some_and(|t| t.is_fold()) {
@@ -587,14 +601,14 @@ impl FoldMap {
edit.old.end = old_transforms.start().0;
}
let old_end =
- old_transforms.start().1.0 + (edit.old.end - old_transforms.start().0).0;
+ old_transforms.start().1.0 + (edit.old.end - old_transforms.start().0);
new_transforms.seek(&edit.new.start, Bias::Left);
if new_transforms.item().is_some_and(|t| t.is_fold()) {
edit.new.start = new_transforms.start().0;
}
let new_start =
- new_transforms.start().1.0 + (edit.new.start - new_transforms.start().0).0;
+ new_transforms.start().1.0 + (edit.new.start - new_transforms.start().0);
new_transforms.seek_forward(&edit.new.end, Bias::Right);
if new_transforms.item().is_some_and(|t| t.is_fold()) {
@@ -602,7 +616,7 @@ impl FoldMap {
edit.new.end = new_transforms.start().0;
}
let new_end =
- new_transforms.start().1.0 + (edit.new.end - new_transforms.start().0).0;
+ new_transforms.start().1.0 + (edit.new.end - new_transforms.start().0);
fold_edits.push(FoldEdit {
old: FoldOffset(old_start)..FoldOffset(old_end),
@@ -643,15 +657,20 @@ impl FoldSnapshot {
&self.inlay_snapshot.buffer
}
+ #[ztracing::instrument(skip_all)]
fn fold_width(&self, fold_id: &FoldId) -> Option<Pixels> {
self.fold_metadata_by_id.get(fold_id)?.width
}
#[cfg(test)]
pub fn text(&self) -> String {
- self.chunks(FoldOffset(0)..self.len(), false, Highlights::default())
- .map(|c| c.text)
- .collect()
+ self.chunks(
+ FoldOffset(MultiBufferOffset(0))..self.len(),
+ false,
+ Highlights::default(),
+ )
+ .map(|c| c.text)
+ .collect()
}
#[cfg(test)]
@@ -659,8 +678,9 @@ impl FoldSnapshot {
self.folds.items(&self.inlay_snapshot.buffer).len()
}
- pub fn text_summary_for_range(&self, range: Range<FoldPoint>) -> TextSummary {
- let mut summary = TextSummary::default();
+ #[ztracing::instrument(skip_all)]
+ pub fn text_summary_for_range(&self, range: Range<FoldPoint>) -> MBTextSummary {
+ let mut summary = MBTextSummary::default();
let mut cursor = self
.transforms
@@ -670,7 +690,7 @@ impl FoldSnapshot {
let start_in_transform = range.start.0 - cursor.start().0.0;
let end_in_transform = cmp::min(range.end, cursor.end().0).0 - cursor.start().0.0;
if let Some(placeholder) = transform.placeholder.as_ref() {
- summary = TextSummary::from(
+ summary = MBTextSummary::from(
&placeholder.text
[start_in_transform.column as usize..end_in_transform.column as usize],
);
@@ -689,14 +709,14 @@ impl FoldSnapshot {
if range.end > cursor.end().0 {
cursor.next();
- summary += &cursor
+ summary += cursor
.summary::<_, TransformSummary>(&range.end, Bias::Right)
.output;
if let Some(transform) = cursor.item() {
let end_in_transform = range.end.0 - cursor.start().0.0;
if let Some(placeholder) = transform.placeholder.as_ref() {
summary +=
- TextSummary::from(&placeholder.text[..end_in_transform.column as usize]);
+ MBTextSummary::from(&placeholder.text[..end_in_transform.column as usize]);
} else {
let inlay_start = self.inlay_snapshot.to_offset(cursor.start().1);
let inlay_end = self
@@ -712,6 +732,7 @@ impl FoldSnapshot {
summary
}
+ #[ztracing::instrument(skip_all)]
pub fn to_fold_point(&self, point: InlayPoint, bias: Bias) -> FoldPoint {
let (start, end, item) = self
.transforms
@@ -728,10 +749,20 @@ impl FoldSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
+ pub fn fold_point_cursor(&self) -> FoldPointCursor<'_> {
+ let cursor = self
+ .transforms
+ .cursor::<Dimensions<InlayPoint, FoldPoint>>(());
+ FoldPointCursor { cursor }
+ }
+
+ #[ztracing::instrument(skip_all)]
pub fn len(&self) -> FoldOffset {
FoldOffset(self.transforms.summary().output.len)
}
+ #[ztracing::instrument(skip_all)]
pub fn line_len(&self, row: u32) -> u32 {
let line_start = FoldPoint::new(row, 0).to_offset(self).0;
let line_end = if row >= self.max_point().row() {
@@ -742,6 +773,7 @@ impl FoldSnapshot {
(line_end - line_start) as u32
}
+ #[ztracing::instrument(skip_all)]
pub fn row_infos(&self, start_row: u32) -> FoldRows<'_> {
if start_row > self.transforms.summary().output.lines.row {
panic!("invalid display row {}", start_row);
@@ -764,6 +796,7 @@ impl FoldSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn max_point(&self) -> FoldPoint {
FoldPoint(self.transforms.summary().output.lines)
}
@@ -773,6 +806,7 @@ impl FoldSnapshot {
self.transforms.summary().output.longest_row
}
+ #[ztracing::instrument(skip_all)]
pub fn folds_in_range<T>(&self, range: Range<T>) -> impl Iterator<Item = &Fold>
where
T: ToOffset,
@@ -787,6 +821,7 @@ impl FoldSnapshot {
})
}
+ #[ztracing::instrument(skip_all)]
pub fn intersects_fold<T>(&self, offset: T) -> bool
where
T: ToOffset,
@@ -799,6 +834,7 @@ impl FoldSnapshot {
item.is_some_and(|t| t.placeholder.is_some())
}
+ #[ztracing::instrument(skip_all)]
pub fn is_line_folded(&self, buffer_row: MultiBufferRow) -> bool {
let mut inlay_point = self
.inlay_snapshot
@@ -827,6 +863,7 @@ impl FoldSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub(crate) fn chunks<'a>(
&'a self,
range: Range<FoldOffset>,
@@ -839,8 +876,8 @@ impl FoldSnapshot {
transform_cursor.seek(&range.start, Bias::Right);
let inlay_start = {
- let overshoot = range.start.0 - transform_cursor.start().0.0;
- transform_cursor.start().1 + InlayOffset(overshoot)
+ let overshoot = range.start - transform_cursor.start().0;
+ transform_cursor.start().1 + overshoot
};
let transform_end = transform_cursor.end();
@@ -851,8 +888,8 @@ impl FoldSnapshot {
{
inlay_start
} else if range.end < transform_end.0 {
- let overshoot = range.end.0 - transform_cursor.start().0.0;
- transform_cursor.start().1 + InlayOffset(overshoot)
+ let overshoot = range.end - transform_cursor.start().0;
+ transform_cursor.start().1 + overshoot
} else {
transform_end.1
};
@@ -871,6 +908,7 @@ impl FoldSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn chars_at(&self, start: FoldPoint) -> impl '_ + Iterator<Item = char> {
self.chunks(
start.to_offset(self)..self.len(),
@@ -880,6 +918,7 @@ impl FoldSnapshot {
.flat_map(|chunk| chunk.text.chars())
}
+ #[ztracing::instrument(skip_all)]
pub fn chunks_at(&self, start: FoldPoint) -> FoldChunks<'_> {
self.chunks(
start.to_offset(self)..self.len(),
@@ -889,6 +928,7 @@ impl FoldSnapshot {
}
#[cfg(test)]
+ #[ztracing::instrument(skip_all)]
pub fn clip_offset(&self, offset: FoldOffset, bias: Bias) -> FoldOffset {
if offset > self.len() {
self.len()
@@ -897,6 +937,7 @@ impl FoldSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn clip_point(&self, point: FoldPoint, bias: Bias) -> FoldPoint {
let (start, end, item) = self
.transforms
@@ -921,7 +962,33 @@ impl FoldSnapshot {
}
}
-fn push_isomorphic(transforms: &mut SumTree<Transform>, summary: TextSummary) {
+pub struct FoldPointCursor<'transforms> {
+ cursor: Cursor<'transforms, 'static, Transform, Dimensions<InlayPoint, FoldPoint>>,
+}
+
+impl FoldPointCursor<'_> {
+ #[ztracing::instrument(skip_all)]
+ pub fn map(&mut self, point: InlayPoint, bias: Bias) -> FoldPoint {
+ let cursor = &mut self.cursor;
+ if cursor.did_seek() {
+ cursor.seek_forward(&point, Bias::Right);
+ } else {
+ cursor.seek(&point, Bias::Right);
+ }
+ if cursor.item().is_some_and(|t| t.is_fold()) {
+ if bias == Bias::Left || point == cursor.start().0 {
+ cursor.start().1
+ } else {
+ cursor.end().1
+ }
+ } else {
+ let overshoot = point.0 - cursor.start().0.0;
+ FoldPoint(cmp::min(cursor.start().1.0 + overshoot, cursor.end().1.0))
+ }
+ }
+}
+
+fn push_isomorphic(transforms: &mut SumTree<Transform>, summary: MBTextSummary) {
let mut did_merge = false;
transforms.update_last(
|last| {
@@ -950,13 +1017,13 @@ fn push_isomorphic(transforms: &mut SumTree<Transform>, summary: TextSummary) {
fn intersecting_folds<'a>(
inlay_snapshot: &'a InlaySnapshot,
folds: &'a SumTree<Fold>,
- range: Range<usize>,
+ range: Range<MultiBufferOffset>,
inclusive: bool,
-) -> FilterCursor<'a, 'a, impl 'a + FnMut(&FoldSummary) -> bool, Fold, usize> {
+) -> FilterCursor<'a, 'a, impl 'a + FnMut(&FoldSummary) -> bool, Fold, MultiBufferOffset> {
let buffer = &inlay_snapshot.buffer;
let start = buffer.anchor_before(range.start.to_offset(buffer));
let end = buffer.anchor_after(range.end.to_offset(buffer));
- let mut cursor = folds.filter::<_, usize>(buffer, move |summary| {
+ let mut cursor = folds.filter::<_, MultiBufferOffset>(buffer, move |summary| {
let start_cmp = start.cmp(&summary.max_end, buffer);
let end_cmp = end.cmp(&summary.min_start, buffer);
@@ -1061,8 +1128,8 @@ impl Transform {
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct TransformSummary {
- output: TextSummary,
- input: TextSummary,
+ output: MBTextSummary,
+ input: MBTextSummary,
}
impl sum_tree::Item for Transform {
@@ -1079,8 +1146,8 @@ impl sum_tree::ContextLessSummary for TransformSummary {
}
fn add_summary(&mut self, other: &Self) {
- self.input += &other.input;
- self.output += &other.output;
+ self.input += other.input;
+ self.output += other.output;
}
}
@@ -1211,7 +1278,7 @@ impl sum_tree::SeekTarget<'_, FoldSummary, FoldRange> for FoldRange {
}
}
-impl<'a> sum_tree::Dimension<'a, FoldSummary> for usize {
+impl<'a> sum_tree::Dimension<'a, FoldSummary> for MultiBufferOffset {
fn zero(_cx: &MultiBufferSnapshot) -> Self {
Default::default()
}
@@ -1229,6 +1296,7 @@ pub struct FoldRows<'a> {
}
impl FoldRows<'_> {
+ #[ztracing::instrument(skip_all)]
pub(crate) fn seek(&mut self, row: u32) {
let fold_point = FoldPoint::new(row, 0);
self.cursor.seek(&fold_point, Bias::Left);
@@ -1242,6 +1310,7 @@ impl FoldRows<'_> {
impl Iterator for FoldRows<'_> {
type Item = RowInfo;
+ #[ztracing::instrument(skip_all)]
fn next(&mut self) -> Option<Self::Item> {
let mut traversed_fold = false;
while self.fold_point > self.cursor.end().0 {
@@ -1353,12 +1422,13 @@ pub struct FoldChunks<'a> {
}
impl FoldChunks<'_> {
+ #[ztracing::instrument(skip_all)]
pub(crate) fn seek(&mut self, range: Range<FoldOffset>) {
self.transform_cursor.seek(&range.start, Bias::Right);
let inlay_start = {
- let overshoot = range.start.0 - self.transform_cursor.start().0.0;
- self.transform_cursor.start().1 + InlayOffset(overshoot)
+ let overshoot = range.start - self.transform_cursor.start().0;
+ self.transform_cursor.start().1 + overshoot
};
let transform_end = self.transform_cursor.end();
@@ -1370,8 +1440,8 @@ impl FoldChunks<'_> {
{
inlay_start
} else if range.end < transform_end.0 {
- let overshoot = range.end.0 - self.transform_cursor.start().0.0;
- self.transform_cursor.start().1 + InlayOffset(overshoot)
+ let overshoot = range.end - self.transform_cursor.start().0;
+ self.transform_cursor.start().1 + overshoot
} else {
transform_end.1
};
@@ -1387,6 +1457,7 @@ impl FoldChunks<'_> {
impl<'a> Iterator for FoldChunks<'a> {
type Item = Chunk<'a>;
+ #[ztracing::instrument(skip_all)]
fn next(&mut self) -> Option<Self::Item> {
if self.output_offset >= self.max_output_offset {
return None;
@@ -1423,8 +1494,8 @@ impl<'a> Iterator for FoldChunks<'a> {
let transform_start = self.transform_cursor.start();
let transform_end = self.transform_cursor.end();
let inlay_end = if self.max_output_offset < transform_end.0 {
- let overshoot = self.max_output_offset.0 - transform_start.0.0;
- transform_start.1 + InlayOffset(overshoot)
+ let overshoot = self.max_output_offset - transform_start.0;
+ transform_start.1 + overshoot
} else {
transform_end.1
};
@@ -1441,15 +1512,15 @@ impl<'a> Iterator for FoldChunks<'a> {
// Otherwise, take a chunk from the buffer's text.
if let Some((buffer_chunk_start, mut inlay_chunk)) = self.inlay_chunk.clone() {
let chunk = &mut inlay_chunk.chunk;
- let buffer_chunk_end = buffer_chunk_start + InlayOffset(chunk.text.len());
+ let buffer_chunk_end = buffer_chunk_start + chunk.text.len();
let transform_end = self.transform_cursor.end().1;
let chunk_end = buffer_chunk_end.min(transform_end);
- let bit_start = (self.inlay_offset - buffer_chunk_start).0;
- let bit_end = (chunk_end - buffer_chunk_start).0;
+ let bit_start = self.inlay_offset - buffer_chunk_start;
+ let bit_end = chunk_end - buffer_chunk_start;
chunk.text = &chunk.text[bit_start..bit_end];
- let bit_end = (chunk_end - buffer_chunk_start).0;
+ let bit_end = chunk_end - buffer_chunk_start;
let mask = 1u128.unbounded_shl(bit_end as u32).wrapping_sub(1);
chunk.tabs = (chunk.tabs >> bit_start) & mask;
@@ -1483,9 +1554,10 @@ impl<'a> Iterator for FoldChunks<'a> {
}
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
-pub struct FoldOffset(pub usize);
+pub struct FoldOffset(pub MultiBufferOffset);
impl FoldOffset {
+ #[ztracing::instrument(skip_all)]
pub fn to_point(self, snapshot: &FoldSnapshot) -> FoldPoint {
let (start, _, item) = snapshot
.transforms
@@ -1493,7 +1565,7 @@ impl FoldOffset {
let overshoot = if item.is_none_or(|t| t.is_fold()) {
Point::new(0, (self.0 - start.0.0) as u32)
} else {
- let inlay_offset = start.1.input.len + self.0 - start.0.0;
+ let inlay_offset = start.1.input.len + (self - start.0);
let inlay_point = snapshot.inlay_snapshot.to_point(InlayOffset(inlay_offset));
inlay_point.0 - start.1.input.lines
};
@@ -1501,11 +1573,12 @@ impl FoldOffset {
}
#[cfg(test)]
+ #[ztracing::instrument(skip_all)]
pub fn to_inlay_offset(self, snapshot: &FoldSnapshot) -> InlayOffset {
let (start, _, _) = snapshot
.transforms
.find::<Dimensions<FoldOffset, InlayOffset>, _>((), &self, Bias::Right);
- let overshoot = self.0 - start.0.0;
+ let overshoot = self - start.0;
InlayOffset(start.1.0 + overshoot)
}
}
@@ -1518,17 +1591,46 @@ impl Add for FoldOffset {
}
}
+impl Sub for FoldOffset {
+ type Output = <MultiBufferOffset as Sub>::Output;
+
+ fn sub(self, rhs: Self) -> Self::Output {
+ self.0 - rhs.0
+ }
+}
+
+impl<T> SubAssign<T> for FoldOffset
+where
+ MultiBufferOffset: SubAssign<T>,
+{
+ fn sub_assign(&mut self, rhs: T) {
+ self.0 -= rhs;
+ }
+}
+
+impl<T> Add<T> for FoldOffset
+where
+ MultiBufferOffset: Add<T, Output = MultiBufferOffset>,
+{
+ type Output = Self;
+
+ fn add(self, rhs: T) -> Self::Output {
+ Self(self.0 + rhs)
+ }
+}
+
impl AddAssign for FoldOffset {
fn add_assign(&mut self, rhs: Self) {
self.0 += rhs.0;
}
}
-impl Sub for FoldOffset {
- type Output = Self;
-
- fn sub(self, rhs: Self) -> Self::Output {
- Self(self.0 - rhs.0)
+impl<T> AddAssign<T> for FoldOffset
+where
+ MultiBufferOffset: AddAssign<T>,
+{
+ fn add_assign(&mut self, rhs: T) {
+ self.0 += rhs;
}
}
@@ -1538,7 +1640,7 @@ impl<'a> sum_tree::Dimension<'a, TransformSummary> for FoldOffset {
}
fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) {
- self.0 += &summary.output.len;
+ self.0 += summary.output.len;
}
}
@@ -1558,7 +1660,7 @@ impl<'a> sum_tree::Dimension<'a, TransformSummary> for InlayOffset {
}
fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) {
- self.0 += &summary.input.len;
+ self.0 += summary.input.len;
}
}
@@ -1596,12 +1698,12 @@ mod tests {
edits,
&[
FoldEdit {
- old: FoldOffset(2)..FoldOffset(16),
- new: FoldOffset(2)..FoldOffset(5),
+ old: FoldOffset(MultiBufferOffset(2))..FoldOffset(MultiBufferOffset(16)),
+ new: FoldOffset(MultiBufferOffset(2))..FoldOffset(MultiBufferOffset(5)),
},
FoldEdit {
- old: FoldOffset(18)..FoldOffset(29),
- new: FoldOffset(7)..FoldOffset(10)
+ old: FoldOffset(MultiBufferOffset(18))..FoldOffset(MultiBufferOffset(29)),
+ new: FoldOffset(MultiBufferOffset(7))..FoldOffset(MultiBufferOffset(10)),
},
]
);
@@ -1626,12 +1728,12 @@ mod tests {
edits,
&[
FoldEdit {
- old: FoldOffset(0)..FoldOffset(1),
- new: FoldOffset(0)..FoldOffset(3),
+ old: FoldOffset(MultiBufferOffset(0))..FoldOffset(MultiBufferOffset(1)),
+ new: FoldOffset(MultiBufferOffset(0))..FoldOffset(MultiBufferOffset(3)),
},
FoldEdit {
- old: FoldOffset(6)..FoldOffset(6),
- new: FoldOffset(8)..FoldOffset(11),
+ old: FoldOffset(MultiBufferOffset(6))..FoldOffset(MultiBufferOffset(6)),
+ new: FoldOffset(MultiBufferOffset(8))..FoldOffset(MultiBufferOffset(11)),
},
]
);
@@ -1668,15 +1770,24 @@ mod tests {
let mut map = FoldMap::new(inlay_snapshot.clone()).0;
let (mut writer, _, _) = map.write(inlay_snapshot.clone(), vec![]);
- writer.fold(vec![(5..8, FoldPlaceholder::test())]);
+ writer.fold(vec![(
+ MultiBufferOffset(5)..MultiBufferOffset(8),
+ FoldPlaceholder::test(),
+ )]);
let (snapshot, _) = map.read(inlay_snapshot.clone(), vec![]);
assert_eq!(snapshot.text(), "abcde⋯ijkl");
// Create an fold adjacent to the start of the first fold.
let (mut writer, _, _) = map.write(inlay_snapshot.clone(), vec![]);
writer.fold(vec![
- (0..1, FoldPlaceholder::test()),
- (2..5, FoldPlaceholder::test()),
+ (
+ MultiBufferOffset(0)..MultiBufferOffset(1),
+ FoldPlaceholder::test(),
+ ),
+ (
+ MultiBufferOffset(2)..MultiBufferOffset(5),
+ FoldPlaceholder::test(),
+ ),
]);
let (snapshot, _) = map.read(inlay_snapshot.clone(), vec![]);
assert_eq!(snapshot.text(), "⋯b⋯ijkl");
@@ -1684,8 +1795,14 @@ mod tests {
// Create an fold adjacent to the end of the first fold.
let (mut writer, _, _) = map.write(inlay_snapshot.clone(), vec![]);
writer.fold(vec![
- (11..11, FoldPlaceholder::test()),
- (8..10, FoldPlaceholder::test()),
+ (
+ MultiBufferOffset(11)..MultiBufferOffset(11),
+ FoldPlaceholder::test(),
+ ),
+ (
+ MultiBufferOffset(8)..MultiBufferOffset(10),
+ FoldPlaceholder::test(),
+ ),
]);
let (snapshot, _) = map.read(inlay_snapshot.clone(), vec![]);
assert_eq!(snapshot.text(), "⋯b⋯kl");
@@ -1697,15 +1814,25 @@ mod tests {
// Create two adjacent folds.
let (mut writer, _, _) = map.write(inlay_snapshot.clone(), vec![]);
writer.fold(vec![
- (0..2, FoldPlaceholder::test()),
- (2..5, FoldPlaceholder::test()),
+ (
+ MultiBufferOffset(0)..MultiBufferOffset(2),
+ FoldPlaceholder::test(),
+ ),
+ (
+ MultiBufferOffset(2)..MultiBufferOffset(5),
+ FoldPlaceholder::test(),
+ ),
]);
let (snapshot, _) = map.read(inlay_snapshot, vec![]);
assert_eq!(snapshot.text(), "⋯fghijkl");
// Edit within one of the folds.
let buffer_snapshot = buffer.update(cx, |buffer, cx| {
- buffer.edit([(0..1, "12345")], None, cx);
+ buffer.edit(
+ [(MultiBufferOffset(0)..MultiBufferOffset(1), "12345")],
+ None,
+ cx,
+ );
buffer.snapshot(cx)
});
let (inlay_snapshot, inlay_edits) =
@@ -1849,7 +1976,7 @@ mod tests {
for fold_range in map.merged_folds().into_iter().rev() {
let fold_inlay_start = inlay_snapshot.to_inlay_offset(fold_range.start);
let fold_inlay_end = inlay_snapshot.to_inlay_offset(fold_range.end);
- expected_text.replace_range(fold_inlay_start.0..fold_inlay_end.0, "⋯");
+ expected_text.replace_range(fold_inlay_start.0.0..fold_inlay_end.0.0, "⋯");
}
assert_eq!(snapshot.text(), expected_text);
@@ -1898,7 +2025,7 @@ mod tests {
.chars()
.count();
let mut fold_point = FoldPoint::new(0, 0);
- let mut fold_offset = FoldOffset(0);
+ let mut fold_offset = FoldOffset(MultiBufferOffset(0));
let mut char_column = 0;
for c in expected_text.chars() {
let inlay_point = fold_point.to_inlay_point(&snapshot);
@@ -1944,18 +2071,18 @@ mod tests {
for _ in 0..5 {
let mut start = snapshot.clip_offset(
- FoldOffset(rng.random_range(0..=snapshot.len().0)),
+ FoldOffset(rng.random_range(MultiBufferOffset(0)..=snapshot.len().0)),
Bias::Left,
);
let mut end = snapshot.clip_offset(
- FoldOffset(rng.random_range(0..=snapshot.len().0)),
+ FoldOffset(rng.random_range(MultiBufferOffset(0)..=snapshot.len().0)),
Bias::Right,
);
if start > end {
mem::swap(&mut start, &mut end);
}
- let text = &expected_text[start.0..end.0];
+ let text = &expected_text[start.0.0..end.0.0];
assert_eq!(
snapshot
.chunks(start..end, false, Highlights::default())
@@ -2004,9 +2131,12 @@ mod tests {
}
for _ in 0..5 {
- let end =
- buffer_snapshot.clip_offset(rng.random_range(0..=buffer_snapshot.len()), Right);
- let start = buffer_snapshot.clip_offset(rng.random_range(0..=end), Left);
+ let end = buffer_snapshot.clip_offset(
+ rng.random_range(MultiBufferOffset(0)..=buffer_snapshot.len()),
+ Right,
+ );
+ let start =
+ buffer_snapshot.clip_offset(rng.random_range(MultiBufferOffset(0)..=end), Left);
let expected_folds = map
.snapshot
.folds
@@ -2046,7 +2176,7 @@ mod tests {
let bytes = start.to_offset(&snapshot)..end.to_offset(&snapshot);
assert_eq!(
snapshot.text_summary_for_range(lines),
- TextSummary::from(&text[bytes.start.0..bytes.end.0])
+ MBTextSummary::from(&text[bytes.start.0.0..bytes.end.0.0])
)
}
@@ -2054,8 +2184,8 @@ mod tests {
for (snapshot, edits) in snapshot_edits.drain(..) {
let new_text = snapshot.text();
for edit in edits {
- let old_bytes = edit.new.start.0..edit.new.start.0 + edit.old_len().0;
- let new_bytes = edit.new.start.0..edit.new.end.0;
+ let old_bytes = edit.new.start.0.0..edit.new.start.0.0 + edit.old_len();
+ let new_bytes = edit.new.start.0.0..edit.new.end.0.0;
text.replace_range(old_bytes, &new_text[new_bytes]);
}
@@ -2126,7 +2256,7 @@ mod tests {
// Get all chunks and verify their bitmaps
let chunks = snapshot.chunks(
- FoldOffset(0)..FoldOffset(snapshot.len().0),
+ FoldOffset(MultiBufferOffset(0))..FoldOffset(snapshot.len().0),
false,
Highlights::default(),
);
@@ -2195,7 +2325,7 @@ mod tests {
}
impl FoldMap {
- fn merged_folds(&self) -> Vec<Range<usize>> {
+ fn merged_folds(&self) -> Vec<Range<MultiBufferOffset>> {
let inlay_snapshot = self.snapshot.inlay_snapshot.clone();
let buffer = &inlay_snapshot.buffer;
let mut folds = self.snapshot.folds.items(buffer);
@@ -2236,8 +2366,12 @@ mod tests {
let buffer = &inlay_snapshot.buffer;
let mut to_unfold = Vec::new();
for _ in 0..rng.random_range(1..=3) {
- let end = buffer.clip_offset(rng.random_range(0..=buffer.len()), Right);
- let start = buffer.clip_offset(rng.random_range(0..=end), Left);
+ let end = buffer.clip_offset(
+ rng.random_range(MultiBufferOffset(0)..=buffer.len()),
+ Right,
+ );
+ let start =
+ buffer.clip_offset(rng.random_range(MultiBufferOffset(0)..=end), Left);
to_unfold.push(start..end);
}
let inclusive = rng.random();
@@ -2252,8 +2386,12 @@ mod tests {
let buffer = &inlay_snapshot.buffer;
let mut to_fold = Vec::new();
for _ in 0..rng.random_range(1..=2) {
- let end = buffer.clip_offset(rng.random_range(0..=buffer.len()), Right);
- let start = buffer.clip_offset(rng.random_range(0..=end), Left);
+ let end = buffer.clip_offset(
+ rng.random_range(MultiBufferOffset(0)..=buffer.len()),
+ Right,
+ );
+ let start =
+ buffer.clip_offset(rng.random_range(MultiBufferOffset(0)..=end), Left);
to_fold.push((start..end, FoldPlaceholder::test()));
}
log::info!("folding {:?}", to_fold);
@@ -1,10 +1,20 @@
+//! The inlay map. See the [`display_map`][super] docs for an overview of how the inlay map fits
+//! into the rest of the [`DisplayMap`][super::DisplayMap]. Much of the documentation for this
+//! module generalizes to other layers.
+//!
+//! The core of this module is the [`InlayMap`] struct, which maintains a vec of [`Inlay`]s, and
+//! [`InlaySnapshot`], which holds a sum tree of [`Transform`]s.
+
use crate::{
ChunkRenderer, HighlightStyles,
inlays::{Inlay, InlayContent},
};
use collections::BTreeSet;
use language::{Chunk, Edit, Point, TextSummary};
-use multi_buffer::{MultiBufferRow, MultiBufferRows, MultiBufferSnapshot, RowInfo, ToOffset};
+use multi_buffer::{
+ MBTextSummary, MultiBufferOffset, MultiBufferRow, MultiBufferRows, MultiBufferSnapshot,
+ RowInfo, ToOffset,
+};
use project::InlayId;
use std::{
cmp,
@@ -42,13 +52,14 @@ impl std::ops::Deref for InlaySnapshot {
#[derive(Clone, Debug)]
enum Transform {
- Isomorphic(TextSummary),
+ Isomorphic(MBTextSummary),
Inlay(Inlay),
}
impl sum_tree::Item for Transform {
type Summary = TransformSummary;
+ #[ztracing::instrument(skip_all)]
fn summary(&self, _: ()) -> Self::Summary {
match self {
Transform::Isomorphic(summary) => TransformSummary {
@@ -56,8 +67,8 @@ impl sum_tree::Item for Transform {
output: *summary,
},
Transform::Inlay(inlay) => TransformSummary {
- input: TextSummary::default(),
- output: inlay.text().summary(),
+ input: MBTextSummary::default(),
+ output: MBTextSummary::from(inlay.text().summary()),
},
}
}
@@ -65,8 +76,10 @@ impl sum_tree::Item for Transform {
#[derive(Clone, Debug, Default)]
struct TransformSummary {
- input: TextSummary,
- output: TextSummary,
+ /// Summary of the text before inlays have been applied.
+ input: MBTextSummary,
+ /// Summary of the text after inlays have been applied.
+ output: MBTextSummary,
}
impl sum_tree::ContextLessSummary for TransformSummary {
@@ -75,15 +88,15 @@ impl sum_tree::ContextLessSummary for TransformSummary {
}
fn add_summary(&mut self, other: &Self) {
- self.input += &other.input;
- self.output += &other.output;
+ self.input += other.input;
+ self.output += other.output;
}
}
pub type InlayEdit = Edit<InlayOffset>;
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
-pub struct InlayOffset(pub usize);
+pub struct InlayOffset(pub MultiBufferOffset);
impl Add for InlayOffset {
type Output = Self;
@@ -94,10 +107,30 @@ impl Add for InlayOffset {
}
impl Sub for InlayOffset {
- type Output = Self;
+ type Output = <MultiBufferOffset as Sub>::Output;
fn sub(self, rhs: Self) -> Self::Output {
- Self(self.0 - rhs.0)
+ self.0 - rhs.0
+ }
+}
+
+impl<T> SubAssign<T> for InlayOffset
+where
+ MultiBufferOffset: SubAssign<T>,
+{
+ fn sub_assign(&mut self, rhs: T) {
+ self.0 -= rhs;
+ }
+}
+
+impl<T> Add<T> for InlayOffset
+where
+ MultiBufferOffset: Add<T, Output = MultiBufferOffset>,
+{
+ type Output = Self;
+
+ fn add(self, rhs: T) -> Self::Output {
+ Self(self.0 + rhs)
}
}
@@ -107,9 +140,12 @@ impl AddAssign for InlayOffset {
}
}
-impl SubAssign for InlayOffset {
- fn sub_assign(&mut self, rhs: Self) {
- self.0 -= rhs.0;
+impl<T> AddAssign<T> for InlayOffset
+where
+ MultiBufferOffset: AddAssign<T>,
+{
+ fn add_assign(&mut self, rhs: T) {
+ self.0 += rhs;
}
}
@@ -119,7 +155,7 @@ impl<'a> sum_tree::Dimension<'a, TransformSummary> for InlayOffset {
}
fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) {
- self.0 += &summary.output.len;
+ self.0 += summary.output.len;
}
}
@@ -152,13 +188,13 @@ impl<'a> sum_tree::Dimension<'a, TransformSummary> for InlayPoint {
}
}
-impl<'a> sum_tree::Dimension<'a, TransformSummary> for usize {
+impl<'a> sum_tree::Dimension<'a, TransformSummary> for MultiBufferOffset {
fn zero(_cx: ()) -> Self {
Default::default()
}
fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) {
- *self += &summary.input.len;
+ *self += summary.input.len;
}
}
@@ -181,7 +217,7 @@ pub struct InlayBufferRows<'a> {
}
pub struct InlayChunks<'a> {
- transforms: Cursor<'a, 'static, Transform, Dimensions<InlayOffset, usize>>,
+ transforms: Cursor<'a, 'static, Transform, Dimensions<InlayOffset, MultiBufferOffset>>,
buffer_chunks: CustomHighlightsChunks<'a>,
buffer_chunk: Option<Chunk<'a>>,
inlay_chunks: Option<text::ChunkWithBitmaps<'a>>,
@@ -202,6 +238,7 @@ pub struct InlayChunk<'a> {
}
impl InlayChunks<'_> {
+ #[ztracing::instrument(skip_all)]
pub fn seek(&mut self, new_range: Range<InlayOffset>) {
self.transforms.seek(&new_range.start, Bias::Right);
@@ -222,6 +259,7 @@ impl InlayChunks<'_> {
impl<'a> Iterator for InlayChunks<'a> {
type Item = InlayChunk<'a>;
+ #[ztracing::instrument(skip_all)]
fn next(&mut self) -> Option<Self::Item> {
if self.output_offset == self.max_output_offset {
return None;
@@ -248,10 +286,8 @@ impl<'a> Iterator for InlayChunks<'a> {
// Determine split index handling edge cases
let split_index = if desired_bytes >= chunk.text.len() {
chunk.text.len()
- } else if chunk.text.is_char_boundary(desired_bytes) {
- desired_bytes
} else {
- find_next_utf8_boundary(chunk.text, desired_bytes)
+ chunk.text.ceil_char_boundary(desired_bytes)
};
let (prefix, suffix) = chunk.text.split_at(split_index);
@@ -334,12 +370,12 @@ impl<'a> Iterator for InlayChunks<'a> {
let offset_in_inlay = self.output_offset - self.transforms.start().0;
if let Some((style, highlight)) = inlay_style_and_highlight {
let range = &highlight.range;
- if offset_in_inlay.0 < range.start {
- next_inlay_highlight_endpoint = range.start - offset_in_inlay.0;
- } else if offset_in_inlay.0 >= range.end {
+ if offset_in_inlay < range.start {
+ next_inlay_highlight_endpoint = range.start - offset_in_inlay;
+ } else if offset_in_inlay >= range.end {
next_inlay_highlight_endpoint = usize::MAX;
} else {
- next_inlay_highlight_endpoint = range.end - offset_in_inlay.0;
+ next_inlay_highlight_endpoint = range.end - offset_in_inlay;
highlight_style = highlight_style
.map(|highlight| highlight.highlight(*style))
.or_else(|| Some(*style));
@@ -352,7 +388,7 @@ impl<'a> Iterator for InlayChunks<'a> {
let start = offset_in_inlay;
let end = cmp::min(self.max_output_offset, self.transforms.end().0)
- self.transforms.start().0;
- let chunks = inlay.text().chunks_in_range(start.0..end.0);
+ let chunks = inlay.text().chunks_in_range(start..end);
text::ChunkWithBitmaps(chunks)
});
let ChunkBitmaps {
@@ -373,10 +409,8 @@ impl<'a> Iterator for InlayChunks<'a> {
.next()
.map(|c| c.len_utf8())
.unwrap_or(1)
- } else if inlay_chunk.is_char_boundary(next_inlay_highlight_endpoint) {
- next_inlay_highlight_endpoint
} else {
- find_next_utf8_boundary(inlay_chunk, next_inlay_highlight_endpoint)
+ inlay_chunk.ceil_char_boundary(next_inlay_highlight_endpoint)
};
let (chunk, remainder) = inlay_chunk.split_at(split_index);
@@ -419,6 +453,7 @@ impl<'a> Iterator for InlayChunks<'a> {
}
impl InlayBufferRows<'_> {
+ #[ztracing::instrument(skip_all)]
pub fn seek(&mut self, row: u32) {
let inlay_point = InlayPoint::new(row, 0);
self.transforms.seek(&inlay_point, Bias::Left);
@@ -443,6 +478,7 @@ impl InlayBufferRows<'_> {
impl Iterator for InlayBufferRows<'_> {
type Item = RowInfo;
+ #[ztracing::instrument(skip_all)]
fn next(&mut self) -> Option<Self::Item> {
let buffer_row = if self.inlay_row == 0 {
self.buffer_rows.next().unwrap()
@@ -472,6 +508,7 @@ impl InlayPoint {
}
impl InlayMap {
+ #[ztracing::instrument(skip_all)]
pub fn new(buffer: MultiBufferSnapshot) -> (Self, InlaySnapshot) {
let version = 0;
let snapshot = InlaySnapshot {
@@ -489,10 +526,11 @@ impl InlayMap {
)
}
+ #[ztracing::instrument(skip_all)]
pub fn sync(
&mut self,
buffer_snapshot: MultiBufferSnapshot,
- mut buffer_edits: Vec<text::Edit<usize>>,
+ mut buffer_edits: Vec<text::Edit<MultiBufferOffset>>,
) -> (InlaySnapshot, Vec<InlayEdit>) {
let snapshot = &mut self.snapshot;
@@ -523,7 +561,7 @@ impl InlayMap {
let mut new_transforms = SumTree::default();
let mut cursor = snapshot
.transforms
- .cursor::<Dimensions<usize, InlayOffset>>(());
+ .cursor::<Dimensions<MultiBufferOffset, InlayOffset>>(());
let mut buffer_edits_iter = buffer_edits.iter().peekable();
while let Some(buffer_edit) = buffer_edits_iter.next() {
new_transforms.append(cursor.slice(&buffer_edit.old.start, Bias::Left), ());
@@ -535,11 +573,9 @@ impl InlayMap {
}
// Remove all the inlays and transforms contained by the edit.
- let old_start =
- cursor.start().1 + InlayOffset(buffer_edit.old.start - cursor.start().0);
+ let old_start = cursor.start().1 + (buffer_edit.old.start - cursor.start().0);
cursor.seek(&buffer_edit.old.end, Bias::Right);
- let old_end =
- cursor.start().1 + InlayOffset(buffer_edit.old.end - cursor.start().0);
+ let old_end = cursor.start().1 + (buffer_edit.old.end - cursor.start().0);
// Push the unchanged prefix.
let prefix_start = new_transforms.summary().input.len;
@@ -623,6 +659,7 @@ impl InlayMap {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn splice(
&mut self,
to_remove: &[InlayId],
@@ -673,11 +710,13 @@ impl InlayMap {
(snapshot, edits)
}
+ #[ztracing::instrument(skip_all)]
pub fn current_inlays(&self) -> impl Iterator<Item = &Inlay> {
self.inlays.iter()
}
#[cfg(test)]
+ #[ztracing::instrument(skip_all)]
pub(crate) fn randomly_mutate(
&mut self,
next_inlay_id: &mut usize,
@@ -691,7 +730,10 @@ impl InlayMap {
let snapshot = &mut self.snapshot;
for i in 0..rng.random_range(1..=5) {
if self.inlays.is_empty() || rng.random() {
- let position = snapshot.buffer.random_byte_range(0, rng).start;
+ let position = snapshot
+ .buffer
+ .random_byte_range(MultiBufferOffset(0), rng)
+ .start;
let bias = if rng.random() {
Bias::Left
} else {
@@ -743,10 +785,13 @@ impl InlayMap {
}
impl InlaySnapshot {
+ #[ztracing::instrument(skip_all)]
pub fn to_point(&self, offset: InlayOffset) -> InlayPoint {
- let (start, _, item) = self
- .transforms
- .find::<Dimensions<InlayOffset, InlayPoint, usize>, _>((), &offset, Bias::Right);
+ let (start, _, item) = self.transforms.find::<Dimensions<
+ InlayOffset,
+ InlayPoint,
+ MultiBufferOffset,
+ >, _>((), &offset, Bias::Right);
let overshoot = offset.0 - start.0.0;
match item {
Some(Transform::Isomorphic(_)) => {
@@ -764,14 +809,17 @@ impl InlaySnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn len(&self) -> InlayOffset {
InlayOffset(self.transforms.summary().output.len)
}
+ #[ztracing::instrument(skip_all)]
pub fn max_point(&self) -> InlayPoint {
InlayPoint(self.transforms.summary().output.lines)
}
+ #[ztracing::instrument(skip_all, fields(point))]
pub fn to_offset(&self, point: InlayPoint) -> InlayOffset {
let (start, _, item) = self
.transforms
@@ -792,6 +840,7 @@ impl InlaySnapshot {
None => self.len(),
}
}
+ #[ztracing::instrument(skip_all)]
pub fn to_buffer_point(&self, point: InlayPoint) -> Point {
let (start, _, item) =
self.transforms
@@ -805,22 +854,26 @@ impl InlaySnapshot {
None => self.buffer.max_point(),
}
}
- pub fn to_buffer_offset(&self, offset: InlayOffset) -> usize {
- let (start, _, item) =
- self.transforms
- .find::<Dimensions<InlayOffset, usize>, _>((), &offset, Bias::Right);
+ #[ztracing::instrument(skip_all)]
+ pub fn to_buffer_offset(&self, offset: InlayOffset) -> MultiBufferOffset {
+ let (start, _, item) = self
+ .transforms
+ .find::<Dimensions<InlayOffset, MultiBufferOffset>, _>((), &offset, Bias::Right);
match item {
Some(Transform::Isomorphic(_)) => {
let overshoot = offset - start.0;
- start.1 + overshoot.0
+ start.1 + overshoot
}
Some(Transform::Inlay(_)) => start.1,
None => self.buffer.len(),
}
}
- pub fn to_inlay_offset(&self, offset: usize) -> InlayOffset {
- let mut cursor = self.transforms.cursor::<Dimensions<usize, InlayOffset>>(());
+ #[ztracing::instrument(skip_all)]
+ pub fn to_inlay_offset(&self, offset: MultiBufferOffset) -> InlayOffset {
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<MultiBufferOffset, InlayOffset>>(());
cursor.seek(&offset, Bias::Left);
loop {
match cursor.item() {
@@ -852,40 +905,22 @@ impl InlaySnapshot {
}
}
}
+
+ #[ztracing::instrument(skip_all)]
pub fn to_inlay_point(&self, point: Point) -> InlayPoint {
- let mut cursor = self.transforms.cursor::<Dimensions<Point, InlayPoint>>(());
- cursor.seek(&point, Bias::Left);
- loop {
- match cursor.item() {
- Some(Transform::Isomorphic(_)) => {
- if point == cursor.end().0 {
- while let Some(Transform::Inlay(inlay)) = cursor.next_item() {
- if inlay.position.bias() == Bias::Right {
- break;
- } else {
- cursor.next();
- }
- }
- return cursor.end().1;
- } else {
- let overshoot = point - cursor.start().0;
- return InlayPoint(cursor.start().1.0 + overshoot);
- }
- }
- Some(Transform::Inlay(inlay)) => {
- if inlay.position.bias() == Bias::Left {
- cursor.next();
- } else {
- return cursor.start().1;
- }
- }
- None => {
- return self.max_point();
- }
- }
+ self.inlay_point_cursor().map(point)
+ }
+
+ #[ztracing::instrument(skip_all)]
+ pub fn inlay_point_cursor(&self) -> InlayPointCursor<'_> {
+ let cursor = self.transforms.cursor::<Dimensions<Point, InlayPoint>>(());
+ InlayPointCursor {
+ cursor,
+ transforms: &self.transforms,
}
}
+ #[ztracing::instrument(skip_all)]
pub fn clip_point(&self, mut point: InlayPoint, mut bias: Bias) -> InlayPoint {
let mut cursor = self.transforms.cursor::<Dimensions<InlayPoint, Point>>(());
cursor.seek(&point, Bias::Left);
@@ -977,14 +1012,18 @@ impl InlaySnapshot {
}
}
- pub fn text_summary(&self) -> TextSummary {
+ #[ztracing::instrument(skip_all)]
+ pub fn text_summary(&self) -> MBTextSummary {
self.transforms.summary().output
}
- pub fn text_summary_for_range(&self, range: Range<InlayOffset>) -> TextSummary {
- let mut summary = TextSummary::default();
+ #[ztracing::instrument(skip_all)]
+ pub fn text_summary_for_range(&self, range: Range<InlayOffset>) -> MBTextSummary {
+ let mut summary = MBTextSummary::default();
- let mut cursor = self.transforms.cursor::<Dimensions<InlayOffset, usize>>(());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<InlayOffset, MultiBufferOffset>>(());
cursor.seek(&range.start, Bias::Right);
let overshoot = range.start.0 - cursor.start().0.0;
@@ -1000,7 +1039,12 @@ impl InlaySnapshot {
Some(Transform::Inlay(inlay)) => {
let suffix_start = overshoot;
let suffix_end = cmp::min(cursor.end().0, range.end).0 - cursor.start().0.0;
- summary = inlay.text().cursor(suffix_start).summary(suffix_end);
+ summary = MBTextSummary::from(
+ inlay
+ .text()
+ .cursor(suffix_start)
+ .summary::<TextSummary>(suffix_end),
+ );
cursor.next();
}
None => {}
@@ -1018,7 +1062,7 @@ impl InlaySnapshot {
let prefix_end = prefix_start + overshoot;
summary += self
.buffer
- .text_summary_for_range::<TextSummary, _>(prefix_start..prefix_end);
+ .text_summary_for_range::<MBTextSummary, _>(prefix_start..prefix_end);
}
Some(Transform::Inlay(inlay)) => {
let prefix_end = overshoot;
@@ -1031,6 +1075,7 @@ impl InlaySnapshot {
summary
}
+ #[ztracing::instrument(skip_all)]
pub fn row_infos(&self, row: u32) -> InlayBufferRows<'_> {
let mut cursor = self.transforms.cursor::<Dimensions<InlayPoint, Point>>(());
let inlay_point = InlayPoint::new(row, 0);
@@ -1058,6 +1103,7 @@ impl InlaySnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn line_len(&self, row: u32) -> u32 {
let line_start = self.to_offset(InlayPoint::new(row, 0)).0;
let line_end = if row >= self.max_point().row() {
@@ -1068,13 +1114,16 @@ impl InlaySnapshot {
(line_end - line_start) as u32
}
+ #[ztracing::instrument(skip_all)]
pub(crate) fn chunks<'a>(
&'a self,
range: Range<InlayOffset>,
language_aware: bool,
highlights: Highlights<'a>,
) -> InlayChunks<'a> {
- let mut cursor = self.transforms.cursor::<Dimensions<InlayOffset, usize>>(());
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<InlayOffset, MultiBufferOffset>>(());
cursor.seek(&range.start, Bias::Right);
let buffer_range = self.to_buffer_offset(range.start)..self.to_buffer_offset(range.end);
@@ -1100,12 +1149,14 @@ impl InlaySnapshot {
}
#[cfg(test)]
+ #[ztracing::instrument(skip_all)]
pub fn text(&self) -> String {
self.chunks(Default::default()..self.len(), false, Highlights::default())
.map(|chunk| chunk.chunk.text)
.collect()
}
+ #[ztracing::instrument(skip_all)]
fn check_invariants(&self) {
#[cfg(any(debug_assertions, feature = "test-support"))]
{
@@ -1126,8 +1177,54 @@ impl InlaySnapshot {
}
}
-fn push_isomorphic(sum_tree: &mut SumTree<Transform>, summary: TextSummary) {
- if summary.len == 0 {
+pub struct InlayPointCursor<'transforms> {
+ cursor: Cursor<'transforms, 'static, Transform, Dimensions<Point, InlayPoint>>,
+ transforms: &'transforms SumTree<Transform>,
+}
+
+impl InlayPointCursor<'_> {
+ #[ztracing::instrument(skip_all)]
+ pub fn map(&mut self, point: Point) -> InlayPoint {
+ let cursor = &mut self.cursor;
+ if cursor.did_seek() {
+ cursor.seek_forward(&point, Bias::Left);
+ } else {
+ cursor.seek(&point, Bias::Left);
+ }
+ loop {
+ match cursor.item() {
+ Some(Transform::Isomorphic(_)) => {
+ if point == cursor.end().0 {
+ while let Some(Transform::Inlay(inlay)) = cursor.next_item() {
+ if inlay.position.bias() == Bias::Right {
+ break;
+ } else {
+ cursor.next();
+ }
+ }
+ return cursor.end().1;
+ } else {
+ let overshoot = point - cursor.start().0;
+ return InlayPoint(cursor.start().1.0 + overshoot);
+ }
+ }
+ Some(Transform::Inlay(inlay)) => {
+ if inlay.position.bias() == Bias::Left {
+ cursor.next();
+ } else {
+ return cursor.start().1;
+ }
+ }
+ None => {
+ return InlayPoint(self.transforms.summary().output.lines);
+ }
+ }
+ }
+ }
+}
+
+fn push_isomorphic(sum_tree: &mut SumTree<Transform>, summary: MBTextSummary) {
+ if summary.len == MultiBufferOffset(0) {
return;
}
@@ -1146,31 +1243,6 @@ fn push_isomorphic(sum_tree: &mut SumTree<Transform>, summary: TextSummary) {
}
}
-/// Given a byte index that is NOT a UTF-8 boundary, find the next one.
-/// Assumes: 0 < byte_index < text.len() and !text.is_char_boundary(byte_index)
-#[inline(always)]
-fn find_next_utf8_boundary(text: &str, byte_index: usize) -> usize {
- let bytes = text.as_bytes();
- let mut idx = byte_index + 1;
-
- // Scan forward until we find a boundary
- while idx < text.len() {
- if is_utf8_char_boundary(bytes[idx]) {
- return idx;
- }
- idx += 1;
- }
-
- // Hit the end, return the full length
- text.len()
-}
-
-// Private helper function taken from Rust's core::num module (which is both Apache2 and MIT licensed)
-const fn is_utf8_char_boundary(byte: u8) -> bool {
- // This is bit magic equivalent to: b < 128 || b >= 192
- (byte as i8) >= -0x40
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -1308,7 +1380,10 @@ mod tests {
&[],
vec![Inlay::mock_hint(
post_inc(&mut next_inlay_id),
- buffer.read(cx).snapshot(cx).anchor_after(3),
+ buffer
+ .read(cx)
+ .snapshot(cx)
+ .anchor_after(MultiBufferOffset(3)),
"|123|",
)],
);
@@ -1364,7 +1439,15 @@ mod tests {
// Edits before or after the inlay should not affect it.
buffer.update(cx, |buffer, cx| {
- buffer.edit([(2..3, "x"), (3..3, "y"), (4..4, "z")], None, cx)
+ buffer.edit(
+ [
+ (MultiBufferOffset(2)..MultiBufferOffset(3), "x"),
+ (MultiBufferOffset(3)..MultiBufferOffset(3), "y"),
+ (MultiBufferOffset(4)..MultiBufferOffset(4), "z"),
+ ],
+ None,
+ cx,
+ )
});
let (inlay_snapshot, _) = inlay_map.sync(
buffer.read(cx).snapshot(cx),
@@ -1373,7 +1456,13 @@ mod tests {
assert_eq!(inlay_snapshot.text(), "abxy|123|dzefghi");
// An edit surrounding the inlay should invalidate it.
- buffer.update(cx, |buffer, cx| buffer.edit([(4..5, "D")], None, cx));
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit(
+ [(MultiBufferOffset(4)..MultiBufferOffset(5), "D")],
+ None,
+ cx,
+ )
+ });
let (inlay_snapshot, _) = inlay_map.sync(
buffer.read(cx).snapshot(cx),
buffer_edits.consume().into_inner(),
@@ -1385,12 +1474,18 @@ mod tests {
vec![
Inlay::mock_hint(
post_inc(&mut next_inlay_id),
- buffer.read(cx).snapshot(cx).anchor_before(3),
+ buffer
+ .read(cx)
+ .snapshot(cx)
+ .anchor_before(MultiBufferOffset(3)),
"|123|",
),
Inlay::edit_prediction(
post_inc(&mut next_inlay_id),
- buffer.read(cx).snapshot(cx).anchor_after(3),
+ buffer
+ .read(cx)
+ .snapshot(cx)
+ .anchor_after(MultiBufferOffset(3)),
"|456|",
),
],
@@ -1398,7 +1493,13 @@ mod tests {
assert_eq!(inlay_snapshot.text(), "abx|123||456|yDzefghi");
// Edits ending where the inlay starts should not move it if it has a left bias.
- buffer.update(cx, |buffer, cx| buffer.edit([(3..3, "JKL")], None, cx));
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit(
+ [(MultiBufferOffset(3)..MultiBufferOffset(3), "JKL")],
+ None,
+ cx,
+ )
+ });
let (inlay_snapshot, _) = inlay_map.sync(
buffer.read(cx).snapshot(cx),
buffer_edits.consume().into_inner(),
@@ -1600,17 +1701,26 @@ mod tests {
vec![
Inlay::mock_hint(
post_inc(&mut next_inlay_id),
- buffer.read(cx).snapshot(cx).anchor_before(0),
+ buffer
+ .read(cx)
+ .snapshot(cx)
+ .anchor_before(MultiBufferOffset(0)),
"|123|\n",
),
Inlay::mock_hint(
post_inc(&mut next_inlay_id),
- buffer.read(cx).snapshot(cx).anchor_before(4),
+ buffer
+ .read(cx)
+ .snapshot(cx)
+ .anchor_before(MultiBufferOffset(4)),
"|456|",
),
Inlay::edit_prediction(
post_inc(&mut next_inlay_id),
- buffer.read(cx).snapshot(cx).anchor_before(7),
+ buffer
+ .read(cx)
+ .snapshot(cx)
+ .anchor_before(MultiBufferOffset(7)),
"\n|567|\n",
),
],
@@ -1687,7 +1797,7 @@ mod tests {
.collect::<Vec<_>>();
let mut expected_text = Rope::from(&buffer_snapshot.text());
for (offset, inlay) in inlays.iter().rev() {
- expected_text.replace(*offset..*offset, &inlay.text().to_string());
+ expected_text.replace(offset.0..offset.0, &inlay.text().to_string());
}
assert_eq!(inlay_snapshot.text(), expected_text.to_string());
@@ -1710,7 +1820,7 @@ mod tests {
let mut text_highlights = TextHighlights::default();
let text_highlight_count = rng.random_range(0_usize..10);
let mut text_highlight_ranges = (0..text_highlight_count)
- .map(|_| buffer_snapshot.random_byte_range(0, &mut rng))
+ .map(|_| buffer_snapshot.random_byte_range(MultiBufferOffset(0), &mut rng))
.collect::<Vec<_>>();
text_highlight_ranges.sort_by_key(|range| (range.start, Reverse(range.end)));
log::info!("highlighting text ranges {text_highlight_ranges:?}");
@@ -1773,12 +1883,13 @@ mod tests {
}
for _ in 0..5 {
- let mut end = rng.random_range(0..=inlay_snapshot.len().0);
+ let mut end = rng.random_range(0..=inlay_snapshot.len().0.0);
end = expected_text.clip_offset(end, Bias::Right);
let mut start = rng.random_range(0..=end);
start = expected_text.clip_offset(start, Bias::Right);
- let range = InlayOffset(start)..InlayOffset(end);
+ let range =
+ InlayOffset(MultiBufferOffset(start))..InlayOffset(MultiBufferOffset(end));
log::info!("calling inlay_snapshot.chunks({range:?})");
let actual_text = inlay_snapshot
.chunks(
@@ -1800,25 +1911,27 @@ mod tests {
);
assert_eq!(
- inlay_snapshot.text_summary_for_range(InlayOffset(start)..InlayOffset(end)),
- expected_text.slice(start..end).summary()
+ inlay_snapshot.text_summary_for_range(
+ InlayOffset(MultiBufferOffset(start))..InlayOffset(MultiBufferOffset(end))
+ ),
+ MBTextSummary::from(expected_text.slice(start..end).summary())
);
}
for edit in inlay_edits {
prev_inlay_text.replace_range(
- edit.new.start.0..edit.new.start.0 + edit.old_len().0,
- &inlay_snapshot.text()[edit.new.start.0..edit.new.end.0],
+ edit.new.start.0.0..edit.new.start.0.0 + edit.old_len(),
+ &inlay_snapshot.text()[edit.new.start.0.0..edit.new.end.0.0],
);
}
assert_eq!(prev_inlay_text, inlay_snapshot.text());
assert_eq!(expected_text.max_point(), inlay_snapshot.max_point().0);
- assert_eq!(expected_text.len(), inlay_snapshot.len().0);
+ assert_eq!(expected_text.len(), inlay_snapshot.len().0.0);
let mut buffer_point = Point::default();
let mut inlay_point = inlay_snapshot.to_inlay_point(buffer_point);
- let mut buffer_chars = buffer_snapshot.chars_at(0);
+ let mut buffer_chars = buffer_snapshot.chars_at(MultiBufferOffset(0));
loop {
// Ensure conversion from buffer coordinates to inlay coordinates
// is consistent.
@@ -1959,7 +2072,7 @@ mod tests {
// Get all chunks and verify their bitmaps
let chunks = snapshot.chunks(
- InlayOffset(0)..InlayOffset(snapshot.len().0),
+ InlayOffset(MultiBufferOffset(0))..snapshot.len(),
false,
Highlights::default(),
);
@@ -2093,7 +2206,7 @@ mod tests {
// Collect chunks - this previously would panic
let chunks: Vec<_> = inlay_snapshot
.chunks(
- InlayOffset(0)..InlayOffset(inlay_snapshot.len().0),
+ InlayOffset(MultiBufferOffset(0))..inlay_snapshot.len(),
false,
highlights,
)
@@ -2207,7 +2320,7 @@ mod tests {
let chunks: Vec<_> = inlay_snapshot
.chunks(
- InlayOffset(0)..InlayOffset(inlay_snapshot.len().0),
+ InlayOffset(MultiBufferOffset(0))..inlay_snapshot.len(),
false,
highlights,
)
@@ -30,6 +30,7 @@
// ref: https://gist.github.com/ConradIrwin/f759e1fc29267143c4c7895aa495dca5?h=1
// ref: https://unicode.org/Public/emoji/13.0/emoji-test.txt
// https://github.com/bits/UTF-8-Unicode-Test-Documents/blob/master/UTF-8_sequence_separated/utf8_sequence_0-0x10ffff_assigned_including-unprintable-asis.txt
+#[ztracing::instrument(skip_all)]
pub fn is_invisible(c: char) -> bool {
if c <= '\u{1f}' {
c != '\t' && c != '\n' && c != '\r'
@@ -20,6 +20,7 @@ const MAX_TABS: NonZeroU32 = NonZeroU32::new(SPACES.len() as u32).unwrap();
pub struct TabMap(TabSnapshot);
impl TabMap {
+ #[ztracing::instrument(skip_all)]
pub fn new(fold_snapshot: FoldSnapshot, tab_size: NonZeroU32) -> (Self, TabSnapshot) {
let snapshot = TabSnapshot {
fold_snapshot,
@@ -36,6 +37,7 @@ impl TabMap {
self.0.clone()
}
+ #[ztracing::instrument(skip_all)]
pub fn sync(
&mut self,
fold_snapshot: FoldSnapshot,
@@ -137,10 +139,10 @@ impl TabMap {
let new_start = fold_edit.new.start.to_point(&new_snapshot.fold_snapshot);
let new_end = fold_edit.new.end.to_point(&new_snapshot.fold_snapshot);
TabEdit {
- old: old_snapshot.to_tab_point(old_start)
- ..old_snapshot.to_tab_point(old_end),
- new: new_snapshot.to_tab_point(new_start)
- ..new_snapshot.to_tab_point(new_end),
+ old: old_snapshot.fold_point_to_tab_point(old_start)
+ ..old_snapshot.fold_point_to_tab_point(old_end),
+ new: new_snapshot.fold_point_to_tab_point(new_start)
+ ..new_snapshot.fold_point_to_tab_point(new_end),
}
})
.collect()
@@ -176,14 +178,16 @@ impl std::ops::Deref for TabSnapshot {
}
impl TabSnapshot {
+ #[ztracing::instrument(skip_all)]
pub fn buffer_snapshot(&self) -> &MultiBufferSnapshot {
&self.fold_snapshot.inlay_snapshot.buffer
}
+ #[ztracing::instrument(skip_all)]
pub fn line_len(&self, row: u32) -> u32 {
let max_point = self.max_point();
if row < max_point.row() {
- self.to_tab_point(FoldPoint::new(row, self.fold_snapshot.line_len(row)))
+ self.fold_point_to_tab_point(FoldPoint::new(row, self.fold_snapshot.line_len(row)))
.0
.column
} else {
@@ -191,13 +195,15 @@ impl TabSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn text_summary(&self) -> TextSummary {
self.text_summary_for_range(TabPoint::zero()..self.max_point())
}
+ #[ztracing::instrument(skip_all, fields(rows))]
pub fn text_summary_for_range(&self, range: Range<TabPoint>) -> TextSummary {
- let input_start = self.to_fold_point(range.start, Bias::Left).0;
- let input_end = self.to_fold_point(range.end, Bias::Right).0;
+ let input_start = self.tab_point_to_fold_point(range.start, Bias::Left).0;
+ let input_end = self.tab_point_to_fold_point(range.end, Bias::Right).0;
let input_summary = self
.fold_snapshot
.text_summary_for_range(input_start..input_end);
@@ -234,6 +240,7 @@ impl TabSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub(crate) fn chunks<'a>(
&'a self,
range: Range<TabPoint>,
@@ -241,11 +248,11 @@ impl TabSnapshot {
highlights: Highlights<'a>,
) -> TabChunks<'a> {
let (input_start, expanded_char_column, to_next_stop) =
- self.to_fold_point(range.start, Bias::Left);
+ self.tab_point_to_fold_point(range.start, Bias::Left);
let input_column = input_start.column();
let input_start = input_start.to_offset(&self.fold_snapshot);
let input_end = self
- .to_fold_point(range.end, Bias::Right)
+ .tab_point_to_fold_point(range.end, Bias::Right)
.0
.to_offset(&self.fold_snapshot);
let to_next_stop = if range.start.0 + Point::new(0, to_next_stop) > range.end.0 {
@@ -276,11 +283,13 @@ impl TabSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn rows(&self, row: u32) -> fold_map::FoldRows<'_> {
self.fold_snapshot.row_infos(row)
}
#[cfg(test)]
+ #[ztracing::instrument(skip_all)]
pub fn text(&self) -> String {
self.chunks(
TabPoint::zero()..self.max_point(),
@@ -291,25 +300,34 @@ impl TabSnapshot {
.collect()
}
+ #[ztracing::instrument(skip_all)]
pub fn max_point(&self) -> TabPoint {
- self.to_tab_point(self.fold_snapshot.max_point())
+ self.fold_point_to_tab_point(self.fold_snapshot.max_point())
}
+ #[ztracing::instrument(skip_all)]
pub fn clip_point(&self, point: TabPoint, bias: Bias) -> TabPoint {
- self.to_tab_point(
+ self.fold_point_to_tab_point(
self.fold_snapshot
- .clip_point(self.to_fold_point(point, bias).0, bias),
+ .clip_point(self.tab_point_to_fold_point(point, bias).0, bias),
)
}
- pub fn to_tab_point(&self, input: FoldPoint) -> TabPoint {
+ #[ztracing::instrument(skip_all)]
+ pub fn fold_point_to_tab_point(&self, input: FoldPoint) -> TabPoint {
let chunks = self.fold_snapshot.chunks_at(FoldPoint::new(input.row(), 0));
let tab_cursor = TabStopCursor::new(chunks);
let expanded = self.expand_tabs(tab_cursor, input.column());
TabPoint::new(input.row(), expanded)
}
- pub fn to_fold_point(&self, output: TabPoint, bias: Bias) -> (FoldPoint, u32, u32) {
+ #[ztracing::instrument(skip_all)]
+ pub fn tab_point_cursor(&self) -> TabPointCursor<'_> {
+ TabPointCursor { this: self }
+ }
+
+ #[ztracing::instrument(skip_all)]
+ pub fn tab_point_to_fold_point(&self, output: TabPoint, bias: Bias) -> (FoldPoint, u32, u32) {
let chunks = self
.fold_snapshot
.chunks_at(FoldPoint::new(output.row(), 0));
@@ -326,20 +344,23 @@ impl TabSnapshot {
)
}
- pub fn make_tab_point(&self, point: Point, bias: Bias) -> TabPoint {
+ #[ztracing::instrument(skip_all)]
+ pub fn point_to_tab_point(&self, point: Point, bias: Bias) -> TabPoint {
let inlay_point = self.fold_snapshot.inlay_snapshot.to_inlay_point(point);
let fold_point = self.fold_snapshot.to_fold_point(inlay_point, bias);
- self.to_tab_point(fold_point)
+ self.fold_point_to_tab_point(fold_point)
}
- pub fn to_point(&self, point: TabPoint, bias: Bias) -> Point {
- let fold_point = self.to_fold_point(point, bias).0;
+ #[ztracing::instrument(skip_all)]
+ pub fn tab_point_to_point(&self, point: TabPoint, bias: Bias) -> Point {
+ let fold_point = self.tab_point_to_fold_point(point, bias).0;
let inlay_point = fold_point.to_inlay_point(&self.fold_snapshot);
self.fold_snapshot
.inlay_snapshot
.to_buffer_point(inlay_point)
}
+ #[ztracing::instrument(skip_all)]
fn expand_tabs<'a, I>(&self, mut cursor: TabStopCursor<'a, I>, column: u32) -> u32
where
I: Iterator<Item = Chunk<'a>>,
@@ -373,6 +394,7 @@ impl TabSnapshot {
expanded_bytes + column.saturating_sub(collapsed_bytes)
}
+ #[ztracing::instrument(skip_all)]
fn collapse_tabs<'a, I>(
&self,
mut cursor: TabStopCursor<'a, I>,
@@ -432,6 +454,18 @@ impl TabSnapshot {
}
}
+// todo(lw): Implement TabPointCursor properly
+pub struct TabPointCursor<'this> {
+ this: &'this TabSnapshot,
+}
+
+impl TabPointCursor<'_> {
+ #[ztracing::instrument(skip_all)]
+ pub fn map(&mut self, point: FoldPoint) -> TabPoint {
+ self.this.fold_point_to_tab_point(point)
+ }
+}
+
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
pub struct TabPoint(pub Point);
@@ -471,6 +505,7 @@ pub struct TextSummary {
}
impl<'a> From<&'a str> for TextSummary {
+ #[ztracing::instrument(skip_all)]
fn from(text: &'a str) -> Self {
let sum = text::TextSummary::from(text);
@@ -485,6 +520,7 @@ impl<'a> From<&'a str> for TextSummary {
}
impl<'a> std::ops::AddAssign<&'a Self> for TextSummary {
+ #[ztracing::instrument(skip_all)]
fn add_assign(&mut self, other: &'a Self) {
let joined_chars = self.last_line_chars + other.first_line_chars;
if joined_chars > self.longest_row_chars {
@@ -526,14 +562,16 @@ pub struct TabChunks<'a> {
}
impl TabChunks<'_> {
+ #[ztracing::instrument(skip_all)]
pub(crate) fn seek(&mut self, range: Range<TabPoint>) {
- let (input_start, expanded_char_column, to_next_stop) =
- self.snapshot.to_fold_point(range.start, Bias::Left);
+ let (input_start, expanded_char_column, to_next_stop) = self
+ .snapshot
+ .tab_point_to_fold_point(range.start, Bias::Left);
let input_column = input_start.column();
let input_start = input_start.to_offset(&self.snapshot.fold_snapshot);
let input_end = self
.snapshot
- .to_fold_point(range.end, Bias::Right)
+ .tab_point_to_fold_point(range.end, Bias::Right)
.0
.to_offset(&self.snapshot.fold_snapshot);
let to_next_stop = if range.start.0 + Point::new(0, to_next_stop) > range.end.0 {
@@ -560,6 +598,7 @@ impl TabChunks<'_> {
impl<'a> Iterator for TabChunks<'a> {
type Item = Chunk<'a>;
+ #[ztracing::instrument(skip_all)]
fn next(&mut self) -> Option<Self::Item> {
if self.chunk.text.is_empty() {
if let Some(chunk) = self.fold_chunks.next() {
@@ -648,6 +687,7 @@ mod tests {
inlay_map::InlayMap,
},
};
+ use multi_buffer::MultiBufferOffset;
use rand::{Rng, prelude::StdRng};
use util;
@@ -803,23 +843,23 @@ mod tests {
assert_eq!(
tab_snapshot.expected_to_fold_point(range.start, Bias::Left),
- tab_snapshot.to_fold_point(range.start, Bias::Left),
+ tab_snapshot.tab_point_to_fold_point(range.start, Bias::Left),
"Failed with tab_point at column {ix}"
);
assert_eq!(
tab_snapshot.expected_to_fold_point(range.start, Bias::Right),
- tab_snapshot.to_fold_point(range.start, Bias::Right),
+ tab_snapshot.tab_point_to_fold_point(range.start, Bias::Right),
"Failed with tab_point at column {ix}"
);
assert_eq!(
tab_snapshot.expected_to_fold_point(range.end, Bias::Left),
- tab_snapshot.to_fold_point(range.end, Bias::Left),
+ tab_snapshot.tab_point_to_fold_point(range.end, Bias::Left),
"Failed with tab_point at column {ix}"
);
assert_eq!(
tab_snapshot.expected_to_fold_point(range.end, Bias::Right),
- tab_snapshot.to_fold_point(range.end, Bias::Right),
+ tab_snapshot.tab_point_to_fold_point(range.end, Bias::Right),
"Failed with tab_point at column {ix}"
);
}
@@ -839,7 +879,7 @@ mod tests {
// This should panic with the expected vs actual mismatch
let tab_point = TabPoint::new(0, 9);
- let result = tab_snapshot.to_fold_point(tab_point, Bias::Left);
+ let result = tab_snapshot.tab_point_to_fold_point(tab_point, Bias::Left);
let expected = tab_snapshot.expected_to_fold_point(tab_point, Bias::Left);
assert_eq!(result, expected);
@@ -883,26 +923,26 @@ mod tests {
assert_eq!(
tab_snapshot.expected_to_fold_point(range.start, Bias::Left),
- tab_snapshot.to_fold_point(range.start, Bias::Left),
+ tab_snapshot.tab_point_to_fold_point(range.start, Bias::Left),
"Failed with input: {}, with idx: {ix}",
input
);
assert_eq!(
tab_snapshot.expected_to_fold_point(range.start, Bias::Right),
- tab_snapshot.to_fold_point(range.start, Bias::Right),
+ tab_snapshot.tab_point_to_fold_point(range.start, Bias::Right),
"Failed with input: {}, with idx: {ix}",
input
);
assert_eq!(
tab_snapshot.expected_to_fold_point(range.end, Bias::Left),
- tab_snapshot.to_fold_point(range.end, Bias::Left),
+ tab_snapshot.tab_point_to_fold_point(range.end, Bias::Left),
"Failed with input: {}, with idx: {ix}",
input
);
assert_eq!(
tab_snapshot.expected_to_fold_point(range.end, Bias::Right),
- tab_snapshot.to_fold_point(range.end, Bias::Right),
+ tab_snapshot.tab_point_to_fold_point(range.end, Bias::Right),
"Failed with input: {}, with idx: {ix}",
input
);
@@ -942,13 +982,13 @@ mod tests {
let input_point = Point::new(0, ix as u32);
let output_point = Point::new(0, output.find(c).unwrap() as u32);
assert_eq!(
- tab_snapshot.to_tab_point(FoldPoint(input_point)),
+ tab_snapshot.fold_point_to_tab_point(FoldPoint(input_point)),
TabPoint(output_point),
"to_tab_point({input_point:?})"
);
assert_eq!(
tab_snapshot
- .to_fold_point(TabPoint(output_point), Bias::Left)
+ .tab_point_to_fold_point(TabPoint(output_point), Bias::Left)
.0,
FoldPoint(input_point),
"to_fold_point({output_point:?})"
@@ -1137,7 +1177,7 @@ mod tests {
let column = rng.random_range(0..=max_column + 10);
let fold_point = FoldPoint::new(row, column);
- let actual = tab_snapshot.to_tab_point(fold_point);
+ let actual = tab_snapshot.fold_point_to_tab_point(fold_point);
let expected = tab_snapshot.expected_to_tab_point(fold_point);
assert_eq!(
@@ -1156,7 +1196,7 @@ mod tests {
let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot);
let (_, fold_snapshot) = FoldMap::new(inlay_snapshot);
let chunks = fold_snapshot.chunks(
- FoldOffset(0)..fold_snapshot.len(),
+ FoldOffset(MultiBufferOffset(0))..fold_snapshot.len(),
false,
Default::default(),
);
@@ -1318,7 +1358,7 @@ mod tests {
let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot);
let (_, fold_snapshot) = FoldMap::new(inlay_snapshot);
let chunks = fold_snapshot.chunks(
- FoldOffset(0)..fold_snapshot.len(),
+ FoldOffset(MultiBufferOffset(0))..fold_snapshot.len(),
false,
Default::default(),
);
@@ -1435,6 +1475,7 @@ impl<'a, I> TabStopCursor<'a, I>
where
I: Iterator<Item = Chunk<'a>>,
{
+ #[ztracing::instrument(skip_all)]
fn new(chunks: impl IntoIterator<Item = Chunk<'a>, IntoIter = I>) -> Self {
Self {
chunks: chunks.into_iter(),
@@ -1444,6 +1485,7 @@ where
}
}
+ #[ztracing::instrument(skip_all)]
fn bytes_until_next_char(&self) -> Option<usize> {
self.current_chunk.as_ref().and_then(|(chunk, idx)| {
let mut idx = *idx;
@@ -1465,6 +1507,7 @@ where
})
}
+ #[ztracing::instrument(skip_all)]
fn is_char_boundary(&self) -> bool {
self.current_chunk
.as_ref()
@@ -1472,6 +1515,7 @@ where
}
/// distance: length to move forward while searching for the next tab stop
+ #[ztracing::instrument(skip_all)]
fn seek(&mut self, distance: u32) -> Option<TabStop> {
if distance == 0 {
return None;
@@ -86,6 +86,7 @@ pub struct WrapRows<'a> {
}
impl WrapRows<'_> {
+ #[ztracing::instrument(skip_all)]
pub(crate) fn seek(&mut self, start_row: WrapRow) {
self.transforms
.seek(&WrapPoint::new(start_row, 0), Bias::Left);
@@ -101,6 +102,7 @@ impl WrapRows<'_> {
}
impl WrapMap {
+ #[ztracing::instrument(skip_all)]
pub fn new(
tab_snapshot: TabSnapshot,
font: Font,
@@ -131,6 +133,7 @@ impl WrapMap {
self.background_task.is_some()
}
+ #[ztracing::instrument(skip_all)]
pub fn sync(
&mut self,
tab_snapshot: TabSnapshot,
@@ -150,6 +153,7 @@ impl WrapMap {
(self.snapshot.clone(), mem::take(&mut self.edits_since_sync))
}
+ #[ztracing::instrument(skip_all)]
pub fn set_font_with_size(
&mut self,
font: Font,
@@ -167,6 +171,7 @@ impl WrapMap {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn set_wrap_width(&mut self, wrap_width: Option<Pixels>, cx: &mut Context<Self>) -> bool {
if wrap_width == self.wrap_width {
return false;
@@ -177,6 +182,7 @@ impl WrapMap {
true
}
+ #[ztracing::instrument(skip_all)]
fn rewrap(&mut self, cx: &mut Context<Self>) {
self.background_task.take();
self.interpolated_edits.clear();
@@ -248,6 +254,7 @@ impl WrapMap {
}
}
+ #[ztracing::instrument(skip_all)]
fn flush_edits(&mut self, cx: &mut Context<Self>) {
if !self.snapshot.interpolated {
let mut to_remove_len = 0;
@@ -330,6 +337,7 @@ impl WrapMap {
}
impl WrapSnapshot {
+ #[ztracing::instrument(skip_all)]
fn new(tab_snapshot: TabSnapshot) -> Self {
let mut transforms = SumTree::default();
let extent = tab_snapshot.text_summary();
@@ -343,10 +351,12 @@ impl WrapSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn buffer_snapshot(&self) -> &MultiBufferSnapshot {
self.tab_snapshot.buffer_snapshot()
}
+ #[ztracing::instrument(skip_all)]
fn interpolate(&mut self, new_tab_snapshot: TabSnapshot, tab_edits: &[TabEdit]) -> WrapPatch {
let mut new_transforms;
if tab_edits.is_empty() {
@@ -411,6 +421,7 @@ impl WrapSnapshot {
old_snapshot.compute_edits(tab_edits, self)
}
+ #[ztracing::instrument(skip_all)]
async fn update(
&mut self,
new_tab_snapshot: TabSnapshot,
@@ -570,6 +581,7 @@ impl WrapSnapshot {
old_snapshot.compute_edits(tab_edits, self)
}
+ #[ztracing::instrument(skip_all)]
fn compute_edits(&self, tab_edits: &[TabEdit], new_snapshot: &WrapSnapshot) -> WrapPatch {
let mut wrap_edits = Vec::with_capacity(tab_edits.len());
let mut old_cursor = self.transforms.cursor::<TransformSummary>(());
@@ -606,6 +618,7 @@ impl WrapSnapshot {
Patch::new(wrap_edits)
}
+ #[ztracing::instrument(skip_all)]
pub(crate) fn chunks<'a>(
&'a self,
rows: Range<WrapRow>,
@@ -622,9 +635,10 @@ impl WrapSnapshot {
if transforms.item().is_some_and(|t| t.is_isomorphic()) {
input_start.0 += output_start.0 - transforms.start().0.0;
}
- let input_end = self
- .to_tab_point(output_end)
- .min(self.tab_snapshot.max_point());
+ let input_end = self.to_tab_point(output_end);
+ let max_point = self.tab_snapshot.max_point();
+ let input_start = input_start.min(max_point);
+ let input_end = input_end.min(max_point);
WrapChunks {
input_chunks: self.tab_snapshot.chunks(
input_start..input_end,
@@ -639,10 +653,12 @@ impl WrapSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn max_point(&self) -> WrapPoint {
WrapPoint(self.transforms.summary().output.lines)
}
+ #[ztracing::instrument(skip_all)]
pub fn line_len(&self, row: WrapRow) -> u32 {
let (start, _, item) = self.transforms.find::<Dimensions<WrapPoint, TabPoint>, _>(
(),
@@ -663,6 +679,7 @@ impl WrapSnapshot {
}
}
+ #[ztracing::instrument(skip_all, fields(rows))]
pub fn text_summary_for_range(&self, rows: Range<WrapRow>) -> TextSummary {
let mut summary = TextSummary::default();
@@ -724,6 +741,7 @@ impl WrapSnapshot {
summary
}
+ #[ztracing::instrument(skip_all)]
pub fn soft_wrap_indent(&self, row: WrapRow) -> Option<u32> {
let (.., item) = self.transforms.find::<WrapPoint, _>(
(),
@@ -739,10 +757,12 @@ impl WrapSnapshot {
})
}
+ #[ztracing::instrument(skip_all)]
pub fn longest_row(&self) -> u32 {
self.transforms.summary().output.longest_row
}
+ #[ztracing::instrument(skip_all)]
pub fn row_infos(&self, start_row: WrapRow) -> WrapRows<'_> {
let mut transforms = self
.transforms
@@ -765,6 +785,7 @@ impl WrapSnapshot {
}
}
+ #[ztracing::instrument(skip_all)]
pub fn to_tab_point(&self, point: WrapPoint) -> TabPoint {
let (start, _, item) =
self.transforms
@@ -776,14 +797,18 @@ impl WrapSnapshot {
TabPoint(tab_point)
}
+ #[ztracing::instrument(skip_all)]
pub fn to_point(&self, point: WrapPoint, bias: Bias) -> Point {
- self.tab_snapshot.to_point(self.to_tab_point(point), bias)
+ self.tab_snapshot
+ .tab_point_to_point(self.to_tab_point(point), bias)
}
+ #[ztracing::instrument(skip_all)]
pub fn make_wrap_point(&self, point: Point, bias: Bias) -> WrapPoint {
- self.tab_point_to_wrap_point(self.tab_snapshot.make_tab_point(point, bias))
+ self.tab_point_to_wrap_point(self.tab_snapshot.point_to_tab_point(point, bias))
}
+ #[ztracing::instrument(skip_all)]
pub fn tab_point_to_wrap_point(&self, point: TabPoint) -> WrapPoint {
let (start, ..) =
self.transforms
@@ -791,6 +816,16 @@ impl WrapSnapshot {
WrapPoint(start.1.0 + (point.0 - start.0.0))
}
+ #[ztracing::instrument(skip_all)]
+ pub fn wrap_point_cursor(&self) -> WrapPointCursor<'_> {
+ WrapPointCursor {
+ cursor: self
+ .transforms
+ .cursor::<Dimensions<TabPoint, WrapPoint>>(()),
+ }
+ }
+
+ #[ztracing::instrument(skip_all)]
pub fn clip_point(&self, mut point: WrapPoint, bias: Bias) -> WrapPoint {
if bias == Bias::Left {
let (start, _, item) = self
@@ -805,32 +840,65 @@ impl WrapSnapshot {
self.tab_point_to_wrap_point(self.tab_snapshot.clip_point(self.to_tab_point(point), bias))
}
- pub fn prev_row_boundary(&self, mut point: WrapPoint) -> WrapRow {
+ /// Try to find a TabRow start that is also a WrapRow start
+ /// Every TabRow start is a WrapRow start
+ #[ztracing::instrument(skip_all, fields(point=?point))]
+ pub fn prev_row_boundary(&self, point: WrapPoint) -> WrapRow {
if self.transforms.is_empty() {
return WrapRow(0);
}
- *point.column_mut() = 0;
+ let point = WrapPoint::new(point.row(), 0);
let mut cursor = self
.transforms
.cursor::<Dimensions<WrapPoint, TabPoint>>(());
+
cursor.seek(&point, Bias::Right);
if cursor.item().is_none() {
cursor.prev();
}
+ // real newline fake fake
+ // text: helloworldasldlfjasd\njdlasfalsk\naskdjfasdkfj\n
+ // dimensions v v v v v
+ // transforms |-------|-----NW----|-----W------|-----W------|
+ // cursor ^ ^^^^^^^^^^^^^ ^
+ // (^) ^^^^^^^^^^^^^^
+ // point: ^
+ // point(col_zero): (^)
+
while let Some(transform) = cursor.item() {
- if transform.is_isomorphic() && cursor.start().1.column() == 0 {
- return cmp::min(cursor.end().0.row(), point.row());
- } else {
- cursor.prev();
+ if transform.is_isomorphic() {
+ // this transform only has real linefeeds
+ let tab_summary = &transform.summary.input;
+ // is the wrap just before the end of the transform a tab row?
+ // thats only if this transform has at least one newline
+ //
+ // "this wrap row is a tab row" <=> self.to_tab_point(WrapPoint::new(wrap_row, 0)).column() == 0
+
+ // Note on comparison:
+ // We have code that relies on this to be row > 1
+ // It should work with row >= 1 but it does not :(
+ //
+ // That means that if every line is wrapped we walk back all the
+ // way to the start. Which invalidates the entire state triggering
+ // a full re-render.
+ if tab_summary.lines.row > 1 {
+ let wrap_point_at_end = cursor.end().0.row();
+ return cmp::min(wrap_point_at_end - RowDelta(1), point.row());
+ } else if cursor.start().1.column() == 0 {
+ return cmp::min(cursor.end().0.row(), point.row());
+ }
}
+
+ cursor.prev();
}
- unreachable!()
+ WrapRow(0)
}
+ #[ztracing::instrument(skip_all)]
pub fn next_row_boundary(&self, mut point: WrapPoint) -> Option<WrapRow> {
point.0 += Point::new(1, 0);
@@ -864,6 +932,7 @@ impl WrapSnapshot {
.map(|h| h.text)
}
+ #[ztracing::instrument(skip_all)]
fn check_invariants(&self) {
#[cfg(test)]
{
@@ -912,7 +981,25 @@ impl WrapSnapshot {
}
}
+pub struct WrapPointCursor<'transforms> {
+ cursor: Cursor<'transforms, 'static, Transform, Dimensions<TabPoint, WrapPoint>>,
+}
+
+impl WrapPointCursor<'_> {
+ #[ztracing::instrument(skip_all)]
+ pub fn map(&mut self, point: TabPoint) -> WrapPoint {
+ let cursor = &mut self.cursor;
+ if cursor.did_seek() {
+ cursor.seek_forward(&point, Bias::Right);
+ } else {
+ cursor.seek(&point, Bias::Right);
+ }
+ WrapPoint(cursor.start().1.0 + (point.0 - cursor.start().0.0))
+ }
+}
+
impl WrapChunks<'_> {
+ #[ztracing::instrument(skip_all)]
pub(crate) fn seek(&mut self, rows: Range<WrapRow>) {
let output_start = WrapPoint::new(rows.start, 0);
let output_end = WrapPoint::new(rows.end, 0);
@@ -921,10 +1008,10 @@ impl WrapChunks<'_> {
if self.transforms.item().is_some_and(|t| t.is_isomorphic()) {
input_start.0 += output_start.0 - self.transforms.start().0.0;
}
- let input_end = self
- .snapshot
- .to_tab_point(output_end)
- .min(self.snapshot.tab_snapshot.max_point());
+ let input_end = self.snapshot.to_tab_point(output_end);
+ let max_point = self.snapshot.tab_snapshot.max_point();
+ let input_start = input_start.min(max_point);
+ let input_end = input_end.min(max_point);
self.input_chunks.seek(input_start..input_end);
self.input_chunk = Chunk::default();
self.output_position = output_start;
@@ -935,6 +1022,7 @@ impl WrapChunks<'_> {
impl<'a> Iterator for WrapChunks<'a> {
type Item = Chunk<'a>;
+ #[ztracing::instrument(skip_all)]
fn next(&mut self) -> Option<Self::Item> {
if self.output_position.row() >= self.max_output_row {
return None;
@@ -1007,6 +1095,7 @@ impl<'a> Iterator for WrapChunks<'a> {
impl Iterator for WrapRows<'_> {
type Item = RowInfo;
+ #[ztracing::instrument(skip_all)]
fn next(&mut self) -> Option<Self::Item> {
if self.output_row > self.max_output_row {
return None;
@@ -1030,6 +1119,7 @@ impl Iterator for WrapRows<'_> {
RowInfo {
buffer_id: None,
buffer_row: None,
+ base_text_row: None,
multibuffer_row: None,
diff_status,
expand_info: None,
@@ -1042,6 +1132,7 @@ impl Iterator for WrapRows<'_> {
}
impl Transform {
+ #[ztracing::instrument(skip_all)]
fn isomorphic(summary: TextSummary) -> Self {
#[cfg(test)]
assert!(!summary.lines.is_zero());
@@ -1055,6 +1146,7 @@ impl Transform {
}
}
+ #[ztracing::instrument(skip_all)]
fn wrap(indent: u32) -> Self {
static WRAP_TEXT: LazyLock<String> = LazyLock::new(|| {
let mut wrap_text = String::new();
@@ -1107,6 +1199,7 @@ trait SumTreeExt {
}
impl SumTreeExt for SumTree<Transform> {
+ #[ztracing::instrument(skip_all)]
fn push_or_extend(&mut self, transform: Transform) {
let mut transform = Some(transform);
self.update_last(
@@ -1170,6 +1263,7 @@ impl<'a> sum_tree::Dimension<'a, TransformSummary> for TabPoint {
}
impl sum_tree::SeekTarget<'_, TransformSummary, TransformSummary> for TabPoint {
+ #[ztracing::instrument(skip_all)]
fn cmp(&self, cursor_location: &TransformSummary, _: ()) -> std::cmp::Ordering {
Ord::cmp(&self.0, &cursor_location.input.lines)
}
@@ -1229,6 +1323,71 @@ mod tests {
use text::Rope;
use theme::LoadThemes;
+ #[gpui::test]
+ async fn test_prev_row_boundary(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ fn test_wrap_snapshot(
+ text: &str,
+ soft_wrap_every: usize, // font size multiple
+ cx: &mut gpui::TestAppContext,
+ ) -> WrapSnapshot {
+ let text_system = cx.read(|cx| cx.text_system().clone());
+ let tab_size = 4.try_into().unwrap();
+ let font = test_font();
+ let _font_id = text_system.resolve_font(&font);
+ let font_size = px(14.0);
+ // this is very much an estimate to try and get the wrapping to
+ // occur at `soft_wrap_every` we check that it pans out for every test case
+ let soft_wrapping = Some(font_size * soft_wrap_every * 0.6);
+
+ let buffer = cx.new(|cx| language::Buffer::local(text, cx));
+ let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+ let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx));
+ let (_inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot);
+ let (_fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot);
+ let (mut tab_map, _) = TabMap::new(fold_snapshot, tab_size);
+ let tabs_snapshot = tab_map.set_max_expansion_column(32);
+ let (_wrap_map, wrap_snapshot) =
+ cx.update(|cx| WrapMap::new(tabs_snapshot, font, font_size, soft_wrapping, cx));
+
+ wrap_snapshot
+ }
+
+ // These two should pass but dont, see the comparison note in
+ // prev_row_boundary about why.
+ //
+ // // 0123 4567 wrap_rows
+ // let wrap_snapshot = test_wrap_snapshot("1234\n5678", 1, cx);
+ // assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8");
+ // let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
+ // assert_eq!(row.0, 3);
+
+ // // 012 345 678 wrap_rows
+ // let wrap_snapshot = test_wrap_snapshot("123\n456\n789", 1, cx);
+ // assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8\n9");
+ // let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
+ // assert_eq!(row.0, 5);
+
+ // 012345678 wrap_rows
+ let wrap_snapshot = test_wrap_snapshot("123456789", 1, cx);
+ assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8\n9");
+ let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
+ assert_eq!(row.0, 0);
+
+ // 111 2222 44 wrap_rows
+ let wrap_snapshot = test_wrap_snapshot("123\n4567\n\n89", 4, cx);
+ assert_eq!(wrap_snapshot.text(), "123\n4567\n\n89");
+ let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
+ assert_eq!(row.0, 2);
+
+ // 11 2223 wrap_rows
+ let wrap_snapshot = test_wrap_snapshot("12\n3456\n\n", 3, cx);
+ assert_eq!(wrap_snapshot.text(), "12\n345\n6\n\n");
+ let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
+ assert_eq!(row.0, 3);
+ }
+
#[gpui::test(iterations = 100)]
async fn test_random_wraps(cx: &mut gpui::TestAppContext, mut rng: StdRng) {
// todo this test is flaky
@@ -1,4 +1,4 @@
-use edit_prediction::EditPredictionProvider;
+use edit_prediction_types::EditPredictionDelegate;
use gpui::{Entity, KeyBinding, Modifiers, prelude::*};
use indoc::indoc;
use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint};
@@ -15,7 +15,7 @@ async fn test_edit_prediction_insert(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
- let provider = cx.new(|_| FakeEditPredictionProvider::default());
+ let provider = cx.new(|_| FakeEditPredictionDelegate::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
cx.set_state("let absolute_zero_celsius = ˇ;");
@@ -37,7 +37,7 @@ async fn test_edit_prediction_modification(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
- let provider = cx.new(|_| FakeEditPredictionProvider::default());
+ let provider = cx.new(|_| FakeEditPredictionDelegate::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
cx.set_state("let pi = ˇ\"foo\";");
@@ -59,7 +59,7 @@ async fn test_edit_prediction_jump_button(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
- let provider = cx.new(|_| FakeEditPredictionProvider::default());
+ let provider = cx.new(|_| FakeEditPredictionDelegate::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
// Cursor is 2+ lines above the proposed edit
@@ -128,7 +128,7 @@ async fn test_edit_prediction_invalidation_range(cx: &mut gpui::TestAppContext)
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
- let provider = cx.new(|_| FakeEditPredictionProvider::default());
+ let provider = cx.new(|_| FakeEditPredictionDelegate::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
// Cursor is 3+ lines above the proposed edit
@@ -233,7 +233,7 @@ async fn test_edit_prediction_jump_disabled_for_non_zed_providers(cx: &mut gpui:
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
- let provider = cx.new(|_| FakeNonZedEditPredictionProvider::default());
+ let provider = cx.new(|_| FakeNonZedEditPredictionDelegate::default());
assign_editor_completion_provider_non_zed(provider.clone(), &mut cx);
// Cursor is 2+ lines above the proposed edit
@@ -281,7 +281,7 @@ async fn test_edit_prediction_preview_cleanup_on_toggle_off(cx: &mut gpui::TestA
cx.update(|cx| cx.bind_keys([KeyBinding::new("ctrl-shift-a", AcceptEditPrediction, None)]));
let mut cx = EditorTestContext::new(cx).await;
- let provider = cx.new(|_| FakeEditPredictionProvider::default());
+ let provider = cx.new(|_| FakeEditPredictionDelegate::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
cx.set_state("let x = ˇ;");
@@ -371,7 +371,7 @@ fn accept_completion(cx: &mut EditorTestContext) {
}
fn propose_edits<T: ToOffset>(
- provider: &Entity<FakeEditPredictionProvider>,
+ provider: &Entity<FakeEditPredictionDelegate>,
edits: Vec<(Range<T>, &str)>,
cx: &mut EditorTestContext,
) {
@@ -383,7 +383,7 @@ fn propose_edits<T: ToOffset>(
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: edits.collect(),
edit_preview: None,
@@ -393,7 +393,7 @@ fn propose_edits<T: ToOffset>(
}
fn assign_editor_completion_provider(
- provider: Entity<FakeEditPredictionProvider>,
+ provider: Entity<FakeEditPredictionDelegate>,
cx: &mut EditorTestContext,
) {
cx.update_editor(|editor, window, cx| {
@@ -402,7 +402,7 @@ fn assign_editor_completion_provider(
}
fn propose_edits_non_zed<T: ToOffset>(
- provider: &Entity<FakeNonZedEditPredictionProvider>,
+ provider: &Entity<FakeNonZedEditPredictionDelegate>,
edits: Vec<(Range<T>, &str)>,
cx: &mut EditorTestContext,
) {
@@ -414,7 +414,7 @@ fn propose_edits_non_zed<T: ToOffset>(
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: edits.collect(),
edit_preview: None,
@@ -424,7 +424,7 @@ fn propose_edits_non_zed<T: ToOffset>(
}
fn assign_editor_completion_provider_non_zed(
- provider: Entity<FakeNonZedEditPredictionProvider>,
+ provider: Entity<FakeNonZedEditPredictionDelegate>,
cx: &mut EditorTestContext,
) {
cx.update_editor(|editor, window, cx| {
@@ -433,17 +433,20 @@ fn assign_editor_completion_provider_non_zed(
}
#[derive(Default, Clone)]
-pub struct FakeEditPredictionProvider {
- pub completion: Option<edit_prediction::EditPrediction>,
+pub struct FakeEditPredictionDelegate {
+ pub completion: Option<edit_prediction_types::EditPrediction>,
}
-impl FakeEditPredictionProvider {
- pub fn set_edit_prediction(&mut self, completion: Option<edit_prediction::EditPrediction>) {
+impl FakeEditPredictionDelegate {
+ pub fn set_edit_prediction(
+ &mut self,
+ completion: Option<edit_prediction_types::EditPrediction>,
+ ) {
self.completion = completion;
}
}
-impl EditPredictionProvider for FakeEditPredictionProvider {
+impl EditPredictionDelegate for FakeEditPredictionDelegate {
fn name() -> &'static str {
"fake-completion-provider"
}
@@ -452,7 +455,7 @@ impl EditPredictionProvider for FakeEditPredictionProvider {
"Fake Completion Provider"
}
- fn show_completions_in_menu() -> bool {
+ fn show_predictions_in_menu() -> bool {
true
}
@@ -469,7 +472,7 @@ impl EditPredictionProvider for FakeEditPredictionProvider {
true
}
- fn is_refreshing(&self) -> bool {
+ fn is_refreshing(&self, _cx: &gpui::App) -> bool {
false
}
@@ -482,15 +485,6 @@ impl EditPredictionProvider for FakeEditPredictionProvider {
) {
}
- fn cycle(
- &mut self,
- _buffer: gpui::Entity<language::Buffer>,
- _cursor_position: language::Anchor,
- _direction: edit_prediction::Direction,
- _cx: &mut gpui::Context<Self>,
- ) {
- }
-
fn accept(&mut self, _cx: &mut gpui::Context<Self>) {}
fn discard(&mut self, _cx: &mut gpui::Context<Self>) {}
@@ -500,23 +494,26 @@ impl EditPredictionProvider for FakeEditPredictionProvider {
_buffer: &gpui::Entity<language::Buffer>,
_cursor_position: language::Anchor,
_cx: &mut gpui::Context<Self>,
- ) -> Option<edit_prediction::EditPrediction> {
+ ) -> Option<edit_prediction_types::EditPrediction> {
self.completion.clone()
}
}
#[derive(Default, Clone)]
-pub struct FakeNonZedEditPredictionProvider {
- pub completion: Option<edit_prediction::EditPrediction>,
+pub struct FakeNonZedEditPredictionDelegate {
+ pub completion: Option<edit_prediction_types::EditPrediction>,
}
-impl FakeNonZedEditPredictionProvider {
- pub fn set_edit_prediction(&mut self, completion: Option<edit_prediction::EditPrediction>) {
+impl FakeNonZedEditPredictionDelegate {
+ pub fn set_edit_prediction(
+ &mut self,
+ completion: Option<edit_prediction_types::EditPrediction>,
+ ) {
self.completion = completion;
}
}
-impl EditPredictionProvider for FakeNonZedEditPredictionProvider {
+impl EditPredictionDelegate for FakeNonZedEditPredictionDelegate {
fn name() -> &'static str {
"fake-non-zed-provider"
}
@@ -525,7 +522,7 @@ impl EditPredictionProvider for FakeNonZedEditPredictionProvider {
"Fake Non-Zed Provider"
}
- fn show_completions_in_menu() -> bool {
+ fn show_predictions_in_menu() -> bool {
false
}
@@ -542,7 +539,7 @@ impl EditPredictionProvider for FakeNonZedEditPredictionProvider {
true
}
- fn is_refreshing(&self) -> bool {
+ fn is_refreshing(&self, _cx: &gpui::App) -> bool {
false
}
@@ -555,15 +552,6 @@ impl EditPredictionProvider for FakeNonZedEditPredictionProvider {
) {
}
- fn cycle(
- &mut self,
- _buffer: gpui::Entity<language::Buffer>,
- _cursor_position: language::Anchor,
- _direction: edit_prediction::Direction,
- _cx: &mut gpui::Context<Self>,
- ) {
- }
-
fn accept(&mut self, _cx: &mut gpui::Context<Self>) {}
fn discard(&mut self, _cx: &mut gpui::Context<Self>) {}
@@ -573,7 +561,7 @@ impl EditPredictionProvider for FakeNonZedEditPredictionProvider {
_buffer: &gpui::Entity<language::Buffer>,
_cursor_position: language::Anchor,
_cx: &mut gpui::Context<Self>,
- ) -> Option<edit_prediction::EditPrediction> {
+ ) -> Option<edit_prediction_types::EditPrediction> {
self.completion.clone()
}
}
@@ -12,7 +12,8 @@
//!
//! If you're looking to improve Vim mode, you should check out Vim crate that wraps Editor and overrides its behavior.
pub mod actions;
-mod blink_manager;
+pub mod blink_manager;
+mod bracket_colorization;
mod clangd_ext;
pub mod code_context_menus;
pub mod display_map;
@@ -35,6 +36,7 @@ mod persistence;
mod rust_analyzer_ext;
pub mod scroll;
mod selections_collection;
+mod split;
pub mod tasks;
#[cfg(test)]
@@ -49,7 +51,7 @@ pub mod test;
pub(crate) use actions::*;
pub use display_map::{ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPlaceholder};
-pub use edit_prediction::Direction;
+pub use edit_prediction_types::Direction;
pub use editor_settings::{
CurrentLineHighlight, DocumentColorsRenderMode, EditorSettings, HideMouseMode,
ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowMinimap,
@@ -64,18 +66,16 @@ pub use items::MAX_TAB_TITLE_LEN;
pub use lsp::CompletionContext;
pub use lsp_ext::lsp_tasks;
pub use multi_buffer::{
- Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, PathKey,
- RowInfo, ToOffset, ToPoint,
+ Anchor, AnchorRangeExt, BufferOffset, ExcerptId, ExcerptRange, MBTextSummary, MultiBuffer,
+ MultiBufferOffset, MultiBufferOffsetUtf16, MultiBufferSnapshot, PathKey, RowInfo, ToOffset,
+ ToPoint,
};
+pub use split::SplittableEditor;
pub use text::Bias;
-use ::git::{
- Restore,
- blame::{BlameEntry, ParsedCommitMessage},
- status::FileStatus,
-};
+use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError};
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result, anyhow, bail};
use blink_manager::BlinkManager;
use buffer_diff::DiffHunkStatus;
use client::{Collaborator, ParticipantIndex, parse_zed_link};
@@ -88,7 +88,9 @@ use collections::{BTreeMap, HashMap, HashSet, VecDeque};
use convert_case::{Case, Casing};
use dap::TelemetrySpawnLocation;
use display_map::*;
-use edit_prediction::{EditPredictionProvider, EditPredictionProviderHandle};
+use edit_prediction_types::{
+ EditPredictionDelegate, EditPredictionDelegateHandle, EditPredictionGranularity,
+};
use editor_settings::{GoToDefinitionFallback, Minimap as MinimapSettings};
use element::{AcceptEditPredictionBinding, LineWithInvisibles, PositionMap, layout_line};
use futures::{
@@ -103,10 +105,11 @@ use gpui::{
AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context,
DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent,
Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers,
- MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, ScrollHandle,
- SharedString, Size, Stateful, Styled, Subscription, Task, TextStyle, TextStyleRefinement,
- UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window,
- div, point, prelude::*, pulsating_between, px, relative, size,
+ MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, ParentElement, Pixels, PressureStage,
+ Render, ScrollHandle, SharedString, Size, Stateful, Styled, Subscription, Task, TextRun,
+ TextStyle, TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle,
+ WeakEntity, WeakFocusHandle, Window, div, point, prelude::*, pulsating_between, px, relative,
+ size,
};
use hover_links::{HoverLink, HoveredLinkState, find_file};
use hover_popover::{HoverState, hide_hover};
@@ -117,11 +120,12 @@ use language::{
AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow,
BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape,
DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind,
- IndentSize, Language, OffsetRangeExt, OutlineItem, Point, Runnable, RunnableRange, Selection,
- SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery,
+ IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, OffsetRangeExt,
+ OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId,
+ TreeSitterOptions, WordsQuery,
language_settings::{
- self, LspInsertMode, RewrapBehavior, WordsCompletionMode, all_language_settings,
- language_settings,
+ self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode,
+ all_language_settings, language_settings,
},
point_from_lsp, point_to_lsp, text_diff_with_options,
};
@@ -142,8 +146,8 @@ use persistence::DB;
use project::{
BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent,
CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, InlayId,
- InvalidationStrategy, Location, LocationLink, PrepareRenameResponse, Project, ProjectItem,
- ProjectPath, ProjectTransaction, TaskSourceKind,
+ InvalidationStrategy, Location, LocationLink, LspAction, PrepareRenameResponse, Project,
+ ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind,
debugger::{
breakpoint_store::{
Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState,
@@ -174,10 +178,11 @@ use std::{
borrow::Cow,
cell::{OnceCell, RefCell},
cmp::{self, Ordering, Reverse},
+ collections::hash_map,
iter::{self, Peekable},
mem,
num::NonZeroU32,
- ops::{Deref, DerefMut, Not, Range, RangeInclusive},
+ ops::{ControlFlow, Deref, DerefMut, Not, Range, RangeInclusive},
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
@@ -186,7 +191,7 @@ use std::{
use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _};
use theme::{
- ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, ThemeSettings,
+ AccentColors, ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, ThemeSettings,
observe_buffer_font_size_adjustment,
};
use ui::{
@@ -279,6 +284,9 @@ pub enum ConflictsTheirs {}
pub enum ConflictsOursMarker {}
pub enum ConflictsTheirsMarker {}
+pub struct HunkAddedColor;
+pub struct HunkRemovedColor;
+
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum Navigated {
Yes,
@@ -302,6 +310,7 @@ enum DisplayDiffHunk {
display_row_range: Range<DisplayRow>,
multi_buffer_range: Range<Anchor>,
status: DiffHunkStatus,
+ word_diffs: Vec<Range<MultiBufferOffset>>,
},
}
@@ -342,8 +351,8 @@ pub fn init(cx: &mut App) {
)
.detach();
}
- });
- cx.on_action(move |_: &workspace::NewWindow, cx| {
+ })
+ .on_action(move |_: &workspace::NewWindow, cx| {
let app_state = workspace::AppState::global(cx);
if let Some(app_state) = app_state.upgrade() {
workspace::open_new(
@@ -371,6 +380,7 @@ pub trait DiagnosticRenderer {
buffer_id: BufferId,
snapshot: EditorSnapshot,
editor: WeakEntity<Editor>,
+ language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut App,
) -> Vec<BlockProperties<Anchor>>;
@@ -379,6 +389,7 @@ pub trait DiagnosticRenderer {
diagnostic_group: Vec<DiagnosticEntryRef<'_, Point>>,
range: Range<Point>,
buffer_id: BufferId,
+ language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut App,
) -> Option<Entity<markdown::Markdown>>;
@@ -564,7 +575,7 @@ impl Default for EditorStyle {
}
}
-pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle {
+pub fn make_inlay_hints_style(cx: &App) -> HighlightStyle {
let show_background = language_settings::language_settings(None, None, cx)
.inlay_hints
.show_background;
@@ -587,7 +598,7 @@ pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle {
style
}
-pub fn make_suggestion_styles(cx: &mut App) -> EditPredictionStyles {
+pub fn make_suggestion_styles(cx: &App) -> EditPredictionStyles {
EditPredictionStyles {
insertion: HighlightStyle {
color: Some(cx.theme().status().predictive),
@@ -715,7 +726,10 @@ impl EditorActionId {
// type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor;
// type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option<HighlightStyle>;
-type BackgroundHighlight = (fn(&Theme) -> Hsla, Arc<[Range<Anchor>]>);
+type BackgroundHighlight = (
+ Arc<dyn Fn(&usize, &Theme) -> Hsla + Send + Sync>,
+ Arc<[Range<Anchor>]>,
+);
type GutterHighlight = (fn(&App) -> Hsla, Vec<Range<Anchor>>);
#[derive(Default)]
@@ -853,9 +867,6 @@ pub struct ResolvedTasks {
position: Anchor,
}
-#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
-struct BufferOffset(usize);
-
/// Addons allow storing per-editor state in other crates (e.g. Vim)
pub trait Addon: 'static {
fn extend_key_context(&self, _: &mut KeyContext, _: &App) {}
@@ -1068,6 +1079,7 @@ pub struct Editor {
show_breakpoints: Option<bool>,
show_wrap_guides: Option<bool>,
show_indent_guides: Option<bool>,
+ buffers_with_disabled_indent_guides: HashSet<BufferId>,
highlight_order: usize,
highlighted_rows: HashMap<TypeId, Vec<RowHighlight>>,
background_highlights: HashMap<HighlightKey, BackgroundHighlight>,
@@ -1095,7 +1107,11 @@ pub struct Editor {
pending_rename: Option<RenameState>,
searchable: bool,
cursor_shape: CursorShape,
+ /// Whether the cursor is offset one character to the left when something is
+ /// selected (needed for vim visual mode)
+ cursor_offset_on_selection: bool,
current_line_highlight: Option<CurrentLineHighlight>,
+ pub collapse_matches: bool,
autoindent_mode: Option<AutoindentMode>,
workspace: Option<(WeakEntity<Workspace>, Option<WorkspaceId>)>,
input_enabled: bool,
@@ -1105,9 +1121,10 @@ pub struct Editor {
remote_id: Option<ViewId>,
pub hover_state: HoverState,
pending_mouse_down: Option<Rc<RefCell<Option<MouseDownEvent>>>>,
+ prev_pressure_stage: Option<PressureStage>,
gutter_hovered: bool,
hovered_link_state: Option<HoveredLinkState>,
- edit_prediction_provider: Option<RegisteredEditPredictionProvider>,
+ edit_prediction_provider: Option<RegisteredEditPredictionDelegate>,
code_action_providers: Vec<Rc<dyn CodeActionProvider>>,
active_edit_prediction: Option<EditPredictionState>,
/// Used to prevent flickering as the user types while the menu is open
@@ -1115,6 +1132,7 @@ pub struct Editor {
edit_prediction_settings: EditPredictionSettings,
edit_predictions_hidden_for_vim_mode: bool,
show_edit_predictions_override: Option<bool>,
+ show_completions_on_input_override: Option<bool>,
menu_edit_predictions_policy: MenuEditPredictionsPolicy,
edit_prediction_preview: EditPredictionPreview,
edit_prediction_indent_conflict: bool,
@@ -1163,6 +1181,7 @@ pub struct Editor {
gutter_breakpoint_indicator: (Option<PhantomBreakpointIndicator>, Option<Task<()>>),
hovered_diff_hunk_row: Option<DisplayRow>,
pull_diagnostics_task: Task<()>,
+ pull_diagnostics_background_task: Task<()>,
in_project_search: bool,
previous_search_ranges: Option<Arc<[Range<Anchor>]>>,
breadcrumb_header: Option<String>,
@@ -1192,6 +1211,16 @@ pub struct Editor {
folding_newlines: Task<()>,
select_next_is_case_sensitive: Option<bool>,
pub lookup_key: Option<Box<dyn Any + Send + Sync>>,
+ applicable_language_settings: HashMap<Option<LanguageName>, LanguageSettings>,
+ accent_data: Option<AccentData>,
+ fetched_tree_sitter_chunks: HashMap<ExcerptId, HashSet<Range<BufferRow>>>,
+ use_base_text_line_numbers: bool,
+}
+
+#[derive(Debug, PartialEq)]
+struct AccentData {
+ colors: AccentColors,
+ overrides: Vec<SharedString>,
}
fn debounce_value(debounce_ms: u64) -> Option<Duration> {
@@ -1224,6 +1253,7 @@ impl NextScrollCursorCenterTopBottom {
pub struct EditorSnapshot {
pub mode: EditorMode,
show_gutter: bool,
+ offset_content: bool,
show_line_numbers: Option<bool>,
show_git_diff_gutter: Option<bool>,
show_code_actions: Option<bool>,
@@ -1297,8 +1327,9 @@ struct SelectionHistoryEntry {
add_selections_state: Option<AddSelectionsState>,
}
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
enum SelectionHistoryMode {
+ #[default]
Normal,
Undoing,
Redoing,
@@ -1311,12 +1342,6 @@ struct HoveredCursor {
selection_id: usize,
}
-impl Default for SelectionHistoryMode {
- fn default() -> Self {
- Self::Normal
- }
-}
-
#[derive(Debug)]
/// SelectionEffects controls the side-effects of updating the selection.
///
@@ -1543,8 +1568,8 @@ pub struct RenameState {
struct InvalidationStack<T>(Vec<T>);
-struct RegisteredEditPredictionProvider {
- provider: Arc<dyn EditPredictionProviderHandle>,
+struct RegisteredEditPredictionDelegate {
+ provider: Arc<dyn EditPredictionDelegateHandle>,
_subscription: Subscription,
}
@@ -1572,11 +1597,50 @@ pub struct ClipboardSelection {
pub is_entire_line: bool,
/// The indentation of the first line when this content was originally copied.
pub first_line_indent: u32,
+ #[serde(default)]
+ pub file_path: Option<PathBuf>,
+ #[serde(default)]
+ pub line_range: Option<RangeInclusive<u32>>,
+}
+
+impl ClipboardSelection {
+ pub fn for_buffer(
+ len: usize,
+ is_entire_line: bool,
+ range: Range<Point>,
+ buffer: &MultiBufferSnapshot,
+ project: Option<&Entity<Project>>,
+ cx: &App,
+ ) -> Self {
+ let first_line_indent = buffer
+ .indent_size_for_line(MultiBufferRow(range.start.row))
+ .len;
+
+ let file_path = util::maybe!({
+ let project = project?.read(cx);
+ let file = buffer.file_at(range.start)?;
+ let project_path = ProjectPath {
+ worktree_id: file.worktree_id(cx),
+ path: file.path().clone(),
+ };
+ project.absolute_path(&project_path, cx)
+ });
+
+ let line_range = file_path.as_ref().map(|_| range.start.row..=range.end.row);
+
+ Self {
+ len,
+ is_entire_line,
+ first_line_indent,
+ file_path,
+ line_range,
+ }
+ }
}
// selections, scroll behavior, was newest selection reversed
type SelectSyntaxNodeHistoryState = (
- Box<[Selection<usize>]>,
+ Box<[Selection<MultiBufferOffset>]>,
SelectSyntaxNodeScrollBehavior,
bool,
);
@@ -1636,7 +1700,7 @@ pub(crate) struct FocusedBlock {
focus_handle: WeakFocusHandle,
}
-#[derive(Clone)]
+#[derive(Clone, Debug)]
enum JumpData {
MultiBufferRow {
row: MultiBufferRow,
@@ -1766,7 +1830,11 @@ impl Editor {
Editor::new_internal(mode, buffer, project, None, window, cx)
}
- pub fn sticky_headers(&self, cx: &App) -> Option<Vec<OutlineItem<Anchor>>> {
+ pub fn sticky_headers(
+ &self,
+ style: &EditorStyle,
+ cx: &App,
+ ) -> Option<Vec<OutlineItem<Anchor>>> {
let multi_buffer = self.buffer().read(cx);
let multi_buffer_snapshot = multi_buffer.snapshot(cx);
let multi_buffer_visible_start = self
@@ -1779,20 +1847,19 @@ impl Editor {
let start_row = (multi_buffer_visible_start.row).min(max_row);
let end_row = (multi_buffer_visible_start.row + 10).min(max_row);
- if let Some((excerpt_id, buffer_id, buffer)) = multi_buffer.read(cx).as_singleton() {
+ if let Some((excerpt_id, _, buffer)) = multi_buffer.read(cx).as_singleton() {
let outline_items = buffer
.outline_items_containing(
Point::new(start_row, 0)..Point::new(end_row, 0),
true,
- self.style().map(|style| style.syntax.as_ref()),
+ Some(style.syntax.as_ref()),
)
.into_iter()
.map(|outline_item| OutlineItem {
depth: outline_item.depth,
- range: Anchor::range_in_buffer(*excerpt_id, buffer_id, outline_item.range),
+ range: Anchor::range_in_buffer(*excerpt_id, outline_item.range),
source_range_for_text: Anchor::range_in_buffer(
*excerpt_id,
- buffer_id,
outline_item.source_range_for_text,
),
text: outline_item.text,
@@ -1800,10 +1867,10 @@ impl Editor {
name_ranges: outline_item.name_ranges,
body_range: outline_item
.body_range
- .map(|range| Anchor::range_in_buffer(*excerpt_id, buffer_id, range)),
+ .map(|range| Anchor::range_in_buffer(*excerpt_id, range)),
annotation_range: outline_item
.annotation_range
- .map(|range| Anchor::range_in_buffer(*excerpt_id, buffer_id, range)),
+ .map(|range| Anchor::range_in_buffer(*excerpt_id, range)),
});
return Some(outline_items.collect());
}
@@ -1887,7 +1954,11 @@ impl Editor {
let selections = SelectionsCollection::new();
let blink_manager = cx.new(|cx| {
- let mut blink_manager = BlinkManager::new(CURSOR_BLINK_INTERVAL, cx);
+ let mut blink_manager = BlinkManager::new(
+ CURSOR_BLINK_INTERVAL,
+ |cx| EditorSettings::get_global(cx).cursor_blink,
+ cx,
+ );
if is_minimap {
blink_manager.disable(cx);
}
@@ -1931,16 +2002,18 @@ impl Editor {
}
}
project::Event::SnippetEdit(id, snippet_edits) => {
- if let Some(buffer) = editor.buffer.read(cx).buffer(*id) {
+ // todo(lw): Non singletons
+ if let Some(buffer) = editor.buffer.read(cx).as_singleton() {
+ let snapshot = buffer.read(cx).snapshot();
let focus_handle = editor.focus_handle(cx);
- if focus_handle.is_focused(window) {
- let snapshot = buffer.read(cx).snapshot();
+ if snapshot.remote_id() == *id && focus_handle.is_focused(window) {
for (range, snippet) in snippet_edits {
- let editor_range =
+ let buffer_range =
language::range_from_lsp(*range).to_offset(&snapshot);
editor
.insert_snippet(
- &[editor_range],
+ &[MultiBufferOffset(buffer_range.start)
+ ..MultiBufferOffset(buffer_range.end)],
snippet.clone(),
window,
cx,
@@ -1987,46 +2060,34 @@ impl Editor {
})
});
});
- let edited_buffers_already_open = {
- let other_editors: Vec<Entity<Editor>> = workspace
- .read(cx)
- .panes()
- .iter()
- .flat_map(|pane| pane.read(cx).items_of_type::<Editor>())
- .filter(|editor| editor.entity_id() != cx.entity_id())
- .collect();
-
- transaction.0.keys().all(|buffer| {
- other_editors.iter().any(|editor| {
- let multi_buffer = editor.read(cx).buffer();
- multi_buffer.read(cx).is_singleton()
- && multi_buffer.read(cx).as_singleton().map_or(
- false,
- |singleton| {
- singleton.entity_id() == buffer.entity_id()
- },
- )
- })
- })
- };
- if !edited_buffers_already_open {
- let workspace = workspace.downgrade();
- let transaction = transaction.clone();
- cx.defer_in(window, move |_, window, cx| {
- cx.spawn_in(window, async move |editor, cx| {
- Self::open_project_transaction(
- &editor,
- workspace,
- transaction,
- "Rename".to_string(),
- cx,
- )
- .await
- .ok()
- })
- .detach();
- });
- }
+
+ Self::open_transaction_for_hidden_buffers(
+ workspace,
+ transaction.clone(),
+ "Rename".to_string(),
+ window,
+ cx,
+ );
+ }
+ }
+
+ project::Event::WorkspaceEditApplied(transaction) => {
+ let Some(workspace) = editor.workspace() else {
+ return;
+ };
+ let Some(active_editor) = workspace.read(cx).active_item_as::<Self>(cx)
+ else {
+ return;
+ };
+
+ if active_editor.entity_id() == cx.entity_id() {
+ Self::open_transaction_for_hidden_buffers(
+ workspace,
+ transaction.clone(),
+ "LSP Edit".to_string(),
+ window,
+ cx,
+ );
}
}
@@ -2181,6 +2242,7 @@ impl Editor {
show_breakpoints: None,
show_wrap_guides: None,
show_indent_guides,
+ buffers_with_disabled_indent_guides: HashSet::default(),
highlight_order: 0,
highlighted_rows: HashMap::default(),
background_highlights: HashMap::default(),
@@ -2211,9 +2273,10 @@ impl Editor {
cursor_shape: EditorSettings::get_global(cx)
.cursor_shape
.unwrap_or_default(),
+ cursor_offset_on_selection: false,
current_line_highlight: None,
autoindent_mode: Some(AutoindentMode::EachLine),
-
+ collapse_matches: false,
workspace: None,
input_enabled: !is_minimap,
use_modal_editing: full_mode,
@@ -2226,6 +2289,7 @@ impl Editor {
remote_id: None,
hover_state: HoverState::default(),
pending_mouse_down: None,
+ prev_pressure_stage: None,
hovered_link_state: None,
edit_prediction_provider: None,
active_edit_prediction: None,
@@ -2250,6 +2314,7 @@ impl Editor {
editor_actions: Rc::default(),
edit_predictions_hidden_for_vim_mode: false,
show_edit_predictions_override: None,
+ show_completions_on_input_override: None,
menu_edit_predictions_policy: MenuEditPredictionsPolicy::ByProvider,
edit_prediction_settings: EditPredictionSettings::Disabled,
edit_prediction_indent_conflict: false,
@@ -2303,6 +2368,7 @@ impl Editor {
.unwrap_or_default(),
tasks_update_task: None,
pull_diagnostics_task: Task::ready(()),
+ pull_diagnostics_background_task: Task::ready(()),
colors: None,
refresh_colors_task: Task::ready(()),
inlay_hints: None,
@@ -2335,12 +2401,19 @@ impl Editor {
folding_newlines: Task::ready(()),
lookup_key: None,
select_next_is_case_sensitive: None,
+ applicable_language_settings: HashMap::default(),
+ accent_data: None,
+ fetched_tree_sitter_chunks: HashMap::default(),
+ use_base_text_line_numbers: false,
};
if is_minimap {
return editor;
}
+ editor.applicable_language_settings = editor.fetch_applicable_language_settings(cx);
+ editor.accent_data = editor.fetch_accent_data(cx);
+
if let Some(breakpoints) = editor.breakpoint_store.as_ref() {
editor
._subscriptions
@@ -2380,13 +2453,17 @@ impl Editor {
InlayHintRefreshReason::NewLinesShown,
cx,
);
+ editor.colorize_brackets(false, cx);
})
.ok();
});
}
}
EditorEvent::Edited { .. } => {
- if vim_flavor(cx).is_none() {
+ let vim_mode = vim_mode_setting::VimModeSetting::try_get(cx)
+ .map(|vim_mode| vim_mode.0)
+ .unwrap_or(false);
+ if !vim_mode {
let display_map = editor.display_snapshot(cx);
let selections = editor.selections.all_adjusted_display(&display_map);
let pop_state = editor
@@ -2468,7 +2545,6 @@ impl Editor {
if let Some(buffer) = multi_buffer.read(cx).as_singleton() {
editor.register_buffer(buffer.read(cx).remote_id(), cx);
}
- editor.update_lsp_data(None, window, cx);
editor.report_editor_event(ReportEditorEvent::EditorOpened, None, cx);
}
@@ -2514,7 +2590,7 @@ impl Editor {
}
self.selections
- .disjoint_in_range::<usize>(range.clone(), &self.display_snapshot(cx))
+ .disjoint_in_range::<MultiBufferOffset>(range.clone(), &self.display_snapshot(cx))
.into_iter()
.any(|selection| {
// This is needed to cover a corner case, if we just check for an existing
@@ -2633,6 +2709,10 @@ impl Editor {
key_context.add("end_of_input");
}
+ if self.has_any_expanded_diff_hunks(cx) {
+ key_context.add("diffs_expanded");
+ }
+
key_context
}
@@ -2685,21 +2765,24 @@ impl Editor {
pub fn accept_edit_prediction_keybind(
&self,
- accept_partial: bool,
+ granularity: EditPredictionGranularity,
window: &mut Window,
cx: &mut App,
) -> AcceptEditPredictionBinding {
let key_context = self.key_context_internal(true, window, cx);
let in_conflict = self.edit_prediction_in_conflict();
- let bindings = if accept_partial {
- window.bindings_for_action_in_context(&AcceptPartialEditPrediction, key_context)
- } else {
- window.bindings_for_action_in_context(&AcceptEditPrediction, key_context)
- };
+ let bindings =
+ match granularity {
+ EditPredictionGranularity::Word => window
+ .bindings_for_action_in_context(&AcceptNextWordEditPrediction, key_context),
+ EditPredictionGranularity::Line => window
+ .bindings_for_action_in_context(&AcceptNextLineEditPrediction, key_context),
+ EditPredictionGranularity::Full => {
+ window.bindings_for_action_in_context(&AcceptEditPrediction, key_context)
+ }
+ };
- // TODO: if the binding contains multiple keystrokes, display all of them, not
- // just the first one.
AcceptEditPredictionBinding(bindings.into_iter().rev().find(|binding| {
!in_conflict
|| binding
@@ -2854,6 +2937,7 @@ impl Editor {
EditorSnapshot {
mode: self.mode.clone(),
show_gutter: self.show_gutter,
+ offset_content: self.offset_content,
show_line_numbers: self.show_line_numbers,
show_git_diff_gutter: self.show_git_diff_gutter,
show_code_actions: self.show_code_actions,
@@ -2948,9 +3032,9 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) where
- T: EditPredictionProvider,
+ T: EditPredictionDelegate,
{
- self.edit_prediction_provider = provider.map(|provider| RegisteredEditPredictionProvider {
+ self.edit_prediction_provider = provider.map(|provider| RegisteredEditPredictionDelegate {
_subscription: cx.observe_in(&provider, window, |this, _, window, cx| {
if this.focus_handle.is_focused(window) {
this.update_visible_edit_prediction(window, cx);
@@ -3004,6 +3088,14 @@ impl Editor {
cx.notify();
}
+ pub fn cursor_shape(&self) -> CursorShape {
+ self.cursor_shape
+ }
+
+ pub fn set_cursor_offset_on_selection(&mut self, set_cursor_offset_on_selection: bool) {
+ self.cursor_offset_on_selection = set_cursor_offset_on_selection;
+ }
+
pub fn set_current_line_highlight(
&mut self,
current_line_highlight: Option<CurrentLineHighlight>,
@@ -3011,17 +3103,21 @@ impl Editor {
self.current_line_highlight = current_line_highlight;
}
- pub fn range_for_match<T: std::marker::Copy>(
- &self,
- range: &Range<T>,
- collapse: bool,
- ) -> Range<T> {
- if collapse {
+ pub fn set_collapse_matches(&mut self, collapse_matches: bool) {
+ self.collapse_matches = collapse_matches;
+ }
+
+ pub fn range_for_match<T: std::marker::Copy>(&self, range: &Range<T>) -> Range<T> {
+ if self.collapse_matches {
return range.start..range.start;
}
range.clone()
}
+ pub fn clip_at_line_ends(&mut self, cx: &mut Context<Self>) -> bool {
+ self.display_map.read(cx).clip_at_line_ends
+ }
+
pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut Context<Self>) {
if self.display_map.read(cx).clip_at_line_ends != clip {
self.display_map
@@ -3109,6 +3205,10 @@ impl Editor {
}
}
+ pub fn set_show_completions_on_input(&mut self, show_completions_on_input: Option<bool>) {
+ self.show_completions_on_input_override = show_completions_on_input;
+ }
+
pub fn set_show_edit_predictions(
&mut self,
show_edit_predictions: Option<bool>,
@@ -3167,7 +3267,9 @@ impl Editor {
// Copy selections to primary selection buffer
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
if local {
- let selections = self.selections.all::<usize>(&self.display_snapshot(cx));
+ let selections = self
+ .selections
+ .all::<MultiBufferOffset>(&self.display_snapshot(cx));
let buffer_handle = self.buffer.read(cx).read(cx);
let mut text = String::new();
@@ -3228,7 +3330,7 @@ impl Editor {
}
if local {
- if let Some(buffer_id) = new_cursor_position.buffer_id {
+ if let Some(buffer_id) = new_cursor_position.text_anchor.buffer_id {
self.register_buffer(buffer_id, cx);
}
@@ -3310,7 +3412,8 @@ impl Editor {
data.selections = inmemory_selections;
});
- if WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None
+ if WorkspaceSettings::get(None, cx).restore_on_startup
+ != RestoreOnStartupBehavior::EmptyTab
&& let Some(workspace_id) = self.workspace_serialization_id(cx)
{
let snapshot = self.buffer().read(cx).snapshot(cx);
@@ -3323,8 +3426,8 @@ impl Editor {
.iter()
.map(|selection| {
(
- selection.start.to_offset(&snapshot),
- selection.end.to_offset(&snapshot),
+ selection.start.to_offset(&snapshot).0,
+ selection.end.to_offset(&snapshot).0,
)
})
.collect();
@@ -3350,7 +3453,8 @@ impl Editor {
use text::ToPoint as _;
if self.mode.is_minimap()
- || WorkspaceSettings::get(None, cx).restore_on_startup == RestoreOnStartupBehavior::None
+ || WorkspaceSettings::get(None, cx).restore_on_startup
+ == RestoreOnStartupBehavior::EmptyTab
{
return;
}
@@ -3366,7 +3470,7 @@ impl Editor {
return;
};
let inmemory_folds = display_snapshot
- .folds_in_range(0..display_snapshot.buffer_snapshot().len())
+ .folds_in_range(MultiBufferOffset(0)..display_snapshot.buffer_snapshot().len())
.map(|fold| {
fold.range.start.text_anchor.to_point(&snapshot)
..fold.range.end.text_anchor.to_point(&snapshot)
@@ -3382,7 +3486,7 @@ impl Editor {
let background_executor = cx.background_executor().clone();
let editor_id = cx.entity().entity_id().as_u64() as ItemId;
let db_folds = display_snapshot
- .folds_in_range(0..display_snapshot.buffer_snapshot().len())
+ .folds_in_range(MultiBufferOffset(0)..display_snapshot.buffer_snapshot().len())
.map(|fold| {
(
fold.range.start.text_anchor.to_offset(&snapshot),
@@ -3639,7 +3743,10 @@ impl Editor {
cx: &mut Context<Self>,
) {
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
- let tail = self.selections.newest::<usize>(&display_map).tail();
+ let tail = self
+ .selections
+ .newest::<MultiBufferOffset>(&display_map)
+ .tail();
let click_count = click_count.max(match self.selections.select_mode() {
SelectMode::Character => 1,
SelectMode::Word(_) => 2,
@@ -3705,7 +3812,7 @@ impl Editor {
) {
if !self.focus_handle.is_focused(window) {
self.last_focused_descendant = None;
- window.focus(&self.focus_handle);
+ window.focus(&self.focus_handle, cx);
}
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
@@ -3748,7 +3855,7 @@ impl Editor {
auto_scroll = true;
}
_ => {
- start = buffer.anchor_before(0);
+ start = buffer.anchor_before(MultiBufferOffset(0));
end = buffer.anchor_before(buffer.len());
mode = SelectMode::All;
auto_scroll = false;
@@ -3810,7 +3917,7 @@ impl Editor {
) {
if !self.focus_handle.is_focused(window) {
self.last_focused_descendant = None;
- window.focus(&self.focus_handle);
+ window.focus(&self.focus_handle, cx);
}
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
@@ -3961,7 +4068,9 @@ impl Editor {
fn end_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.columnar_selection_state.take();
if let Some(pending_mode) = self.selections.pending_mode() {
- let selections = self.selections.all::<usize>(&self.display_snapshot(cx));
+ let selections = self
+ .selections
+ .all::<MultiBufferOffset>(&self.display_snapshot(cx));
self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select(selections);
s.clear_pending();
@@ -4076,17 +4185,24 @@ impl Editor {
self.selection_mark_mode = false;
self.selection_drag_state = SelectionDragState::None;
+ if self.dismiss_menus_and_popups(true, window, cx) {
+ cx.notify();
+ return;
+ }
if self.clear_expanded_diff_hunks(cx) {
cx.notify();
return;
}
- if self.dismiss_menus_and_popups(true, window, cx) {
+ if self.show_git_blame_gutter {
+ self.show_git_blame_gutter = false;
+ cx.notify();
return;
}
if self.mode.is_full()
&& self.change_selections(Default::default(), window, cx, |s| s.try_cancel())
{
+ cx.notify();
return;
}
@@ -4099,44 +4215,23 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
- if self.take_rename(false, window, cx).is_some() {
- return true;
- }
-
- if self.hide_blame_popover(true, cx) {
- return true;
- }
-
- if hide_hover(self, cx) {
- return true;
- }
-
- if self.hide_signature_help(cx, SignatureHelpHiddenBy::Escape) {
- return true;
- }
-
- if self.hide_context_menu(window, cx).is_some() {
- return true;
- }
-
- if self.mouse_context_menu.take().is_some() {
- return true;
- }
+ let mut dismissed = false;
- if is_user_requested && self.discard_edit_prediction(true, cx) {
- return true;
- }
-
- if self.snippet_stack.pop().is_some() {
- return true;
- }
+ dismissed |= self.take_rename(false, window, cx).is_some();
+ dismissed |= self.hide_blame_popover(true, cx);
+ dismissed |= hide_hover(self, cx);
+ dismissed |= self.hide_signature_help(cx, SignatureHelpHiddenBy::Escape);
+ dismissed |= self.hide_context_menu(window, cx).is_some();
+ dismissed |= self.mouse_context_menu.take().is_some();
+ dismissed |= is_user_requested && self.discard_edit_prediction(true, cx);
+ dismissed |= self.snippet_stack.pop().is_some();
if self.mode.is_full() && matches!(self.active_diagnostics, ActiveDiagnostic::Group(_)) {
self.dismiss_diagnostics(cx);
- return true;
+ dismissed = true;
}
- false
+ dismissed
}
fn linked_editing_ranges_for(
@@ -4176,8 +4271,8 @@ impl Editor {
continue;
}
if self.selections.disjoint_anchor_ranges().any(|s| {
- if s.start.buffer_id != selection.start.buffer_id
- || s.end.buffer_id != selection.end.buffer_id
+ if s.start.text_anchor.buffer_id != selection.start.buffer_id
+ || s.end.text_anchor.buffer_id != selection.end.buffer_id
{
return false;
}
@@ -4296,10 +4391,50 @@ impl Editor {
&& bracket_pair.start.len() == 1
{
let target = bracket_pair.start.chars().next().unwrap();
+ let mut byte_offset = 0u32;
let current_line_count = snapshot
.reversed_chars_at(selection.start)
.take_while(|&c| c != '\n')
- .filter(|&c| c == target)
+ .filter(|c| {
+ byte_offset += c.len_utf8() as u32;
+ if *c != target {
+ return false;
+ }
+
+ let point = Point::new(
+ selection.start.row,
+ selection.start.column.saturating_sub(byte_offset),
+ );
+
+ let is_enabled = snapshot
+ .language_scope_at(point)
+ .and_then(|scope| {
+ scope
+ .brackets()
+ .find(|(pair, _)| {
+ pair.start == bracket_pair.start
+ })
+ .map(|(_, enabled)| enabled)
+ })
+ .unwrap_or(true);
+
+ let is_delimiter = snapshot
+ .language_scope_at(Point::new(
+ point.row,
+ point.column + 1,
+ ))
+ .and_then(|scope| {
+ scope
+ .brackets()
+ .find(|(pair, _)| {
+ pair.start == bracket_pair.start
+ })
+ .map(|(_, enabled)| !enabled)
+ })
+ .unwrap_or(false);
+
+ is_enabled && !is_delimiter
+ })
.count();
current_line_count % 2 == 1
} else {
@@ -2,7 +2,7 @@ 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,
@@ -17,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::{
@@ -32,20 +32,25 @@ use language::{
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::{
- FakeFs,
+ FakeFs, Project,
debugger::breakpoint_store::{BreakpointState, SourceBreakpoint},
project_settings::LspSettings,
+ trusted_worktrees::{PathTrust, TrustedWorktrees},
};
use serde_json::{self, json};
use settings::{
AllLanguageSettingsContent, EditorSettingsContent, IndentGuideBackgroundColoring,
- IndentGuideColoring, ProjectSettingsContent, SearchSettingsContent,
+ IndentGuideColoring, InlayHintSettingsContent, ProjectSettingsContent, SearchSettingsContent,
+ SettingsStore,
};
use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
use std::{
@@ -64,7 +69,6 @@ use util::{
use workspace::{
CloseActiveItem, CloseAllItems, CloseOtherItems, MoveItemToPaneInDirection, NavigationEntry,
OpenOptions, ViewId,
- invalid_item_view::InvalidItemView,
item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions},
register_project_item,
};
@@ -196,7 +200,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);
@@ -221,7 +225,7 @@ 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);
@@ -229,38 +233,46 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) {
assert_eq!(editor.text(cx), "12cd56");
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- vec![4..4]
+ 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(&editor.display_snapshot(cx)),
- vec![5..5]
+ 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(&editor.display_snapshot(cx)),
- vec![3..3]
+ vec![MultiBufferOffset(3)..MultiBufferOffset(3)]
);
// Last transaction happened past the group interval in a different editor.
@@ -269,7 +281,7 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) {
assert_eq!(editor.text(cx), "12cde6");
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- vec![2..2]
+ vec![MultiBufferOffset(2)..MultiBufferOffset(2)]
);
// First two transactions happened within the group interval in this editor.
@@ -279,7 +291,7 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) {
assert_eq!(editor.text(cx), "123456");
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- vec![0..0]
+ vec![MultiBufferOffset(0)..MultiBufferOffset(0)]
);
// Redo the first two transactions together.
@@ -287,7 +299,7 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) {
assert_eq!(editor.text(cx), "12cde6");
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- vec![5..5]
+ vec![MultiBufferOffset(5)..MultiBufferOffset(5)]
);
// Redo the last transaction on its own.
@@ -295,7 +307,7 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) {
assert_eq!(editor.text(cx), "ab2cde6");
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- vec![6..6]
+ vec![MultiBufferOffset(6)..MultiBufferOffset(6)]
);
// Test empty transactions.
@@ -328,7 +340,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.
@@ -348,7 +362,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.
@@ -361,7 +377,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.
@@ -372,9 +390,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);
@@ -382,9 +400,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))
])
);
@@ -394,9 +412,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))
])
);
@@ -756,7 +774,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![
@@ -793,9 +815,11 @@ 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::<Vec<_>>(),
+ snapshot
+ .folds_in_range(MultiBufferOffset(0)..MultiBufferOffset(text.len()))
.collect::<Vec<_>>(),
- snapshot.folds_in_range(0..text.len()).collect::<Vec<_>>(),
);
assert_set_eq!(
cloned_editor
@@ -1417,7 +1441,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);
@@ -2191,10 +2219,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())
});
@@ -2307,10 +2334,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())
});
@@ -2373,8 +2399,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())
});
@@ -2453,10 +2478,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())
});
@@ -3699,7 +3723,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
});
@@ -3707,12 +3735,24 @@ 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(&editor.display_snapshot(cx)),
- &[2..2, 7..7, 12..12],
+ &[
+ MultiBufferOffset(2)..MultiBufferOffset(2),
+ MultiBufferOffset(7)..MultiBufferOffset(7),
+ MultiBufferOffset(12)..MultiBufferOffset(12)
+ ],
);
editor.insert("Z", window, cx);
@@ -3721,7 +3761,11 @@ fn test_insert_with_old_selections(cx: &mut TestAppContext) {
// The selections are moved after the inserted characters
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- &[3..3, 9..9, 15..15],
+ &[
+ MultiBufferOffset(3)..MultiBufferOffset(3),
+ MultiBufferOffset(9)..MultiBufferOffset(9),
+ MultiBufferOffset(15)..MultiBufferOffset(15)
+ ],
);
});
}
@@ -4691,7 +4735,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<_>>(),
Vec::new(),
"Should not have any diffs for files with custom newlines"
@@ -5730,6 +5774,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, |_| {});
@@ -5963,27 +6117,27 @@ 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(&editor.display_snapshot(cx)),
- [2..2]
+ [MultiBufferOffset(2)..MultiBufferOffset(2)]
);
editor.transpose(&Default::default(), window, cx);
assert_eq!(editor.text(cx), "bca");
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- [3..3]
+ [MultiBufferOffset(3)..MultiBufferOffset(3)]
);
editor.transpose(&Default::default(), window, cx);
assert_eq!(editor.text(cx), "bac");
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- [3..3]
+ [MultiBufferOffset(3)..MultiBufferOffset(3)]
);
editor
@@ -5993,37 +6147,37 @@ 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(&editor.display_snapshot(cx)),
- [3..3]
+ [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(&editor.display_snapshot(cx)),
- [5..5]
+ [MultiBufferOffset(5)..MultiBufferOffset(5)]
);
editor.transpose(&Default::default(), window, cx);
assert_eq!(editor.text(cx), "acbde\n");
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- [6..6]
+ [MultiBufferOffset(6)..MultiBufferOffset(6)]
);
editor.transpose(&Default::default(), window, cx);
assert_eq!(editor.text(cx), "acbd\ne");
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- [6..6]
+ [MultiBufferOffset(6)..MultiBufferOffset(6)]
);
editor
@@ -6033,41 +6187,62 @@ 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(&editor.display_snapshot(cx)),
- [2..2, 3..3, 5..5]
+ [
+ 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(&editor.display_snapshot(cx)),
- [3..3, 4..4, 6..6]
+ [
+ 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(&editor.display_snapshot(cx)),
- [4..4, 6..6]
+ [
+ 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(&editor.display_snapshot(cx)),
- [4..4, 6..6]
+ [
+ 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(&editor.display_snapshot(cx)),
- [5..5, 6..6]
+ [
+ MultiBufferOffset(5)..MultiBufferOffset(5),
+ MultiBufferOffset(6)..MultiBufferOffset(6)
+ ]
);
editor
@@ -6077,27 +6252,27 @@ 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(&editor.display_snapshot(cx)),
- [8..8]
+ [MultiBufferOffset(8)..MultiBufferOffset(8)]
);
editor.transpose(&Default::default(), window, cx);
assert_eq!(editor.text(cx), "🏀✋🍐");
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- [11..11]
+ [MultiBufferOffset(11)..MultiBufferOffset(11)]
);
editor.transpose(&Default::default(), window, cx);
assert_eq!(editor.text(cx), "🏀🍐✋");
assert_eq!(
editor.selections.ranges(&editor.display_snapshot(cx)),
- [11..11]
+ [MultiBufferOffset(11)..MultiBufferOffset(11)]
);
editor
@@ -7576,10 +7751,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!(
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),
]
);
@@ -7598,9 +7775,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!(
display_ranges(editor, cx),
- vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(5), 5)]
+ vec![
+ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(4), 0),
+ DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 5),
+ ]
);
});
}
@@ -8568,7 +8749,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);
});
@@ -8591,7 +8772,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,
@@ -9730,7 +9911,11 @@ 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");
@@ -9795,7 +9980,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!(
@@ -9894,7 +10083,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()),
@@ -10452,7 +10641,7 @@ async fn test_autoclose_with_embedded_language(cx: &mut TestAppContext) {
let snapshot = editor.snapshot(window, cx);
let cursors = editor
.selections
- .ranges::<usize>(&editor.display_snapshot(cx));
+ .ranges::<MultiBufferOffset>(&editor.display_snapshot(cx));
let languages = cursors
.iter()
.map(|c| snapshot.language_at(c.start).unwrap().name())
@@ -10681,6 +10870,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, |_| {});
@@ -11142,17 +11440,26 @@ 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::<Vec<_>>(),
+ snippet,
+ window,
+ cx,
+ )
.unwrap();
fn assert(editor: &mut Editor, cx: &mut Context<Editor>, 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::<usize>(&editor.display_snapshot(cx)),
+ editor.selections.ranges(&editor.display_snapshot(cx)),
selection_ranges
+ .iter()
+ .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end))
+ .collect::<Vec<_>>()
);
}
@@ -11176,10 +11483,11 @@ async fn test_snippet_tabstop_navigation_with_placeholders(cx: &mut TestAppConte
let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false);
assert_eq!(editor.text(cx), expected_text);
assert_eq!(
- editor
- .selections
- .ranges::<usize>(&editor.display_snapshot(cx)),
+ editor.selections.ranges(&editor.display_snapshot(cx)),
selection_ranges
+ .iter()
+ .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end))
+ .collect::<Vec<_>>()
);
}
@@ -11197,7 +11505,15 @@ async fn test_snippet_tabstop_navigation_with_placeholders(cx: &mut TestAppConte
let snippet = Snippet::parse("type ${1|,i32,u32|} = $2; $3").unwrap();
editor
- .insert_snippet(&insertion_ranges, snippet, window, cx)
+ .insert_snippet(
+ &insertion_ranges
+ .iter()
+ .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end))
+ .collect::<Vec<_>>(),
+ snippet,
+ window,
+ cx,
+ )
.unwrap();
assert_state(
@@ -11414,6 +11730,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, |_| {});
@@ -6,11 +6,12 @@ use crate::{
EditDisplayMode, EditPrediction, Editor, EditorMode, EditorSettings, EditorSnapshot,
EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp,
HandleInput, HoveredCursor, InlayHintRefreshReason, JumpData, LineDown, LineHighlight, LineUp,
- MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts,
- OpenExcerptsSplit, PageDown, PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt,
- SelectPhase, SelectedTextHighlight, Selection, SelectionDragState, SelectionEffects,
- SizingBehavior, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll,
+ MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown,
+ PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt, SelectPhase,
+ 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,
@@ -36,22 +37,18 @@ use crate::{
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
use collections::{BTreeMap, HashMap};
use file_icons::FileIcons;
-use git::{
- Oid,
- blame::{BlameEntry, ParsedCommitMessage},
- status::FileStatus,
-};
+use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
use gpui::{
Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle,
DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId,
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero,
KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent,
- MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta,
- ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement,
- Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill,
- linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background,
- transparent_black,
+ 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 +58,7 @@ use multi_buffer::{
MultiBufferRow, RowInfo,
};
+use edit_prediction_types::EditPredictionGranularity;
use project::{
Entry, ProjectPath,
debugger::breakpoint_store::{Breakpoint, BreakpointSessionState},
@@ -74,6 +72,7 @@ use smallvec::{SmallVec, smallvec};
use std::{
any::TypeId,
borrow::Cow,
+ cell::Cell,
cmp::{self, Ordering},
fmt::{self, Write},
iter, mem,
@@ -88,7 +87,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 +129,7 @@ impl SelectionLayout {
fn new<T: ToPoint + ToDisplayPoint + Clone>(
selection: Selection<T>,
line_mode: bool,
+ cursor_offset: bool,
cursor_shape: CursorShape,
map: &DisplaySnapshot,
is_newest: bool,
@@ -150,12 +150,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 +182,13 @@ impl SelectionLayout {
}
}
+#[derive(Default)]
+struct RenderBlocksOutput {
+ blocks: Vec<BlockLayout>,
+ row_block_types: HashMap<DisplayRow, bool>,
+ resized_blocks: Option<HashMap<CustomBlockId, u32>>,
+}
+
pub struct EditorElement {
editor: Entity<Editor>,
style: EditorStyle,
@@ -245,6 +249,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);
@@ -357,6 +363,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();
});
@@ -583,8 +590,6 @@ impl EditorElement {
register_action(editor, window, Editor::show_signature_help);
register_action(editor, window, Editor::signature_help_prev);
register_action(editor, window, Editor::signature_help_next);
- register_action(editor, window, Editor::next_edit_prediction);
- register_action(editor, window, Editor::previous_edit_prediction);
register_action(editor, window, Editor::show_edit_prediction);
register_action(editor, window, Editor::context_menu_first);
register_action(editor, window, Editor::context_menu_prev);
@@ -593,7 +598,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);
@@ -1005,10 +1011,16 @@ impl EditorElement {
let pending_nonempty_selections = editor.has_pending_nonempty_selection();
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);
@@ -1019,6 +1031,28 @@ impl EditorElement {
}
}
+ fn pressure_click(
+ editor: &mut Editor,
+ event: &MousePressureEvent,
+ position_map: &PositionMap,
+ window: &mut Window,
+ cx: &mut Context<Editor>,
+ ) {
+ 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,
@@ -1150,7 +1184,7 @@ impl EditorElement {
}
}
- fn mouse_moved(
+ pub(crate) fn mouse_moved(
editor: &mut Editor,
event: &MouseMoveEvent,
position_map: &PositionMap,
@@ -1161,7 +1195,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);
@@ -1219,7 +1253,13 @@ impl EditorElement {
editor.hide_blame_popover(false, cx);
}
} else {
- editor.hide_blame_popover(false, 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 {
@@ -1417,6 +1457,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,
@@ -1463,6 +1504,7 @@ impl EditorElement {
let drag_cursor_layout = SelectionLayout::new(
drop_cursor.clone(),
false,
+ editor.cursor_offset_on_selection,
CursorShape::Bar,
&snapshot.display_snapshot,
false,
@@ -1526,6 +1568,7 @@ impl EditorElement {
.push(SelectionLayout::new(
selection.selection,
selection.line_mode,
+ editor.cursor_offset_on_selection,
selection.cursor_shape,
&snapshot.display_snapshot,
false,
@@ -1536,6 +1579,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)
@@ -1543,6 +1588,7 @@ impl EditorElement {
SelectionLayout::new(
selection,
line_mode,
+ cursor_offset_on_selection,
cursor_shape,
&snapshot.display_snapshot,
false,
@@ -2252,7 +2298,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
@@ -2326,7 +2373,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();
@@ -2503,7 +2550,6 @@ impl EditorElement {
scroll_position: gpui::Point<ScrollOffset>,
scroll_pixel_position: gpui::Point<ScrollPixelOffset>,
line_height: Pixels,
- text_hitbox: &Hitbox,
window: &mut Window,
cx: &mut App,
) -> Option<InlineBlameLayout> {
@@ -2556,7 +2602,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,
);
@@ -2572,16 +2619,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 {
@@ -2592,16 +2629,48 @@ impl EditorElement {
})
}
- fn layout_blame_entry_popover(
+ fn layout_blame_popover(
&self,
- blame_entry: BlameEntry,
- blame: Entity<GitBlame>,
- 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::<language::Point>(&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
@@ -2623,7 +2692,7 @@ impl EditorElement {
popover_state.markdown,
workspace,
&blame,
- buffer,
+ buffer_id,
window,
cx,
)
@@ -2758,7 +2827,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)
@@ -2815,7 +2884,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;
@@ -3243,6 +3312,7 @@ impl EditorElement {
SelectionLayout::new(
newest,
editor.selections.line_mode(),
+ editor.cursor_offset_on_selection,
editor.cursor_shape,
&snapshot.display_snapshot,
true,
@@ -3266,6 +3336,8 @@ impl EditorElement {
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
};
@@ -3274,6 +3346,7 @@ impl EditorElement {
&& row_info
.diff_status
.is_some_and(|status| status.is_deleted())
+ && !self.editor.read(cx).use_base_text_line_numbers
{
return None;
}
@@ -3664,8 +3737,10 @@ impl EditorElement {
row_block_types: &mut HashMap<DisplayRow, bool>,
selections: &[Selection<Point>],
selected_buffer_ids: &Vec<BufferId>,
+ latest_selection_anchors: &HashMap<BufferId, Anchor>,
is_row_soft_wrapped: impl Copy + Fn(usize) -> bool,
sticky_header_excerpt_id: Option<ExcerptId>,
+ block_resize_offset: &mut i32,
window: &mut Window,
cx: &mut App,
) -> Option<(AnyElement, Size<Pixels>, DisplayRow, Pixels)> {
@@ -3739,7 +3814,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,
@@ -3774,7 +3855,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);
@@ -3807,7 +3894,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;
@@ -3837,6 +3927,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);
}
}
@@ -3859,6 +3950,8 @@ 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.status_for_buffer_id(for_excerpt.buffer_id, cx))
@@ -3911,7 +4004,7 @@ impl EditorElement {
.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)
@@ -3920,7 +4013,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))
@@ -3943,9 +4036,14 @@ impl EditorElement {
.children(toggle_chevron_icon)
.tooltip({
let focus_handle = focus_handle.clone();
+ 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",
@@ -3995,21 +4093,24 @@ impl EditorElement {
})
.take(1),
)
- .child(
- h_flex()
- .size(rems_from_px(12.0))
- .justify_center()
- .flex_shrink_0()
- .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_0p5().map(|path_header| {
+ .child(h_flex().min_w_0().flex_1().gap_0p5().map(|path_header| {
let filename = filename
.map(SharedString::from)
.unwrap_or_else(|| "untitled".into());
@@ -4019,54 +4120,38 @@ impl EditorElement {
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)
+
+ el.child(Icon::from_path(icon).color(Color::Muted))
})
.child(
ButtonLike::new("filename-button")
- .style(ButtonStyle::Subtle)
.child(
- div()
- .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(),
- ),
- )
- .group_hover("", |div| div.underline()),
+ 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({
- let focus_handle = focus_handle.clone();
- move |event, window, cx| {
- if event.modifiers().secondary() {
- focus_handle.dispatch_action(
- &OpenExcerptsSplit,
- window,
- cx,
- );
- } else {
- focus_handle.dispatch_action(
- &OpenExcerpts,
- window,
- 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,
+ );
}
- }),
+ })),
)
.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)
},
))
})
@@ -4075,36 +4160,24 @@ impl EditorElement {
can_open_excerpts && is_selected && relative_path.is_some(),
|el| {
el.child(
- ButtonLike::new("open-file-button")
+ Button::new("open-file-button", "Open File")
.style(ButtonStyle::OutlinedGhost)
- .child(
- h_flex()
- .gap_2p5()
- .child(Label::new("Open file"))
- .child(KeyBinding::for_action_in(
- &OpenExcerpts,
- &focus_handle,
+ .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_click({
- let focus_handle = focus_handle.clone();
- move |event, window, cx| {
- if event.modifiers().secondary() {
- focus_handle.dispatch_action(
- &OpenExcerptsSplit,
- window,
- cx,
- );
- } else {
- focus_handle.dispatch_action(
- &OpenExcerpts,
- window,
- cx,
- );
- }
+ );
}
- }),
+ })),
)
},
)
@@ -4250,11 +4323,12 @@ impl EditorElement {
line_layouts: &mut [LineWithInvisibles],
selections: &[Selection<Point>],
selected_buffer_ids: &Vec<BufferId>,
+ latest_selection_anchors: &HashMap<BufferId, Anchor>,
is_row_soft_wrapped: impl Copy + Fn(usize) -> bool,
sticky_header_excerpt_id: Option<ExcerptId>,
window: &mut Window,
cx: &mut App,
- ) -> Result<(Vec<BlockLayout>, HashMap<DisplayRow, bool>), HashMap<CustomBlockId, u32>> {
+ ) -> RenderBlocksOutput {
let (fixed_blocks, non_fixed_blocks) = snapshot
.blocks_in_range(rows.clone())
.partition::<Vec<_>, _>(|(_, block)| block.style() == BlockStyle::Fixed);
@@ -4266,6 +4340,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();
@@ -4293,8 +4368,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,
) {
@@ -4350,8 +4427,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,
) {
@@ -4405,8 +4484,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,
) {
@@ -4426,9 +4507,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),
}
}
@@ -4487,6 +4571,7 @@ impl EditorElement {
hitbox: &Hitbox,
selected_buffer_ids: &Vec<BufferId>,
blocks: &[BlockLayout],
+ latest_selection_anchors: &HashMap<BufferId, Anchor>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
@@ -4495,6 +4580,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;
@@ -4565,6 +4651,7 @@ impl EditorElement {
gutter_dimensions: &GutterDimensions,
gutter_hitbox: &Hitbox,
text_hitbox: &Hitbox,
+ style: &EditorStyle,
window: &mut Window,
cx: &mut App,
) -> Option<StickyHeaders> {
@@ -4572,7 +4659,7 @@ impl EditorElement {
.show_line_numbers
.unwrap_or_else(|| EditorSettings::get_global(cx).gutter.line_numbers);
- let rows = Self::sticky_headers(self.editor.read(cx), snapshot, cx);
+ let rows = Self::sticky_headers(self.editor.read(cx), snapshot, style, cx);
let mut lines = Vec::<StickyHeaderLine>::new();
@@ -4631,6 +4718,7 @@ impl EditorElement {
pub(crate) fn sticky_headers(
editor: &Editor,
snapshot: &EditorSnapshot,
+ style: &EditorStyle,
cx: &App,
) -> Vec<StickyHeader> {
let scroll_top = snapshot.scroll_position().y;
@@ -4638,7 +4726,7 @@ impl EditorElement {
let mut end_rows = Vec::<DisplayRow>::new();
let mut rows = Vec::<StickyHeader>::new();
- let items = editor.sticky_headers(cx).unwrap_or_default();
+ 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());
@@ -4808,8 +4896,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,
@@ -5201,7 +5292,7 @@ impl EditorElement {
) -> Option<AnyElement> {
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)
})
}
@@ -5228,16 +5319,18 @@ impl EditorElement {
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
- 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()?;
@@ -5245,7 +5338,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)
}
@@ -5552,6 +5646,50 @@ impl EditorElement {
}
}
+ fn layout_word_diff_highlights(
+ display_hunks: &[(DisplayDiffHunk, Option<Hitbox>)],
+ row_infos: &[RowInfo],
+ start_row: DisplayRow,
+ snapshot: &EditorSnapshot,
+ highlighted_ranges: &mut Vec<(Range<DisplayPoint>, 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<DisplayRow>,
@@ -7652,6 +7790,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();
@@ -7675,29 +7826,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(),
- ..Default::default()
- }],
- 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,
@@ -7794,18 +7922,52 @@ fn file_status_label_color(file_status: Option<FileStatus>) -> Color {
}
fn header_jump_data(
+ editor_snapshot: &EditorSnapshot,
+ block_row_start: DisplayRow,
+ height: u32,
+ first_excerpt: &ExcerptInfo,
+ latest_selection_anchors: &HashMap<BufferId, Anchor>,
+) -> 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);
@@ -1,11 +1,11 @@
use crate::Editor;
-use anyhow::Result;
+use anyhow::{Context as _, Result};
use collections::HashMap;
-use futures::StreamExt;
+
use git::{
- GitHostingProviderRegistry, GitRemote, Oid,
- blame::{Blame, BlameEntry, ParsedCommitMessage},
- parse_git_remote_url,
+ GitHostingProviderRegistry, Oid,
+ blame::{Blame, BlameEntry},
+ commit::ParsedCommitMessage,
};
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
@@ -67,7 +67,7 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
struct GitBlameBuffer {
entries: SumTree<GitBlameEntry>,
buffer_snapshot: BufferSnapshot,
- buffer_edits: text::Subscription,
+ buffer_edits: text::Subscription<usize>,
commit_details: HashMap<Oid, ParsedCommitMessage>,
}
@@ -494,76 +494,103 @@ 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(async move { (id, snapshot, buffer_edits, blame_buffer.await) })
- })
- .collect::<Vec<_>>()
- });
- 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::<Vec<_>>()
+ })
+ .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 blame = futures::stream::iter(blame)
- .buffered(4)
- .collect::<Vec<_>>()
- .await;
- let mut res = vec![];
- let mut errors = vec![];
- for (id, snapshot, buffer_edits, blame) in blame {
- match blame {
- Ok(Some(Blame {
- entries,
- messages,
- remote_url,
- })) => {
- let entries = build_blame_entry_sum_tree(
- entries,
- snapshot.max_point().row,
- );
- let commit_details = parse_commit_messages(
- messages,
- remote_url,
- provider_registry.clone(),
- )
- .await;
-
- res.push((
+ let mut all_results = Vec::new();
+ let mut all_errors = Vec::new();
+
+ 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).default_remote_url());
+ 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::<Result<Vec<_>>>()
+ })??;
+ 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 = messages
+ .into_iter()
+ .map(|(oid, message)| {
+ let parsed_commit_message =
+ ParsedCommitMessage::parse(
+ oid.to_string(),
+ message,
+ remote_url.as_deref(),
+ Some(provider_registry.clone()),
+ );
+ (oid, parsed_commit_message)
+ })
+ .collect();
+ 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;
};
@@ -578,11 +605,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();
@@ -593,7 +620,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:?}");
}
})
}
@@ -654,55 +681,6 @@ fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree
entries
}
-async fn parse_commit_messages(
- messages: impl IntoIterator<Item = (Oid, String)>,
- remote_url: Option<String>,
- provider_registry: Arc<GitHostingProviderRegistry>,
-) -> HashMap<Oid, ParsedCommitMessage> {
- let mut commit_details = HashMap::default();
-
- let parsed_remote_url = remote_url
- .as_deref()
- .and_then(|remote_url| parse_git_remote_url(provider_registry, remote_url));
-
- for (oid, message) in messages {
- let permalink = if let Some((provider, git_remote)) = parsed_remote_url.as_ref() {
- Some(provider.build_commit_permalink(
- git_remote,
- git::BuildCommitPermalinkParams {
- sha: oid.to_string().as_str(),
- },
- ))
- } else {
- None
- };
-
- let remote = parsed_remote_url
- .as_ref()
- .map(|(provider, remote)| GitRemote {
- host: provider.clone(),
- owner: remote.owner.clone().into(),
- repo: remote.repo.clone().into(),
- });
-
- let pull_request = parsed_remote_url
- .as_ref()
- .and_then(|(provider, remote)| provider.extract_pull_request(remote, &message));
-
- commit_details.insert(
- oid,
- ParsedCommitMessage {
- message: message.into(),
- permalink,
- remote,
- pull_request,
- },
- );
- }
-
- commit_details
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -1,11 +1,13 @@
use crate::{Editor, RangeToAnchorExt};
use gpui::{Context, HighlightStyle, Window};
use language::CursorShape;
+use multi_buffer::MultiBufferOffset;
use theme::ActiveTheme;
enum MatchingBracketHighlight {}
impl Editor {
+ #[ztracing::instrument(skip_all)]
pub fn refresh_matching_bracket_highlights(
&mut self,
window: &Window,
@@ -15,7 +17,7 @@ impl Editor {
let snapshot = self.snapshot(window, cx);
let buffer_snapshot = snapshot.buffer_snapshot();
- let newest_selection = self.selections.newest::<usize>(&snapshot);
+ let newest_selection = self.selections.newest::<MultiBufferOffset>(&snapshot);
// Don't highlight brackets if the selection isn't empty
if !newest_selection.is_empty() {
return;
@@ -9,8 +9,10 @@ use language::{Bias, ToOffset};
use linkify::{LinkFinder, LinkKind};
use lsp::LanguageServerId;
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};
@@ -168,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)
}
}
})
@@ -216,7 +218,7 @@ impl Editor {
self.hide_hovered_link(cx);
if !hovered_link_state.links.is_empty() {
if !self.focus_handle.is_focused(window) {
- window.focus(&self.focus_handle);
+ window.focus(&self.focus_handle, cx);
}
// exclude links pointing back to the current anchor
@@ -595,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,
@@ -612,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<usize>)> {
+ static MD_LINK_REGEX: LazyLock<Regex> =
+ 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<text::Anchor>, String)> {
const LIMIT: usize = 2048;
@@ -735,9 +775,10 @@ mod tests {
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;
@@ -1067,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)
});
@@ -1122,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»;
@@ -1179,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(
@@ -1203,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]);
@@ -1315,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<String> = link_pattern_file_candidates("[LinkTitle](link_file.txt)")
+ .into_iter()
+ .map(|(c, _)| c)
+ .collect();
+ assert_eq!(
+ candidates,
+ vec", "link_file.txt",]
+ );
+ // Link title with spaces in it
+ let candidates: Vec<String> = link_pattern_file_candidates("LinkTitle](link_file.txt)")
+ .into_iter()
+ .map(|(c, _)| c)
+ .collect();
+ assert_eq!(
+ candidates,
+ vec", "link_file.txt",]
+ );
+
+ // Link with spaces
+ let candidates: Vec<String> = link_pattern_file_candidates("LinkTitle](link\\ _file.txt)")
+ .into_iter()
+ .map(|(c, _)| c)
+ .collect();
+
+ assert_eq!(
+ candidates,
+ vec", "link\\ _file.txt",]
+ );
+ //
+ // Square brackets not strictly necessary
+ let candidates: Vec<String> = 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<String> =
+ link_pattern_file_candidates("LinkTitle](link_(link_file)file.txt)")
+ .into_iter()
+ .map(|(c, _)| c)
+ .collect();
+
+ assert_eq!(
+ candidates,
+ vecfile.txt)", "link_(link_file",]
+ )
+ }
+
#[gpui::test]
async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
@@ -1373,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);
@@ -1705,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::<GotoDefinition, _, _>(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(); }
+ "});
+ }
}
@@ -17,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};
@@ -106,7 +106,7 @@ pub fn find_hovered_hint_part(
hovered_offset: InlayOffset,
) -> Option<(InlayHintLabelPart, Range<InlayOffset>)> {
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,7 +151,7 @@ pub fn hover_at_inlay(
false
})
{
- hide_hover(editor, cx);
+ return;
}
let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0;
@@ -316,12 +316,12 @@ fn show_hover(
} else {
snapshot
.buffer_snapshot()
- .diagnostics_with_buffer_ids_in_range::<usize>(offset..offset)
+ .diagnostics_with_buffer_ids_in_range::<MultiBufferOffset>(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 {
@@ -341,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")
})??;
@@ -512,7 +518,7 @@ fn show_hover(
// Highlight the selected symbol using a background highlight
editor.highlight_background::<HoverState>(
&hover_highlights,
- |theme| theme.colors().element_hover, // todo update theme
+ |_, theme| theme.colors().element_hover, // todo update theme
cx,
);
}
@@ -601,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()
},
@@ -643,6 +656,7 @@ pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
.text_base()
.mt(rems(1.))
.mb_0(),
+ table_columns_min_size: true,
..Default::default()
}
}
@@ -651,12 +665,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()
@@ -667,6 +684,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()
},
@@ -692,6 +710,7 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
.font_weight(FontWeight::BOLD)
.text_base()
.mb_0(),
+ table_columns_min_size: true,
..Default::default()
}
}
@@ -887,7 +906,6 @@ impl InfoPopover {
*keyboard_grace = false;
cx.stop_propagation();
})
- .p_2()
.when_some(self.parsed_content.clone(), |this, markdown| {
this.child(
div()
@@ -903,12 +921,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::<EditorSettings>()
- .tracked_scroll_handle(self.scroll_handle.clone()),
+ .tracked_scroll_handle(&self.scroll_handle),
window,
cx,
)
@@ -986,6 +1005,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)
@@ -1001,7 +1025,7 @@ impl DiagnosticPopover {
)
.custom_scrollbars(
Scrollbars::for_settings::<EditorSettings>()
- .tracked_scroll_handle(self.scroll_handle.clone()),
+ .tracked_scroll_handle(&self.scroll_handle),
window,
cx,
),
@@ -1622,7 +1646,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;
@@ -1697,8 +1721,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(
@@ -1808,7 +1832,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"
@@ -1821,8 +1846,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(
@@ -1862,7 +1887,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(),
}),
@@ -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;
}
@@ -1,4 +1,5 @@
use std::{
+ collections::hash_map,
ops::{ControlFlow, Range},
time::Duration,
};
@@ -290,7 +291,7 @@ impl Editor {
}),
};
- let mut visible_excerpts = self.visible_excerpts(cx);
+ 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(_)
@@ -583,8 +584,11 @@ impl Editor {
})
.max_by_key(|hint| hint.id)
{
- if let Some(ResolvedHint::Resolved(cached_hint)) =
- hovered_hint.position.buffer_id.and_then(|buffer_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)
})
@@ -644,9 +648,9 @@ impl Editor {
)
{
let highlight_start =
- (part_range.start - hint_start).0 + extra_shift_left;
+ (part_range.start - hint_start) + extra_shift_left;
let highlight_end =
- (part_range.end - hint_start).0 + extra_shift_right;
+ (part_range.end - hint_start) + extra_shift_right;
let highlight = InlayHighlight {
inlay: hovered_hint.id,
inlay_position: hovered_hint.position,
@@ -756,7 +760,7 @@ impl Editor {
let visible_inlay_hint_ids = self
.visible_inlay_hints(cx)
.iter()
- .filter(|inlay| inlay.position.buffer_id == Some(buffer_id))
+ .filter(|inlay| inlay.position.text_anchor.buffer_id == Some(buffer_id))
.map(|inlay| inlay.id)
.collect::<Vec<_>>();
let Some(inlay_hints) = &mut self.inlay_hints else {
@@ -778,6 +782,7 @@ impl Editor {
}
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)| {
@@ -804,8 +809,35 @@ impl Editor {
}
}
})
- .flat_map(|hints| hints.into_values())
- .flatten()
+ .flat_map(|new_hints| {
+ let mut hints_deduplicated = Vec::new();
+
+ 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 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
@@ -829,9 +861,13 @@ impl Editor {
self.visible_inlay_hints(cx)
.iter()
.filter(|inlay| {
- inlay.position.buffer_id.is_none_or(|buffer_id| {
- invalidate_hints_for_buffers.contains(&buffer_id)
- })
+ inlay
+ .position
+ .text_anchor
+ .buffer_id
+ .is_none_or(|buffer_id| {
+ invalidate_hints_for_buffers.contains(&buffer_id)
+ })
})
.map(|inlay| inlay.id),
);
@@ -912,14 +948,14 @@ pub mod tests {
use crate::{ExcerptRange, scroll::Autoscroll};
use collections::HashSet;
use futures::{StreamExt, future};
- use gpui::{AppContext as _, Context, SemanticVersion, TestAppContext, WindowHandle};
+ 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;
+ use multi_buffer::{MultiBuffer, MultiBufferOffset};
use parking_lot::Mutex;
use pretty_assertions::assert_eq;
use project::{FakeFs, Project};
@@ -1000,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);
})
@@ -1400,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);
})
@@ -1432,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);
})
@@ -1880,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);
})
@@ -1926,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);
})
@@ -2175,7 +2211,7 @@ pub mod tests {
cx: &mut gpui::TestAppContext,
) -> Range<Point> {
let ranges = editor
- .update(cx, |editor, _window, cx| editor.visible_excerpts(cx))
+ .update(cx, |editor, _window, cx| editor.visible_excerpts(true, cx))
.unwrap();
assert_eq!(
ranges.len(),
@@ -2677,7 +2713,7 @@ let c = 3;"#
let mut editor =
Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx);
editor.change_selections(SelectionEffects::default(), window, cx, |s| {
- s.select_ranges([0..0])
+ s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)])
});
editor
});
@@ -3732,6 +3768,7 @@ let c = 3;"#
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()
@@ -3804,6 +3841,78 @@ let c = 3;"#
},
);
+ // 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::<lsp::request::InlayHintRequest, _, _>(
+ 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)
@@ -3847,6 +3956,7 @@ let c = 3;"#
});
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();
@@ -3855,11 +3965,16 @@ let c = 3;"#
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![
@@ -3869,7 +3984,7 @@ let c = 3;"#
": i33".to_string(),
],
visible_hint_labels(editor, cx),
- "lib.rs is added before main.rs , so its excerpts should be visible first"
+ "lib.rs is added before main.rs , so its excerpts should be visible first; hints should be deduplicated per label"
);
})
.unwrap();
@@ -3919,8 +4034,12 @@ let c = 3;"#
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),
@@ -3950,7 +4069,7 @@ let c = 3;"#
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
theme::init(theme::LoadThemes::JustBase, cx);
- release_channel::init(SemanticVersion::default(), cx);
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
crate::init(cx);
});
@@ -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};
@@ -454,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::<Vec<_>>();
- 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::<Vec<_>>();
+ 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.
@@ -562,24 +555,20 @@ fn deserialize_excerpt_range(
))
}
-fn deserialize_selection(
- buffer: &MultiBufferSnapshot,
- selection: proto::Selection,
-) -> Option<Selection<Anchor>> {
+fn deserialize_selection(selection: proto::Selection) -> Option<Selection<Anchor>> {
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<Anchor> {
+fn deserialize_anchor(anchor: proto::EditorAnchor) -> Option<Anchor> {
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?)?,
))
}
@@ -587,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<Self>,
+ cx: &'a App,
+ ) -> Option<gpui::AnyEntity> {
+ if TypeId::of::<Self>() == type_id {
+ Some(self_handle.clone().into())
+ } else if TypeId::of::<MultiBuffer>() == type_id {
+ Some(self_handle.read(cx).buffer.clone().into())
+ } else {
+ None
+ }
+ }
+
fn navigate(
&mut self,
data: Box<dyn std::any::Any>,
@@ -629,18 +633,20 @@ impl Item for Editor {
}
fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
- 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> {
@@ -836,7 +842,6 @@ impl Item for Editor {
.map(|handle| handle.read(cx).base_buffer().unwrap_or(handle.clone()))
.collect::<HashSet<_>>();
- // let mut buffers_to_save =
let buffers_to_save = if self.buffer.read(cx).is_singleton() && !options.autosave {
buffers
} else {
@@ -923,7 +928,11 @@ impl Item for Editor {
})
}
- fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(
+ &self,
+ handle: &Entity<Self>,
+ _: &App,
+ ) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(handle.clone()))
}
@@ -941,7 +950,7 @@ impl Item for Editor {
fn breadcrumbs(&self, variant: &Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
let cursor = self.selections.newest_anchor().head();
- let multibuffer = &self.buffer().read(cx);
+ let multibuffer = self.buffer().read(cx);
let (buffer_id, symbols) = multibuffer
.read(cx)
.symbols_containing(cursor, Some(variant.syntax()))?;
@@ -1356,7 +1365,7 @@ impl ProjectItem for Editor {
cx: &mut Context<Self>,
) -> 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()
@@ -1379,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);
}
@@ -1480,6 +1486,7 @@ impl SearchableItem for Editor {
fn update_matches(
&mut self,
matches: &[Range<Anchor>],
+ active_match_index: Option<usize>,
_: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1490,7 +1497,13 @@ impl SearchableItem for Editor {
let updated = existing_range != Some(matches);
self.highlight_background::<BufferSearchHighlights>(
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 {
@@ -1586,12 +1599,11 @@ impl SearchableItem for Editor {
&mut self,
index: usize,
matches: &[Range<Anchor>],
- collapse: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.unfold_ranges(&[matches[index].clone()], false, true, cx);
- let range = self.range_for_match(&matches[index], collapse);
+ let range = self.range_for_match(&matches[index]);
let autoscroll = if EditorSettings::get_global(cx).search.center_on_match {
Autoscroll::center()
} else {
@@ -1736,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
};
@@ -1747,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| {
@@ -1763,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)
}
}),
);
@@ -1886,15 +1897,20 @@ fn path_for_buffer<'a>(
cx: &'a App,
) -> Option<Cow<'a, str>> {
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<dyn language::File>,
mut height: usize,
include_filename: bool,
cx: &'a App,
) -> Option<Cow<'a, str>> {
+ 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;
@@ -1934,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<dyn language::File> = Arc::new(TestFile {
path: RelPath::empty().into(),
root_name: String::new(),
local_root: None,
- };
+ });
assert_eq!(path_for_file(&file, 0, false, cx), None);
}
@@ -1974,20 +1990,6 @@ mod tests {
.unwrap()
}
- fn rust_language() -> Arc<language::Language> {
- 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, |_| {});
@@ -2069,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));
@@ -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 _;
@@ -19,7 +19,7 @@ pub struct JsxTagCompletionState {
/// that corresponds to the tag name
/// Note that this is not configurable, i.e. we assume the first
/// named child of a tag node is the tag name
-const TS_NODE_TAG_NAME_CHILD_INDEX: usize = 0;
+const TS_NODE_TAG_NAME_CHILD_INDEX: u32 = 0;
/// Maximum number of parent elements to walk back when checking if an open tag
/// is already closed.
@@ -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)),
])
})
});
@@ -1,6 +1,7 @@
use collections::HashMap;
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;
@@ -60,15 +61,17 @@ pub(super) fn refresh_linked_ranges(
editor
.update(cx, |editor, cx| {
let display_snapshot = editor.display_snapshot(cx);
- let selections = editor.selections.all::<usize>(&display_snapshot);
+ let selections = editor
+ .selections
+ .all::<MultiBufferOffset>(&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;
@@ -164,7 +164,7 @@ impl Editor {
}
let visible_buffers = self
- .visible_excerpts(cx)
+ .visible_excerpts(true, cx)
.into_values()
.map(|(buffer, ..)| buffer)
.filter(|editor_buffer| {
@@ -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)?;
@@ -59,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),
@@ -81,14 +81,26 @@ impl MouseContextMenu {
cx: &mut Context<Editor>,
) -> 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, cx);
+ });
+ });
let _dismiss_subscription = cx.subscribe_in(&context_menu, window, {
let context_menu_focus = context_menu_focus.clone();
move |editor, _, _event: &DismissEvent, window, cx| {
editor.mouse_context_menu.take();
if context_menu_focus.contains_focused(window, cx) {
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
}
}
});
@@ -115,7 +127,7 @@ impl MouseContextMenu {
}
editor.mouse_context_menu.take();
if context_menu_focus.contains_focused(window, cx) {
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
}
},
);
@@ -149,7 +161,7 @@ pub fn deploy_context_menu(
cx: &mut Context<Editor>,
) {
if !editor.is_focused(window) {
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
}
// Don't show context menu for inline editors
@@ -223,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))
@@ -264,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),
@@ -329,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! {"
@@ -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<Item = (char, Range<usize>)> + '_ {
+ mut offset: MultiBufferOffset,
+) -> impl Iterator<Item = (char, Range<MultiBufferOffset>)> + '_ {
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<Item = (char, Range<usize>)> + '_ {
+ mut offset: MultiBufferOffset,
+) -> impl Iterator<Item = (char, Range<MultiBufferOffset>)> + '_ {
map.buffer_snapshot()
.reversed_chars_at(offset)
.map(move |ch| {
@@ -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"
);
@@ -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)?
@@ -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 {
@@ -500,6 +504,7 @@ impl Editor {
editor.register_visible_buffers(cx);
editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
editor.update_lsp_data(None, window, cx);
+ editor.colorize_brackets(false, cx);
})
.ok();
});
@@ -71,14 +71,20 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Editor>,
) {
+ 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(&self.display_snapshot(cx))
+ .newest_display(&display_snapshot)
.head()
.row()
.0;
- let new_screen_top = new_screen_top.saturating_sub(scroll_margin_rows);
+ 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);
}
@@ -1,18 +1,18 @@
use std::{
cmp, fmt, iter, mem,
- ops::{Deref, DerefMut, Range, Sub},
+ ops::{AddAssign, Deref, DerefMut, Range, Sub},
sync::Arc,
};
use collections::HashMap;
use gpui::Pixels;
use itertools::Itertools as _;
-use language::{Bias, Point, Selection, SelectionGoal, TextDimension};
+use language::{Bias, Point, Selection, SelectionGoal};
+use multi_buffer::{MultiBufferDimension, MultiBufferOffset};
use util::post_inc;
use crate::{
Anchor, DisplayPoint, DisplayRow, ExcerptId, MultiBufferSnapshot, SelectMode, ToOffset,
- ToPoint,
display_map::{DisplaySnapshot, ToDisplayPoint},
movement::TextLayoutDetails,
};
@@ -97,7 +97,7 @@ impl SelectionsCollection {
if self.pending.is_none() {
self.disjoint_anchors_arc()
} else {
- let all_offset_selections = self.all::<usize>(snapshot);
+ let all_offset_selections = self.all::<MultiBufferOffset>(snapshot);
all_offset_selections
.into_iter()
.map(|selection| selection_to_anchor_selection(selection, snapshot))
@@ -113,10 +113,10 @@ impl SelectionsCollection {
self.pending.as_mut().map(|pending| &mut pending.selection)
}
- pub fn pending<D: TextDimension + Ord + Sub<D, Output = D>>(
- &self,
- snapshot: &DisplaySnapshot,
- ) -> Option<Selection<D>> {
+ pub fn pending<D>(&self, snapshot: &DisplaySnapshot) -> Option<Selection<D>>
+ where
+ D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
+ {
resolve_selections_wrapping_blocks(self.pending_anchor(), &snapshot).next()
}
@@ -124,9 +124,9 @@ impl SelectionsCollection {
self.pending.as_ref().map(|pending| pending.mode.clone())
}
- pub fn all<'a, D>(&self, snapshot: &DisplaySnapshot) -> Vec<Selection<D>>
+ pub fn all<D>(&self, snapshot: &DisplaySnapshot) -> Vec<Selection<D>>
where
- D: 'a + TextDimension + Ord + Sub<D, Output = D>,
+ D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
{
let disjoint_anchors = &self.disjoint;
let mut disjoint =
@@ -136,7 +136,13 @@ impl SelectionsCollection {
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;
@@ -204,13 +210,13 @@ impl SelectionsCollection {
}
}
- pub fn disjoint_in_range<'a, D>(
+ pub fn disjoint_in_range<D>(
&self,
range: Range<Anchor>,
snapshot: &DisplaySnapshot,
) -> Vec<Selection<D>>
where
- D: 'a + TextDimension + Ord + Sub<D, Output = D> + std::fmt::Debug,
+ D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord + std::fmt::Debug,
{
let start_ix = match self
.disjoint
@@ -236,7 +242,13 @@ impl SelectionsCollection {
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,10 +279,10 @@ impl SelectionsCollection {
.unwrap()
}
- pub fn newest<D: TextDimension + Ord + Sub<D, Output = D>>(
- &self,
- snapshot: &DisplaySnapshot,
- ) -> Selection<D> {
+ pub fn newest<D>(&self, snapshot: &DisplaySnapshot) -> Selection<D>
+ where
+ D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
+ {
resolve_selections_wrapping_blocks([self.newest_anchor()], &snapshot)
.next()
.unwrap()
@@ -290,10 +302,10 @@ impl SelectionsCollection {
.unwrap()
}
- pub fn oldest<D: TextDimension + Ord + Sub<D, Output = D>>(
- &self,
- snapshot: &DisplaySnapshot,
- ) -> Selection<D> {
+ pub fn oldest<D>(&self, snapshot: &DisplaySnapshot) -> Selection<D>
+ where
+ D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
+ {
resolve_selections_wrapping_blocks([self.oldest_anchor()], &snapshot)
.next()
.unwrap()
@@ -306,27 +318,27 @@ impl SelectionsCollection {
.unwrap_or_else(|| self.disjoint.first().cloned().unwrap())
}
- pub fn first<D: TextDimension + Ord + Sub<D, Output = D>>(
- &self,
- snapshot: &DisplaySnapshot,
- ) -> Selection<D> {
+ pub fn first<D>(&self, snapshot: &DisplaySnapshot) -> Selection<D>
+ where
+ D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
+ {
self.all(snapshot).first().unwrap().clone()
}
- pub fn last<D: TextDimension + Ord + Sub<D, Output = D>>(
- &self,
- snapshot: &DisplaySnapshot,
- ) -> Selection<D> {
+ pub fn last<D>(&self, snapshot: &DisplaySnapshot) -> Selection<D>
+ where
+ D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::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<D: TextDimension + Ord + Sub<D, Output = D>>(
- &self,
- snapshot: &DisplaySnapshot,
- ) -> Vec<Range<D>> {
+ pub fn ranges<D>(&self, snapshot: &DisplaySnapshot) -> Vec<Range<D>>
+ where
+ D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
+ {
self.all::<D>(snapshot)
.iter()
.map(|s| {
@@ -372,7 +384,7 @@ impl SelectionsCollection {
let is_empty = positions.start == positions.end;
let line_len = display_map.line_len(row);
let line = display_map.layout_row(row, text_layout_details);
- let start_col = line.index_for_x(positions.start) as u32;
+ let start_col = line.closest_index_for_x(positions.start) as u32;
let (start, end) = if is_empty {
let point = DisplayPoint::new(row, std::cmp::min(start_col, line_len));
@@ -382,7 +394,7 @@ impl SelectionsCollection {
return None;
}
let start = DisplayPoint::new(row, start_col);
- let end_col = line.index_for_x(positions.end) as u32;
+ let end_col = line.closest_index_for_x(positions.end) as u32;
let end = DisplayPoint::new(row, end_col);
(start, end)
};
@@ -415,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)
}
@@ -509,11 +552,18 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> {
};
if filtered_selections.is_empty() {
- let default_anchor = self.snapshot.anchor_before(0);
+ 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: default_anchor,
- end: default_anchor,
+ start: anchor,
+ end: anchor,
reversed: false,
goal: SelectionGoal::None,
}]);
@@ -590,7 +640,7 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> {
pub fn insert_range<T>(&mut self, range: Range<T>)
where
- T: 'a + ToOffset + ToPoint + TextDimension + Ord + Sub<T, Output = T> + std::marker::Copy,
+ T: ToOffset,
{
let display_map = self.display_snapshot();
let mut selections = self.collection.all(&display_map);
@@ -628,10 +678,13 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> {
})
.collect::<Vec<_>>();
selections.sort_unstable_by_key(|s| s.start);
- // Merge overlapping selections.
+
let mut i = 1;
while i < selections.len() {
- if selections[i].start <= selections[i - 1].end {
+ 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;
@@ -656,7 +709,8 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> {
pub fn select_anchors(&mut self, selections: Vec<Selection<Anchor>>) {
let map = self.display_snapshot();
let resolved_selections =
- resolve_selections_wrapping_blocks::<usize, _>(&selections, &map).collect::<Vec<_>>();
+ resolve_selections_wrapping_blocks::<MultiBufferOffset, _>(&selections, &map)
+ .collect::<Vec<_>>();
self.select(resolved_selections);
}
@@ -673,7 +727,7 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> {
fn select_offset_ranges<I>(&mut self, ranges: I)
where
- I: IntoIterator<Item = Range<usize>>,
+ I: IntoIterator<Item = Range<MultiBufferOffset>>,
{
let selections = ranges
.into_iter()
@@ -808,13 +862,13 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> {
pub fn move_offsets_with(
&mut self,
- mut move_selection: impl FnMut(&MultiBufferSnapshot, &mut Selection<usize>),
+ mut move_selection: impl FnMut(&MultiBufferSnapshot, &mut Selection<MultiBufferOffset>),
) {
let mut changed = false;
let display_map = self.display_snapshot();
let selections = self
.collection
- .all::<usize>(&display_map)
+ .all::<MultiBufferOffset>(&display_map)
.into_iter()
.map(|selection| {
let mut moved_selection = selection.clone();
@@ -938,7 +992,7 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> {
let map = self.display_snapshot();
let resolved_selections =
resolve_selections_wrapping_blocks(adjusted_disjoint.iter(), &map).collect();
- self.select::<usize>(resolved_selections);
+ self.select::<MultiBufferOffset>(resolved_selections);
}
if let Some(pending) = pending.as_mut() {
@@ -981,7 +1035,7 @@ impl DerefMut for MutableSelectionsCollection<'_, '_> {
}
fn selection_to_anchor_selection(
- selection: Selection<usize>,
+ selection: Selection<MultiBufferOffset>,
buffer: &MultiBufferSnapshot,
) -> Selection<Anchor> {
let end_bias = if selection.start == selection.end {
@@ -1054,7 +1108,7 @@ fn resolve_selections_display<'a>(
coalesce_selections(selections)
}
-/// Resolves the passed in anchors to [`TextDimension`]s `D`
+/// Resolves the passed in anchors to [`MultiBufferDimension`]s `D`
/// wrapping around blocks inbetween.
///
/// # Panics
@@ -1065,7 +1119,7 @@ pub(crate) fn resolve_selections_wrapping_blocks<'a, D, I>(
map: &'a DisplaySnapshot,
) -> impl 'a + Iterator<Item = Selection<D>>
where
- D: TextDimension + Ord + Sub<D, Output = D>,
+ D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
I: 'a + IntoIterator<Item = &'a Selection<Anchor>>,
{
// Transforms `Anchor -> DisplayPoint -> Point -> DisplayPoint -> D`
@@ -1100,7 +1154,13 @@ fn coalesce_selections<D: Ord + fmt::Debug + Copy>(
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();
@@ -1122,3 +1182,35 @@ fn coalesce_selections<D: Ord + fmt::Debug + Copy>(
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<T: Ord + Copy>(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
+}
@@ -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::<usize>(&self.display_snapshot(cx));
+ let newest_selection = self
+ .selections
+ .newest::<MultiBufferOffset>(&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<usize>, end: Range<usize>| {
+ |buffer: &BufferSnapshot, start: Range<BufferOffset>, end: Range<BufferOffset>| {
let text_start = buffer.text_for_range(start).collect::<String>();
let text_end = buffer.text_for_range(end).collect::<String>();
QUOTE_PAIRS
@@ -389,7 +391,7 @@ 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)
@@ -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<Editor>,
+ secondary: Option<SecondaryEditor>,
+ panes: PaneGroup,
+ workspace: WeakEntity<Workspace>,
+ _subscriptions: Vec<Subscription>,
+}
+
+struct SecondaryEditor {
+ editor: Entity<Editor>,
+ pane: Entity<Pane>,
+ has_latest_selection: bool,
+ _subscriptions: Vec<Subscription>,
+}
+
+impl SplittableEditor {
+ pub fn primary_editor(&self) -> &Entity<Editor> {
+ &self.primary_editor
+ }
+
+ pub fn last_selected_editor(&self) -> &Entity<Editor> {
+ if let Some(secondary) = &self.secondary
+ && secondary.has_latest_selection
+ {
+ &secondary.editor
+ } else {
+ &self.primary_editor
+ }
+ }
+
+ pub fn new_unsplit(
+ buffer: Entity<MultiBuffer>,
+ project: Entity<Project>,
+ workspace: Entity<Workspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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<Self>) {
+ if !cx.has_flag::<SplitDiffFeatureFlag>() {
+ 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<Self>) {
+ 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>,
+ ) {
+ 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<EditorEvent> 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<Self>,
+ ) -> 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)
+ }
+}
@@ -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(&editor.display_snapshot(cx)),
+ &editor
+ .selections
+ .ranges::<MultiBufferOffset>(&editor.display_snapshot(cx))
+ .into_iter()
+ .map(|range| range.start.0..range.end.0)
+ .collect::<Vec<_>>(),
marked_text.contains("«"),
);
assert_eq!(actual, marked_text, "Selections don't match");
@@ -167,11 +176,9 @@ pub fn block_content_for_tests(
}
pub fn editor_content_with_blocks(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> String {
- cx.draw(
- gpui::Point::default(),
- size(px(3000.0), px(3000.0)),
- |_, _| editor.clone(),
- );
+ let draw_size = size(px(3000.0), px(3000.0));
+ cx.simulate_resize(draw_size);
+ cx.draw(gpui::Point::default(), draw_size, |_, _| editor.clone());
let (snapshot, mut lines, blocks) = editor.update_in(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let text = editor.display_text(cx);
@@ -6,7 +6,8 @@ use std::{
};
use anyhow::Result;
-use language::rust_lang;
+use language::{markdown_lang, rust_lang};
+use multi_buffer::MultiBufferOffset;
use serde_json::json;
use crate::{Editor, ToPoint};
@@ -125,7 +126,7 @@ impl EditorLspTestContext {
.read(cx)
.nav_history_for_item(&cx.entity());
editor.set_nav_history(Some(nav_history));
- window.focus(&editor.focus_handle(cx))
+ window.focus(&editor.focus_handle(cx), cx)
});
let lsp = fake_servers.next().await.unwrap();
@@ -313,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<usize>) -> lsp::Range {
+ pub fn to_lsp_range(&mut self, range: Range<MultiBufferOffset>) -> 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)))
})
}
@@ -13,7 +13,7 @@ use gpui::{
};
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::{
@@ -59,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,
@@ -67,7 +78,7 @@ impl EditorTestContext {
cx,
);
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
editor
});
let editor_view = editor.root(cx).unwrap();
@@ -128,7 +139,7 @@ impl EditorTestContext {
let editor = cx.add_window(|window, cx| {
let editor = build_editor(buffer, window, cx);
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
editor
});
@@ -256,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<Pixels> {
@@ -272,8 +283,7 @@ impl EditorTestContext {
.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);
@@ -295,6 +305,12 @@ impl EditorTestContext {
snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end)
}
+ pub async fn wait_for_autoindent_applied(&mut self) {
+ if let Some(fut) = self.update_buffer(|buffer, _| buffer.wait_for_autoindent_applied()) {
+ fut.await.ok();
+ }
+ }
+
pub fn set_head_text(&mut self, diff_base: &str) {
self.cx.run_until_parked();
let fs =
@@ -362,7 +378,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
@@ -379,7 +399,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
@@ -471,11 +495,7 @@ impl EditorTestContext {
);
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::<String>(),
expected_text,
"{}",
@@ -565,6 +585,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);
@@ -580,6 +601,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);
}
@@ -597,14 +619,16 @@ impl EditorTestContext {
fn editor_selections(&mut self) -> Vec<Range<usize>> {
self.editor
.update(&mut self.cx, |editor, cx| {
- editor.selections.all::<usize>(&editor.display_snapshot(cx))
+ editor
+ .selections
+ .all::<MultiBufferOffset>(&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::<Vec<_>>()
@@ -652,11 +676,7 @@ impl std::fmt::Display for FormatMultiBufferAsMarkedText {
}
let mut text = 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::<String>();
let selections = selections
@@ -700,7 +720,10 @@ pub fn assert_state_with_diff(
snapshot.buffer_snapshot().clone(),
editor
.selections
- .ranges::<usize>(&snapshot.display_snapshot),
+ .ranges::<MultiBufferOffset>(&snapshot.display_snapshot)
+ .into_iter()
+ .map(|range| range.start.0..range.end.0)
+ .collect::<Vec<_>>(),
)
});
@@ -25,7 +25,7 @@ use language_model::{ConfiguredModel, LanguageModel, LanguageModelRegistry, Sele
use node_runtime::{NodeBinaryOptions, NodeRuntime};
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;
@@ -347,8 +347,15 @@ pub struct AgentAppState {
}
pub fn init(cx: &mut App) -> Arc<AgentAppState> {
- 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());
@@ -463,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))
@@ -261,7 +261,7 @@ impl ExampleContext {
.expect("Unknown tool_name content in meta");
tool_uses_by_id.insert(
- tool_call.id,
+ tool_call.tool_call_id,
ToolUse {
name: tool_name.to_string(),
value: tool_call.raw_input.unwrap_or_default(),
@@ -277,7 +277,9 @@ impl ExampleContext {
ThreadEvent::ToolCallUpdate(tool_call_update) => {
if let acp_thread::ToolCallUpdate::UpdateFields(update) = tool_call_update {
if let Some(raw_input) = update.fields.raw_input {
- if let Some(tool_use) = tool_uses_by_id.get_mut(&update.id) {
+ if let Some(tool_use) =
+ tool_uses_by_id.get_mut(&update.tool_call_id)
+ {
tool_use.value = raw_input;
}
}
@@ -290,7 +292,7 @@ impl ExampleContext {
update.fields.status == Some(acp::ToolCallStatus::Completed);
let tool_use = tool_uses_by_id
- .remove(&update.id)
+ .remove(&update.tool_call_id)
.expect("Unrecognized tool call completed");
let log_message = if succeeded {
@@ -337,10 +339,7 @@ impl ExampleContext {
acp::StopReason::MaxTurnRequests => {
return Err(anyhow!("Exceeded maximum turn requests"));
}
- acp::StopReason::Refusal => {
- return Err(anyhow!("Refusal"));
- }
- acp::StopReason::Cancelled => return Err(anyhow!("Cancelled")),
+ stop_reason => return Err(anyhow!("{stop_reason:?}")),
},
}
}
@@ -202,6 +202,7 @@ impl ExampleInstance {
app_state.languages.clone(),
app_state.fs.clone(),
None,
+ false,
cx,
);
@@ -303,13 +304,12 @@ impl ExampleInstance {
let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
let thread = if let Some(json) = &meta.existing_thread_json {
- let session_id = acp::SessionId(
+ let session_id = acp::SessionId::new(
rand::rng()
.sample_iter(&distr::Alphanumeric)
.take(7)
.map(char::from)
- .collect::<String>()
- .into(),
+ .collect::<String>(),
);
let db_thread = agent::DbThread::from_json(json.as_bytes()).expect("Can't read serialized thread");
@@ -553,6 +553,7 @@ impl ExampleInstance {
role: Role::User,
content: vec![MessageContent::Text(to_prompt(assertion.description))],
cache: false,
+ reasoning_details: None,
}],
temperature: None,
tools: Vec::new(),
@@ -625,6 +626,15 @@ impl agent::TerminalHandle for EvalTerminalHandle {
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 {
@@ -639,7 +649,7 @@ impl agent::ThreadEnvironment for EvalThreadEnvironment {
cx.spawn(async move |cx| {
let language_registry =
project.read_with(cx, |project, _cx| project.languages().clone())?;
- let id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into());
+ let id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string());
let terminal =
acp_thread::create_terminal_entity(command, &[], vec![], cwd.clone(), &project, cx)
.await?;
@@ -892,7 +902,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()
})
@@ -1251,8 +1261,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, ..
@@ -1337,9 +1351,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,
@@ -1366,6 +1384,7 @@ impl ThreadDialog {
role: Role::Assistant,
content,
cache: false,
+ reasoning_details: None,
})
} else {
None
@@ -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
@@ -0,0 +1 @@
+LICENSE-GPL
@@ -0,0 +1,3 @@
+# eval_utils
+
+Utilities for evals of agents.
@@ -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<Self::Metadata>);
+ fn assert(&mut self);
+}
+
+#[derive(Clone, Debug)]
+pub struct EvalOutput<M> {
+ pub outcome: OutcomeKind,
+ pub data: String,
+ pub metadata: M,
+}
+
+impl<M: Default> EvalOutput<M> {
+ pub fn passed(message: impl Into<String>) -> Self {
+ EvalOutput {
+ outcome: OutcomeKind::Passed,
+ data: message.into(),
+ metadata: M::default(),
+ }
+ }
+
+ pub fn failed(message: impl Into<String>) -> 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<Self::Metadata>) {}
+
+ fn assert(&mut self) {}
+}
+
+pub fn eval<P>(
+ iterations: usize,
+ expected_pass_ratio: f32,
+ mut processor: P,
+ evalf: impl Fn() -> EvalOutput<P::Metadata> + 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();
+}
@@ -25,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,4 +37,8 @@ wasm-encoder.workspace = true
wasmparser.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
@@ -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<DebugRequest>;
}
-pub fn parse_wasm_extension_version(
- extension_id: &str,
- wasm_bytes: &[u8],
-) -> Result<SemanticVersion> {
+pub fn parse_wasm_extension_version(extension_id: &str, wasm_bytes: &[u8]) -> Result<Version> {
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<SemanticVersion> {
+fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option<Version> {
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 _,
@@ -2,8 +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 futures::AsyncReadExt;
+use futures::{AsyncReadExt, StreamExt};
use heck::ToSnakeCase;
use http_client::{self, AsyncBody, HttpClient};
use serde::Deserialize;
@@ -77,8 +78,9 @@ impl ExtensionBuilder {
extension_dir: &Path,
extension_manifest: &mut ExtensionManifest,
options: CompileExtensionOptions,
+ fs: Arc<dyn Fs>,
) -> Result<()> {
- populate_defaults(extension_manifest, extension_dir)?;
+ populate_defaults(extension_manifest, extension_dir, fs).await?;
if extension_dir.is_relative() {
bail!(
@@ -247,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(())
@@ -538,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<dyn Fs>,
+) -> 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() {
@@ -553,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) {
@@ -569,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) {
@@ -583,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();
@@ -595,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 {
@@ -619,7 +650,7 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) ->
pub path: Option<String>,
}
- 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
@@ -643,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<bool, std::io::Error> {
+ 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())
+ )
+ }
+}
@@ -3,7 +3,7 @@ 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,
@@ -137,7 +137,7 @@ pub fn build_debug_adapter_schema_path(
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct LibManifestEntry {
pub kind: Option<ExtensionLibraryKind>,
- pub version: Option<SemanticVersion>,
+ pub version: Option<Version>,
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
@@ -193,6 +193,36 @@ pub struct TargetConfig {
/// If not provided and the URL is a GitHub release, we'll attempt to fetch it from GitHub.
#[serde(default)]
pub sha256: Option<String>,
+ /// 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<String, String>,
+}
+
+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)]
@@ -265,27 +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::<OldExtensionManifest>(&manifest_content)
- .with_context(|| {
- format!("invalid extension.json for extension {extension_name}")
- })?;
-
- Ok(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"))?;
+ 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:?}")
+ })?;
+
+ serde_json::from_str::<OldExtensionManifest>(&manifest_content)
+ .with_context(|| format!("invalid extension.json for extension {extension_name}"))
+ .map(|manifest_json| manifest_from_old_manifest(manifest_json, extension_name))
+ } else {
+ anyhow::bail!("No extension manifest found for extension {extension_name}")
}
}
}
@@ -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]
@@ -334,7 +334,7 @@ mod wit {
wit_bindgen::generate!({
skip: ["init-extension"],
- path: "./wit/since_v0.6.0",
+ path: "./wit/since_v0.8.0",
});
}
@@ -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<tuple<string, string>>;
+}
@@ -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,
+ }
+}
@@ -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<tcp-arguments, string>;
+
+ record launch-request {
+ program: string,
+ cwd: option<string>,
+ args: list<string>,
+ envs: env-vars,
+ }
+
+ record attach-request {
+ process-id: option<u32>,
+ }
+
+ variant debug-request {
+ launch(launch-request),
+ attach(attach-request)
+ }
+
+ record tcp-arguments {
+ port: u16,
+ host: u32,
+ timeout: option<u64>,
+ }
+
+ record tcp-arguments-template {
+ port: option<u16>,
+ host: option<u32>,
+ timeout: option<u64>,
+ }
+
+ /// 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<bool>,
+ }
+
+ record task-template {
+ /// Human readable name of the task to display in the UI.
+ label: string,
+ /// Executable command to spawn.
+ command: string,
+ args: list<string>,
+ env: env-vars,
+ cwd: option<string>,
+ }
+
+ /// 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<string>,
+ 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<build-task-definition>,
+ /// JSON-encoded configuration for a given debug adapter.
+ config: string,
+ /// TCP connection parameters (if they were specified by user)
+ tcp-connection: option<tcp-arguments-template>,
+ }
+
+ 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<tcp-arguments-template>,
+ }
+
+ 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<string>,
+ arguments: list<string>,
+ envs: env-vars,
+ cwd: option<string>,
+ /// Zed will use TCP transport if `connection` is specified.
+ connection: option<tcp-arguments>,
+ request-args: start-debugging-request-arguments
+ }
+}
@@ -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<settings-location>, category: string, key: option<string>) -> result<string, string>;
+
+ /// 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<string, string>;
+ /// Returns the path to the given binary name, if one is present on the `$PATH`.
+ which: func(binary-name: string) -> option<string>;
+ /// 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<u64>;
+ }
+
+ /// 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<worktree>) -> result<command, string>;
+
+ /// 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<worktree>) -> result<option<string>, string>;
+
+ /// Returns the workspace configuration options to pass to the language server.
+ export language-server-workspace-configuration: func(language-server-id: string, worktree: borrow<worktree>) -> result<option<string>, 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<worktree>) -> result<option<string>, 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<worktree>) -> result<option<string>, 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<code-label-span>,
+ /// 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<string>,
+ }
+
+ export labels-for-completions: func(language-server-id: string, completions: list<completion>) -> result<list<option<code-label>>, string>;
+ export labels-for-symbols: func(language-server-id: string, symbols: list<symbol>) -> result<list<option<code-label>>, 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<string>) -> result<list<slash-command-argument-completion>, string>;
+
+ /// Returns the output from running the provided slash command.
+ export run-slash-command: func(command: slash-command, args: list<string>, worktree: option<borrow<worktree>>) -> result<slash-command-output, string>;
+
+ /// Returns the command used to start up a context server.
+ export context-server-command: func(context-server-id: string, project: borrow<project>) -> result<command, string>;
+
+ /// Returns the configuration for a context server.
+ export context-server-configuration: func(context-server-id: string, project: borrow<project>) -> result<option<context-server-configuration>, 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<list<string>, string>;
+
+ /// Indexes the docs for the specified package.
+ export index-docs: func(provider-name: string, package-name: string, database: borrow<key-value-store>) -> 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<string>, worktree: borrow<worktree>) -> result<debug-adapter-binary, string>;
+ /// Returns the kind of a debug scenario (launch or attach).
+ export dap-request-kind: func(adapter-name: string, config: string) -> result<start-debugging-request-arguments-request, string>;
+ export dap-config-to-scenario: func(config: debug-config) -> result<debug-scenario, string>;
+ export dap-locator-create-scenario: func(locator-name: string, build-config-template: build-task-template, resolved-label: string, debug-adapter-name: string) -> option<debug-scenario>;
+ export run-dap-locator: func(locator-name: string, config: resolved-task) -> result<debug-request, string>;
+}
@@ -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<github-release-asset>,
+ }
+
+ /// 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 "<owner-name>/<repo-name>", for example: "zed-industries/zed".
+ latest-github-release: func(repo: string, options: github-release-options) -> result<github-release, string>;
+
+ /// 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<github-release, string>;
+}
@@ -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<tuple<string, string>>,
+ /// The request body.
+ body: option<list<u8>>,
+ /// 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<tuple<string, string>>,
+ /// The response body.
+ body: list<u8>,
+ }
+
+ /// Performs an HTTP request and returns the response.
+ fetch: func(req: http-request) -> result<http-response, string>;
+
+ /// 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<option<list<u8>>, string>;
+ }
+
+ /// Performs an HTTP request and returns a response stream.
+ fetch-stream: func(req: http-request) -> result<http-response-stream, string>;
+}
@@ -0,0 +1,90 @@
+interface lsp {
+ /// An LSP completion.
+ record completion {
+ label: string,
+ label-details: option<completion-label-details>,
+ detail: option<string>,
+ kind: option<completion-kind>,
+ insert-text-format: option<insert-text-format>,
+ }
+
+ /// 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<string>,
+ description: option<string>,
+ }
+
+ /// 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),
+ }
+}
@@ -0,0 +1,13 @@
+interface nodejs {
+ /// Returns the path to the Node binary used by Zed.
+ node-binary-path: func() -> result<string, string>;
+
+ /// Returns the latest version of the given NPM package.
+ npm-package-latest-version: func(package-name: string) -> result<string, string>;
+
+ /// Returns the installed version of the given NPM package, if it exists.
+ npm-package-installed-version: func(package-name: string) -> result<option<string>, string>;
+
+ /// Installs the specified NPM package.
+ npm-install-package: func(package-name: string, version: string) -> result<_, string>;
+}
@@ -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<os, architecture>;
+}
@@ -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<string>,
+ /// 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<s32>,
+ /// The data that the process wrote to stdout.
+ stdout: list<u8>,
+ /// The data that the process wrote to stderr.
+ stderr: list<u8>,
+ }
+
+ /// 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<output, string>;
+}
@@ -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<CommandSettings>,
+ /// The initialization options to pass to the language server.
+ pub initialization_options: Option<serde_json::Value>,
+ /// The settings to pass to language server.
+ pub settings: Option<serde_json::Value>,
+}
+
+/// 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<CommandSettings>,
+ /// The settings to pass to the context server.
+ pub settings: Option<serde_json::Value>,
+}
+
+/// The settings for a command.
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
+pub struct CommandSettings {
+ /// The path to the command.
+ pub path: Option<String>,
+ /// The arguments to pass to the command.
+ pub arguments: Option<Vec<String>>,
+ /// The environment variables.
+ pub env: Option<HashMap<String, String>>,
+}
@@ -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<slash-command-output-section>,
+ }
+
+ /// 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,
+ }
+}
@@ -71,6 +71,7 @@ async fn main() -> Result<()> {
&extension_path,
&mut manifest,
CompileExtensionOptions { release: true },
+ fs.clone(),
)
.await
.context("failed to compile extension")?;
@@ -38,7 +38,7 @@ paths.workspace = true
project.workspace = true
remote.workspace = true
release_channel.workspace = true
-semantic_version.workspace = true
+semver.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_json_lenient.workspace = true
@@ -7,8 +7,8 @@ use extension::{
extension_builder::{CompileExtensionOptions, ExtensionBuilder},
};
use extension_host::wasm_host::WasmHost;
-use fs::RealFs;
-use gpui::{SemanticVersion, TestAppContext, TestDispatcher};
+use fs::{Fs, RealFs};
+use gpui::{TestAppContext, TestDispatcher};
use http_client::{FakeHttpClient, Response};
use node_runtime::NodeRuntime;
use rand::{SeedableRng, rngs::StdRng};
@@ -24,7 +24,11 @@ fn extension_benchmarks(c: &mut Criterion) {
let mut group = c.benchmark_group("load");
let mut manifest = manifest();
- let wasm_bytes = wasm_bytes(&cx, &mut manifest);
+ let wasm_bytes = wasm_bytes(
+ &cx,
+ &mut manifest,
+ Arc::new(RealFs::new(None, cx.executor())),
+ );
let manifest = Arc::new(manifest);
let extensions_dir = TempTree::new(json!({
"installed": {},
@@ -54,13 +58,13 @@ fn init() -> 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<u8> {
+fn wasm_bytes(cx: &TestAppContext, manifest: &mut ExtensionManifest, fs: Arc<dyn Fs>) -> Vec<u8> {
let extension_builder = extension_builder();
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
@@ -73,6 +77,7 @@ fn wasm_bytes(cx: &TestAppContext, manifest: &mut ExtensionManifest) -> Vec<u8>
&path,
manifest,
CompileExtensionOptions { release: true },
+ fs,
))
.unwrap();
std::fs::read(path.join("extension.wasm")).unwrap()
@@ -124,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(),
@@ -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<WasmHost>,
pub wasm_extensions: Vec<(Arc<ExtensionManifest>, WasmExtension)>,
pub tasks: Vec<Task<()>>,
- pub remote_clients: HashMap<RemoteConnectionOptions, WeakEntity<RemoteClient>>,
+ pub remote_clients: Vec<WeakEntity<RemoteClient>>,
pub ssh_registered_tx: UnboundedSender<()>,
}
@@ -274,7 +274,7 @@ impl ExtensionStore {
reload_tx,
tasks: Vec::new(),
- remote_clients: HashMap::default(),
+ remote_clients: Default::default(),
ssh_registered_tx: connection_registered_tx,
};
@@ -343,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
@@ -639,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;
@@ -758,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();
}
@@ -982,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
}
@@ -1044,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
});
@@ -1129,6 +1130,7 @@ impl ExtensionStore {
}
if extensions_to_load.is_empty() && extensions_to_unload.is_empty() {
+ self.reload_complete_senders.clear();
return Task::ready(());
}
@@ -1377,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()))
})
@@ -1726,7 +1732,7 @@ impl ExtensionStore {
})
}
- async fn sync_extensions_over_ssh(
+ async fn sync_extensions_to_remotes(
this: &WeakEntity<Self>,
client: WeakEntity<RemoteClient>,
cx: &mut AsyncApp,
@@ -1779,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| {
@@ -1792,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<Self>, cx: &mut AsyncApp) -> Result<()> {
+ pub async fn update_remote_clients(this: &WeakEntity<Self>, 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::<Vec<_>>()
+ 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();
}
@@ -1820,16 +1838,12 @@ impl ExtensionStore {
anyhow::Ok(())
}
- pub fn register_remote_client(&mut self, client: Entity<RemoteClient>, cx: &mut Context<Self>) {
- 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<RemoteClient>,
+ _cx: &mut Context<Self>,
+ ) {
+ self.remote_clients.push(client.downgrade());
self.ssh_registered_tx.unbounded_send(()).ok();
}
}
@@ -8,7 +8,7 @@ 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;
@@ -307,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!(
@@ -463,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!(
@@ -523,7 +523,7 @@ 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(), []);
});
@@ -705,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()),
@@ -866,7 +866,7 @@ 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);
gpui_tokio::init(cx);
@@ -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
})
@@ -28,7 +28,7 @@ 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,
@@ -45,7 +45,7 @@ use wasmtime::{
CacheStore, Engine, Store,
component::{Component, ResourceTable},
};
-use wasmtime_wasi::{self as wasi, WasiView};
+use wasmtime_wasi::p2::{self as wasi, IoView as _};
use wit::Extension;
pub struct WasmHost {
@@ -68,7 +68,7 @@ pub struct WasmExtension {
pub manifest: Arc<ExtensionManifest>,
pub work_dir: Arc<Path>,
#[allow(unused)]
- pub zed_api_version: SemanticVersion,
+ pub zed_api_version: Version,
_task: Arc<Task<Result<(), gpui_tokio::JoinError>>>,
}
@@ -537,7 +537,6 @@ fn wasm_engine(executor: &BackgroundExecutor) -> wasmtime::Engine {
let engine_ref = engine.weak();
executor
.spawn(async move {
- IS_WASM_THREAD.with(|v| v.store(true, Ordering::Release));
// Somewhat arbitrary interval, as it isn't a guaranteed interval.
// But this is a rough upper bound for how long the extension execution can block on
// `Future::poll`.
@@ -631,7 +630,7 @@ impl WasmHost {
&executor,
&mut store,
this.release_channel,
- zed_api_version,
+ zed_api_version.clone(),
&component,
)
.await?;
@@ -643,6 +642,12 @@ impl WasmHost {
let (tx, mut rx) = mpsc::unbounded::<ExtensionCall>();
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;
}
@@ -659,8 +664,8 @@ impl WasmHost {
cx.spawn(async move |cx| {
let (extension_task, manifest, work_dir, tx, zed_api_version) =
cx.background_executor().spawn(load_extension_task).await?;
- // we need to run run the task in an extension context as wasmtime_wasi may
- // call into tokio, accessing its runtime handle
+ // 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 {
@@ -680,8 +685,8 @@ impl WasmHost {
.await
.context("failed to create extension work dir")?;
- let file_perms = wasi::FilePerms::all();
- let dir_perms = wasi::DirPerms::all();
+ let file_perms = wasmtime_wasi::FilePerms::all();
+ let dir_perms = wasmtime_wasi::DirPerms::all();
let path = SanitizedPath::new(&extension_work_dir).to_string();
#[cfg(target_os = "windows")]
let path = path.replace('\\', "/");
@@ -708,10 +713,7 @@ impl WasmHost {
}
}
-pub fn parse_wasm_extension_version(
- extension_id: &str,
- wasm_bytes: &[u8],
-) -> Result<SemanticVersion> {
+pub fn parse_wasm_extension_version(extension_id: &str, wasm_bytes: &[u8]) -> Result<Version> {
let mut version = None;
for part in wasmparser::Parser::new(0).parse_all(wasm_bytes) {
@@ -738,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<SemanticVersion> {
+fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option<Version> {
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 _,
@@ -763,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)
.await
- .with_context(|| format!("failed to load wasm extension {}", manifest.id))
+ .with_context(|| format!("loading wasm extension: {}", manifest.id))
}
pub async fn call<T, Fn>(&self, f: Fn) -> Result<T>
@@ -854,11 +856,13 @@ impl WasmState {
}
}
-impl wasi::WasiView for WasmState {
+impl wasi::IoView for WasmState {
fn table(&mut self) -> &mut ResourceTable {
&mut self.table
}
+}
+impl wasi::WasiView for WasmState {
fn ctx(&mut self) -> &mut wasi::WasiCtx {
&mut self.ctx
}
@@ -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,
@@ -44,7 +45,7 @@ pub fn new_linker(
f: impl Fn(&mut Linker<WasmState>, fn(&mut WasmState) -> &mut WasmState) -> Result<()>,
) -> Linker<WasmState> {
let mut linker = Linker::new(&wasm_engine(executor));
- wasmtime_wasi::add_to_linker_async(&mut linker).unwrap();
+ wasmtime_wasi::p2::add_to_linker_async(&mut linker).unwrap();
f(&mut linker, wasi_view).unwrap();
linker
}
@@ -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<SemanticVersion> {
+pub fn wasm_api_version_range(release_channel: ReleaseChannel) -> RangeInclusive<Version> {
// 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<WasmState>,
release_channel: ReleaseChannel,
- version: SemanticVersion,
+ version: Version,
component: &Component,
) -> Result<Self> {
// 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<WasmState>) -> 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<Arc<dyn WorktreeDelegate>>,
) -> Result<Result<Command, String>> {
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<Arc<dyn WorktreeDelegate>>,
) -> Result<Result<Option<String>, 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<Arc<dyn WorktreeDelegate>>,
) -> Result<Result<Option<String>, 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<Arc<dyn WorktreeDelegate>>,
) -> Result<Result<Option<String>, 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<Arc<dyn WorktreeDelegate>>,
) -> Result<Result<Option<String>, 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<latest::Completion>,
) -> Result<Result<Vec<Option<CodeLabel>>, 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::<Vec<_>>(),
+ )
+ .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<latest::Symbol>,
) -> Result<Result<Vec<Option<CodeLabel>>, 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::<Vec<_>>(),
+ )
+ .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<Result<Vec<SlashCommandArgumentCompletion>, 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<Resource<Arc<dyn WorktreeDelegate>>>,
) -> Result<Result<SlashCommandOutput, String>> {
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<ExtensionProject>,
) -> Result<Result<Command, String>> {
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<ExtensionProject>,
) -> Result<Result<Option<ContextServerConfiguration>, 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<Result<Vec<String>, 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<Arc<dyn KeyValueStoreDelegate>>,
) -> Result<Result<(), String>> {
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<WasmState>,
@@ -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<WasmState>,
@@ -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<WasmState>,
@@ -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<WasmState>,
@@ -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<WasmState>,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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<dyn WorktreeDelegate>;
pub type ExtensionProject = Arc<dyn ProjectDelegate>;
pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
-pub type ExtensionHttpResponseStream = Arc<Mutex<::http_client::Response<AsyncBody>>>;
pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
}
-impl From<Range> for std::ops::Range<usize> {
- fn from(range: Range) -> Self {
- let start = range.start as usize;
- let end = range.end as usize;
- start..end
- }
-}
-
-impl From<Command> for extension::Command {
- fn from(value: Command) -> Self {
- Self {
- command: value.command.into(),
- args: value.args,
- env: value.env,
- }
- }
-}
-
-impl From<StartDebuggingRequestArgumentsRequest>
- for extension::StartDebuggingRequestArgumentsRequest
-{
- fn from(value: StartDebuggingRequestArgumentsRequest) -> Self {
- match value {
- StartDebuggingRequestArgumentsRequest::Launch => Self::Launch,
- StartDebuggingRequestArgumentsRequest::Attach => Self::Attach,
- }
- }
-}
-impl TryFrom<StartDebuggingRequestArguments> for extension::StartDebuggingRequestArguments {
- type Error = anyhow::Error;
-
- fn try_from(value: StartDebuggingRequestArguments) -> Result<Self, Self::Error> {
- Ok(Self {
- configuration: serde_json::from_str(&value.configuration)?,
- request: value.request.into(),
- })
- }
-}
-impl From<TcpArguments> for extension::TcpArguments {
- fn from(value: TcpArguments) -> Self {
- Self {
- host: value.host.into(),
- port: value.port,
- timeout: value.timeout,
- }
- }
-}
-
-impl From<extension::TcpArgumentsTemplate> for TcpArgumentsTemplate {
- fn from(value: extension::TcpArgumentsTemplate) -> Self {
- Self {
- host: value.host.map(Ipv4Addr::to_bits),
- port: value.port,
- timeout: value.timeout,
- }
- }
-}
-
-impl From<TcpArgumentsTemplate> for extension::TcpArgumentsTemplate {
- fn from(value: TcpArgumentsTemplate) -> Self {
- Self {
- host: value.host.map(Ipv4Addr::from_bits),
- port: value.port,
- timeout: value.timeout,
- }
- }
-}
-
-impl TryFrom<extension::DebugTaskDefinition> for DebugTaskDefinition {
- type Error = anyhow::Error;
- fn try_from(value: extension::DebugTaskDefinition) -> Result<Self, Self::Error> {
- 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<task::DebugRequest> 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<DebugRequest> 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<task::LaunchRequest> 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<task::AttachRequest> for AttachRequest {
- fn from(value: task::AttachRequest) -> Self {
- Self {
- process_id: value.process_id,
- }
- }
-}
-
-impl From<LaunchRequest> 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<AttachRequest> for task::AttachRequest {
- fn from(value: AttachRequest) -> Self {
- Self {
- process_id: value.process_id,
- }
- }
-}
-
-impl From<ZedDebugConfig> 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<DebugAdapterBinary> for extension::DebugAdapterBinary {
- type Error = anyhow::Error;
- fn try_from(value: DebugAdapterBinary) -> Result<Self, Self::Error> {
- 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<BuildTaskDefinition> 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<extension::BuildTaskDefinition> 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<BuildTaskTemplate> 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<extension::BuildTaskTemplate> 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<DebugScenario> for extension::DebugScenario {
- type Error = anyhow::Error;
-
- fn try_from(value: DebugScenario) -> std::result::Result<Self, Self::Error> {
- 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<extension::DebugScenario> 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<SpawnInTerminal> for ResolvedTask {
- type Error = anyhow::Error;
-
- fn try_from(value: SpawnInTerminal) -> Result<Self, Self::Error> {
- 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<CodeLabel> for extension::CodeLabel {
+impl From<CodeLabel> 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<CodeLabelSpan> for extension::CodeLabelSpan {
+impl From<CodeLabelSpan> 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<CodeLabelSpanLiteral> for extension::CodeLabelSpanLiteral {
+impl From<CodeLabelSpanLiteral> for latest::CodeLabelSpanLiteral {
fn from(value: CodeLabelSpanLiteral) -> Self {
Self {
text: value.text,
@@ -352,167 +76,37 @@ impl From<CodeLabelSpanLiteral> for extension::CodeLabelSpanLiteral {
}
}
-impl From<extension::Completion> for Completion {
- fn from(value: extension::Completion) -> Self {
+impl From<SettingsLocation> 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<extension::CompletionLabelDetails> for CompletionLabelDetails {
- fn from(value: extension::CompletionLabelDetails) -> Self {
- Self {
- detail: value.detail,
- description: value.description,
- }
- }
-}
-
-impl From<extension::CompletionKind> for CompletionKind {
- fn from(value: extension::CompletionKind) -> Self {
+impl From<LanguageServerInstallationStatus> 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<extension::InsertTextFormat> for InsertTextFormat {
- fn from(value: extension::InsertTextFormat) -> Self {
+impl From<DownloadedFileType> 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<extension::Symbol> for Symbol {
- fn from(value: extension::Symbol) -> Self {
- Self {
- kind: value.kind.into(),
- name: value.name,
- }
- }
-}
-
-impl From<extension::SymbolKind> 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<extension::SlashCommand> 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<SlashCommandOutput> for extension::SlashCommandOutput {
- fn from(value: SlashCommandOutput) -> Self {
- Self {
- text: value.text,
- sections: value.sections.into_iter().map(Into::into).collect(),
- }
- }
-}
-
-impl From<SlashCommandOutputSection> 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<SlashCommandArgumentCompletion> for extension::SlashCommandArgumentCompletion {
- fn from(value: SlashCommandArgumentCompletion) -> Self {
- Self {
- label: value.label,
- new_text: value.new_text,
- run_command: value.run_command,
- }
- }
-}
-
-impl TryFrom<ContextServerConfiguration> for extension::ContextServerConfiguration {
- type Error = anyhow::Error;
-
- fn try_from(value: ContextServerConfiguration) -> Result<Self, Self::Error> {
- 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<Result<(), String>> {
- 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<ExtensionKeyValueStore>) -> Result<()> {
@@ -535,8 +128,7 @@ impl HostProject for WasmState {
&mut self,
project: Resource<ExtensionProject>,
) -> wasmtime::Result<Vec<u64>> {
- let project = self.table.get(&project)?;
- Ok(project.worktree_ids())
+ latest::HostProject::worktree_ids(self, project).await
}
async fn drop(&mut self, _project: Resource<Project>) -> Result<()> {
@@ -547,16 +139,14 @@ impl HostProject for WasmState {
impl HostWorktree for WasmState {
async fn id(&mut self, delegate: Resource<Arc<dyn WorktreeDelegate>>) -> wasmtime::Result<u64> {
- let delegate = self.table.get(&delegate)?;
- Ok(delegate.id())
+ latest::HostWorktree::id(self, delegate).await
}
async fn root_path(
&mut self,
delegate: Resource<Arc<dyn WorktreeDelegate>>,
) -> wasmtime::Result<String> {
- 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<Arc<dyn WorktreeDelegate>>,
path: String,
) -> wasmtime::Result<Result<String, String>> {
- 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<Arc<dyn WorktreeDelegate>>,
) -> wasmtime::Result<EnvVars> {
- 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<Arc<dyn WorktreeDelegate>>,
binary_name: String,
) -> wasmtime::Result<Option<String>> {
- 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<Worktree>) -> 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<Result<http_client::HttpResponse, String>> {
- 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<Result<Resource<ExtensionHttpResponseStream>, 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<ExtensionHttpResponseStream>,
- ) -> wasmtime::Result<Result<Option<Vec<u8>>, 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<ExtensionHttpResponseStream>) -> Result<()> {
- Ok(())
- }
-}
-
-impl From<http_client::HttpMethod> 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<AsyncBody>> {
- 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<AsyncBody>,
-) -> anyhow::Result<http_client::HttpResponse> {
- 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<Result<String, String>> {
- 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<Result<String, String>> {
- 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<Result<Option<String>, 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<Result<(), String>> {
- 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<Result<github::GithubRelease, String>> {
- 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<Result<github::GithubRelease, String>> {
- 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<std::process::Output> 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<Result<process::Output, String>> {
- 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<Result<TcpArguments, String>> {
- 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<String>,
) -> wasmtime::Result<Result<String, String>> {
- 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(
@@ -0,0 +1,1111 @@
+use crate::wasm_host::wit::since_v0_6_0::{
+ dap::{
+ BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, 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<dyn WorktreeDelegate>;
+pub type ExtensionProject = Arc<dyn ProjectDelegate>;
+pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
+pub type ExtensionHttpResponseStream = Arc<Mutex<::http_client::Response<AsyncBody>>>;
+
+pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
+ static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
+ LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
+}
+
+impl From<Range> for std::ops::Range<usize> {
+ fn from(range: Range) -> Self {
+ let start = range.start as usize;
+ let end = range.end as usize;
+ start..end
+ }
+}
+
+impl From<Command> for extension::Command {
+ fn from(value: Command) -> Self {
+ Self {
+ command: value.command.into(),
+ args: value.args,
+ env: value.env,
+ }
+ }
+}
+
+impl From<StartDebuggingRequestArgumentsRequest>
+ for extension::StartDebuggingRequestArgumentsRequest
+{
+ fn from(value: StartDebuggingRequestArgumentsRequest) -> Self {
+ match value {
+ StartDebuggingRequestArgumentsRequest::Launch => Self::Launch,
+ StartDebuggingRequestArgumentsRequest::Attach => Self::Attach,
+ }
+ }
+}
+impl TryFrom<StartDebuggingRequestArguments> for extension::StartDebuggingRequestArguments {
+ type Error = anyhow::Error;
+
+ fn try_from(value: StartDebuggingRequestArguments) -> Result<Self, Self::Error> {
+ Ok(Self {
+ configuration: serde_json::from_str(&value.configuration)?,
+ request: value.request.into(),
+ })
+ }
+}
+impl From<TcpArguments> for extension::TcpArguments {
+ fn from(value: TcpArguments) -> Self {
+ Self {
+ host: value.host.into(),
+ port: value.port,
+ timeout: value.timeout,
+ }
+ }
+}
+
+impl From<extension::TcpArgumentsTemplate> for TcpArgumentsTemplate {
+ fn from(value: extension::TcpArgumentsTemplate) -> Self {
+ Self {
+ host: value.host.map(Ipv4Addr::to_bits),
+ port: value.port,
+ timeout: value.timeout,
+ }
+ }
+}
+
+impl From<TcpArgumentsTemplate> for extension::TcpArgumentsTemplate {
+ fn from(value: TcpArgumentsTemplate) -> Self {
+ Self {
+ host: value.host.map(Ipv4Addr::from_bits),
+ port: value.port,
+ timeout: value.timeout,
+ }
+ }
+}
+
+impl TryFrom<extension::DebugTaskDefinition> for DebugTaskDefinition {
+ type Error = anyhow::Error;
+ fn try_from(value: extension::DebugTaskDefinition) -> Result<Self, Self::Error> {
+ 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<task::DebugRequest> 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<DebugRequest> 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<task::LaunchRequest> 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<task::AttachRequest> for AttachRequest {
+ fn from(value: task::AttachRequest) -> Self {
+ Self {
+ process_id: value.process_id,
+ }
+ }
+}
+
+impl From<LaunchRequest> 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<AttachRequest> for task::AttachRequest {
+ fn from(value: AttachRequest) -> Self {
+ Self {
+ process_id: value.process_id,
+ }
+ }
+}
+
+impl From<ZedDebugConfig> 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<DebugAdapterBinary> for extension::DebugAdapterBinary {
+ type Error = anyhow::Error;
+ fn try_from(value: DebugAdapterBinary) -> Result<Self, Self::Error> {
+ 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<BuildTaskDefinition> 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<extension::BuildTaskDefinition> 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<BuildTaskTemplate> 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<extension::BuildTaskTemplate> 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<DebugScenario> for extension::DebugScenario {
+ type Error = anyhow::Error;
+
+ fn try_from(value: DebugScenario) -> std::result::Result<Self, Self::Error> {
+ 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<extension::DebugScenario> 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<SpawnInTerminal> for ResolvedTask {
+ type Error = anyhow::Error;
+
+ fn try_from(value: SpawnInTerminal) -> Result<Self, Self::Error> {
+ 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<CodeLabel> 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<CodeLabelSpan> 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<CodeLabelSpanLiteral> for extension::CodeLabelSpanLiteral {
+ fn from(value: CodeLabelSpanLiteral) -> Self {
+ Self {
+ text: value.text,
+ highlight_name: value.highlight_name,
+ }
+ }
+}
+
+impl From<extension::Completion> 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<extension::CompletionLabelDetails> for CompletionLabelDetails {
+ fn from(value: extension::CompletionLabelDetails) -> Self {
+ Self {
+ detail: value.detail,
+ description: value.description,
+ }
+ }
+}
+
+impl From<extension::CompletionKind> 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<extension::InsertTextFormat> 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<extension::Symbol> for Symbol {
+ fn from(value: extension::Symbol) -> Self {
+ Self {
+ kind: value.kind.into(),
+ name: value.name,
+ }
+ }
+}
+
+impl From<extension::SymbolKind> 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<extension::SlashCommand> 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<SlashCommandOutput> for extension::SlashCommandOutput {
+ fn from(value: SlashCommandOutput) -> Self {
+ Self {
+ text: value.text,
+ sections: value.sections.into_iter().map(Into::into).collect(),
+ }
+ }
+}
+
+impl From<SlashCommandOutputSection> 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<SlashCommandArgumentCompletion> for extension::SlashCommandArgumentCompletion {
+ fn from(value: SlashCommandArgumentCompletion) -> Self {
+ Self {
+ label: value.label,
+ new_text: value.new_text,
+ run_command: value.run_command,
+ }
+ }
+}
+
+impl TryFrom<ContextServerConfiguration> for extension::ContextServerConfiguration {
+ type Error = anyhow::Error;
+
+ fn try_from(value: ContextServerConfiguration) -> Result<Self, Self::Error> {
+ 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<ExtensionKeyValueStore>,
+ key: String,
+ value: String,
+ ) -> wasmtime::Result<Result<(), String>> {
+ let kv_store = self.table.get(&kv_store)?;
+ kv_store.insert(key, value).await.to_wasmtime_result()
+ }
+
+ async fn drop(&mut self, _worktree: Resource<ExtensionKeyValueStore>) -> Result<()> {
+ // We only ever hand out borrows of key-value stores.
+ Ok(())
+ }
+}
+
+impl HostProject for WasmState {
+ async fn worktree_ids(
+ &mut self,
+ project: Resource<ExtensionProject>,
+ ) -> wasmtime::Result<Vec<u64>> {
+ let project = self.table.get(&project)?;
+ Ok(project.worktree_ids())
+ }
+
+ async fn drop(&mut self, _project: Resource<Project>) -> Result<()> {
+ // We only ever hand out borrows of projects.
+ Ok(())
+ }
+}
+
+impl HostWorktree for WasmState {
+ async fn id(&mut self, delegate: Resource<Arc<dyn WorktreeDelegate>>) -> wasmtime::Result<u64> {
+ let delegate = self.table.get(&delegate)?;
+ Ok(delegate.id())
+ }
+
+ async fn root_path(
+ &mut self,
+ delegate: Resource<Arc<dyn WorktreeDelegate>>,
+ ) -> wasmtime::Result<String> {
+ let delegate = self.table.get(&delegate)?;
+ Ok(delegate.root_path())
+ }
+
+ async fn read_text_file(
+ &mut self,
+ delegate: Resource<Arc<dyn WorktreeDelegate>>,
+ path: String,
+ ) -> wasmtime::Result<Result<String, String>> {
+ 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<Arc<dyn WorktreeDelegate>>,
+ ) -> wasmtime::Result<EnvVars> {
+ let delegate = self.table.get(&delegate)?;
+ Ok(delegate.shell_env().await.into_iter().collect())
+ }
+
+ async fn which(
+ &mut self,
+ delegate: Resource<Arc<dyn WorktreeDelegate>>,
+ binary_name: String,
+ ) -> wasmtime::Result<Option<String>> {
+ let delegate = self.table.get(&delegate)?;
+ Ok(delegate.which(binary_name).await)
+ }
+
+ async fn drop(&mut self, _worktree: Resource<Worktree>) -> 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<Result<http_client::HttpResponse, String>> {
+ 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<Result<Resource<ExtensionHttpResponseStream>, 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<ExtensionHttpResponseStream>,
+ ) -> wasmtime::Result<Result<Option<Vec<u8>>, 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<ExtensionHttpResponseStream>) -> Result<()> {
+ Ok(())
+ }
+}
+
+impl From<http_client::HttpMethod> 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<AsyncBody>> {
+ 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<AsyncBody>,
+) -> anyhow::Result<http_client::HttpResponse> {
+ 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<Result<String, String>> {
+ 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<Result<String, String>> {
+ self.host
+ .node_runtime
+ .npm_package_latest_version(&package_name)
+ .await
+ .map(|v| v.to_string())
+ .to_wasmtime_result()
+ }
+
+ async fn npm_package_installed_version(
+ &mut self,
+ package_name: String,
+ ) -> wasmtime::Result<Result<Option<String>, String>> {
+ self.host
+ .node_runtime
+ .npm_package_installed_version(&self.work_dir(), &package_name)
+ .await
+ .map(|option| option.map(|version| version.to_string()))
+ .to_wasmtime_result()
+ }
+
+ async fn npm_install_package(
+ &mut self,
+ package_name: String,
+ version: String,
+ ) -> wasmtime::Result<Result<(), String>> {
+ 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<Result<github::GithubRelease, String>> {
+ 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<Result<github::GithubRelease, String>> {
+ 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<std::process::Output> 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<Result<process::Output, String>> {
+ 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<Result<TcpArguments, String>> {
+ 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<self::SettingsLocation>,
+ category: String,
+ key: Option<String>,
+ ) -> wasmtime::Result<Result<String, String>> {
+ 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<Result<(), String>> {
+ 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<Result<(), String>> {
+ 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()
+ }
+}
@@ -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
@@ -75,6 +75,7 @@ const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[
("vue", &["vue"]),
("wgsl", &["wgsl"]),
("wit", &["wit"]),
+ ("xml", &["xml"]),
("zig", &["zig"]),
];
@@ -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<ExtensionMetadata>,
) -> 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),
@@ -24,8 +24,9 @@ use settings::{Settings, SettingsContent};
use strum::IntoEnumIterator as _;
use theme::ThemeSettings;
use ui::{
- Banner, Chip, ContextMenu, Divider, PopoverMenu, ScrollableHandle, Switch, 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::{
@@ -228,8 +229,10 @@ enum Feature {
AgentClaude,
AgentCodex,
AgentGemini,
+ ExtensionBasedpyright,
ExtensionRuff,
ExtensionTailwind,
+ ExtensionTy,
Git,
LanguageBash,
LanguageC,
@@ -250,8 +253,13 @@ fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
(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"]),
@@ -292,6 +300,7 @@ pub struct ExtensionsPage {
workspace: WeakEntity<Workspace>,
list: UniformListScrollHandle,
is_fetching_extensions: bool,
+ fetch_failed: bool,
filter: ExtensionFilter,
remote_extension_entries: Vec<ExtensionMetadata>,
dev_extension_entries: Vec<Arc<ExtensionManifest>>,
@@ -352,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(),
@@ -478,6 +488,7 @@ impl ExtensionsPage {
cx: &mut Context<Self>,
) {
self.is_fetching_extensions = true;
+ self.fetch_failed = false;
cx.notify();
let extension_store = ExtensionStore::global(cx);
@@ -533,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);
}
@@ -714,7 +739,7 @@ impl ExtensionsPage {
extension: &ExtensionMetadata,
cx: &mut Context<Self>,
) -> 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);
@@ -805,37 +830,47 @@ impl ExtensionsPage {
)
.child(
h_flex()
- .gap_1()
.justify_between()
.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(),
+ h_flex()
+ .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()
- .ml_auto()
.gap_1()
- .child(
+ .child({
+ let repo_url_for_tooltip = repository_url.clone();
+
IconButton::new(
SharedString::from(format!("repository-{}", extension.id)),
IconName::Github,
)
.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-{}",
@@ -854,13 +889,15 @@ impl ExtensionsPage {
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,
+ )
+ })
}),
),
),
@@ -1136,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))
}
@@ -1267,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 => {
@@ -1294,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(
@@ -1325,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()
@@ -1407,8 +1472,7 @@ impl ExtensionsPage {
},
);
},
- ))
- .color(ui::SwitchColor::Accent),
+ )),
),
),
)
@@ -1443,6 +1507,12 @@ impl ExtensionsPage {
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(),
@@ -1455,6 +1525,12 @@ impl ExtensionsPage {
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(),
@@ -1544,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)
}),
@@ -1559,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(),
- )
- .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(),
+ 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-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(),
+ ),
),
),
)
@@ -1670,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()
@@ -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 {
@@ -17,3 +11,15 @@ pub struct PanicFeatureFlag;
impl FeatureFlag for PanicFeatureFlag {
const NAME: &'static str = "panic";
}
+
+pub struct InlineAssistantUseToolFeatureFlag;
+
+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";
+}
@@ -1060,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 {
@@ -1071,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(),
)
@@ -1713,7 +1713,7 @@ impl PickerDelegate for FileFinderDelegate {
ui::IconPosition::End,
Some(ToggleIncludeIgnored.boxed_clone()),
move |window, cx| {
- window.focus(&focus_handle);
+ window.focus(&focus_handle, cx);
window.dispatch_action(
ToggleIncludeIgnored.boxed_clone(),
cx,
@@ -1598,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]);
});
@@ -3452,3 +3452,99 @@ async fn test_paths_with_starting_slash(cx: &mut TestAppContext) {
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"
+ );
+}
@@ -44,8 +44,9 @@ impl OpenPathDelegate {
tx: oneshot::Sender<Option<Vec<PathBuf>>>,
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>,
) {
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 {
""
}
@@ -821,7 +826,13 @@ impl PickerDelegate for OpenPathDelegate {
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- 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<usize> {
@@ -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::<String>::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"]);
@@ -406,16 +356,15 @@ fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
fn build_open_path_prompt(
project: Entity<Project>,
creating_path: bool,
- path_style: PathStyle,
cx: &mut TestAppContext,
) -> (Entity<Picker<OpenPathDelegate>>, &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.))
@@ -33,6 +33,7 @@ tempfile.workspace = true
text.workspace = true
time.workspace = true
util.workspace = true
+is_executable = "1.0.5"
[target.'cfg(target_os = "macos")'.dependencies]
fsevent.workspace = true
@@ -3,7 +3,7 @@ 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,
@@ -23,6 +23,7 @@ use std::{
path::PathBuf,
sync::{Arc, LazyLock},
};
+use text::LineEnding;
use util::{paths::PathStyle, rel_path::RelPath};
pub static LOAD_INDEX_TEXT_TASK: LazyLock<TaskLabel> = LazyLock::new(TaskLabel::new);
@@ -50,6 +51,8 @@ pub struct FakeGitRepositoryState {
pub blames: HashMap<RepoPath, Blame>,
pub current_branch_name: Option<String>,
pub branches: HashSet<String>,
+ /// List of remotes, keys are names and values are URLs
+ pub remotes: HashMap<String, String>,
pub simulated_index_write_error_message: Option<String>,
pub refs: HashMap<String, String>,
}
@@ -68,6 +71,7 @@ impl FakeGitRepositoryState {
refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
merge_base_contents: Default::default(),
oids: Default::default(),
+ remotes: HashMap::default(),
}
}
}
@@ -138,6 +142,7 @@ impl GitRepository for FakeGitRepository {
path: RepoPath,
content: Option<String>,
_env: Arc<HashMap<String, String>>,
+ _is_executable: bool,
) -> BoxFuture<'_, anyhow::Result<()>> {
self.with_state_async(true, move |state| {
if let Some(message) = &state.simulated_index_write_error_message {
@@ -151,8 +156,8 @@ impl GitRepository for FakeGitRepository {
})
}
- fn remote_url(&self, _name: &str) -> Option<String> {
- None
+ fn remote_url(&self, _name: &str) -> BoxFuture<'_, Option<String>> {
+ async move { None }.boxed()
}
fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
@@ -196,6 +201,7 @@ impl GitRepository for FakeGitRepository {
async {
Ok(CommitDetails {
sha: commit.into(),
+ message: "initial commit".into(),
..Default::default()
})
}
@@ -377,11 +383,18 @@ 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())
})
@@ -431,7 +444,21 @@ impl GitRepository for FakeGitRepository {
})
}
- fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result<git::blame::Blame>> {
+ 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<git::blame::Blame>> {
self.with_state_async(false, move |state| {
state
.blames
@@ -441,6 +468,25 @@ impl GitRepository for FakeGitRepository {
})
}
+ fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<git::repository::FileHistory>> {
+ self.file_history_paginated(path, 0, None)
+ }
+
+ fn file_history_paginated(
+ &self,
+ path: RepoPath,
+ _skip: usize,
+ _limit: Option<usize>,
+ ) -> BoxFuture<'_, Result<git::repository::FileHistory>> {
+ async move {
+ Ok(git::repository::FileHistory {
+ entries: Vec::new(),
+ path,
+ })
+ }
+ .boxed()
+ }
+
fn stage_paths(
&self,
paths: Vec<RepoPath>,
@@ -529,7 +575,15 @@ impl GitRepository for FakeGitRepository {
_askpass: AskPassDelegate,
_env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>> {
- unimplemented!()
+ async { Ok(()) }.boxed()
+ }
+
+ fn run_hook(
+ &self,
+ _hook: RunHook,
+ _env: Arc<HashMap<String, String>>,
+ ) -> BoxFuture<'_, Result<()>> {
+ async { Ok(()) }.boxed()
}
fn push(
@@ -566,7 +620,24 @@ impl GitRepository for FakeGitRepository {
unimplemented!()
}
- fn get_remotes(&self, _branch: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
+ fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
+ self.with_state_async(false, move |state| {
+ let remotes = state
+ .remotes
+ .keys()
+ .map(|r| Remote {
+ name: r.clone().into(),
+ })
+ .collect::<Vec<_>>();
+ Ok(remotes)
+ })
+ }
+
+ fn get_push_remote(&self, _branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
+ unimplemented!()
+ }
+
+ fn get_branch_remote(&self, _branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
unimplemented!()
}
@@ -643,6 +714,20 @@ impl GitRepository for FakeGitRepository {
fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
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(())
+ })
+ }
}
#[cfg(test)]
@@ -32,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;
@@ -192,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)]
@@ -208,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
@@ -421,6 +425,86 @@ impl RealFs {
job_event_subscribers: Arc::new(Mutex::new(Vec::new())),
}
}
+
+ #[cfg(target_os = "windows")]
+ fn canonicalize(path: &Path) -> Result<PathBuf> {
+ let mut strip_prefix = None;
+
+ let mut new_path = PathBuf::new();
+ for component in path.components() {
+ match component {
+ std::path::Component::Prefix(_) => {
+ let component = component.as_os_str();
+ let canonicalized = if component
+ .to_str()
+ .map(|e| e.ends_with("\\"))
+ .unwrap_or(false)
+ {
+ std::fs::canonicalize(component)
+ } else {
+ let mut component = component.to_os_string();
+ component.push("\\");
+ 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]
@@ -508,6 +592,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(())
}
@@ -562,6 +652,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() }
}
@@ -724,7 +816,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?;
@@ -749,7 +841,13 @@ impl Fs for RealFs {
let path = path.to_owned();
self.executor
.spawn(async move {
- std::fs::canonicalize(&path).with_context(|| format!("canonicalizing {path:?}"))
+ #[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
}
@@ -820,6 +918,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)),
@@ -827,6 +931,7 @@ impl Fs for RealFs {
is_symlink,
is_dir: metadata.file_type().is_dir(),
is_fifo,
+ is_executable,
}))
}
@@ -2273,6 +2378,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 {
@@ -2457,7 +2568,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::<String>();
+ let content = text::chunks_with_line_ending(text, line_ending).collect::<String>();
if let Some(path) = path.parent() {
self.create_dir(path).await?;
}
@@ -2527,6 +2638,7 @@ impl Fs for FakeFs {
is_dir: false,
is_symlink,
is_fifo: false,
+ is_executable: false,
},
FakeFsEntry::Dir {
inode, mtime, len, ..
@@ -2537,6 +2649,7 @@ impl Fs for FakeFs {
is_dir: true,
is_symlink,
is_fifo: false,
+ is_executable: false,
},
FakeFsEntry::Symlink { .. } => unreachable!(),
}))
@@ -2673,25 +2786,6 @@ impl Fs for FakeFs {
}
}
-fn chunks(rope: &Rope, line_ending: LineEnding) -> impl Iterator<Item = &str> {
- 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() {
@@ -3310,4 +3404,83 @@ mod tests {
let content = std::fs::read_to_string(&file_to_be_replaced).unwrap();
assert_eq!(content, "Hello");
}
+
+ #[gpui::test]
+ #[cfg(target_os = "windows")]
+ async fn test_realfs_canonicalize(executor: BackgroundExecutor) {
+ use util::paths::SanitizedPath;
+
+ 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 = temp_dir.path().join("test (1).txt");
+ let file = SanitizedPath::new(&file);
+ std::fs::write(&file, "test").unwrap();
+
+ let canonicalized = fs.canonicalize(file.as_path()).await;
+ assert!(canonicalized.is_ok());
+ }
+
+ #[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")),
+ ]
+ );
+ }
}
@@ -72,8 +72,8 @@ impl Watcher for FsWatcher {
}
#[cfg(target_os = "linux")]
{
- log::trace!("path to watch is already watched: {path:?}");
if self.registrations.lock().contains_key(path) {
+ log::trace!("path to watch is already watched: {path:?}");
return Ok(());
}
}
@@ -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);
+ }
}
}
@@ -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;
+ }
}
@@ -107,7 +107,7 @@ pub fn match_fixed_path_set(
.display(path_style)
.chars()
.collect::<Vec<_>>();
- path_prefix_chars.extend(path_style.separator().chars());
+ path_prefix_chars.extend(path_style.primary_separator().chars());
let lowercase_pfx = path_prefix_chars
.iter()
.map(|c| c.to_ascii_lowercase())
@@ -1,14 +1,13 @@
+use crate::Oid;
use crate::commit::get_messages;
use crate::repository::RepoPath;
-use crate::{GitRemote, Oid};
use anyhow::{Context as _, Result};
use collections::{HashMap, HashSet};
use futures::AsyncWriteExt;
-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,15 +18,6 @@ pub use git2 as libgit;
pub struct Blame {
pub entries: Vec<BlameEntry>,
pub messages: HashMap<Oid, String>,
- pub remote_url: Option<String>,
-}
-
-#[derive(Clone, Debug, Default)]
-pub struct ParsedCommitMessage {
- pub message: SharedString,
- pub permalink: Option<url::Url>,
- pub pull_request: Option<crate::hosting_provider::PullRequest>,
- pub remote: Option<GitRemote>,
}
impl Blame {
@@ -36,9 +26,10 @@ impl Blame {
working_directory: &Path,
path: &RepoPath,
content: &Rope,
- remote_url: Option<String>,
+ line_ending: LineEnding,
) -> Result<Self> {
- 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 +44,7 @@ impl Blame {
.await
.context("failed to get commit messages")?;
- Ok(Self {
- entries,
- messages,
- remote_url,
- })
+ Ok(Self { entries, messages })
}
}
@@ -69,12 +56,12 @@ async fn run_git_blame(
working_directory: &Path,
path: &RepoPath,
contents: &Rope,
+ line_ending: LineEnding,
) -> Result<String> {
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 +76,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?;
@@ -1,7 +1,52 @@
-use crate::{Oid, status::StatusCode};
+use crate::{
+ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, parse_git_remote_url,
+ status::StatusCode,
+};
use anyhow::{Context as _, Result};
use collections::HashMap;
-use std::path::Path;
+use gpui::SharedString;
+use std::{path::Path, sync::Arc};
+
+#[derive(Clone, Debug, Default)]
+pub struct ParsedCommitMessage {
+ pub message: SharedString,
+ pub permalink: Option<url::Url>,
+ pub pull_request: Option<crate::hosting_provider::PullRequest>,
+ pub remote: Option<GitRemote>,
+}
+
+impl ParsedCommitMessage {
+ pub fn parse(
+ sha: String,
+ message: String,
+ remote_url: Option<&str>,
+ provider_registry: Option<Arc<GitHostingProviderRegistry>>,
+ ) -> Self {
+ if let Some((hosting_provider, remote)) = provider_registry
+ .and_then(|reg| remote_url.and_then(|url| parse_git_remote_url(reg, url)))
+ {
+ let pull_request = hosting_provider.extract_pull_request(&remote, &message);
+ Self {
+ message: message.into(),
+ permalink: Some(
+ hosting_provider
+ .build_commit_permalink(&remote, BuildCommitPermalinkParams { sha: &sha }),
+ ),
+ pull_request,
+ remote: Some(GitRemote {
+ host: hosting_provider,
+ owner: remote.owner.into(),
+ repo: remote.repo.into(),
+ }),
+ }
+ } else {
+ Self {
+ message: message.into(),
+ ..Default::default()
+ }
+ }
+ }
+}
pub async fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result<HashMap<Oid, String>> {
if shas.is_empty() {
@@ -23,6 +23,7 @@ pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon";
pub const LFS_DIR: &str = "lfs";
pub const COMMIT_MESSAGE: &str = "COMMIT_EDITMSG";
pub const INDEX_LOCK: &str = "index.lock";
+pub const REPO_EXCLUDE: &str = "info/exclude";
actions!(
git,
@@ -43,6 +44,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.
@@ -225,3 +228,28 @@ impl From<Oid> 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<Self> {
+ match value {
+ 0 => Some(Self::PreCommit),
+ _ => None,
+ }
+ }
+}
@@ -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<Regex> =
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<Self, Self::Err> {
@@ -1,19 +1,22 @@
use crate::commit::parse_git_diff_name_status;
use crate::stash::GitStash;
use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff};
-use crate::{Oid, SHORT_SHA_LENGTH};
+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 text::LineEnding;
+
+use std::collections::HashSet;
use std::ffi::{OsStr, OsString};
use std::process::{ExitStatus, Stdio};
use std::{
@@ -55,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<UpstreamTrackingStatus> {
self.upstream
.as_ref()
@@ -207,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<FileHistoryEntry>,
+ pub path: RepoPath,
+}
+
#[derive(Debug)]
pub struct CommitDiff {
pub files: Vec<CommitFile>,
@@ -400,10 +425,11 @@ pub trait GitRepository: Send + Sync {
path: RepoPath,
content: Option<String>,
env: Arc<HashMap<String, String>>,
+ is_executable: bool,
) -> BoxFuture<'_, anyhow::Result<()>>;
/// Returns the URL of the remote with the given name.
- fn remote_url(&self, name: &str) -> Option<String>;
+ fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>>;
/// Resolve a list of refs to SHAs.
fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
@@ -434,6 +460,8 @@ pub trait GitRepository: Send + Sync {
-> 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<Vec<Worktree>>>;
fn create_worktree(
@@ -460,7 +488,19 @@ pub trait GitRepository: Send + Sync {
fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
- fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
+ fn blame(
+ &self,
+ path: RepoPath,
+ content: Rope,
+ line_ending: LineEnding,
+ ) -> BoxFuture<'_, Result<crate::blame::Blame>>;
+ fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>>;
+ fn file_history_paginated(
+ &self,
+ path: RepoPath,
+ skip: usize,
+ limit: Option<usize>,
+ ) -> BoxFuture<'_, Result<FileHistory>>;
/// 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/<name>`).
@@ -485,6 +525,12 @@ pub trait GitRepository: Send + Sync {
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>>;
+ fn run_hook(
+ &self,
+ hook: RunHook,
+ env: Arc<HashMap<String, String>>,
+ ) -> BoxFuture<'_, Result<()>>;
+
fn commit(
&self,
message: SharedString,
@@ -552,7 +598,15 @@ pub trait GitRepository: Send + Sync {
cx: AsyncApp,
) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
- fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>>;
+ fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
+
+ fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
+
+ fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>>;
+
+ 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<Vec<SharedString>>>;
@@ -604,6 +658,7 @@ pub struct RealGitRepository {
pub repository: Arc<Mutex<git2::Repository>>,
pub system_git_binary_path: Option<PathBuf>,
pub any_git_binary_path: PathBuf,
+ any_git_binary_help_output: Arc<Mutex<Option<SharedString>>>,
executor: BackgroundExecutor,
}
@@ -622,6 +677,7 @@ impl RealGitRepository {
system_git_binary_path,
any_git_binary_path,
executor,
+ any_git_binary_help_output: Arc::new(Mutex::new(None)),
})
}
@@ -632,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)]
@@ -931,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),
};
@@ -981,12 +1066,15 @@ impl GitRepository for RealGitRepository {
path: RepoPath,
content: Option<String>,
env: Arc<HashMap<String, String>>,
+ 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)
@@ -1007,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?;
@@ -1038,10 +1126,16 @@ impl GitRepository for RealGitRepository {
.boxed()
}
- fn remote_url(&self, name: &str) -> Option<String> {
- 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<String>> {
+ 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<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
@@ -1332,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 {
@@ -1350,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?;
@@ -1400,25 +1503,131 @@ impl GitRepository for RealGitRepository {
.boxed()
}
- fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>> {
+ fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
+ let git_binary_path = self.any_git_binary_path.clone();
+ let working_directory = self.working_directory();
+ let executor = self.executor.clone();
+
+ self.executor
+ .spawn(async move {
+ GitBinary::new(git_binary_path, working_directory?, executor)
+ .run(&["branch", "-d", &name])
+ .await?;
+ anyhow::Ok(())
+ })
+ .boxed()
+ }
+
+ fn blame(
+ &self,
+ path: RepoPath,
+ content: Rope,
+ line_ending: LineEnding,
+ ) -> BoxFuture<'_, Result<crate::blame::Blame>> {
let working_directory = self.working_directory();
let git_binary_path = self.any_git_binary_path.clone();
+ let executor = self.executor.clone();
- let remote_url = self
- .remote_url("upstream")
- .or_else(|| self.remote_url("origin"));
+ executor
+ .spawn(async move {
+ crate::blame::Blame::for_path(
+ &git_binary_path,
+ &working_directory?,
+ &path,
+ &content,
+ line_ending,
+ )
+ .await
+ })
+ .boxed()
+ }
- async move {
- crate::blame::Blame::for_path(
- &git_binary_path,
- &working_directory?,
- &path,
- &content,
- remote_url,
- )
- .await
- }
- .boxed()
+ fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>> {
+ self.file_history_paginated(path, 0, None)
+ }
+
+ fn file_history_paginated(
+ &self,
+ path: RepoPath,
+ skip: usize,
+ limit: Option<usize>,
+ ) -> BoxFuture<'_, Result<FileHistory>> {
+ 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!("<<COMMIT_END-", "3f8a9c2e-7d4b-4e1a-9f6c-8b5d2a1e4c3f>>",);
+
+ 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<String>> {
@@ -1636,6 +1845,8 @@ impl GitRepository for RealGitRepository {
let working_directory = self.working_directory();
let git_binary_path = self.any_git_binary_path.clone();
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?)
@@ -1643,6 +1854,7 @@ impl GitRepository for RealGitRepository {
.args(["commit", "--quiet", "-m"])
.arg(&message.to_string())
.arg("--cleanup=strip")
+ .arg("--no-verify")
.stdout(smol::process::Stdio::piped())
.stderr(smol::process::Stdio::piped());
@@ -1677,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?;
@@ -1712,6 +1926,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 pull")?;
let mut command = new_smol_command(git_binary_path);
@@ -1746,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);
@@ -1761,48 +1979,111 @@ impl GitRepository for RealGitRepository {
.boxed()
}
- fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
+ fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
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?;
+ let output = new_smol_command(&git_binary_path)
+ .current_dir(&working_directory)
+ .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(),
+ });
- if output.status.success() {
- let remote_name = String::from_utf8_lossy(&output.stdout);
+ Ok(remote_name)
+ })
+ .boxed()
+ }
- return Ok(vec![Remote {
- name: remote_name.trim().to_string().into(),
- }]);
- }
+ fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
+ 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<Vec<Remote>>> {
+ 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"])
+ .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<Remote> = 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()
}
@@ -2037,6 +2318,55 @@ impl GitRepository for RealGitRepository {
})
.boxed()
}
+
+ fn run_hook(
+ &self,
+ hook: RunHook,
+ env: Arc<HashMap<String, String>>,
+ ) -> 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<OsString> {
@@ -2387,22 +2717,37 @@ fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
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::<i64>()?;
- 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::<i64>().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,
@@ -2744,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<()>> {
@@ -18,6 +18,7 @@ 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
@@ -26,18 +26,18 @@ pub fn init(cx: &mut App) {
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<GitHostingProviderRegistry>,
repository: Arc<dyn GitRepository>,
) {
- let Some(origin_url) = repository.remote_url("origin") else {
+ let Some(origin_url) = repository.remote_url("origin").await else {
return;
};
@@ -51,6 +51,8 @@ pub fn register_additional_providers(
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));
}
}
@@ -1,8 +1,14 @@
-use std::str::FromStr;
use std::sync::LazyLock;
-
-use anyhow::{Result, bail};
+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::{
@@ -20,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<Link>,
+}
+
+#[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<String>,
+}
+
pub struct Bitbucket {
name: String,
base_url: Url,
@@ -61,8 +103,60 @@ impl Bitbucket {
.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<dyn HttpClient>,
+ ) -> Result<Option<String>> {
+ 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::<CommitDetailsSelfHosted>(body_str)
+ .map(|commit| commit.author.avatar_url)
+ } else {
+ serde_json::from_str::<CommitDetails>(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()
@@ -73,7 +167,7 @@ impl GitHostingProvider for Bitbucket {
}
fn supports_avatars(&self) -> bool {
- false
+ true
}
fn format_line_number(&self, line: u32) -> String {
@@ -98,9 +192,16 @@ impl GitHostingProvider for Bitbucket {
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::<Vec<_>>();
+ 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(),
@@ -176,6 +277,22 @@ impl GitHostingProvider for Bitbucket {
Some(PullRequest { number, url })
}
+
+ async fn commit_author_avatar_url(
+ &self,
+ repo_owner: &str,
+ repo: &str,
+ commit: SharedString,
+ http_client: Arc<dyn HttpClient>,
+ ) -> Result<Option<Url>> {
+ 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)]
@@ -264,6 +381,38 @@ mod tests {
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]
@@ -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<Self> {
+ 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;
}
@@ -96,7 +134,7 @@ mod tests {
#[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();
@@ -111,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();
@@ -126,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();
@@ -139,9 +177,63 @@ 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(),
@@ -159,7 +251,7 @@ 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(),
@@ -175,9 +267,49 @@ mod tests {
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(),
@@ -195,7 +327,7 @@ 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(),
@@ -210,4 +342,44 @@ mod tests {
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())
+ }
}
@@ -8,7 +8,7 @@ use settings::{
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) {
init_git_hosting_provider_settings(cx);
@@ -46,6 +46,11 @@ 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 _
+ }
})
});
@@ -13,7 +13,6 @@ name = "git_ui"
path = "src/git_ui.rs"
[features]
-default = []
test-support = ["multi_buffer/test-support"]
[dependencies]
@@ -44,12 +43,14 @@ notifications.workspace = true
panel.workspace = true
picker.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
@@ -61,18 +62,24 @@ watch.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"]
@@ -3,10 +3,7 @@ use crate::{
commit_view::CommitView,
};
use editor::{BlameRenderer, Editor, hover_markdown_style};
-use git::{
- blame::{BlameEntry, ParsedCommitMessage},
- repository::CommitSummary,
-};
+use git::{blame::BlameEntry, commit::ParsedCommitMessage, repository::CommitSummary};
use gpui::{
ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle,
TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
@@ -47,14 +44,17 @@ 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()
@@ -64,7 +64,7 @@ impl BlameRenderer for GitBlameRenderer {
.w_full()
.gap_2()
.justify_between()
- .font_family(style.font().family)
+ .font(style.font())
.line_height(style.line_height)
.text_color(cx.theme().status().hint)
.child(
@@ -80,7 +80,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(),
@@ -101,22 +104,25 @@ impl BlameRenderer for GitBlameRenderer {
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(),
@@ -147,7 +153,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))
@@ -197,9 +203,6 @@ impl BlameRenderer for GitBlameRenderer {
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 {
@@ -260,7 +263,7 @@ impl BlameRenderer for GitBlameRenderer {
.flex_wrap()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
- .children(avatar)
+ .child(avatar)
.child(author)
.when(!author_email.is_empty(), |this| {
this.child(
@@ -325,6 +328,7 @@ impl BlameRenderer for GitBlameRenderer {
repository.downgrade(),
workspace.clone(),
None,
+ None,
window,
cx,
);
@@ -365,6 +369,7 @@ impl BlameRenderer for GitBlameRenderer {
repository.downgrade(),
workspace,
None,
+ None,
window,
cx,
)
@@ -396,6 +401,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();
});
@@ -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,13 +16,30 @@ use project::project_settings::ProjectSettings;
use settings::Settings;
use std::sync::Arc;
use time::OffsetDateTime;
-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);
}
@@ -49,21 +68,30 @@ pub fn open(
window: &mut Window,
cx: &mut Context<Workspace>,
) {
+ 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(workspace_handle, repository, style, rems(34.), window, cx)
})
}
pub fn popover(
+ workspace: WeakEntity<Workspace>,
repository: Option<Entity<Repository>>,
window: &mut Window,
cx: &mut App,
) -> Entity<BranchList> {
cx.new(|cx| {
- let list = BranchList::new(repository, BranchListStyle::Popover, rems(20.), window, cx);
- list.focus_handle(cx).focus(window);
+ let list = BranchList::new(
+ workspace,
+ repository,
+ BranchListStyle::Popover,
+ rems(20.),
+ window,
+ cx,
+ );
+ list.focus_handle(cx).focus(window, cx);
list
})
}
@@ -77,11 +105,13 @@ enum BranchListStyle {
pub struct BranchList {
width: Rems,
pub picker: Entity<Picker<BranchListDelegate>>,
+ picker_focus_handle: FocusHandle,
_subscription: Subscription,
}
impl BranchList {
fn new(
+ workspace: WeakEntity<Workspace>,
repository: Option<Entity<Repository>>,
style: BranchListStyle,
width: Rems,
@@ -148,8 +178,12 @@ impl BranchList {
})
.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);
@@ -157,6 +191,7 @@ impl BranchList {
Self {
picker,
+ picker_focus_handle,
width,
_subscription,
}
@@ -171,13 +206,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>,
+ ) {
+ 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>,
+ ) {
+ 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<DismissEvent> 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()
}
}
@@ -187,6 +249,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| {
@@ -198,15 +262,72 @@ impl Render for BranchList {
}
}
-#[derive(Debug, Clone)]
-struct BranchEntry {
- branch: Branch,
- positions: Vec<usize>,
- is_new: bool,
+#[derive(Debug, Clone, PartialEq)]
+enum Entry {
+ Branch {
+ branch: Branch,
+ positions: Vec<usize>,
+ },
+ 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 {
+ /// Show both local and remote branches.
+ All,
+ /// Only show remote branches.
+ Remote,
+}
+
+impl BranchFilter {
+ fn invert(&self) -> Self {
+ match self {
+ BranchFilter::All => BranchFilter::Remote,
+ BranchFilter::Remote => BranchFilter::All,
+ }
+ }
}
pub struct BranchListDelegate {
- matches: Vec<BranchEntry>,
+ workspace: WeakEntity<Workspace>,
+ matches: Vec<Entry>,
all_branches: Option<Vec<Branch>>,
default_branch: Option<SharedString>,
repo: Option<Entity<Repository>>,
@@ -214,11 +335,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<Entity<Repository>>, style: BranchListStyle) -> Self {
+ fn new(
+ workspace: WeakEntity<Workspace>,
+ repo: Option<Entity<Repository>>,
+ style: BranchListStyle,
+ cx: &mut Context<BranchList>,
+ ) -> Self {
Self {
+ workspace,
matches: vec![],
repo,
style,
@@ -227,6 +369,9 @@ impl BranchListDelegate {
selected_index: 0,
last_query: Default::default(),
modifiers: Default::default(),
+ branch_filter: BranchFilter::All,
+ state: PickerState::List,
+ focus_handle: cx.focus_handle(),
}
}
@@ -255,13 +400,189 @@ impl BranchListDelegate {
});
cx.emit(DismissEvent);
}
+
+ fn create_remote(
+ &self,
+ remote_name: String,
+ remote_url: String,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) {
+ 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<Picker<Self>>) {
+ 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.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<str> {
- "Select branch…".into()
+ match self.state {
+ PickerState::List | PickerState::NewRemote | PickerState::NewBranch => {
+ match self.branch_filter {
+ BranchFilter::All => "Select branch or remote…",
+ 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<SharedString> {
+ match self.state {
+ PickerState::CreateRemote(_) => {
+ Some(SharedString::new_static("Remote name can't be empty"))
+ }
+ _ => None,
+ }
+ }
+
+ fn render_editor(
+ &self,
+ editor: &Entity<Editor>,
+ _window: &mut Window,
+ _cx: &mut Context<Picker<Self>>,
+ ) -> 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::All => "Filter Remote Branches",
+ BranchFilter::Remote => "Show All 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 {
@@ -298,26 +619,38 @@ impl PickerDelegate for BranchListDelegate {
return Task::ready(());
};
- const RECENT_BRANCHES_COUNT: usize = 10;
+ let branch_filter = self.branch_filter;
cx.spawn_in(window, async move |picker, cx| {
- let mut matches: Vec<BranchEntry> = if query.is_empty() {
- all_branches
+ let branch_matches_filter = |branch: &Branch| match branch_filter {
+ BranchFilter::All => true,
+ BranchFilter::Remote => branch.is_remote(),
+ };
+
+ let mut matches: Vec<Entry> = if query.is_empty() {
+ let mut matches: Vec<Entry> = all_branches
.into_iter()
- .filter(|branch| !branch.is_remote())
- .take(RECENT_BRANCHES_COUNT)
- .map(|branch| BranchEntry {
+ .filter(|branch| branch_matches_filter(branch))
+ .map(|branch| Entry::Branch {
branch,
positions: Vec::new(),
- is_new: false,
})
- .collect()
+ .collect();
+
+ // Keep the existing recency sort within each group, but show local branches first.
+ matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote()));
+
+ matches
} else {
- let candidates = all_branches
+ let branches = all_branches
+ .iter()
+ .filter(|branch| branch_matches_filter(branch))
+ .collect::<Vec<_>>();
+ let candidates = branches
.iter()
.enumerate()
.map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
.collect::<Vec<StringMatchCandidate>>();
- fuzzy::match_strings(
+ let mut matches: Vec<Entry> = fuzzy::match_strings(
&candidates,
&query,
true,
@@ -328,31 +661,59 @@ 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()
+ .collect();
+
+ // Keep fuzzy-relevance ordering within local/remote groups, but show locals first.
+ matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote()));
+
+ matches
};
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::<Url>().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;
@@ -372,52 +733,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())
- })
- });
+ 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())
+ })
+ });
- if current_branch
- .flatten()
- .is_some_and(|current_branch| current_branch == entry.branch.ref_name)
- {
- cx.emit(DismissEvent);
- return;
- }
+ 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 Some(repo) = self.repo.clone() else {
+ return;
+ };
- let branch = entry.branch.clone();
- cx.spawn(async move |_, cx| {
- repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))?
- .await??;
+ 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);
+ anyhow::Ok(())
+ })
+ .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<Picker<Self>>) {
+ self.state = PickerState::List;
cx.emit(DismissEvent);
}
@@ -431,141 +814,1134 @@ 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 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))
+ .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,
- cx,
- )
- }),
- )
- } else {
- None
+ let entry_icon = match entry {
+ Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => {
+ Icon::new(IconName::Plus).color(Color::Muted)
+ }
+ Entry::Branch { branch, .. } => {
+ if branch.is_remote() {
+ Icon::new(IconName::Screen).color(Color::Muted)
+ } else {
+ Icon::new(IconName::GitBranchAlt).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 {
- h_flex()
- .max_w_48()
- .child(
- HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone())
- .truncate(),
- )
- .into_any_element()
+ let entry_title = match entry {
+ Entry::NewUrl { .. } => Label::new("Create Remote Repository")
+ .single_line()
+ .truncate()
+ .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 deleted_branch_icon = |entry_ix: usize, is_head_branch: bool| {
+ IconButton::new(("delete", entry_ix), IconName::Trash)
+ .tooltip(move |_, cx| {
+ Tooltip::for_action_in(
+ "Delete Branch",
+ &branch_picker::DeleteBranch,
+ &focus_handle,
+ cx,
+ )
+ })
+ .disabled(is_head_branch)
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.delegate.delete_at(entry_ix, 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())
+ 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
+ && let Some(author) =
+ author_name
+ {
+ format!(
+ "{} • {}",
+ author, subject
+ )
+ } else {
+ subject.to_string()
+ }
+ },
+ )
+ }
+ };
+
+ Label::new(message)
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .truncate()
+ }))
})
- {
- 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;
-
- 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()
- }
+ .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)),
+ ),
+ ),
+ )
+ .when(
+ self.editor_position() == PickerEditorPosition::End && !is_new_items,
+ |this| {
+ this.map(|this| {
+ let is_head_branch =
+ entry.as_branch().is_some_and(|branch| branch.is_head);
+ if self.selected_index() == ix {
+ this.end_slot(deleted_branch_icon(ix, is_head_branch))
+ } else {
+ this.end_hover_slot(deleted_branch_icon(ix, is_head_branch))
+ }
+ })
+ },
+ )
+ .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 render_header(
+ &self,
+ _window: &mut Window,
+ _cx: &mut Context<Picker<Self>>,
+ ) -> Option<AnyElement> {
+ matches!(self.state, PickerState::List).then(|| {
+ let label = match self.branch_filter {
+ BranchFilter::All => "Branches",
+ BranchFilter::Remote => "Remotes",
+ };
+
+ ListHeader::new(label).inset(true).into_any_element()
+ })
+ }
+
+ fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
+ 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,
+ );
+ })
})
- };
- Label::new(message)
- .size(LabelSize::Small)
- .truncate()
- .color(Color::Muted)
+ .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(),
)
- .end_slot::<IconButton>(icon),
+ }
+ 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::{AppContext, 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<i64>,
+ ) -> 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<Branch> {
+ 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)),
+ ]
+ }
+
+ async fn init_branch_list_test(
+ repository: Option<Entity<Repository>>,
+ branches: Vec<Branch>,
+ cx: &mut TestAppContext,
+ ) -> (Entity<BranchList>, VisualTestContext) {
+ 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 branch_list = workspace
+ .update(cx, |workspace, window, cx| {
+ cx.new(|cx| {
+ let mut delegate = BranchListDelegate::new(
+ workspace.weak_handle(),
+ 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,
+ }
+ })
+ })
+ .unwrap();
+
+ let cx = VisualTestContext::from_window(*workspace, cx);
+
+ (branch_list, cx)
+ }
+
+ async fn init_fake_repository(cx: &mut TestAppContext) -> Entity<Repository> {
+ 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()
}
- fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
- None
+ #[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).await;
+ 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<BranchList>,
+ 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::<Vec<String>>();
+ 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).await;
+ 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::<HashSet<_>>();
+ assert_eq!(
+ branches,
+ ["main", "feature-auth", "feature-ui", "develop"]
+ .into_iter()
+ .filter(|name| name != &branch_to_delete)
+ .collect::<HashSet<_>>()
+ );
+ })
+ });
+ }
+
+ #[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::<Vec<String>>();
+ 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).await;
+ 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::<HashSet<_>>();
+ assert_eq!(
+ branches,
+ [
+ "origin/main",
+ "origin/feature-auth",
+ "fork/feature-ui",
+ "private/develop"
+ ]
+ .into_iter()
+ .filter(|name| name != &branch_to_delete)
+ .collect::<HashSet<_>>()
+ );
+ })
+ });
+ }
+
+ #[gpui::test]
+ async fn test_branch_filter_shows_all_then_remotes_and_applies_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).await;
+ 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(), 4);
+
+ let branches = picker
+ .delegate
+ .matches
+ .iter()
+ .map(|be| be.name())
+ .collect::<HashSet<_>>();
+ assert_eq!(
+ branches,
+ ["origin/main", "fork/feature-auth", "feature-ui", "develop"]
+ .into_iter()
+ .collect::<HashSet<_>>()
+ );
+
+ // Locals should be listed before remotes.
+ let ordered = picker
+ .delegate
+ .matches
+ .iter()
+ .map(|be| be.name())
+ .collect::<Vec<_>>();
+ assert_eq!(
+ ordered,
+ vec!["feature-ui", "develop", "origin/main", "fork/feature-auth"]
+ );
+
+ // 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());
+ })
+ });
+
+ 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;
+
+ 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::<HashSet<_>>();
+ assert_eq!(
+ branches,
+ ["origin/main", "fork/feature-auth"]
+ .into_iter()
+ .collect::<HashSet<_>>()
+ );
+
+ // 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).await;
+ 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).await;
+ 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).await;
+ 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).await;
+ 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, cx);
+ assert!(
+ branch_list.picker_focus_handle.is_focused(window),
+ "Branch picker should be focused when selecting an entry"
+ );
+
+ 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| {
+ // Re-focus the picker since workspace initialization during run_until_parked
+ window.focus(&branch_list.picker_focus_handle, 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<Branch> = (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).await;
+ 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);
+ })
+ });
}
}
@@ -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 => {
@@ -337,6 +337,7 @@ impl CommitModal {
active_repo,
is_amend_pending,
is_signoff_enabled,
+ workspace,
) = self.git_panel.update(cx, |git_panel, cx| {
let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
let title = git_panel.commit_button_title();
@@ -354,6 +355,7 @@ impl CommitModal {
active_repo,
is_amend_pending,
is_signoff_enabled,
+ git_panel.workspace.clone(),
)
});
@@ -375,7 +377,14 @@ impl CommitModal {
.style(ButtonStyle::Transparent);
let branch_picker = PopoverMenu::new("popover-button")
- .menu(move |window, cx| Some(branch_picker::popover(active_repo.clone(), window, cx)))
+ .menu(move |window, cx| {
+ Some(branch_picker::popover(
+ workspace.clone(),
+ active_repo.clone(),
+ window,
+ cx,
+ ))
+ })
.with_handle(self.branch_list_handle.clone())
.trigger_with_tooltip(
branch_picker_button,
@@ -492,60 +501,27 @@ impl CommitModal {
}
}
- fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
- if self.git_panel.read(cx).amend_pending() {
- return;
+ fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
+ 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<Self>) {
- 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<Self>) {
+ 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);
}
}
fn toggle_branch_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.branch_list_handle.is_focused(window, cx) {
- self.focus_handle(cx).focus(window)
+ self.focus_handle(cx).focus(window, cx)
} else {
self.branch_list_handle.toggle(window, cx);
}
@@ -564,8 +540,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| {
@@ -611,8 +587,8 @@ impl Render for CommitModal {
.bg(cx.theme().colors().editor_background)
.border_1()
.border_color(cx.theme().colors().border_variant)
- .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
- window.focus(&editor_focus_handle);
+ .on_click(cx.listener(move |_, _: &ClickEvent, window, cx| {
+ window.focus(&editor_focus_handle, cx);
}))
.child(
div()
@@ -3,7 +3,7 @@ use editor::hover_markdown_style;
use futures::Future;
use git::blame::BlameEntry;
use git::repository::CommitSummary;
-use git::{GitRemote, blame::ParsedCommitMessage};
+use git::{GitRemote, commit::ParsedCommitMessage};
use gpui::{
App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
StatefulInteractiveElement, WeakEntity, prelude::*,
@@ -29,11 +29,16 @@ pub struct CommitDetails {
pub struct CommitAvatar<'a> {
sha: &'a SharedString,
remote: Option<&'a GitRemote>,
+ size: Option<IconSize>,
}
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 {
@@ -43,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<impl IntoElement + use<>> {
+ 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<Avatar> {
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::<CommitAvatarAsset>(&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::<CommitAvatarAsset>(&avatar_url, cx)??;
+ Some(Avatar::new(url.to_string()))
}
}
@@ -197,10 +211,7 @@ impl Render for CommitTooltip {
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
};
@@ -256,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(
@@ -323,6 +334,7 @@ impl Render for CommitTooltip {
repo.downgrade(),
workspace.clone(),
None,
+ None,
window,
cx,
);
@@ -1,11 +1,16 @@
use anyhow::{Context as _, Result};
use buffer_diff::{BufferDiff, BufferDiffSnapshot};
-use editor::{Editor, EditorEvent, MultiBuffer, SelectionEffects, multibuffer_context_lines};
+use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle};
+use editor::{Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines};
use git::repository::{CommitDetails, CommitDiff, RepoPath};
+use git::{
+ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, ParsedGitRemote,
+ parse_git_remote_url,
+};
use gpui::{
- Action, AnyElement, AnyView, App, AppContext as _, AsyncApp, AsyncWindowContext, Context,
- Entity, EventEmitter, FocusHandle, Focusable, IntoElement, PromptLevel, Render, Task,
- WeakEntity, Window, actions,
+ 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 _,
@@ -15,14 +20,13 @@ 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::{
- Button, Color, Icon, IconName, Label, LabelCommon as _, SharedString, Tooltip, prelude::*,
-};
+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, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
Workspace,
@@ -32,20 +36,21 @@ use workspace::{
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| {
- register_workspace_action(workspace, |toolbar, _: &ApplyCurrentStash, window, cx| {
- toolbar.apply_stash(window, cx);
+ workspace.register_action(|workspace, _: &ApplyCurrentStash, window, cx| {
+ CommitView::apply_stash(workspace, window, cx);
});
- register_workspace_action(workspace, |toolbar, _: &DropCurrentStash, window, cx| {
- toolbar.remove_stash(window, cx);
+ workspace.register_action(|workspace, _: &DropCurrentStash, window, cx| {
+ CommitView::remove_stash(workspace, window, cx);
});
- register_workspace_action(workspace, |toolbar, _: &PopCurrentStash, window, cx| {
- toolbar.pop_stash(window, cx);
+ workspace.register_action(|workspace, _: &PopCurrentStash, window, cx| {
+ CommitView::pop_stash(workspace, window, cx);
});
})
.detach();
@@ -56,20 +61,18 @@ pub struct CommitView {
editor: Entity<Editor>,
stash: Option<usize>,
multibuffer: Entity<MultiBuffer>,
+ repository: Entity<Repository>,
+ remote: Option<GitRemote>,
}
struct GitBlob {
path: RepoPath,
worktree_id: WorktreeId,
is_deleted: bool,
+ display_name: Arc<str>,
}
-struct CommitMetadataFile {
- title: Arc<RelPath>,
- worktree_id: WorktreeId,
-}
-
-const COMMIT_METADATA_SORT_PREFIX: u64 = 0;
+const COMMIT_MESSAGE_SORT_PREFIX: u64 = 0;
const FILE_NAMESPACE_SORT_PREFIX: u64 = 1;
impl CommitView {
@@ -78,6 +81,7 @@ impl CommitView {
repo: WeakEntity<Repository>,
workspace: WeakEntity<Workspace>,
stash: Option<usize>,
+ file_filter: Option<RepoPath>,
window: &mut Window,
cx: &mut App,
) {
@@ -91,8 +95,14 @@ impl CommitView {
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
@@ -140,64 +150,87 @@ impl CommitView {
) -> 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::<str>::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 title = if let Some(stash) = stash {
- format!("stash@{{{}}}", stash)
- } else {
- format!("commit {}", commit.sha)
- };
- let file = Arc::new(CommitMetadataFile {
- title: RelPath::unix(&title).unwrap().into(),
- worktree_id,
- });
- let buffer = cx.new(|cx| {
- let buffer = TextBuffer::new_normalized(
- ReplicaId::LOCAL,
- cx.entity_id().as_non_zero_u64().into(),
- LineEnding::default(),
- format_commit(&commit, stash.is_some()).into(),
- );
- metadata_buffer_id = Some(buffer.remote_id());
- Buffer::build(buffer, Some(file.clone()), Capability::ReadWrite)
- });
- multibuffer.update(cx, |multibuffer, cx| {
- multibuffer.set_excerpts_for_path(
- PathKey::with_sort_prefix(COMMIT_METADATA_SORT_PREFIX, 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)
@@ -205,10 +238,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<str> =
+ 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<dyn language::File>;
let buffer = build_buffer(new_text, file, &language_registry, cx).await?;
@@ -218,16 +261,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::<Vec<_>>();
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::<Vec<_>>()
+ }
+ };
+
let _is_newly_added = multibuffer.set_excerpts_for_path(
PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path),
buffer,
- diff_hunk_ranges,
+ excerpt_ranges,
multibuffer_context_lines(cx),
cx,
);
@@ -235,68 +284,388 @@ 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,
}
}
-}
-impl language::File for GitBlob {
- fn as_local(&self) -> Option<&dyn language::LocalFile> {
- None
+ fn render_commit_avatar(
+ &self,
+ sha: &SharedString,
+ size: impl Into<gpui::AbsoluteLength>,
+ 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 disk_state(&self) -> DiskState {
- if self.is_deleted {
- DiskState::Deleted
- } else {
- DiskState::New
+ 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;
+ }
}
- }
- fn path_style(&self, _: &App) -> PathStyle {
- PathStyle::Posix
+ (total_additions, total_deletions)
}
- fn path(&self) -> &Arc<RelPath> {
- self.path.as_ref()
+ fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> 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 parsed_remote = ParsedGitRemote {
+ owner: remote.owner.as_ref().into(),
+ repo: remote.repo.as_ref().into(),
+ };
+ let params = BuildCommitPermalinkParams { sha: &commit.sha };
+ let url = remote
+ .host
+ .build_commit_permalink(&parsed_remote, params)
+ .to_string();
+ (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 full_path(&self, _: &App) -> PathBuf {
- self.path.as_std_path().to_path_buf()
+ 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 file_name<'a>(&'a self, _: &'a App) -> &'a str {
- self.path.file_name().unwrap()
+ 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 worktree_id(&self, _: &App) -> WorktreeId {
- self.worktree_id
+ 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 to_proto(&self, _cx: &App) -> language::proto::File {
- unimplemented!()
+ fn stash_action<AsyncFn>(
+ workspace: &mut Workspace,
+ str_action: &str,
+ window: &mut Window,
+ cx: &mut App,
+ callback: AsyncFn,
+ ) where
+ AsyncFn: AsyncFnOnce(
+ Entity<Repository>,
+ &SharedString,
+ usize,
+ Entity<CommitView>,
+ WeakEntity<Workspace>,
+ &mut AsyncWindowContext,
+ ) -> anyhow::Result<()>
+ + 'static,
+ {
+ let Some(commit_view) = workspace.active_item_as::<CommitView>(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::<GitPanel>(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);
}
- fn is_private(&self) -> bool {
- false
+ async fn close_commit_view(
+ commit_view: Entity<CommitView>,
+ workspace: WeakEntity<Workspace>,
+ 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 CommitMetadataFile {
+impl language::File for GitBlob {
fn as_local(&self) -> Option<&dyn language::LocalFile> {
None
}
fn disk_state(&self) -> DiskState {
- DiskState::New
+ if self.is_deleted {
+ DiskState::Deleted
+ } else {
+ DiskState::New
+ }
}
fn path_style(&self, _: &App) -> PathStyle {
@@ -304,22 +673,22 @@ impl language::File for CommitMetadataFile {
}
fn path(&self) -> &Arc<RelPath> {
- &self.title
+ self.path.as_ref()
}
fn full_path(&self, _: &App) -> PathBuf {
- PathBuf::from(self.title.as_unix_str().to_owned())
+ self.path.as_std_path().to_path_buf()
}
fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
- self.title.file_name().unwrap()
+ self.display_name.as_ref()
}
fn worktree_id(&self, _: &App) -> WorktreeId {
self.worktree_id
}
- fn to_proto(&self, _: &App) -> language::proto::File {
+ fn to_proto(&self, _cx: &App) -> language::proto::File {
unimplemented!()
}
@@ -328,6 +697,45 @@ impl language::File for CommitMetadataFile {
}
}
+// 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<RelPath> {
+// &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,
blob: Arc<dyn File>,
@@ -355,7 +763,7 @@ async fn build_buffer(
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)
@@ -402,45 +810,6 @@ async fn build_buffer_diff(
})
}
-fn format_commit(commit: &CommitDetails, is_stash: bool) -> String {
- let mut result = String::new();
- if is_stash {
- writeln!(&mut result, "stash commit {}", commit.sha).unwrap();
- } else {
- writeln!(&mut result, "commit {}", commit.sha).unwrap();
- }
- writeln!(
- &mut result,
- "Author: {} <{}>",
- commit.author_name, commit.author_email
- )
- .unwrap();
- let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
- writeln!(
- &mut result,
- "Date: {}",
- time_format::format_localized_timestamp(
- time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp).unwrap(),
- time::OffsetDateTime::now_utc(),
- local_offset,
- 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<EditorEvent> for CommitView {}
impl Focusable for CommitView {
@@ -469,13 +838,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<ui::SharedString> {
+ fn tab_tooltip_content(&self, _: &App) -> Option<TabTooltipContent> {
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)) {
@@ -496,17 +880,17 @@ impl Item for CommitView {
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
- ) -> Option<AnyView> {
+ ) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
- Some(self_handle.to_any())
+ Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
- Some(self.editor.to_any())
+ Some(self.editor.clone().into())
} else {
None
}
}
- fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
@@ -540,11 +924,11 @@ impl Item for CommitView {
}
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
- ToolbarItemLocation::PrimaryLeft
+ ToolbarItemLocation::Hidden
}
- fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
- self.editor.breadcrumbs(theme, cx)
+ fn breadcrumbs(&self, _theme: &theme::Theme, _cx: &App) -> Option<Vec<BreadcrumbText>> {
+ None
}
fn added_to_workspace(
@@ -582,192 +966,46 @@ impl Item for CommitView {
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, cx: &mut Context<Self>) -> impl IntoElement {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_stash = self.stash.is_some();
- div()
+
+ v_flex()
.key_context(if is_stash { "StashDiff" } else { "CommitDiff" })
- .bg(cx.theme().colors().editor_background)
- .flex()
- .items_center()
- .justify_center()
.size_full()
- .child(self.editor.clone())
+ .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<WeakEntity<CommitView>>,
- workspace: WeakEntity<Workspace>,
}
impl CommitViewToolbar {
- pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
- Self {
- commit_view: None,
- workspace: workspace.weak_handle(),
- }
- }
-
- fn commit_view(&self, _: &App) -> Option<Entity<CommitView>> {
- self.commit_view.as_ref()?.upgrade()
+ pub fn new() -> Self {
+ Self { commit_view: None }
}
+}
- async fn close_commit_view(
- commit_view: Entity<CommitView>,
- workspace: WeakEntity<Workspace>,
- cx: &mut AsyncWindowContext,
- ) -> anyhow::Result<()> {
- workspace
- .update_in(cx, |workspace, window, cx| {
- let active_pane = workspace.active_pane();
- let commit_view_id = commit_view.entity_id();
- active_pane.update(cx, |pane, cx| {
- pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx)
- })
- })?
- .await?;
- anyhow::Ok(())
- }
-
- fn apply_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- self.stash_action(
- "Apply",
- window,
- cx,
- async move |repository, sha, stash, commit_view, workspace, cx| {
- let result = repository.update(cx, |repo, cx| {
- if !stash_matches_index(&sha, stash, repo) {
- return Err(anyhow::anyhow!("Stash has changed, not applying"));
- }
- Ok(repo.stash_apply(Some(stash), cx))
- })?;
-
- match result {
- Ok(task) => task.await?,
- Err(err) => {
- Self::close_commit_view(commit_view, workspace, cx).await?;
- return Err(err);
- }
- };
- Self::close_commit_view(commit_view, workspace, cx).await?;
- anyhow::Ok(())
- },
- );
- }
-
- fn pop_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- self.stash_action(
- "Pop",
- window,
- cx,
- async move |repository, sha, stash, commit_view, workspace, cx| {
- let result = repository.update(cx, |repo, cx| {
- if !stash_matches_index(&sha, stash, repo) {
- return Err(anyhow::anyhow!("Stash has changed, pop aborted"));
- }
- Ok(repo.stash_pop(Some(stash), cx))
- })?;
-
- match result {
- Ok(task) => task.await?,
- Err(err) => {
- Self::close_commit_view(commit_view, workspace, cx).await?;
- return Err(err);
- }
- };
- Self::close_commit_view(commit_view, workspace, cx).await?;
- anyhow::Ok(())
- },
- );
- }
-
- fn remove_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- self.stash_action(
- "Drop",
- window,
- cx,
- async move |repository, sha, stash, commit_view, workspace, cx| {
- let result = repository.update(cx, |repo, cx| {
- if !stash_matches_index(&sha, stash, repo) {
- return Err(anyhow::anyhow!("Stash has changed, drop aborted"));
- }
- Ok(repo.stash_drop(Some(stash), cx))
- })?;
-
- match result {
- Ok(task) => task.await??,
- Err(err) => {
- Self::close_commit_view(commit_view, workspace, cx).await?;
- return Err(err);
- }
- };
- Self::close_commit_view(commit_view, workspace, cx).await?;
- anyhow::Ok(())
- },
- );
- }
-
- fn stash_action<AsyncFn>(
- &mut self,
- str_action: &str,
- window: &mut Window,
- cx: &mut Context<Self>,
- callback: AsyncFn,
- ) where
- AsyncFn: AsyncFnOnce(
- Entity<Repository>,
- &SharedString,
- usize,
- Entity<CommitView>,
- WeakEntity<Workspace>,
- &mut AsyncWindowContext,
- ) -> anyhow::Result<()>
- + 'static,
- {
- let Some(commit_view) = self.commit_view(cx) else {
- return;
- };
- let Some(stash) = commit_view.read(cx).stash else {
- return;
- };
- let sha = commit_view.read(cx).commit.sha.clone();
- let answer = window.prompt(
- PromptLevel::Info,
- &format!("{} stash@{{{}}}?", str_action, stash),
- None,
- &[str_action, "Cancel"],
- cx,
- );
-
- let workspace = self.workspace.clone();
- cx.spawn_in(window, async move |_, cx| {
- if answer.await != Ok(0) {
- return anyhow::Ok(());
- }
- let repo = workspace.update(cx, |workspace, cx| {
- workspace
- .panel::<GitPanel>(cx)
- .and_then(|p| p.read(cx).active_repository.clone())
- })?;
+impl EventEmitter<ToolbarItemEvent> for CommitViewToolbar {}
- let Some(repo) = repo else {
- return Ok(());
- };
- callback(repo, &sha, stash, commit_view, workspace, cx).await?;
- anyhow::Ok(())
- })
- .detach_and_notify_err(window, cx);
+impl Render for CommitViewToolbar {
+ fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+ div().hidden()
}
}
-impl EventEmitter<ToolbarItemEvent> for CommitViewToolbar {}
-
impl ToolbarItemView for CommitViewToolbar {
fn set_active_pane_item(
&mut self,
@@ -111,6 +111,7 @@ fn excerpt_for_buffer_updated(
);
}
+#[ztracing::instrument(skip_all)]
fn buffer_added(editor: &mut Editor, buffer: Entity<Buffer>, cx: &mut Context<Editor>) {
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<ConflictSet>,
@@ -311,6 +313,7 @@ fn conflicts_updated(
}
}
+#[ztracing::instrument(skip_all)]
fn update_conflict_highlighting(
editor: &mut Editor,
conflict: &ConflictRegion,
@@ -372,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();
@@ -392,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();
@@ -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<Self>,
_: &'a App,
- ) -> Option<AnyView> {
+ ) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
- Some(self_handle.to_any())
+ Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
- Some(self.editor.to_any())
+ Some(self.editor.clone().into())
} else {
None
}
}
- fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
@@ -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<Repository>,
+ git_store: WeakEntity<GitStore>,
+ workspace: WeakEntity<Workspace>,
+ remote: Option<GitRemote>,
+ selected_entry: Option<usize>,
+ scroll_handle: UniformListScrollHandle,
+ focus_handle: FocusHandle,
+ loading_more: bool,
+ has_more: bool,
+}
+
+impl FileHistoryView {
+ pub fn open(
+ path: RepoPath,
+ git_store: WeakEntity<GitStore>,
+ repo: WeakEntity<Repository>,
+ workspace: WeakEntity<Workspace>,
+ 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::<FileHistoryView>();
+ 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<GitStore>,
+ repository: Entity<Repository>,
+ workspace: WeakEntity<Workspace>,
+ _project: Entity<Project>,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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<Self>) {
+ 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<Self>) {
+ 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<Self>,
+ ) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<usize>, cx: &mut Context<Self>) {
+ 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>) {
+ self.open_commit_view(window, cx);
+ }
+
+ fn open_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ 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::<CommitAvatarAsset>(&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<Self>,
+ ) -> AnyElement {
+ let pr_number = entry
+ .subject
+ .rfind("(#")
+ .and_then(|start| {
+ let rest = &entry.subject[start + 2..];
+ rest.find(')')
+ .and_then(|end| rest[..end].parse::<u32>().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<H: std::hash::Hasher>(&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<SharedString>;
+
+ fn load(
+ source: Self::Source,
+ cx: &mut App,
+ ) -> impl Future<Output = Self::Output> + 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<ItemEvent> 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<Self>) -> 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<SharedString> {
+ Some(format!("Git history for {}", self.history.path.as_unix_str()).into())
+ }
+
+ fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
+ Some(Icon::new(IconName::GitBranch))
+ }
+
+ fn telemetry_event_text(&self) -> Option<&'static str> {
+ Some("file history")
+ }
+
+ fn clone_on_split(
+ &self,
+ _workspace_id: Option<workspace::WorkspaceId>,
+ _window: &mut Window,
+ _cx: &mut Context<Self>,
+ ) -> Task<Option<Entity<Self>>> {
+ Task::ready(None)
+ }
+
+ fn navigate(&mut self, _: Box<dyn Any>, _window: &mut Window, _: &mut Context<Self>) -> bool {
+ false
+ }
+
+ fn deactivated(&mut self, _window: &mut Window, _: &mut Context<Self>) {}
+
+ fn can_save(&self, _: &App) -> bool {
+ false
+ }
+
+ fn save(
+ &mut self,
+ _options: SaveOptions,
+ _project: Entity<Project>,
+ _window: &mut Window,
+ _: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ Task::ready(Ok(()))
+ }
+
+ fn save_as(
+ &mut self,
+ _project: Entity<Project>,
+ _path: ProjectPath,
+ _window: &mut Window,
+ _: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ Task::ready(Ok(()))
+ }
+
+ fn reload(
+ &mut self,
+ _project: Entity<Project>,
+ _window: &mut Window,
+ _: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ 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<Vec<workspace::item::BreadcrumbText>> {
+ None
+ }
+
+ fn added_to_workspace(
+ &mut self,
+ _workspace: &mut Workspace,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ window.focus(&self.focus_handle, cx);
+ }
+
+ fn show_toolbar(&self) -> bool {
+ true
+ }
+
+ fn pixel_position_of_cursor(&self, _: &App) -> Option<gpui::Point<gpui::Pixels>> {
+ None
+ }
+
+ fn set_nav_history(
+ &mut self,
+ _: workspace::ItemNavHistory,
+ _window: &mut Window,
+ _: &mut Context<Self>,
+ ) {
+ }
+
+ fn act_as_type<'a>(
+ &'a self,
+ type_id: TypeId,
+ self_handle: &'a Entity<Self>,
+ _: &'a App,
+ ) -> Option<AnyEntity> {
+ if type_id == TypeId::of::<Self>() {
+ Some(self_handle.clone().into())
+ } else {
+ None
+ }
+ }
+}
@@ -6,15 +6,22 @@ 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::RewrapOptions;
+use editor::{
+ Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset,
+ actions::ExpandAllDiffHunks,
+};
use futures::StreamExt as _;
-use git::blame::ParsedCommitMessage;
+use git::commit::ParsedCommitMessage;
use git::repository::{
Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
@@ -24,21 +31,22 @@ use git::stash::GitStash;
use git::status::StageStatus;
use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
use git::{
- ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashApply, StashPop,
- TrashUntrackedFiles, UnstageAll,
+ ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll,
+ StashApply, StashPop, 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, MouseButton, MouseDownEvent, Point,
+ PromptLevel, ScrollStrategy, Subscription, Task, UniformListScrollHandle, WeakEntity, actions,
+ anchored, deferred, point, size, uniform_list,
};
use itertools::Itertools;
use language::{Buffer, File};
use language_model::{
- ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
+ ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
+ Role, ZED_CLOUD_PROVIDER_ID,
};
-use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
+use menu;
use multi_buffer::ExcerptInfo;
use notifications::status_toast::{StatusToast, ToastIcon};
use panel::{
@@ -48,30 +56,30 @@ use panel::{
use project::{
Fs, Project, ProjectPath,
git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
+ project_settings::{GitPathStyle, ProjectSettings},
};
+use prompt_store::{PromptId, PromptStore, 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::{
- ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, 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,
[
@@ -85,10 +93,24 @@ actions!(
FocusEditor,
/// Focuses on the changes list.
FocusChanges,
+ /// Select next git panel menu item, and show it in the diff view
+ NextEntry,
+ /// Select previous git panel menu item, and show it in the diff view
+ PreviousEntry,
+ /// Select first git panel menu item, and show it in the diff view
+ FirstEntry,
+ /// Select last git panel menu item, and show it in the diff view
+ LastEntry,
/// Toggles automatic co-author suggestions.
ToggleFillCoAuthors,
/// Toggles sorting entries by path vs status.
ToggleSortByPath,
+ /// Toggles showing entries in tree vs flat view.
+ ToggleTreeView,
+ /// Expands the selected entry to show its children.
+ ExpandSelectedEntry,
+ /// Collapses the selected entry to hide its children.
+ CollapseSelectedEntry,
]
);
@@ -119,6 +141,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 +186,33 @@ 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 = 16.0;
pub fn register(workspace: &mut Workspace) {
workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
@@ -201,7 +237,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 +273,8 @@ impl GitHeaderEntry {
#[derive(Debug, PartialEq, Eq, Clone)]
enum GitListEntry {
Status(GitStatusEntry),
+ TreeStatus(GitTreeStatusEntry),
+ Directory(GitTreeDirEntry),
Header(GitHeaderEntry),
}
@@ -244,11 +282,215 @@ impl GitListEntry {
fn status_entry(&self) -> Option<&GitStatusEntry> {
match self {
GitListEntry::Status(entry) => Some(entry),
+ GitListEntry::TreeStatus(entry) => Some(&entry.entry),
+ _ => None,
+ }
+ }
+
+ fn directory_entry(&self) -> Option<&GitTreeDirEntry> {
+ match self {
+ GitListEntry::Directory(entry) => Some(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<usize>,
+ expanded_dirs: HashMap<TreeKey, bool>,
+ directory_descendants: HashMap<TreeKey, Vec<GitStatusEntry>>,
+}
+
+impl TreeViewState {
+ fn build_tree_entries(
+ &mut self,
+ section: Section,
+ mut entries: Vec<GitStatusEntry>,
+ seen_directories: &mut HashSet<TreeKey>,
+ ) -> 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<TreeKey>,
+ ) -> (Vec<(GitListEntry, bool)>, Vec<GitStatusEntry>) {
+ 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<RepoPath>,
+ children: BTreeMap<SharedString, TreeNode>,
+ files: Vec<GitStatusEntry>,
+}
+
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct GitStatusEntry {
pub(crate) repo_path: RepoPath,
@@ -271,6 +513,69 @@ impl GitStatusEntry {
}
}
+struct TruncatedPatch {
+ header: String,
+ hunks: Vec<String>,
+ hunks_to_keep: usize,
+}
+
+impl TruncatedPatch {
+ fn from_unified_diff(patch_str: &str) -> Option<Self> {
+ 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 {
pub(crate) active_repository: Option<Entity<Repository>>,
pub(crate) commit_editor: Entity<Editor>,
@@ -279,12 +584,15 @@ pub struct GitPanel {
add_coauthors: bool,
generate_commit_message_task: Option<Task<Option<()>>>,
entries: Vec<GitListEntry>,
+ view_mode: GitPanelViewMode,
+ entries_indices: HashMap<RepoPath, usize>,
single_staged_entry: Option<GitStatusEntry>,
single_tracked_entry: Option<GitStatusEntry>,
focus_handle: FocusHandle,
fs: Arc<dyn Fs>,
new_count: usize,
entry_count: usize,
+ changes_count: usize,
new_staged_count: usize,
pending_commit: Option<Task<()>>,
amend_pending: bool,
@@ -300,7 +608,7 @@ pub struct GitPanel {
tracked_staged_count: usize,
update_visible_entries_task: Task<()>,
width: Option<Pixels>,
- workspace: WeakEntity<Workspace>,
+ pub(crate) workspace: WeakEntity<Workspace>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
modal_open: bool,
show_placeholders: bool,
@@ -367,13 +675,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::<SettingsStore>(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();
@@ -409,17 +723,10 @@ impl GitPanel {
}
GitStoreEvent::RepositoryUpdated(
_,
- RepositoryEvent::StatusesChanged { full_scan: true }
+ RepositoryEvent::StatusesChanged
| RepositoryEvent::BranchChanged
| RepositoryEvent::MergeHeadsChanged,
true,
- ) => {
- this.schedule_update(window, cx);
- }
- GitStoreEvent::RepositoryUpdated(
- _,
- RepositoryEvent::StatusesChanged { full_scan: false },
- true,
)
| GitStoreEvent::RepositoryAdded
| GitStoreEvent::RepositoryRemoved(_) => {
@@ -446,10 +753,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,
+ changes_count: 0,
pending_commit: None,
amend_pending: false,
original_commit_message: None,
@@ -483,70 +793,70 @@ impl GitPanel {
})
}
- pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option<usize> {
- 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<usize> {
+ self.entries_indices.get(path).copied()
}
pub fn select_entry_by_path(
&mut self,
path: ProjectPath,
- _: &mut Window,
+ window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(git_repo) = self.active_repository.as_ref() else {
return;
};
- let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else {
- return;
+
+ let (repo_path, section) = {
+ let repo = git_repo.read(cx);
+ let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else {
+ return;
+ };
+
+ let section = repo
+ .status_for_path(&repo_path)
+ .map(|status| status.status)
+ .map(|status| {
+ if repo.had_conflict_on_last_merge_head_change(&repo_path) {
+ Section::Conflict
+ } else if status.is_created() {
+ Section::New
+ } else {
+ Section::Tracked
+ }
+ });
+
+ (repo_path, section)
};
- let Some(ix) = self.entry_by_path(&repo_path, cx) else {
+
+ let mut needs_rebuild = false;
+ if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) {
+ let mut current_dir = repo_path.parent();
+ while let Some(dir) = current_dir {
+ let key = TreeKey {
+ section,
+ path: RepoPath::from_rel_path(dir),
+ };
+
+ if tree_state.expanded_dirs.get(&key) == Some(&false) {
+ tree_state.expanded_dirs.insert(key, true);
+ needs_rebuild = true;
+ }
+
+ current_dir = dir.parent();
+ }
+ }
+
+ if needs_rebuild {
+ self.update_visible_entries(window, cx);
+ }
+
+ let Some(ix) = self.entry_by_path(&repo_path) else {
return;
};
+
self.selected_entry = Some(ix);
- cx.notify();
+ self.scroll_to_selected_entry(cx);
}
fn serialization_key(workspace: &Workspace) -> Option<String> {
@@ -634,24 +944,98 @@ impl GitPanel {
}
fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
- if let Some(selected_entry) = self.selected_entry {
+ let Some(selected_entry) = self.selected_entry else {
+ cx.notify();
+ return;
+ };
+
+ let visible_index = match &self.view_mode {
+ GitPanelViewMode::Flat => Some(selected_entry),
+ GitPanelViewMode::Tree(state) => state
+ .logical_indices
+ .iter()
+ .position(|&ix| ix == selected_entry),
+ };
+
+ if let Some(visible_index) = visible_index {
self.scroll_handle
- .scroll_to_item(selected_entry, ScrollStrategy::Center);
+ .scroll_to_item(visible_index, ScrollStrategy::Center);
}
cx.notify();
}
- fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
- if !self.entries.is_empty() {
- self.selected_entry = Some(1);
+ fn expand_selected_entry(
+ &mut self,
+ _: &ExpandSelectedEntry,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(entry) = self.get_selected_entry().cloned() else {
+ return;
+ };
+
+ if let GitListEntry::Directory(dir_entry) = entry {
+ if dir_entry.expanded {
+ self.select_next(&menu::SelectNext, window, cx);
+ } else {
+ self.toggle_directory(&dir_entry.key, window, cx);
+ }
+ } else {
+ self.select_next(&menu::SelectNext, window, cx);
+ }
+ }
+
+ fn collapse_selected_entry(
+ &mut self,
+ _: &CollapseSelectedEntry,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(entry) = self.get_selected_entry().cloned() else {
+ return;
+ };
+
+ if let GitListEntry::Directory(dir_entry) = entry {
+ if dir_entry.expanded {
+ self.toggle_directory(&dir_entry.key, window, cx);
+ } else {
+ self.select_previous(&menu::SelectPrevious, window, cx);
+ }
+ } else {
+ self.select_previous(&menu::SelectPrevious, window, cx);
+ }
+ }
+
+ fn select_first(
+ &mut self,
+ _: &menu::SelectFirst,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let first_entry = match &self.view_mode {
+ GitPanelViewMode::Flat => self
+ .entries
+ .iter()
+ .position(|entry| entry.status_entry().is_some()),
+ GitPanelViewMode::Tree(state) => {
+ let index = self.entries.iter().position(|entry| {
+ entry.status_entry().is_some() || entry.directory_entry().is_some()
+ });
+
+ index.map(|index| state.logical_indices[index])
+ }
+ };
+
+ if let Some(first_entry) = first_entry {
+ self.selected_entry = Some(first_entry);
self.scroll_to_selected_entry(cx);
}
}
fn select_previous(
&mut self,
- _: &SelectPrevious,
+ _: &menu::SelectPrevious,
_window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -660,80 +1044,142 @@ impl GitPanel {
return;
}
- if let Some(selected_entry) = self.selected_entry {
- let new_selected_entry = if selected_entry > 0 {
- selected_entry - 1
- } else {
- selected_entry
- };
+ let Some(selected_entry) = self.selected_entry else {
+ return;
+ };
- if matches!(
- self.entries.get(new_selected_entry),
- Some(GitListEntry::Header(..))
- ) {
- if new_selected_entry > 0 {
- self.selected_entry = Some(new_selected_entry - 1)
- }
- } else {
- self.selected_entry = Some(new_selected_entry);
+ let new_index = match &self.view_mode {
+ GitPanelViewMode::Flat => selected_entry.saturating_sub(1),
+ GitPanelViewMode::Tree(state) => {
+ let Some(current_logical_index) = state
+ .logical_indices
+ .iter()
+ .position(|&i| i == selected_entry)
+ else {
+ return;
+ };
+
+ state.logical_indices[current_logical_index.saturating_sub(1)]
}
+ };
- self.scroll_to_selected_entry(cx);
+ if selected_entry == 0 && new_index == 0 {
+ return;
}
- cx.notify();
+ if matches!(
+ self.entries.get(new_index.saturating_sub(1)),
+ Some(GitListEntry::Header(..))
+ ) && new_index == 0
+ {
+ return;
+ }
+
+ if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
+ self.selected_entry = Some(new_index.saturating_sub(1));
+ } else {
+ self.selected_entry = Some(new_index);
+ }
+
+ self.scroll_to_selected_entry(cx);
}
- fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+ fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
let item_count = self.entries.len();
if item_count == 0 {
return;
}
- if let Some(selected_entry) = self.selected_entry {
- let new_selected_entry = if selected_entry < item_count - 1 {
- selected_entry + 1
- } else {
- selected_entry
- };
- if matches!(
- self.entries.get(new_selected_entry),
- Some(GitListEntry::Header(..))
- ) {
- self.selected_entry = Some(new_selected_entry + 1);
- } else {
- self.selected_entry = Some(new_selected_entry);
+ let Some(selected_entry) = self.selected_entry else {
+ return;
+ };
+
+ if selected_entry == item_count - 1 {
+ return;
+ }
+
+ let new_index = match &self.view_mode {
+ GitPanelViewMode::Flat => selected_entry.saturating_add(1),
+ GitPanelViewMode::Tree(state) => {
+ let Some(current_logical_index) = state
+ .logical_indices
+ .iter()
+ .position(|&i| i == selected_entry)
+ else {
+ return;
+ };
+
+ state.logical_indices[current_logical_index.saturating_add(1)]
}
+ };
- self.scroll_to_selected_entry(cx);
+ if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
+ self.selected_entry = Some(new_index.saturating_add(1));
+ } else {
+ self.selected_entry = Some(new_index);
}
- cx.notify();
+ self.scroll_to_selected_entry(cx);
}
- fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+ fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
if self.entries.last().is_some() {
self.selected_entry = Some(self.entries.len() - 1);
self.scroll_to_selected_entry(cx);
}
}
+ /// Show diff view at selected entry, only if the diff view is open
+ fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ maybe!({
+ let workspace = self.workspace.upgrade()?;
+
+ if let Some(project_diff) = workspace.read(cx).item_of_type::<ProjectDiff>(cx) {
+ let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
+
+ project_diff.update(cx, |project_diff, cx| {
+ project_diff.move_to_entry(entry.clone(), window, cx);
+ });
+ }
+
+ Some(())
+ });
+ }
+
+ fn first_entry(&mut self, _: &FirstEntry, window: &mut Window, cx: &mut Context<Self>) {
+ self.select_first(&menu::SelectFirst, window, cx);
+ self.move_diff_to_entry(window, cx);
+ }
+
+ fn last_entry(&mut self, _: &LastEntry, window: &mut Window, cx: &mut Context<Self>) {
+ self.select_last(&menu::SelectLast, window, cx);
+ self.move_diff_to_entry(window, cx);
+ }
+
+ fn next_entry(&mut self, _: &NextEntry, window: &mut Window, cx: &mut Context<Self>) {
+ self.select_next(&menu::SelectNext, window, cx);
+ self.move_diff_to_entry(window, cx);
+ }
+
+ fn previous_entry(&mut self, _: &PreviousEntry, window: &mut Window, cx: &mut Context<Self>) {
+ self.select_previous(&menu::SelectPrevious, window, cx);
+ self.move_diff_to_entry(window, cx);
+ }
+
fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
self.commit_editor.update(cx, |editor, cx| {
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
});
cx.notify();
}
- fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
+ fn select_first_entry_if_none(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let have_entries = self
.active_repository
.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.scroll_to_selected_entry(cx);
- cx.notify();
+ self.select_first(&menu::SelectFirst, window, cx);
}
}
@@ -743,10 +1189,8 @@ impl GitPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
- self.select_first_entry_if_none(cx);
-
- cx.focus_self(window);
- cx.notify();
+ self.focus_handle.focus(window, cx);
+ self.select_first_entry_if_none(window, cx);
}
fn get_selected_entry(&self) -> Option<&GitListEntry> {
@@ -767,7 +1211,7 @@ impl GitPanel {
.project_path_to_repo_path(&project_path, cx)
.as_ref()
{
- project_diff.focus_handle(cx).focus(window);
+ project_diff.focus_handle(cx).focus(window, cx);
project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
return None;
};
@@ -777,15 +1221,35 @@ impl GitPanel {
ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
})
.ok();
- self.focus_handle.focus(window);
+ self.focus_handle.focus(window, cx);
Some(())
});
}
- fn open_file(
- &mut self,
- _: &menu::SecondaryConfirm,
+ fn file_history(&mut self, _: &git::FileHistory, window: &mut Window, cx: &mut Context<Self>) {
+ 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,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -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 {
@@ -56,6 +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(),
+ tree_view: git_panel.tree_view.unwrap(),
}
}
}
@@ -3,6 +3,7 @@ use std::any::Any;
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,
@@ -36,6 +37,7 @@ pub mod commit_tooltip;
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;
@@ -58,6 +60,7 @@ actions!(
pub fn init(cx: &mut App) {
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);
@@ -228,6 +231,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::<Editor>() 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();
}
@@ -780,7 +818,7 @@ impl GitCloneModal {
});
let focus_handle = repo_input.focus_handle(cx);
- window.focus(&focus_handle);
+ window.focus(&focus_handle, cx);
Self {
panel,
@@ -85,8 +85,8 @@ impl Render for GitOnboardingModal {
git_onboarding_event!("Cancelled", trigger = "Action");
cx.emit(DismissEvent);
}))
- .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
- this.focus_handle.focus(window);
+ .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+ this.focus_handle.focus(window, cx);
}))
.child(
div().p_1p5().absolute().inset_0().h(px(160.)).child(
@@ -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)
@@ -8,7 +8,7 @@ use anyhow::{Context as _, Result, anyhow};
use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
use collections::{HashMap, HashSet};
use editor::{
- Addon, Editor, EditorEvent, SelectionEffects,
+ Addon, Editor, EditorEvent, SelectionEffects, SplittableEditor,
actions::{GoToHunk, GoToPreviousHunk},
multibuffer_context_lines,
scroll::Autoscroll,
@@ -19,7 +19,7 @@ use git::{
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};
@@ -32,8 +32,8 @@ use project::{
},
};
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};
@@ -45,6 +45,7 @@ use workspace::{
notifications::NotifyTaskExt,
searchable::SearchableItemHandle,
};
+use ztracing::instrument;
actions!(
git,
@@ -55,7 +56,8 @@ actions!(
Add,
/// Shows the diff between the working directory and your default
/// branch (typically main or master).
- BranchDiff
+ BranchDiff,
+ LeaderAndFollower,
]
);
@@ -63,7 +65,7 @@ pub struct ProjectDiff {
project: Entity<Project>,
multibuffer: Entity<MultiBuffer>,
branch_diff: Entity<branch_diff::BranchDiff>,
- editor: Entity<Editor>,
+ editor: Entity<SplittableEditor>,
buffer_diff_subscriptions: HashMap<Arc<RelPath>, (Entity<BufferDiff>, Subscription)>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
@@ -72,6 +74,13 @@ pub struct ProjectDiff {
_subscription: Subscription,
}
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RefreshReason {
+ DiffChanged,
+ StatusesChanged,
+ EditorSaved,
+}
+
const CONFLICT_SORT_PREFIX: u64 = 1;
const TRACKED_SORT_PREFIX: u64 = 2;
const NEW_SORT_PREFIX: u64 = 3;
@@ -147,6 +156,10 @@ impl ProjectDiff {
.items_of_type::<Self>(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 {
@@ -171,7 +184,9 @@ impl ProjectDiff {
pub fn autoscroll(&self, cx: &mut Context<Self>) {
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);
+ })
})
}
@@ -225,44 +240,44 @@ impl ProjectDiff {
cx: &mut Context<Self>,
) -> 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);
-
- match branch_diff.read(cx).diff_base() {
- DiffBase::Head => {
- diff_display_editor.register_addon(GitPanelAddon {
- workspace: workspace.downgrade(),
- });
- }
- DiffBase::Merge { .. } => {
- diff_display_editor.register_addon(BranchDiffAddon {
- branch_diff: branch_diff.clone(),
- });
- diff_display_editor.start_temporary_diff_override();
- diff_display_editor.set_render_diff_hunk_controls(
- Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
- cx,
- );
- //
- }
- }
+ 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();
@@ -274,7 +289,7 @@ impl ProjectDiff {
BranchDiffEvent::FileListChanged => {
this._task = window.spawn(cx, {
let this = cx.weak_entity();
- async |cx| Self::refresh(this, cx).await
+ async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
})
}
},
@@ -293,7 +308,7 @@ impl ProjectDiff {
this._task = {
window.spawn(cx, {
let this = cx.weak_entity();
- async |cx| Self::refresh(this, cx).await
+ async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
})
}
}
@@ -304,7 +319,7 @@ impl ProjectDiff {
let task = window.spawn(cx, {
let this = cx.weak_entity();
- async |cx| Self::refresh(this, cx).await
+ async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
});
Self {
@@ -342,7 +357,7 @@ impl ProjectDiff {
}
pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
- 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)?;
@@ -354,17 +369,27 @@ impl ProjectDiff {
})
}
+ fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ 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<Self>) {
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);
@@ -372,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;
@@ -383,12 +408,14 @@ impl ProjectDiff {
.collect::<Vec<_>>();
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();
}
@@ -435,32 +462,41 @@ impl ProjectDiff {
fn handle_editor_event(
&mut self,
- editor: &Entity<Editor>,
+ editor: &Entity<SplittableEditor>,
event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
- 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::<GitPanel>(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::<GitPanel>(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()
{
- self.focus_handle.focus(window)
+ self.focus_handle.focus(window, cx)
}
}
+ #[instrument(skip_all)]
fn register_buffer(
&mut self,
path_key: PathKey,
@@ -473,33 +509,47 @@ impl ProjectDiff {
let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| {
this._task = window.spawn(cx, {
let this = cx.weak_entity();
- async |cx| Self::refresh(this, cx).await
+ 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::<ConflictAddon>()
.expect("project diff editor should have a conflict addon");
let snapshot = buffer.read(cx).snapshot();
let diff_read = diff.read(cx);
- let diff_hunk_ranges = diff_read
- .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 excerpt_ranges =
- merge_anchor_ranges(diff_hunk_ranges.into_iter(), conflicts, &snapshot)
- .map(|range| range.to_point(&snapshot))
- .collect::<Vec<_>>();
+
+ 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::<Vec<_>>()
+ } else {
+ diff_hunk_ranges.collect()
+ }
+ };
let (was_empty, is_excerpt_newly_added) = self.multibuffer.update(cx, |multibuffer, cx| {
let was_empty = multibuffer.is_empty();
@@ -517,19 +567,27 @@ impl ProjectDiff {
});
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
- && (file_status.is_deleted()
- || (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()
@@ -539,10 +597,10 @@ impl ProjectDiff {
.focus_handle(cx)
.contains_focused(window, cx)
{
- self.focus_handle.focus(window);
+ self.focus_handle.focus(window, cx);
} else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
self.editor.update(cx, |editor, cx| {
- editor.focus_handle(cx).focus(window);
+ editor.focus_handle(cx).focus(window, cx);
});
}
if self.pending_scroll.as_ref() == Some(&path_key) {
@@ -550,14 +608,23 @@ impl ProjectDiff {
}
}
- pub async fn refresh(this: WeakEntity<Self>, cx: &mut AsyncWindowContext) -> Result<()> {
+ pub async fn refresh(
+ this: WeakEntity<Self>,
+ reason: RefreshReason,
+ cx: &mut AsyncWindowContext,
+ ) -> Result<()> {
let mut path_keys = Vec::new();
let buffers_to_load = this.update(cx, |this, cx| {
let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
let load_buffers = branch_diff.load_buffers(cx);
(branch_diff.repo().cloned(), load_buffers)
});
- let mut previous_paths = this.multibuffer.read(cx).paths().collect::<HashSet<_>>();
+ let mut previous_paths = this
+ .multibuffer
+ .read(cx)
+ .paths()
+ .cloned()
+ .collect::<HashSet<_>>();
if let Some(repo) = repo {
let repo = repo.read(cx);
@@ -574,8 +641,20 @@ impl ProjectDiff {
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, cx);
+ multibuffer.remove_excerpts_for_path(path.clone(), cx);
}
});
buffers_to_load
@@ -583,9 +662,32 @@ impl ProjectDiff {
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| {
- this.register_buffer(path_key, entry.file_status, buffer, diff, window, 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();
})?;
@@ -603,14 +705,17 @@ impl ProjectDiff {
pub fn excerpt_paths(&self, cx: &App) -> Vec<std::sync::Arc<util::rel_path::RelPath>> {
self.multibuffer
.read(cx)
- .excerpt_paths()
+ .paths()
.map(|key| key.path.clone())
.collect()
}
}
fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 {
- if GitPanelSettings::get_global(cx).sort_by_path {
+ 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
@@ -645,8 +750,11 @@ impl Item for ProjectDiff {
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- 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(
@@ -655,8 +763,11 @@ impl Item for ProjectDiff {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<SharedString> {
@@ -684,8 +795,9 @@ impl Item for ProjectDiff {
Some("Project Diff Opened")
}
- fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
- Some(Box::new(self.editor.clone()))
+ fn as_searchable(&self, _: &Entity<Self>, cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
+ // TODO(split-diff) SplitEditor should be searchable
+ Some(Box::new(self.editor.read(cx).primary_editor().clone()))
}
fn for_each_project_item(
@@ -693,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(
@@ -702,8 +818,10 @@ impl Item for ProjectDiff {
_: &mut Window,
cx: &mut Context<Self>,
) {
- 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));
+ })
});
}
@@ -747,7 +865,11 @@ impl Item for ProjectDiff {
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
- 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(
@@ -766,19 +888,23 @@ impl Item for ProjectDiff {
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
- 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<Self>,
- _: &'a App,
- ) -> Option<AnyView> {
+ cx: &'a App,
+ ) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
- Some(self_handle.to_any())
+ Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
- Some(self.editor.to_any())
+ Some(self.editor.read(cx).primary_editor().clone().into())
} else {
None
}
@@ -789,7 +915,11 @@ impl Item for ProjectDiff {
}
fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
- self.editor.breadcrumbs(theme, cx)
+ self.editor
+ .read(cx)
+ .last_selected_editor()
+ .read(cx)
+ .breadcrumbs(theme, cx)
}
fn added_to_workspace(
@@ -853,7 +983,7 @@ impl Render for ProjectDiff {
cx,
))
.on_click(move |_, window, cx| {
- window.focus(&keybinding_focus_handle);
+ window.focus(&keybinding_focus_handle, cx);
window.dispatch_action(
Box::new(CloseActiveItem::default()),
cx,
@@ -1023,7 +1153,7 @@ impl ProjectDiffToolbar {
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
if let Some(project_diff) = self.project_diff(cx) {
- project_diff.focus_handle(cx).focus(window);
+ project_diff.focus_handle(cx).focus(window, cx);
}
let action = action.boxed_clone();
cx.defer(move |cx| {
@@ -1491,53 +1621,6 @@ mod preview {
}
}
-fn merge_anchor_ranges<'a>(
- left: impl 'a + Iterator<Item = Range<Anchor>>,
- right: impl 'a + Iterator<Item = Range<Anchor>>,
- snapshot: &'a language::BufferSnapshot,
-) -> impl 'a + Iterator<Item = Range<Anchor>> {
- let mut left = left.fuse().peekable();
- let mut right = right.fuse().peekable();
-
- 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();
- };
-
- 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)
- })
-}
-
struct BranchDiffAddon {
branch_diff: Entity<branch_diff::BranchDiff>,
}
@@ -1624,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,
@@ -1635,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());
@@ -1680,7 +1767,7 @@ mod tests {
window,
cx,
);
- diff.editor.clone()
+ diff.editor.read(cx).primary_editor().clone()
});
assert_state_with_diff(
&editor,
@@ -1701,7 +1788,7 @@ mod tests {
window,
cx,
);
- diff.editor.clone()
+ diff.editor.read(cx).primary_editor().clone()
});
assert_state_with_diff(
&editor,
@@ -1754,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,
@@ -1825,8 +1913,8 @@ mod tests {
cx,
&"
- original
- + ˇdifferent
- "
+ + different
+ ˇ"
.unindent(),
);
}
@@ -1878,7 +1966,7 @@ mod tests {
workspace.active_item_as::<ProjectDiff>(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;
@@ -1992,7 +2080,7 @@ mod tests {
workspace.active_item_as::<ProjectDiff>(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;
@@ -2039,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];
@@ -2056,6 +2144,8 @@ mod tests {
.read(cx)
.editor
.read(cx)
+ .primary_editor()
+ .read(cx)
.addon::<ConflictAddon>()
.unwrap()
.conflict_set(buffer_id)
@@ -2139,7 +2229,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,
@@ -2250,7 +2340,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,
@@ -2344,7 +2434,7 @@ mod tests {
workspace.active_item_as::<ProjectDiff>(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());
fs.set_head_and_index_for_repo(
Path::new(path!("/project/.git")),
@@ -1,4 +1,5 @@
use anyhow::Context as _;
+
use git::repository::{Remote, RemoteCommandOutput};
use linkify::{LinkFinder, LinkKind};
use ui::SharedString;
@@ -269,6 +269,7 @@ impl StashListDelegate {
repo.downgrade(),
self.workspace.clone(),
Some(stash_index),
+ None,
window,
cx,
);
@@ -463,7 +464,7 @@ 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)
@@ -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;
@@ -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<Self>,
_: &'a App,
- ) -> Option<AnyView> {
+ ) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
- Some(self_handle.to_any())
+ Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
- Some(self.diff_editor.to_any())
+ Some(self.diff_editor.clone().into())
} else {
None
}
}
- fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.diff_editor.clone()))
}
@@ -446,7 +446,7 @@ 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;
@@ -691,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
@@ -1,4 +1,5 @@
use anyhow::Context as _;
+use collections::HashSet;
use fuzzy::StringMatchCandidate;
use git::repository::Worktree as GitWorktree;
@@ -9,7 +10,11 @@ use gpui::{
actions, rems,
};
use picker::{Picker, PickerDelegate, PickerEditorPosition};
-use project::{DirectoryLister, git_store::Repository};
+use project::{
+ DirectoryLister,
+ git_store::Repository,
+ trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
+};
use recent_projects::{RemoteConnectionModal, connect};
use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
use std::{path::PathBuf, sync::Arc};
@@ -219,7 +224,6 @@ impl WorktreeListDelegate {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
- let workspace = self.workspace.clone();
let Some(repo) = self.repo.clone() else {
return;
};
@@ -247,6 +251,7 @@ impl WorktreeListDelegate {
let branch = worktree_branch.to_string();
let window_handle = window.window_handle();
+ let workspace = self.workspace.clone();
cx.spawn_in(window, async move |_, cx| {
let Some(paths) = worktree_path.await? else {
return anyhow::Ok(());
@@ -257,8 +262,32 @@ impl WorktreeListDelegate {
repo.create_worktree(branch.clone(), path.clone(), commit)
})?
.await??;
-
- let final_path = path.join(branch);
+ let new_worktree_path = path.join(branch);
+
+ workspace.update(cx, |workspace, cx| {
+ if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+ let repo_path = &repo.read(cx).snapshot().work_directory_abs_path;
+ let project = workspace.project();
+ if let Some((parent_worktree, _)) =
+ project.read(cx).find_worktree(repo_path, cx)
+ {
+ trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+ if trusted_worktrees.can_trust(parent_worktree.read(cx).id(), cx) {
+ trusted_worktrees.trust(
+ HashSet::from_iter([PathTrust::AbsPath(
+ new_worktree_path.clone(),
+ )]),
+ project
+ .read(cx)
+ .remote_connection_options(cx)
+ .map(RemoteHostLocation::from),
+ cx,
+ );
+ }
+ });
+ }
+ }
+ })?;
let (connection_options, app_state, is_local) =
workspace.update(cx, |workspace, cx| {
@@ -274,7 +303,7 @@ impl WorktreeListDelegate {
.update_in(cx, |workspace, window, cx| {
workspace.open_workspace_for_paths(
replace_current_window,
- vec![final_path],
+ vec![new_worktree_path],
window,
cx,
)
@@ -283,7 +312,7 @@ impl WorktreeListDelegate {
} else if let Some(connection_options) = connection_options {
open_remote_worktree(
connection_options,
- vec![final_path],
+ vec![new_worktree_path],
app_state,
window_handle,
replace_current_window,
@@ -421,6 +450,7 @@ async fn open_remote_worktree(
app_state.user_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
+ true,
cx,
)
})?;
@@ -665,7 +695,7 @@ impl PickerDelegate for WorktreeListDelegate {
};
Some(
- ListItem::new(SharedString::from(format!("worktree-menu-{ix}")))
+ ListItem::new(format!("worktree-menu-{ix}"))
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
@@ -1,4 +1,4 @@
-use editor::{Editor, EditorEvent, MultiBufferSnapshot};
+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};
@@ -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::<text::TextSummary, _>(line_start..selection_end)
+ .text_summary_for_range::<MBTextSummary, _>(line_start..selection_end)
.chars as u32;
(selection_end.row, chars_to_last_position)
};
@@ -116,7 +116,7 @@ impl CursorPosition {
for selection in editor.selections.all_adjusted(&snapshot) {
let selection_summary = snapshot
.buffer_snapshot()
- .text_summary_for_range::<text::TextSummary, _>(
+ .text_summary_for_range::<MBTextSummary, _>(
selection.start..selection.end,
);
cursor_position.selected_count.characters +=
@@ -268,7 +268,7 @@ impl GoToLine {
cx,
|s| s.select_anchor_ranges([start..start]),
);
- editor.focus_handle(cx).focus(window);
+ editor.focus_handle(cx).focus(window, cx);
cx.notify()
});
self.prev_scroll_position.take();
@@ -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<String>,
}
#[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,10 @@ pub enum Model {
alias = "gemini-2.5-pro-preview-06-05"
)]
Gemini25Pro,
+ #[serde(rename = "gemini-3-pro-preview")]
+ Gemini3Pro,
+ #[serde(rename = "gemini-3-flash-preview")]
+ Gemini3Flash,
#[serde(rename = "custom")]
Custom {
name: String,
@@ -530,46 +527,37 @@ 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::Gemini3Flash => "gemini-3-flash-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::Gemini3Flash => "gemini-3-flash-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::Gemini3Flash => "Gemini 3 Flash",
Self::Custom {
name, display_name, ..
} => display_name.as_ref().unwrap_or(name),
@@ -578,28 +566,22 @@ 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::Gemini3Flash => 1_048_576,
Self::Custom { max_tokens, .. } => *max_tokens,
}
}
pub fn max_output_tokens(&self) -> Option<u64> {
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::Gemini3Flash => Some(65_536),
Model::Custom { .. } => None,
}
}
@@ -614,18 +596,17 @@ 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
budget_tokens: None,
}
}
+ Self::Gemini3Flash => GoogleModelMode::Default,
Self::Custom { mode, .. } => *mode,
}
}
@@ -636,3 +617,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"], "");
+ }
+}
@@ -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",
@@ -109,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 = [
@@ -121,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
@@ -138,6 +137,8 @@ waker-fn = "1.2.0"
lyon = "1.0"
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"
@@ -156,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"
@@ -185,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",
@@ -327,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"
@@ -11,7 +11,7 @@ GPUI is still in active development as we work on the Zed code editor, and is st
gpui = { version = "*" }
```
- - [Ownership and data flow](src/_ownership_and_data_flow.rs)
+ - [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.
@@ -63,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).
@@ -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")
@@ -438,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)),
),
@@ -29,7 +29,7 @@ impl Example {
];
let focus_handle = cx.focus_handle();
- window.focus(&focus_handle);
+ window.focus(&focus_handle, cx);
Self {
focus_handle,
@@ -40,13 +40,13 @@ impl Example {
}
}
- fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context<Self>) {
- window.focus_next();
+ fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
+ window.focus_next(cx);
self.message = SharedString::from("Pressed Tab - focus-visible border should appear!");
}
- fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context<Self>) {
- window.focus_prev();
+ fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context<Self>) {
+ window.focus_prev(cx);
self.message =
SharedString::from("Pressed Shift-Tab - focus-visible border should appear!");
}
@@ -178,7 +178,7 @@ impl TextInput {
if position.y > bounds.bottom() {
return self.content.len();
}
- line.index_for_x(position.x - bounds.left())
+ line.closest_index_for_x(position.x - bounds.left())
}
fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
@@ -380,7 +380,7 @@ impl EntityInputHandler for TextInput {
let last_layout = self.last_layout.as_ref()?;
assert_eq!(last_layout.text, self.content);
- let utf8_index = last_layout.index_for_x(point.x - line_point.x);
+ let utf8_index = last_layout.index_for_x(point.x - line_point.x)?;
Some(self.offset_to_utf16(utf8_index))
}
}
@@ -736,7 +736,7 @@ fn main() {
window
.update(cx, |view, window, cx| {
- window.focus(&view.text_input.focus_handle(cx));
+ window.focus(&view.text_input.focus_handle(cx), cx);
cx.activate(true);
})
.unwrap();
@@ -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<Self>) -> 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>,
+ ) {
+ 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);
+ });
+}
@@ -55,7 +55,7 @@ fn main() {
cx.activate(false);
cx.new(|cx| {
let focus_handle = cx.focus_handle();
- focus_handle.focus(window);
+ focus_handle.focus(window, cx);
ExampleWindow { focus_handle }
})
},
@@ -72,7 +72,7 @@ fn main() {
|window, cx| {
cx.new(|cx| {
let focus_handle = cx.focus_handle();
- focus_handle.focus(window);
+ focus_handle.focus(window, cx);
ExampleWindow { focus_handle }
})
},
@@ -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<PaintingViewer>) + '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())
@@ -22,7 +22,7 @@ impl Example {
];
let focus_handle = cx.focus_handle();
- window.focus(&focus_handle);
+ window.focus(&focus_handle, cx);
Self {
focus_handle,
@@ -31,13 +31,13 @@ impl Example {
}
}
- fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context<Self>) {
- window.focus_next();
+ fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
+ window.focus_next(cx);
self.message = SharedString::from("You have pressed `Tab`.");
}
- fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context<Self>) {
- window.focus_prev();
+ fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context<Self>) {
+ window.focus_prev(cx);
self.message = SharedString::from("You have pressed `Shift-Tab`.");
}
}
@@ -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))
@@ -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,
};
@@ -551,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<AppCell>,
pub(crate) platform: Rc<dyn Platform>,
+ pub(crate) mode: GpuiMode,
text_system: Arc<TextSystem>,
flushing_effects: bool,
pending_updates: usize,
@@ -635,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,
@@ -1410,7 +1439,7 @@ impl App {
let quit_on_empty = match cx.quit_mode {
QuitMode::Explicit => false,
QuitMode::LastWindowClosed => true,
- QuitMode::Default => !cfg!(macos),
+ QuitMode::Default => cfg!(not(target_os = "macos")),
};
if quit_on_empty && cx.windows.is_empty() {
@@ -1466,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<AsyncFn, R>(&self, priority: Priority, f: AsyncFn) -> Task<R>
+ 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) {
@@ -1730,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<A: Action>(&mut self, listener: impl Fn(&A, &mut Self) + 'static) {
+ pub fn on_action<A: Action>(
+ &mut self,
+ listener: impl Fn(&A, &mut Self) + 'static,
+ ) -> &mut Self {
self.global_action_listeners
.entry(TypeId::of::<A>())
.or_default()
@@ -1740,6 +1790,7 @@ impl App {
listener(action, cx)
}
}));
+ self
}
/// Event handlers propagate events by default. Call this method to stop dispatching to
@@ -1849,8 +1900,11 @@ impl App {
pub(crate) fn clear_pending_keystrokes(&mut self) {
for window in self.windows() {
window
- .update(self, |_, window, _| {
- window.clear_pending_keystrokes();
+ .update(self, |_, window, cx| {
+ if window.pending_input_keystrokes().is_some() {
+ window.clear_pending_keystrokes();
+ window.pending_input_changed(cx);
+ }
})
.ok();
}
@@ -2400,10 +2454,6 @@ impl HttpClient for NullHttpClient {
fn proxy(&self) -> Option<&Url> {
None
}
-
- fn type_name(&self) -> &'static str {
- type_name::<Self>()
- }
}
/// A mutable reference to an entity owned by GPUI
@@ -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<R> {
- 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<PromptButton>,
{
- 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<T: 'static>(&mut self) -> Result<Reservation<T>> {
- self.window.update(self, |_, _, cx| cx.reserve_entity())
+ self.app
+ .update_window(self.window, |_, _, cx| cx.reserve_entity())
}
fn insert_entity<T: 'static>(
@@ -377,8 +379,9 @@ impl AppContext for AsyncWindowContext {
reservation: Reservation<T>,
build_entity: impl FnOnce(&mut Context<T>) -> T,
) -> Self::Result<Entity<T>> {
- 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<T: 'static, R>(
@@ -386,8 +389,8 @@ impl AppContext for AsyncWindowContext {
handle: &Entity<T>,
update: impl FnOnce(&mut T, &mut Context<T>) -> R,
) -> Result<R> {
- 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<T>) -> Self::Result<super::GpuiBorrow<'a, T>>
@@ -452,8 +455,9 @@ impl VisualContext for AsyncWindowContext {
&mut self,
build_entity: impl FnOnce(&mut Window, &mut Context<T>) -> T,
) -> Self::Result<Entity<T>> {
- 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<T: 'static, R>(
@@ -461,7 +465,7 @@ impl VisualContext for AsyncWindowContext {
view: &Entity<T>,
update: impl FnOnce(&mut T, &mut Window, &mut Context<T>) -> R,
) -> Self::Result<R> {
- self.window.update(self, |_, window, cx| {
+ self.app.update_window(self.window, |_, window, cx| {
view.update(cx, |entity, cx| update(entity, window, cx))
})
}
@@ -473,16 +477,17 @@ 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<V>(&mut self, view: &Entity<V>) -> Self::Result<()>
where
V: Focusable,
{
- self.window.update(self, |_, window, cx| {
- view.read(cx).focus_handle(cx).focus(window);
+ self.app.update_window(self.window, |_, window, cx| {
+ view.read(cx).focus_handle(cx).focus(window, cx);
})
}
}
@@ -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;
@@ -285,7 +285,7 @@ impl<'a, T: 'static> Context<'a, T> {
/// Focus the given view in the given window. View type is required to implement Focusable.
pub fn focus_view<W: Focusable>(&mut self, view: &Entity<W>, window: &mut Window) {
- window.focus(&view.focus_handle(self));
+ window.focus(&view.focus_handle(self), self);
}
/// Sets a given callback to be run on the next frame.
@@ -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<V>`] 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<AsyncFn, R>(
+ &self,
+ priority: Priority,
+ window: &Window,
+ f: AsyncFn,
+ ) -> Task<R>
+ where
+ R: 'static,
+ AsyncFn: AsyncFnOnce(WeakEntity<T>, &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<G: Global>(
&mut self,
@@ -713,7 +732,7 @@ impl<'a, T: 'static> Context<'a, T> {
{
let view = self.entity();
window.defer(self, move |window, cx| {
- view.read(cx).focus_handle(cx).focus(window)
+ view.read(cx).focus_handle(cx).focus(window, cx)
})
}
}
@@ -736,14 +755,17 @@ impl<T> Context<'_, T> {
impl<T> AppContext for Context<'_, T> {
type Result<U> = U;
+ #[inline]
fn new<U: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<U>) -> U) -> Entity<U> {
self.app.new(build_entity)
}
+ #[inline]
fn reserve_entity<U: 'static>(&mut self) -> Reservation<U> {
self.app.reserve_entity()
}
+ #[inline]
fn insert_entity<U: 'static>(
&mut self,
reservation: Reservation<U>,
@@ -752,6 +774,7 @@ impl<T> AppContext for Context<'_, T> {
self.app.insert_entity(reservation, build_entity)
}
+ #[inline]
fn update_entity<U: 'static, R>(
&mut self,
handle: &Entity<U>,
@@ -760,6 +783,7 @@ impl<T> AppContext for Context<'_, T> {
self.app.update_entity(handle, update)
}
+ #[inline]
fn as_mut<'a, E>(&'a mut self, handle: &Entity<E>) -> Self::Result<super::GpuiBorrow<'a, E>>
where
E: 'static,
@@ -767,6 +791,7 @@ impl<T> AppContext for Context<'_, T> {
self.app.as_mut(handle)
}
+ #[inline]
fn read_entity<U, R>(
&self,
handle: &Entity<U>,
@@ -778,6 +803,7 @@ impl<T> AppContext for Context<'_, T> {
self.app.read_entity(handle, read)
}
+ #[inline]
fn update_window<R, F>(&mut self, window: AnyWindowHandle, update: F) -> Result<R>
where
F: FnOnce(AnyView, &mut Window, &mut App) -> R,
@@ -785,6 +811,7 @@ impl<T> AppContext for Context<'_, T> {
self.app.update_window(window, update)
}
+ #[inline]
fn read_window<U, R>(
&self,
window: &WindowHandle<U>,
@@ -796,6 +823,7 @@ impl<T> AppContext for Context<'_, T> {
self.app.read_window(window, read)
}
+ #[inline]
fn background_spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>
where
R: Send + 'static,
@@ -803,6 +831,7 @@ impl<T> AppContext for Context<'_, T> {
self.app.background_executor.spawn(future)
}
+ #[inline]
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result<R>
where
G: Global,
@@ -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<T> From<Entity<T>> for AnyEntity {
+ #[inline]
fn from(entity: Entity<T>) -> Self {
entity.any_entity
}
}
impl Hash for AnyEntity {
+ #[inline]
fn hash<H: Hasher>(&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<Ordering> {
Some(self.cmp(other))
}
@@ -384,6 +391,7 @@ pub struct Entity<T> {
impl<T> Sealed for Entity<T> {}
impl<T: 'static> Entity<T> {
+ #[inline]
fn new(id: EntityId, entity_map: Weak<RwLock<EntityRefCounts>>) -> Self
where
T: 'static,
@@ -395,11 +403,13 @@ impl<T: 'static> Entity<T> {
}
/// 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<T> {
WeakEntity {
any_entity: self.any_entity.downgrade(),
@@ -408,16 +418,19 @@ impl<T: 'static> Entity<T> {
}
/// 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<R, C: AppContext>(
&self,
cx: &C,
@@ -427,6 +440,7 @@ impl<T: 'static> Entity<T> {
}
/// Updates the entity referenced by this handle with the given function.
+ #[inline]
pub fn update<R, C: AppContext>(
&self,
cx: &mut C,
@@ -436,6 +450,7 @@ impl<T: 'static> Entity<T> {
}
/// 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<GpuiBorrow<'a, T>> {
cx.as_mut(self)
}
@@ -451,6 +466,7 @@ impl<T: 'static> Entity<T> {
/// 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<R, C: VisualContext>(
&self,
cx: &mut C,
@@ -461,6 +477,7 @@ impl<T: 'static> Entity<T> {
}
impl<T> Clone for Entity<T> {
+ #[inline]
fn clone(&self) -> Self {
Self {
any_entity: self.any_entity.clone(),
@@ -479,12 +496,14 @@ impl<T> std::fmt::Debug for Entity<T> {
}
impl<T> Hash for Entity<T> {
+ #[inline]
fn hash<H: Hasher>(&self, state: &mut H) {
self.any_entity.hash(state);
}
}
impl<T> PartialEq for Entity<T> {
+ #[inline]
fn eq(&self, other: &Self) -> bool {
self.any_entity == other.any_entity
}
@@ -493,18 +512,21 @@ impl<T> PartialEq for Entity<T> {
impl<T> Eq for Entity<T> {}
impl<T> PartialEq<WeakEntity<T>> for Entity<T> {
+ #[inline]
fn eq(&self, other: &WeakEntity<T>) -> bool {
self.any_entity.entity_id() == other.entity_id()
}
}
impl<T: 'static> Ord for Entity<T> {
+ #[inline]
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.entity_id().cmp(&other.entity_id())
}
}
impl<T: 'static> PartialOrd for Entity<T> {
+ #[inline]
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
@@ -520,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
}
@@ -561,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
@@ -618,18 +667,21 @@ impl std::fmt::Debug for AnyWeakEntity {
}
impl<T> From<WeakEntity<T>> for AnyWeakEntity {
+ #[inline]
fn from(entity: WeakEntity<T>) -> Self {
entity.any_entity
}
}
impl Hash for AnyWeakEntity {
+ #[inline]
fn hash<H: Hasher>(&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
}
@@ -638,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<Ordering> {
Some(self.cmp(other))
}
@@ -740,6 +794,7 @@ impl<T: 'static> WeakEntity<T> {
}
/// Create a new weak entity that can never be upgraded.
+ #[inline]
pub fn new_invalid() -> Self {
Self {
any_entity: AnyWeakEntity::new_invalid(),
@@ -749,12 +804,14 @@ impl<T: 'static> WeakEntity<T> {
}
impl<T> Hash for WeakEntity<T> {
+ #[inline]
fn hash<H: Hasher>(&self, state: &mut H) {
self.any_entity.hash(state);
}
}
impl<T> PartialEq for WeakEntity<T> {
+ #[inline]
fn eq(&self, other: &Self) -> bool {
self.any_entity == other.any_entity
}
@@ -763,33 +820,90 @@ impl<T> PartialEq for WeakEntity<T> {
impl<T> Eq for WeakEntity<T> {}
impl<T> PartialEq<Entity<T>> for WeakEntity<T> {
+ #[inline]
fn eq(&self, other: &Entity<T>) -> bool {
self.entity_id() == other.any_entity.entity_id()
}
}
impl<T: 'static> Ord for WeakEntity<T> {
+ #[inline]
fn cmp(&self, other: &Self) -> Ordering {
self.entity_id().cmp(&other.entity_id())
}
}
impl<T: 'static> PartialOrd for WeakEntity<T> {
+ #[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
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<bool> =
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<T>` 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,
@@ -798,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);
@@ -810,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}");
}
}
}
@@ -5,7 +5,7 @@ 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};
@@ -132,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,
@@ -144,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));
@@ -1037,7 +1045,7 @@ impl VisualContext for VisualTestContext {
fn focus<V: crate::Focusable>(&mut self, view: &Entity<V>) -> Self::Result<()> {
self.window
.update(&mut self.cx, |_, window, cx| {
- view.read(cx).focus_handle(cx).focus(window)
+ view.read(cx).focus_handle(cx).focus(window, cx)
})
.unwrap()
}
@@ -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<U>
where
U: Clone + Debug + Default + PartialEq,
{
- root: Option<usize>,
+ /// All nodes stored contiguously for cache efficiency.
nodes: Vec<Node<U>>,
- stack: Vec<usize>,
+ /// Index of the root node, if any.
+ root: Option<usize>,
+ /// Index of the leaf with the highest ordering (for fast-path lookups).
+ max_leaf: Option<usize>,
+ /// Reusable stack for tree traversal during insertion.
+ insert_path: Vec<usize>,
+ /// Reusable stack for search operations.
+ search_stack: Vec<usize>,
+}
+
+/// A node in the bounds tree.
+#[derive(Debug, Clone)]
+struct Node<U>
+where
+ U: Clone + Debug + Default + PartialEq,
+{
+ /// Bounding box containing this node and all descendants.
+ bounds: Bounds<U>,
+ /// 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<U> BoundsTree<U>
@@ -26,158 +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<U>) -> u32 {
- // If the tree is empty, make the root the new leaf.
- let Some(mut index) = self.root else {
- 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;
+
+ // Insert the new leaf
+ let new_leaf_idx = self.insert_leaf(new_bounds, ordering);
+
+ // 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,
};
- // Search for the best place to add the new leaf based on heuristics.
- let mut max_intersecting_ordering = 0;
- 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);
-
- // 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;
+ ordering
+ }
+
+ /// Finds the maximum ordering among all bounds that intersect with the query.
+ fn find_max_ordering(&mut self, query: &Bounds<U>) -> 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;
}
}
- // 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!();
- };
- if sibling_bounds.intersects(&new_bounds) {
- max_intersecting_ordering = cmp::max(max_intersecting_ordering, *sibling_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);
+ }
+ }
+ }
+ }
}
- let ordering = max_intersecting_ordering + 1;
- let new_node = self.push_leaf(new_bounds, ordering);
- let new_parent = self.push_internal(sibling, new_node);
+ max_found
+ }
- // 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!();
- };
+ /// Inserts a leaf node with the given bounds and ordering.
+ /// Returns the index of the new leaf.
+ fn insert_leaf(&mut self, bounds: Bounds<U>, order: u32) -> usize {
+ let new_leaf_idx = self.nodes.len();
+ self.nodes.push(Node {
+ bounds: bounds.clone(),
+ max_order: order,
+ kind: NodeKind::Leaf { order },
+ });
- if *left == sibling {
- *left = new_parent;
+ 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 {
- *right = new_parent;
+ children.push(new_leaf_idx);
+ children.push(root_idx);
}
- } else {
- // If the old parent was the root, the new parent is the new root.
- self.root = Some(new_parent);
+
+ 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;
}
- for node_index in self.stack.drain(..).rev() {
- let Node::Internal {
- max_order: max_ordering,
- ..
- } = &mut self.nodes[node_index]
- else {
- unreachable!()
+ // 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");
};
- if *max_ordering >= ordering {
- break;
- }
- *max_ordering = ordering;
- }
- ordering
- }
+ 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();
- fn find_max_ordering(&self, index: usize, bounds: &Bounds<U>, 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);
+ 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;
}
}
- 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);
+
+ // 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 {
- max_ordering = self.find_max_ordering(*right, bounds, max_ordering);
- max_ordering = self.find_max_ordering(*left, bounds, max_ordering);
+ 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;
}
}
- max_ordering
- }
- fn push_leaf(&mut self, bounds: Bounds<U>, order: u32) -> usize {
- self.nodes.push(Node::Leaf { bounds, order });
- self.nodes.len() - 1
- }
+ // 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);
- 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,
- });
- self.nodes.len() - 1
+ 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
}
}
@@ -187,50 +356,11 @@ where
{
fn default() -> Self {
BoundsTree {
- root: None,
nodes: Vec::new(),
- stack: Vec::new(),
- }
- }
-}
-
-#[derive(Debug, Clone)]
-enum Node<U>
-where
- U: Clone + Debug + Default + PartialEq,
-{
- Leaf {
- bounds: Bounds<U>,
- order: u32,
- },
- Internal {
- left: usize,
- right: usize,
- bounds: Bounds<U>,
- max_order: u32,
- },
-}
-
-impl<U> Node<U>
-where
- U: Clone + Debug + Default + PartialEq,
-{
- fn bounds(&self) -> &Bounds<U> {
- 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(),
}
}
}
@@ -20,8 +20,8 @@ use crate::{
DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId,
Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext,
KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent,
- MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Overflow,
- ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style,
+ 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,
};
@@ -166,6 +166,38 @@ impl Interactivity {
}));
}
+ /// 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`].
///
@@ -622,7 +654,7 @@ pub trait InteractiveElement: Sized {
/// Set whether this element is a tab stop.
///
/// When false, the element remains in tab-index order but cannot be reached via keyboard navigation.
- /// Useful for container elements: focus the container, then call `window.focus_next()` to focus
+ /// Useful for container elements: focus the container, then call `window.focus_next(cx)` to focus
/// the first tab stop inside it while having the container element itself be unreachable via the keyboard.
/// Should only be used with `tab_index`.
fn tab_stop(mut self, tab_stop: bool) -> Self {
@@ -769,6 +801,30 @@ 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`].
@@ -1197,7 +1253,8 @@ pub(crate) type MouseDownListener =
Box<dyn Fn(&MouseDownEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
pub(crate) type MouseUpListener =
Box<dyn Fn(&MouseUpEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
-
+pub(crate) type MousePressureListener =
+ Box<dyn Fn(&MousePressureEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
pub(crate) type MouseMoveListener =
Box<dyn Fn(&MouseMoveEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
@@ -1521,6 +1578,7 @@ pub struct Interactivity {
pub(crate) group_drag_over_styles: Vec<(TypeId, GroupStyle)>,
pub(crate) mouse_down_listeners: Vec<MouseDownListener>,
pub(crate) mouse_up_listeners: Vec<MouseUpListener>,
+ pub(crate) mouse_pressure_listeners: Vec<MousePressureListener>,
pub(crate) mouse_move_listeners: Vec<MouseMoveListener>,
pub(crate) scroll_wheel_listeners: Vec<ScrollWheelListener>,
pub(crate) key_down_listeners: Vec<KeyDownListener>,
@@ -1714,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()
@@ -2037,12 +2096,12 @@ impl Interactivity {
// This behavior can be suppressed by using `cx.prevent_default()`.
if let Some(focus_handle) = self.tracked_focus_handle.clone() {
let hitbox = hitbox.clone();
- window.on_mouse_event(move |_: &MouseDownEvent, phase, window, _| {
+ window.on_mouse_event(move |_: &MouseDownEvent, phase, window, cx| {
if phase == DispatchPhase::Bubble
&& hitbox.is_hovered(window)
&& !window.default_prevented()
{
- window.focus(&focus_handle);
+ window.focus(&focus_handle, cx);
// If there is a parent that is also focusable, prevent it
// from transferring focus because we already did so.
window.prevent_default();
@@ -2064,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| {
@@ -3193,7 +3259,11 @@ impl ScrollHandle {
match active_item.strategy {
ScrollStrategy::FirstVisible => {
if state.overflow.y == Overflow::Scroll {
- if bounds.top() + scroll_offset.y < state.bounds.top() {
+ 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();
@@ -3206,7 +3276,11 @@ impl ScrollHandle {
}
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();
@@ -3268,3 +3342,46 @@ impl ScrollHandle {
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.));
+ }
+}
@@ -29,6 +29,7 @@ pub struct Surface {
}
/// Create a new surface element.
+#[cfg(target_os = "macos")]
pub fn surface(source: impl Into<SurfaceSource>) -> Surface {
Surface {
source: source.into(),
@@ -6,6 +6,7 @@ use crate::{
register_tooltip_mouse_handlers, set_tooltip_on_window,
};
use anyhow::Context as _;
+use itertools::Itertools;
use smallvec::SmallVec;
use std::{
borrow::Cow,
@@ -597,14 +598,14 @@ impl TextLayout {
.unwrap()
.lines
.iter()
- .map(|s| s.text.to_string())
- .collect::<Vec<_>>()
+ .map(|s| &s.text)
.join("\n")
}
/// The text for this layout (with soft-wraps as newlines)
pub fn wrapped_text(&self) -> String {
- let mut lines = Vec::new();
+ let mut accumulator = String::new();
+
for wrapped in self.0.borrow().as_ref().unwrap().lines.iter() {
let mut seen = 0;
for boundary in wrapped.layout.wrap_boundaries.iter() {
@@ -612,13 +613,16 @@ impl TextLayout {
[boundary.glyph_ix]
.index;
- lines.push(wrapped.text[seen..index].to_string());
+ accumulator.push_str(&wrapped.text[seen..index]);
+ accumulator.push('\n');
seen = index;
}
- lines.push(wrapped.text[seen..].to_string());
+ accumulator.push_str(&wrapped.text[seen..]);
+ accumulator.push('\n');
}
-
- lines.join("\n")
+ // Remove trailing newline
+ accumulator.pop();
+ accumulator
}
}
@@ -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)]
@@ -231,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 {
@@ -391,39 +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;
+ 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;
-
- if item_top < scroll_top + offset_pixels {
- scrolled_to_top = true;
- // todo: using the padding here is wrong - this only works well for few scenarios
- updated_scroll_offset.y = -item_top + padding.top + offset_pixels;
- } else if item_bottom > scroll_top + list_height {
- scrolled_to_top = true;
- updated_scroll_offset.y = -(item_bottom - list_height);
- }
+ let offset_pixels = item_height * offset;
+
+ // 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 scroll_strict || is_above || is_below {
+ if strategy == ScrollStrategy::Nearest {
+ if is_above {
+ strategy = ScrollStrategy::Top;
+ } else if is_below {
+ strategy = ScrollStrategy::Bottom;
+ }
+ }
- if deferred_scroll.scroll_strict
- || (scrolled_to_top
- && (item_top < scroll_top + offset_pixels
- || item_bottom > scroll_top + list_height))
- {
- match deferred_scroll.strategy {
+ let max_scroll_offset =
+ (content_height - list_height).max(Pixels::ZERO);
+ match strategy {
ScrollStrategy::Top => {
updated_scroll_offset.y = -(item_top - offset_pixels)
- .max(Pixels::ZERO)
- .min(content_height - list_height)
- .max(Pixels::ZERO);
+ .clamp(Pixels::ZERO, max_scroll_offset);
}
ScrollStrategy::Center => {
let item_center = item_top + item_height / 2.0;
@@ -431,18 +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
- + offset_pixels)
- .max(Pixels::ZERO)
- .min(content_height - list_height)
- .max(Pixels::ZERO);
+ updated_scroll_offset.y = -(item_bottom - list_height)
+ .clamp(Pixels::ZERO, max_scroll_offset);
+ }
+ ScrollStrategy::Nearest => {
+ // Nearest, but the item is visible -> no scroll is required
}
}
}
@@ -659,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
}
@@ -695,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, 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<usize>,
+ }
+
+ impl TestView {
+ pub fn select_next(
+ &mut self,
+ _: &SelectNext,
+ window: &mut Window,
+ _: &mut Context<Self>,
+ ) {
+ 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<Self>,
+ ) {
+ 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<Self>) -> 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<usize>, _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, cx);
+ 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);
+ })
+ }
+ }
+}
@@ -1,6 +1,7 @@
-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::{
fmt::Debug,
@@ -46,6 +47,52 @@ pub struct ForegroundExecutor {
not_send: PhantomData<Rc<()>>,
}
+/// 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.
@@ -62,7 +109,7 @@ enum TaskState<T> {
Ready(Option<T>),
/// A task that is currently running.
- Spawned(async_task::Task<T>),
+ Spawned(async_task::Task<T, RunnableMeta>),
}
impl<T> Task<T> {
@@ -146,15 +193,87 @@ impl BackgroundExecutor {
}
/// Enqueues the given future to be run to completion on a background thread.
+ #[track_caller]
pub fn spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>
where
R: Send + 'static,
{
- self.spawn_internal::<R>(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<R>(
+ &self,
+ priority: Priority,
+ future: impl Future<Output = R> + Send + 'static,
+ ) -> Task<R>
+ where
+ R: Send + 'static,
+ {
+ self.spawn_internal::<R>(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<R>(&self, future: impl Future<Output = R> + 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<bool>));
+
+ impl Drop for NotifyOnDrop<'_> {
+ fn drop(&mut self) {
+ *self.0.1.lock() = true;
+ self.0.0.notify_all();
+ }
+ }
+
+ struct WaitOnDrop<'a>(&'a (Condvar, Mutex<bool>));
+
+ 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<R>(
&self,
label: TaskLabel,
@@ -163,17 +282,73 @@ impl BackgroundExecutor {
where
R: Send + 'static,
{
- self.spawn_internal::<R>(Box::pin(future), Some(label))
+ self.spawn_internal::<R>(Box::pin(future), Some(label), Priority::default())
}
+ #[track_caller]
fn spawn_internal<R: Send + 'static>(
&self,
future: AnyFuture<R>,
label: Option<TaskLabel>,
+ #[cfg_attr(
+ target_os = "windows",
+ expect(
+ unused_variables,
+ reason = "Multi priority scheduler is broken on windows"
+ )
+ )]
+ priority: Priority,
) -> Task<R> {
let dispatcher = self.dispatcher.clone();
- let (runnable, task) =
- async_task::spawn(future, move |runnable| dispatcher.dispatch(runnable, label));
+ #[cfg(target_os = "windows")]
+ let priority = Priority::Medium; // multi-prio scheduler is broken on windows
+
+ let (runnable, task) = if let Priority::Realtime(realtime) = priority {
+ let location = core::panic::Location::caller();
+ let (mut tx, rx) = flume::bounded::<Runnable<RunnableMeta>>(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))
}
@@ -281,7 +456,11 @@ impl BackgroundExecutor {
});
let mut cx = std::task::Context::from_waker(&waker);
- let duration = Duration::from_secs(180);
+ let duration = Duration::from_secs(
+ option_env!("GPUI_TEST_TIMEOUT")
+ .and_then(|s| s.parse::<u64>().ok())
+ .unwrap_or(180),
+ );
let mut test_should_end_by = Instant::now() + duration;
loop {
@@ -315,10 +494,8 @@ impl BackgroundExecutor {
"parked with nothing left to run{waiting_message}{backtrace_message}",
)
}
- dispatcher.set_unparker(unparker.clone());
- parker.park_timeout(
- test_should_end_by.saturating_duration_since(Instant::now()),
- );
+ 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")
}
@@ -344,11 +521,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_with_priority(scope.priority, f))
+ .collect::<Vec<_>>();
+ 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(f))
+ .map(|f| self.spawn_with_priority(scope.priority, f))
.collect::<Vec<_>>();
for task in spawned {
task.await;
@@ -370,10 +564,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))
}
@@ -479,24 +676,45 @@ impl ForegroundExecutor {
}
/// Enqueues the given Task to run on the main thread at some point in the future.
+ #[track_caller]
pub fn spawn<R>(&self, future: impl Future<Output = R> + 'static) -> Task<R>
+ 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<R>(
+ &self,
+ priority: Priority,
+ future: impl Future<Output = R> + 'static,
+ ) -> Task<R>
where
R: 'static,
{
let dispatcher = self.dispatcher.clone();
+ let location = core::panic::Location::caller();
#[track_caller]
fn inner<R: 'static>(
dispatcher: Arc<dyn PlatformDispatcher>,
future: AnyLocalFuture<R>,
+ location: &'static core::panic::Location<'static>,
+ priority: Priority,
) -> Task<R> {
- 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::<R>(dispatcher, Box::pin(future))
+ inner::<R>(dispatcher, Box::pin(future), location, priority)
}
}
@@ -505,14 +723,16 @@ impl ForegroundExecutor {
/// Copy-modified from:
/// <https://github.com/smol-rs/async-task/blob/ca9dbe1db9c422fd765847fa91306e30a6bb58a9/src/runnable.rs#L405>
#[track_caller]
-fn spawn_local_with_source_location<Fut, S>(
+fn spawn_local_with_source_location<Fut, S, M>(
future: Fut,
schedule: S,
-) -> (Runnable<()>, async_task::Task<Fut::Output, ()>)
+ metadata: M,
+) -> (Runnable<M>, async_task::Task<Fut::Output, M>)
where
Fut: Future + 'static,
Fut::Output: 'static,
- S: async_task::Schedule<()> + Send + Sync + 'static,
+ S: async_task::Schedule<M> + Send + Sync + 'static,
+ M: 'static,
{
#[inline]
fn thread_id() -> ThreadId {
@@ -560,12 +780,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<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>,
tx: Option<mpsc::Sender<()>>,
rx: mpsc::Receiver<()>,
@@ -573,10 +798,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(),
@@ -590,6 +816,7 @@ impl<'a> Scope<'a> {
}
/// Spawn a future into this scope.
+ #[track_caller]
pub fn spawn<F>(&mut self, f: F)
where
F: Future<Output = ()> + Send + 'a,
@@ -748,7 +748,7 @@ impl Size<Length> {
/// 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<T: Clone + Debug + Default + PartialEq> {
@@ -1416,9 +1416,9 @@ where
/// ```
pub fn contains(&self, point: &Point<T>) -> 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<DevicePixels> {
}
}
-impl<T: Copy + Clone + Debug + Default + PartialEq> Copy for Bounds<T> {}
-
/// 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<I: Iterator<Item = Self>>(iter: I) -> Self {
+ iter.fold(Self::ZERO, |a, b| a + b)
+ }
+}
+
+impl<'a> std::iter::Sum<&'a Pixels> for Pixels {
+ fn sum<I: Iterator<Item = &'a Self>>(iter: I) -> Self {
+ iter.fold(Self::ZERO, |a, b| a + *b)
+ }
+}
+
impl TryFrom<&'_ str> for Pixels {
type Error = anyhow::Error;
@@ -3569,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)
}
@@ -3582,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)
}
@@ -3610,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
}
@@ -30,6 +30,9 @@ mod keymap;
mod path_builder;
mod platform;
pub mod prelude;
+mod profiler;
+#[cfg(target_os = "linux")]
+mod queue;
mod scene;
mod shared_string;
mod shared_uri;
@@ -87,16 +90,21 @@ use key_dispatch::*;
pub use keymap::*;
pub use path_builder::*;
pub use platform::*;
+pub use profiler::*;
+#[cfg(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::*;
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::*;
@@ -107,9 +115,6 @@ pub use util::{FutureExt, Timeout, arc_cow::ArcCow};
pub use view::*;
pub use window::*;
-use std::{any::Any, future::Future};
-use taffy::TaffyLayoutEngine;
-
/// The context trait, allows the different contexts in GPUI to be used
/// interchangeably for certain operations.
pub trait AppContext {
@@ -174,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<Pixels>,
+ /// 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 {
@@ -305,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.
@@ -333,28 +368,17 @@ 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.
#[derive(Clone, Debug, Default)]
pub struct MouseMoveEvent {
@@ -519,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 {
@@ -581,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.
@@ -600,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),
@@ -614,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,
@@ -677,8 +705,8 @@ mod test {
});
window
- .update(cx, |test_view, window, _cx| {
- window.focus(&test_view.focus_handle)
+ .update(cx, |test_view, window, cx| {
+ window.focus(&test_view.focus_handle, cx)
})
.unwrap();
@@ -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<KeyContext>,
@@ -461,6 +462,17 @@ impl DispatchTree {
(bindings, partial, context_stack)
}
+ /// Find the bindings that can follow the current input sequence.
+ pub fn possible_next_bindings_for_input(
+ &self,
+ input: &[Keystroke],
+ context_stack: &[KeyContext],
+ ) -> Vec<KeyBinding> {
+ self.keymap
+ .borrow()
+ .possible_next_bindings_for_input(input, context_stack)
+ }
+
/// dispatch_key processes the keystroke
/// input should be set to the value of `pending` from the previous call to dispatch_key.
/// This returns three instructions to the input handler:
@@ -480,6 +492,7 @@ impl DispatchTree {
if pending {
return DispatchResult {
pending: input,
+ pending_has_binding: !bindings.is_empty(),
context_stack,
..Default::default()
};
@@ -608,15 +621,17 @@ impl DispatchTree {
#[cfg(test)]
mod tests {
use crate::{
- self as gpui, Element, ElementId, GlobalElementId, InspectorElementId, LayoutId, Style,
+ self as gpui, AppContext, DispatchResult, Element, ElementId, GlobalElementId,
+ InspectorElementId, Keystroke, LayoutId, Style,
};
use core::panic;
+ use smallvec::SmallVec;
use std::{cell::RefCell, ops::Range, rc::Rc};
use crate::{
Action, ActionRegistry, App, Bounds, Context, DispatchTree, FocusHandle, InputHandler,
- IntoElement, KeyBinding, KeyContext, Keymap, Pixels, Point, Render, TestAppContext,
- UTF16Selection, Window,
+ IntoElement, KeyBinding, KeyContext, Keymap, Pixels, Point, Render, Subscription,
+ TestAppContext, UTF16Selection, Window,
};
#[derive(PartialEq, Eq)]
@@ -676,6 +691,256 @@ 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::<TestAction>();
+ 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_pending_input_observers_notified_on_focus_change(cx: &mut TestAppContext) {
+ #[derive(Clone)]
+ struct CustomElement {
+ focus_handle: FocusHandle,
+ text: Rc<RefCell<String>>,
+ }
+
+ impl CustomElement {
+ fn new(cx: &mut Context<Self>) -> Self {
+ Self {
+ focus_handle: cx.focus_handle(),
+ text: Rc::default(),
+ }
+ }
+ }
+
+ impl Element for CustomElement {
+ type RequestLayoutState = ();
+
+ type PrepaintState = ();
+
+ fn id(&self) -> Option<ElementId> {
+ Some("custom".into())
+ }
+
+ fn source_location(&self) -> Option<&'static panic::Location<'static>> {
+ None
+ }
+
+ fn request_layout(
+ &mut self,
+ _: Option<&GlobalElementId>,
+ _: Option<&InspectorElementId>,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> (LayoutId, Self::RequestLayoutState) {
+ (window.request_layout(Style::default(), [], cx), ())
+ }
+
+ fn prepaint(
+ &mut self,
+ _: Option<&GlobalElementId>,
+ _: Option<&InspectorElementId>,
+ _: Bounds<Pixels>,
+ _: &mut Self::RequestLayoutState,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Self::PrepaintState {
+ window.set_focus_handle(&self.focus_handle, cx);
+ }
+
+ fn paint(
+ &mut self,
+ _: Option<&GlobalElementId>,
+ _: Option<&InspectorElementId>,
+ _: Bounds<Pixels>,
+ _: &mut Self::RequestLayoutState,
+ _: &mut Self::PrepaintState,
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ let mut key_context = KeyContext::default();
+ key_context.add("Terminal");
+ window.set_key_context(key_context);
+ window.handle_input(&self.focus_handle, self.clone(), cx);
+ window.on_action(std::any::TypeId::of::<TestAction>(), |_, _, _, _| {});
+ }
+ }
+
+ impl IntoElement for CustomElement {
+ type Element = Self;
+
+ fn into_element(self) -> Self::Element {
+ self
+ }
+ }
+
+ impl InputHandler for CustomElement {
+ fn selected_text_range(
+ &mut self,
+ _: bool,
+ _: &mut Window,
+ _: &mut App,
+ ) -> Option<UTF16Selection> {
+ None
+ }
+
+ fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option<Range<usize>> {
+ None
+ }
+
+ fn text_for_range(
+ &mut self,
+ _: Range<usize>,
+ _: &mut Option<Range<usize>>,
+ _: &mut Window,
+ _: &mut App,
+ ) -> Option<String> {
+ None
+ }
+
+ fn replace_text_in_range(
+ &mut self,
+ replacement_range: Option<Range<usize>>,
+ text: &str,
+ _: &mut Window,
+ _: &mut App,
+ ) {
+ if replacement_range.is_some() {
+ unimplemented!()
+ }
+ self.text.borrow_mut().push_str(text)
+ }
+
+ fn replace_and_mark_text_in_range(
+ &mut self,
+ replacement_range: Option<Range<usize>>,
+ new_text: &str,
+ _: Option<Range<usize>>,
+ _: &mut Window,
+ _: &mut App,
+ ) {
+ if replacement_range.is_some() {
+ unimplemented!()
+ }
+ self.text.borrow_mut().push_str(new_text)
+ }
+
+ fn unmark_text(&mut self, _: &mut Window, _: &mut App) {}
+
+ fn bounds_for_range(
+ &mut self,
+ _: Range<usize>,
+ _: &mut Window,
+ _: &mut App,
+ ) -> Option<Bounds<Pixels>> {
+ None
+ }
+
+ fn character_index_for_point(
+ &mut self,
+ _: Point<Pixels>,
+ _: &mut Window,
+ _: &mut App,
+ ) -> Option<usize> {
+ None
+ }
+ }
+
+ impl Render for CustomElement {
+ fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ self.clone()
+ }
+ }
+
+ cx.update(|cx| {
+ cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]);
+ cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]);
+ });
+
+ let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx));
+ let focus_handle = test.update(cx, |test, _| test.focus_handle.clone());
+
+ let pending_input_changed_count = Rc::new(RefCell::new(0usize));
+ let pending_input_changed_count_for_observer = pending_input_changed_count.clone();
+
+ struct PendingInputObserver {
+ _subscription: Subscription,
+ }
+
+ let _observer = cx.update(|window, cx| {
+ cx.new(|cx| PendingInputObserver {
+ _subscription: cx.observe_pending_input(window, move |_, _, _| {
+ *pending_input_changed_count_for_observer.borrow_mut() += 1;
+ }),
+ })
+ });
+
+ cx.update(|window, cx| {
+ window.focus(&focus_handle, cx);
+ window.activate_window();
+ });
+
+ cx.simulate_keystrokes("ctrl-b");
+
+ let count_after_pending = Rc::new(RefCell::new(0usize));
+ let count_after_pending_for_assertion = count_after_pending.clone();
+
+ cx.update(|window, cx| {
+ assert!(window.has_pending_keystrokes());
+ *count_after_pending.borrow_mut() = *pending_input_changed_count.borrow();
+ assert!(*count_after_pending.borrow() > 0);
+
+ window.focus(&cx.focus_handle(), cx);
+
+ assert!(!window.has_pending_keystrokes());
+ });
+
+ // Focus-triggered pending-input notifications are deferred to the end of the current
+ // effect cycle, so the observer callback should run after the focus update completes.
+ cx.update(|_, _| {
+ let count_after_focus_change = *pending_input_changed_count.borrow();
+ assert!(count_after_focus_change > *count_after_pending_for_assertion.borrow());
+ });
+ }
+
#[crate::test]
fn test_input_handler_pending(cx: &mut TestAppContext) {
#[derive(Clone)]
@@ -829,8 +1094,9 @@ mod tests {
cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]);
});
let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx));
+ let focus_handle = test.update(cx, |test, _| test.focus_handle.clone());
cx.update(|window, cx| {
- window.focus(&test.read(cx).focus_handle);
+ window.focus(&focus_handle, cx);
window.activate_window();
});
cx.simulate_keystrokes("ctrl-b [");
@@ -215,6 +215,41 @@ impl Keymap {
Some(contexts.len())
}
}
+
+ /// Find the bindings that can follow the current input sequence.
+ pub fn possible_next_bindings_for_input(
+ &self,
+ input: &[Keystroke],
+ context_stack: &[KeyContext],
+ ) -> Vec<KeyBinding> {
+ let mut bindings = self
+ .bindings()
+ .enumerate()
+ .rev()
+ .filter_map(|(ix, binding)| {
+ let depth = self.binding_enabled(binding, context_stack)?;
+ let pending = binding.match_keystrokes(input);
+ match pending {
+ None => None,
+ Some(is_pending) => {
+ if !is_pending || is_no_action(&*binding.action) {
+ return None;
+ }
+ Some((depth, BindingIndex(ix), binding))
+ }
+ }
+ })
+ .collect::<Vec<_>>();
+
+ bindings.sort_by(|(depth_a, ix_a, _), (depth_b, ix_b, _)| {
+ depth_b.cmp(depth_a).then(ix_b.cmp(ix_a))
+ });
+
+ bindings
+ .into_iter()
+ .map(|(_, _, binding)| binding.clone())
+ .collect::<Vec<_>>()
+ }
}
#[cfg(test)]
@@ -39,9 +39,10 @@ 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, 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;
@@ -76,7 +77,6 @@ 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")]
@@ -290,6 +290,13 @@ pub trait PlatformDisplay: Send + Sync + Debug {
/// Get the bounds for this display
fn bounds(&self) -> Bounds<Pixels>;
+ /// 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<Pixels> {
+ self.bounds()
+ }
+
/// Get the default bounds for this display to place a window
fn default_bounds(&self) -> Bounds<Pixels> {
let bounds = self.bounds();
@@ -559,14 +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<RunnableMeta>),
+ 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<ThreadTaskTimings>;
+ fn get_current_thread_timings(&self) -> Vec<TaskTiming>;
fn is_main_thread(&self) -> bool;
- fn dispatch(&self, runnable: Runnable, label: Option<TaskLabel>);
- fn dispatch_on_main_thread(&self, runnable: Runnable);
- fn dispatch_after(&self, duration: Duration, runnable: Runnable);
+ fn dispatch(&self, runnable: RunnableVariant, label: Option<TaskLabel>, 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<dyn FnOnce() + Send>);
+
fn now(&self) -> Instant {
Instant::now()
}
@@ -1328,11 +1354,12 @@ pub enum WindowKind {
///
/// 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.
@@ -1351,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)]
@@ -1376,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
@@ -1457,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
@@ -1546,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 {
@@ -1565,6 +1585,8 @@ pub enum ClipboardEntry {
String(ClipboardString),
/// An image entry
Image(Image),
+ /// A file entry
+ ExternalPaths(crate::ExternalPaths),
}
impl ClipboardItem {
@@ -1605,16 +1627,29 @@ impl ClipboardItem {
/// Returns None if there were no ClipboardString entries.
pub fn text(&self) -> Option<String> {
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.
@@ -1,46 +1,84 @@
-use crate::{PlatformDispatcher, TaskLabel};
-use async_task::Runnable;
use calloop::{
- EventLoop,
+ EventLoop, PostAction,
channel::{self, Sender},
timer::TimeoutAction,
};
+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 {
- main_sender: Sender<Runnable>,
+ main_sender: PriorityQueueCalloopSender<RunnableVariant>,
timer_sender: Sender<TimerAfter>,
- background_sender: flume::Sender<Runnable>,
+ background_sender: PriorityQueueSender<RunnableVariant>,
_background_threads: Vec<thread::JoinHandle<()>>,
main_thread_id: thread::ThreadId,
}
+const MIN_THREADS: usize = 2;
+
impl LinuxDispatcher {
- pub fn new(main_sender: Sender<Runnable>) -> Self {
- let (background_sender, background_receiver) = flume::unbounded::<Runnable>();
- let thread_count = std::thread::available_parallelism()
- .map(|i| i.get())
- .unwrap_or(1);
+ pub fn new(main_sender: PriorityQueueCalloopSender<RunnableVariant>) -> 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: {:?}",
@@ -72,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
},
@@ -99,31 +166,305 @@ impl LinuxDispatcher {
}
impl PlatformDispatcher for LinuxDispatcher {
+ fn get_all_timings(&self) -> Vec<crate::ThreadTaskTimings> {
+ let global_timings = GLOBAL_THREAD_TIMINGS.lock();
+ ThreadTaskTimings::convert(&global_timings)
+ }
+
+ fn get_current_thread_timings(&self) -> Vec<crate::TaskTiming> {
+ 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<TaskLabel>) {
- self.background_sender.send(runnable).unwrap();
+ fn dispatch(&self, runnable: RunnableVariant, _: Option<TaskLabel>, 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 spawn_realtime(&self, priority: RealtimePriority, f: Box<dyn FnOnce() + Send>) {
+ 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::<libc::sched_param>::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<T> {
+ sender: PriorityQueueSender<T>,
+ ping: calloop::ping::Ping,
+}
+
+impl<T> PriorityQueueCalloopSender<T> {
+ fn new(tx: PriorityQueueSender<T>, ping: calloop::ping::Ping) -> Self {
+ Self { sender: tx, ping }
+ }
+
+ fn send(&self, priority: Priority, item: T) -> Result<(), crate::queue::SendError<T>> {
+ let res = self.sender.send(priority, item);
+ if res.is_ok() {
+ self.ping.ping();
+ }
+ res
+ }
+}
+
+impl<T> Drop for PriorityQueueCalloopSender<T> {
+ fn drop(&mut self) {
+ self.ping.ping();
+ }
+}
+
+pub struct PriorityQueueCalloopReceiver<T> {
+ receiver: PriorityQueueReceiver<T>,
+ source: calloop::ping::PingSource,
+ ping: calloop::ping::Ping,
+}
+
+impl<T> PriorityQueueCalloopReceiver<T> {
+ pub fn new() -> (PriorityQueueCalloopSender<T>, 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<T> calloop::EventSource for PriorityQueueCalloopReceiver<T> {
+ type Event = Event<T>;
+ type Metadata = ();
+ type Ret = ();
+ type Error = ChannelError;
+
+ fn process_events<F>(
+ &mut self,
+ readiness: calloop::Readiness,
+ token: calloop::Token,
+ mut callback: F,
+ ) -> Result<calloop::PostAction, Self::Error>
+ 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 {
+ // Re-notify the ping source so we can try again.
+ self.ping.ping();
+ Ok(PostAction::Continue)
+ }
+ }
+
+ 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
@@ -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();
@@ -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<T> ResultExt for anyhow::Result<T> {
+ 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<R>(&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<Runnable>) {
- let (main_sender, main_receiver) = calloop::channel::channel::<Runnable>();
+ pub fn new(signal: LoopSignal) -> (Self, PriorityQueueCalloopReceiver<RunnableVariant>) {
+ 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<P: LinuxClient + 'static> 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<P: LinuxClient + 'static> 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),
}
@@ -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;
@@ -71,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,
@@ -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(
@@ -1386,6 +1420,7 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
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
@@ -1395,7 +1430,7 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
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
@@ -1412,7 +1447,8 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> 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();
@@ -1025,13 +1025,26 @@ impl PlatformWindow for WaylandWindow {
fn resize(&mut self, size: Size<Pixels>) {
let state = self.borrow();
let state_ptr = self.0.clone();
- let dp_size = size.to_device_pixels(self.scale_factor());
+
+ // Keep window geometry consistent with configure handling. On Wayland, window geometry is
+ // surface-local: resizing should not attempt to translate the window; the compositor
+ // controls placement. We also account for client-side decoration insets and tiling.
+ let window_geometry = inset_by_tiling(
+ Bounds {
+ origin: Point::default(),
+ size,
+ },
+ state.inset(),
+ state.tiling,
+ )
+ .map(|v| v.0 as i32)
+ .map_size(|v| if v <= 0 { 1 } else { v });
state.surface_state.set_geometry(
- state.bounds.origin.x.0 as i32,
- state.bounds.origin.y.0 as i32,
- dp_size.width.0,
- dp_size.height.0,
+ window_geometry.origin.x,
+ window_geometry.origin.y,
+ window_geometry.size.width,
+ window_geometry.size.height,
);
state
@@ -1270,10 +1283,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.surface_state.decoration() {
- 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);
+ }
}
}
@@ -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},
@@ -29,7 +29,7 @@ use x11rb::{
protocol::xkb::ConnectionExt as _,
protocol::xproto::{
AtomEnum, ChangeWindowAttributesAux, ClientMessageData, ClientMessageEvent,
- ConnectionExt as _, EventMask, Visibility,
+ ConnectionExt as _, EventMask, ModMask, Visibility,
},
protocol::{Event, randr, render, xinput, xkb, xproto},
resource_manager::Database,
@@ -313,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);
});
}
}
@@ -407,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")?;
@@ -988,6 +1018,12 @@ impl X11Client {
let modifiers = modifiers_from_state(event.state);
state.modifiers = modifiers;
state.pre_key_char_down.take();
+
+ // Macros containing modifiers might result in
+ // the modifiers missing from the event.
+ // We therefore update the mask from the global state.
+ update_xkb_mask_from_event_state(&mut state.xkb, event.state);
+
let keystroke = {
let code = event.detail.into();
let mut keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code);
@@ -1053,6 +1089,11 @@ impl X11Client {
let modifiers = modifiers_from_state(event.state);
state.modifiers = modifiers;
+ // Macros containing modifiers might result in
+ // the modifiers missing from the event.
+ // We therefore update the mask from the global state.
+ update_xkb_mask_from_event_state(&mut state.xkb, event.state);
+
let keystroke = {
let code = event.detail.into();
let keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code);
@@ -2486,3 +2527,19 @@ fn get_dpi_factor((width_px, height_px): (u32, u32), (width_mm, height_mm): (u64
fn valid_scale_factor(scale_factor: f32) -> bool {
scale_factor.is_sign_positive() && scale_factor.is_normal()
}
+
+#[inline]
+fn update_xkb_mask_from_event_state(xkb: &mut xkbc::State, event_state: xproto::KeyButMask) {
+ let depressed_mods = event_state.remove((ModMask::LOCK | ModMask::M2).bits());
+ let latched_mods = xkb.serialize_mods(xkbc::STATE_MODS_LATCHED);
+ let locked_mods = xkb.serialize_mods(xkbc::STATE_MODS_LOCKED);
+ let locked_layout = xkb.serialize_layout(xkbc::STATE_LAYOUT_LOCKED);
+ xkb.update_mask(
+ depressed_mods.into(),
+ latched_mods,
+ locked_mods,
+ 0,
+ 0,
+ locked_layout,
+ );
+}
@@ -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() }
}
@@ -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];
@@ -2,8 +2,23 @@
#![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},
@@ -11,9 +26,11 @@ use objc::{
};
use std::{
ffi::c_void,
+ mem::MaybeUninit,
ptr::{NonNull, addr_of},
- 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,47 +46,277 @@ pub(crate) fn dispatch_get_main_queue() -> dispatch_queue_t {
pub(crate) struct MacDispatcher;
impl PlatformDispatcher for MacDispatcher {
+ fn get_all_timings(&self) -> Vec<ThreadTaskTimings> {
+ let global_timings = GLOBAL_THREAD_TIMINGS.lock();
+ ThreadTaskTimings::convert(&global_timings)
+ }
+
+ fn get_current_thread_timings(&self) -> Vec<TaskTiming> {
+ 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<TaskLabel>) {
+ fn dispatch(&self, runnable: RunnableVariant, _: Option<TaskLabel>, 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 spawn_realtime(&self, priority: RealtimePriority, f: Box<dyn FnOnce() + Send>) {
+ 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::<libc::sched_param>::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")
+ }
+
+ 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::<RunnableMeta>::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);
+ });
}
@@ -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<Pixels> {
+ 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
+ }
}
@@ -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,
@@ -187,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() {
@@ -132,11 +132,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();
@@ -52,6 +52,11 @@ pub fn apply_features_and_fallbacks(
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks,
);
+
+ for value in &values {
+ CFRelease(*value as _);
+ }
+
let new_descriptor = CTFontDescriptorCreateWithAttributes(attrs);
CFRelease(attrs as _);
let new_descriptor = CTFontDescriptor::wrap_under_create_rule(new_descriptor);
@@ -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;
@@ -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;
@@ -387,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);
@@ -450,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,
)
}
}
@@ -552,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)
@@ -651,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]
}
@@ -663,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"
)));
@@ -807,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()
@@ -864,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")
@@ -1043,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.
@@ -1055,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);
}
@@ -1129,32 +1137,7 @@ impl Platform for MacPlatform {
}
}
- // Next, check for URL flavors (including file URLs). Some tools only provide a URL
- // with no plain text entry.
- {
- // Try the modern UTType identifiers first.
- let file_url_type: id = ns_string("public.file-url");
- let url_type: id = ns_string("public.url");
-
- let url_data = if msg_send![types, containsObject: file_url_type] {
- pasteboard.dataForType(file_url_type)
- } else if msg_send![types, containsObject: url_type] {
- pasteboard.dataForType(url_type)
- } else {
- nil
- };
-
- if url_data != nil && !url_data.bytes().is_null() {
- let bytes = slice::from_raw_parts(
- url_data.bytes() as *mut u8,
- url_data.length() as usize,
- );
-
- return Some(self.read_string_from_clipboard(&state, bytes));
- }
- }
-
- // If it wasn't a string or URL, try the various supported image types.
+ // If it wasn't a string, try the various supported image types.
for format in ImageFormat::iter() {
if let Some(item) = try_clipboard_image(pasteboard, format) {
return Some(item);
@@ -1162,7 +1145,7 @@ impl Platform for MacPlatform {
}
}
- // If it wasn't a string, URL, or a supported image type, give up.
+ // If it wasn't a string or a supported image type, give up.
None
}
@@ -1562,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<PathBuf> {
let path: *mut c_char = msg_send![url, fileSystemRepresentation];
anyhow::ensure!(!path.is_null(), "url is not a file path: {}", unsafe {
@@ -1737,40 +1716,6 @@ mod tests {
);
}
- #[test]
- fn test_file_url_reads_as_url_string() {
- let platform = build_platform();
-
- // Create a file URL for an arbitrary test path and write it to the pasteboard.
- // This path does not need to exist; we only validate URL→path conversion.
- let mock_path = "/tmp/zed-clipboard-file-url-test";
- unsafe {
- // Build an NSURL from the file path
- let url: id = msg_send![class!(NSURL), fileURLWithPath: ns_string(mock_path)];
- let abs: id = msg_send![url, absoluteString];
-
- // Encode the URL string as UTF-8 bytes
- let len: usize = msg_send![abs, lengthOfBytesUsingEncoding: NSUTF8StringEncoding];
- let bytes_ptr = abs.UTF8String() as *const u8;
- let data = NSData::dataWithBytes_length_(nil, bytes_ptr as *const c_void, len as u64);
-
- // Write as public.file-url to the unique pasteboard
- let file_url_type: id = ns_string("public.file-url");
- platform
- .0
- .lock()
- .pasteboard
- .setData_forType(data, file_url_type);
- }
-
- // Ensure the clipboard read returns the URL string, not a converted path
- let expected_url = format!("file://{}", mock_path);
- assert_eq!(
- platform.read_from_clipboard(),
- Some(ClipboardItem::new_string(expected_url))
- );
- }
-
fn build_platform() -> MacPlatform {
let platform = MacPlatform::new(false);
platform.0.lock().pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) };
@@ -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;
@@ -109,13 +110,21 @@ impl ScreenCaptureSource for MacScreenCaptureSource {
let _: id = msg_send![configuration, setHeight: meta.resolution.height.0 as i64];
let stream: id = msg_send![stream, initWithFilter:filter configuration:configuration delegate:delegate];
+ // Stream contains filter, configuration, and delegate internally so we release them here
+ // to prevent a memory leak when steam is dropped
+ let _: () = msg_send![filter, release];
+ let _: () = msg_send![configuration, release];
+ let _: () = msg_send![delegate, release];
+
let (mut tx, rx) = oneshot::channel();
let mut error: id = nil;
let _: () = msg_send![stream, addStreamOutput:output type:SCStreamOutputTypeScreen sampleHandlerQueue:0 error:&mut error as *mut id];
if error != nil {
let message: id = msg_send![error, localizedDescription];
- tx.send(Err(anyhow!("failed to add stream output {message:?}")))
+ let _: () = msg_send![stream, release];
+ let _: () = msg_send![output, release];
+ tx.send(Err(anyhow!("failed to add stream output {message:?}")))
.ok();
return rx;
}
@@ -131,8 +140,10 @@ impl ScreenCaptureSource for MacScreenCaptureSource {
};
Ok(Box::new(stream) as Box<dyn ScreenCaptureStream>)
} else {
+ let _: () = msg_send![stream, release];
+ let _: () = msg_send![output, release];
let message: id = msg_send![error, localizedDescription];
- Err(anyhow!("failed to stop screen capture stream {message:?}"))
+ Err(anyhow!("failed to start screen capture stream {message:?}"))
};
if let Some(tx) = tx.borrow_mut().take() {
tx.send(result).ok();
@@ -195,7 +206,7 @@ unsafe fn screen_id_to_human_label() -> HashMap<CGDirectDisplayID, ScreenMeta> {
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];
@@ -8,6 +8,7 @@ use anyhow::anyhow;
use cocoa::appkit::CGFloat;
use collections::HashMap;
use core_foundation::{
+ array::{CFArray, CFArrayRef},
attributed_string::CFMutableAttributedString,
base::{CFRange, TCFType},
number::CFNumber,
@@ -21,8 +22,10 @@ use core_graphics::{
};
use core_text::{
font::CTFont,
+ font_collection::CTFontCollectionRef,
font_descriptor::{
- kCTFontSlantTrait, kCTFontSymbolicTrait, kCTFontWeightTrait, kCTFontWidthTrait,
+ CTFontDescriptor, kCTFontSlantTrait, kCTFontSymbolicTrait, kCTFontWeightTrait,
+ kCTFontWidthTrait,
},
line::CTLine,
string_attributes::kCTFontAttributeName,
@@ -97,7 +100,26 @@ impl PlatformTextSystem for MacTextSystem {
fn all_font_names(&self) -> Vec<String> {
let mut names = Vec::new();
let collection = core_text::font_collection::create_for_all_families();
- let Some(descriptors) = collection.get_descriptors() else {
+ // NOTE: We intentionally avoid using `collection.get_descriptors()` here because
+ // it has a memory leak bug in core-text v21.0.0. The upstream code uses
+ // `wrap_under_get_rule` but `CTFontCollectionCreateMatchingFontDescriptors`
+ // follows the Create Rule (caller owns the result), so it should use
+ // `wrap_under_create_rule`. We call the function directly with correct memory management.
+ unsafe extern "C" {
+ fn CTFontCollectionCreateMatchingFontDescriptors(
+ collection: CTFontCollectionRef,
+ ) -> CFArrayRef;
+ }
+ let descriptors: Option<CFArray<CTFontDescriptor>> = unsafe {
+ let array_ref =
+ CTFontCollectionCreateMatchingFontDescriptors(collection.as_concrete_TypeRef());
+ if array_ref.is_null() {
+ None
+ } else {
+ Some(CFArray::wrap_under_create_rule(array_ref))
+ }
+ };
+ let Some(descriptors) = descriptors else {
return names;
};
for descriptor in descriptors.into_iter() {
@@ -435,6 +457,7 @@ impl MacTextSystemState {
{
let mut text = text;
+ let mut break_ligature = true;
for run in font_runs {
let text_run;
(text_run, text) = text.split_at(run.len);
@@ -444,7 +467,8 @@ impl MacTextSystemState {
string.replace_str(&CFString::new(text_run), CFRange::init(utf16_start, 0));
let utf16_end = string.char_len();
- let cf_range = CFRange::init(utf16_start, utf16_end - utf16_start);
+ let length = utf16_end - utf16_start;
+ let cf_range = CFRange::init(utf16_start, length);
let font = &self.fonts[run.font_id.0];
let font_metrics = font.metrics();
@@ -452,6 +476,11 @@ impl MacTextSystemState {
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,
@@ -459,6 +488,7 @@ impl MacTextSystemState {
&font.native_font().clone_with_font_size(font_size.into()),
);
}
+ break_ligature = !break_ligature;
}
}
// Retrieve the glyphs from the shaped line, converting UTF16 offsets to UTF8 offsets.
@@ -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),
@@ -781,7 +785,7 @@ impl MacWindow {
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<UserTabbingPreference> {
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];
@@ -1188,6 +1190,7 @@ impl PlatformWindow for MacWindow {
let (done_tx, done_rx) = oneshot::channel();
let done_tx = Cell::new(Some(done_tx));
let block = ConcreteBlock::new(move |answer: NSInteger| {
+ let _: () = msg_send![alert, release];
if let Some(done_tx) = done_tx.take() {
let _ = done_tx.send(answer.try_into().unwrap());
}
@@ -1505,8 +1508,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 +1546,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 {
@@ -1967,10 +1981,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<Mutex<MacWindowState>>) {
+ 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 +2119,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) {
@@ -2491,7 +2511,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
@@ -2537,7 +2557,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];
@@ -1,5 +1,4 @@
-use crate::{PlatformDispatcher, TaskLabel};
-use async_task::Runnable;
+use crate::{PlatformDispatcher, Priority, RunnableVariant, TaskLabel};
use backtrace::Backtrace;
use collections::{HashMap, HashSet, VecDeque};
use parking::Unparker;
@@ -26,10 +25,10 @@ pub struct TestDispatcher {
struct TestDispatcherState {
random: StdRng,
- foreground: HashMap<TestDispatcherId, VecDeque<Runnable>>,
- background: Vec<Runnable>,
- deprioritized_background: Vec<Runnable>,
- delayed: Vec<(Duration, Runnable)>,
+ foreground: HashMap<TestDispatcherId, VecDeque<RunnableVariant>>,
+ background: Vec<RunnableVariant>,
+ deprioritized_background: Vec<RunnableVariant>,
+ delayed: Vec<(Duration, RunnableVariant)>,
start_time: Instant,
time: Duration,
is_main_thread: bool,
@@ -39,7 +38,7 @@ struct TestDispatcherState {
waiting_backtrace: Option<Backtrace>,
deprioritized_task_labels: HashSet<TaskLabel>,
block_on_ticks: RangeInclusive<usize>,
- last_parked: Option<Unparker>,
+ unparkers: Vec<Unparker>,
}
impl TestDispatcher {
@@ -59,7 +58,7 @@ impl TestDispatcher {
waiting_backtrace: None,
deprioritized_task_labels: Default::default(),
block_on_ticks: 0..=1000,
- last_parked: None,
+ unparkers: Default::default(),
};
TestDispatcher {
@@ -175,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
@@ -240,20 +245,14 @@ impl TestDispatcher {
let block_on_ticks = lock.block_on_ticks.clone();
lock.random.random_range(block_on_ticks)
}
- pub fn unpark_last(&self) {
- self.state
- .lock()
- .last_parked
- .take()
- .as_ref()
- .map(Unparker::unpark);
+
+ pub fn unpark_all(&self) {
+ self.state.lock().unparkers.retain(|parker| parker.unpark());
}
- pub fn set_unparker(&self, unparker: Unparker) {
- let last = { self.state.lock().last_parked.replace(unparker) };
- if let Some(last) = last {
- last.unpark();
- }
+ pub fn push_unparker(&self, unparker: Unparker) {
+ let mut state = self.state.lock();
+ state.unparkers.push(unparker);
}
}
@@ -268,6 +267,14 @@ impl Clone for TestDispatcher {
}
impl PlatformDispatcher for TestDispatcher {
+ fn get_all_timings(&self) -> Vec<crate::ThreadTaskTimings> {
+ Vec::new()
+ }
+
+ fn get_current_thread_timings(&self) -> Vec<crate::TaskTiming> {
+ Vec::new()
+ }
+
fn is_main_thread(&self) -> bool {
self.state.lock().is_main_thread
}
@@ -277,7 +284,7 @@ impl PlatformDispatcher for TestDispatcher {
state.start_time + state.time
}
- fn dispatch(&self, runnable: Runnable, label: Option<TaskLabel>) {
+ fn dispatch(&self, runnable: RunnableVariant, label: Option<TaskLabel>, _priority: Priority) {
{
let mut state = self.state.lock();
if label.is_some_and(|label| state.deprioritized_task_labels.contains(&label)) {
@@ -286,20 +293,20 @@ impl PlatformDispatcher for TestDispatcher {
state.background.push(runnable);
}
}
- self.unpark_last();
+ 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.unpark_last();
+ 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) {
@@ -311,4 +318,10 @@ impl PlatformDispatcher for TestDispatcher {
fn as_test(&self) -> Option<&TestDispatcher> {
Some(self)
}
+
+ fn spawn_realtime(&self, _priority: crate::RealtimePriority, f: Box<dyn FnOnce() + Send>) {
+ std::thread::spawn(move || {
+ f();
+ });
+ }
}
@@ -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<FxHashMap<u32, ClipboardFormatType>> = LazyLock::ne
formats_map.insert(CF_HDROP.0 as u32, ClipboardFormatType::Files);
formats_map
});
-static FORMATS_SET: LazyLock<FxHashSet<u32>> = 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<FxHashMap<u32, ImageFormat>> = 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: F) -> Option<ClipboardItem>
where
F: Fn(u32) -> Option<ClipboardEntry>,
{
+ 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<Clipbo
}
fn read_files_from_clipboard() -> Option<ClipboardEntry> {
- 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<F, R>(format: u32, f: F) -> Option<R>
@@ -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();
@@ -1506,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,
@@ -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(),
+ }
}
}
@@ -48,6 +48,12 @@ pub(crate) struct DirectXRenderer {
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
@@ -167,6 +173,7 @@ impl DirectXRenderer {
font_info: Self::get_font_info(),
width: 1,
height: 1,
+ skip_draws: false,
})
}
@@ -192,8 +199,13 @@ impl DirectXRenderer {
}],
)?;
unsafe {
- device_context
- .ClearRenderTargetView(resources.render_target_view.as_ref().unwrap(), &[0.0; 4]);
+ device_context.ClearRenderTargetView(
+ resources
+ .render_target_view
+ .as_ref()
+ .context("missing render target view")?,
+ &[0.0; 4],
+ );
device_context
.OMSetRenderTargets(Some(slice::from_ref(&resources.render_target_view)), None);
device_context.RSSetViewports(Some(slice::from_ref(&resources.viewport)));
@@ -283,10 +295,16 @@ impl DirectXRenderer {
self.globals = globals;
self.pipelines = pipelines;
self.direct_composition = direct_composition;
+ 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 {
@@ -306,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()
}
@@ -352,6 +374,7 @@ impl DirectXRenderer {
}
resources.recreate_resources(devices, width, height)?;
+
unsafe {
devices
.device_context
@@ -647,6 +670,10 @@ impl DirectXRenderer {
}
})
}
+
+ pub(crate) fn mark_drawable(&mut self) {
+ self.skip_draws = false;
+ }
}
impl DirectXResources {
@@ -1,16 +1,13 @@
use std::{
sync::atomic::{AtomicBool, Ordering},
thread::{ThreadId, current},
- time::Duration,
+ time::{Duration, Instant},
};
-use async_task::Runnable;
use flume::Sender;
use util::ResultExt;
use windows::{
- System::Threading::{
- ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemPriority,
- },
+ System::Threading::{ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler},
Win32::{
Foundation::{LPARAM, WPARAM},
UI::WindowsAndMessaging::PostMessageW,
@@ -18,20 +15,21 @@ use windows::{
};
use crate::{
- HWND, PlatformDispatcher, SafeHwnd, TaskLabel, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD,
+ GLOBAL_THREAD_TIMINGS, HWND, PlatformDispatcher, RunnableVariant, SafeHwnd, THREAD_TIMINGS,
+ TaskLabel, TaskTiming, ThreadTaskTimings, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD,
};
pub(crate) struct WindowsDispatcher {
pub(crate) wake_posted: AtomicBool,
- main_sender: Sender<Runnable>,
+ main_sender: Sender<RunnableVariant>,
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<Runnable>,
+ main_sender: Sender<RunnableVariant>,
platform_window_handle: HWND,
validation_number: usize,
) -> Self {
@@ -47,42 +45,120 @@ impl WindowsDispatcher {
}
}
- fn dispatch_on_threadpool(&self, runnable: Runnable) {
+ fn dispatch_on_threadpool(&self, 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::RunAsync(&handler).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,
+ };
+ Self::add_task_timing(timing);
+
+ runnable.run();
+
+ timing
+ }
+ RunnableVariant::Compat(runnable) => {
+ let timing = TaskTiming {
+ location: core::panic::Location::caller(),
+ start,
+ end: None,
+ };
+ Self::add_task_timing(timing);
+
+ runnable.run();
+
+ timing
+ }
+ };
+
+ let end = Instant::now();
+ timing.end = Some(end);
+
+ Self::add_task_timing(timing);
+ }
+
+ 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);
+ });
+ }
}
impl PlatformDispatcher for WindowsDispatcher {
+ fn get_all_timings(&self) -> Vec<ThreadTaskTimings> {
+ let global_thread_timings = GLOBAL_THREAD_TIMINGS.lock();
+ ThreadTaskTimings::convert(&global_thread_timings)
+ }
+
+ fn get_current_thread_timings(&self) -> Vec<crate::TaskTiming> {
+ 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<TaskLabel>) {
+ fn dispatch(
+ &self,
+ runnable: RunnableVariant,
+ label: Option<TaskLabel>,
+ _priority: gpui::Priority,
+ ) {
self.dispatch_on_threadpool(runnable);
if let Some(label) = label {
log::debug!("TaskLabel: {label:?}");
}
}
- fn dispatch_on_main_thread(&self, runnable: Runnable) {
+ fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: gpui::Priority) {
match self.main_sender.send(runnable) {
Ok(_) => {
if !self.wake_posted.swap(true, Ordering::AcqRel) {
@@ -111,7 +187,12 @@ 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 spawn_realtime(&self, _priority: crate::RealtimePriority, _f: Box<dyn FnOnce() + Send>) {
+ // disabled on windows for now.
+ unimplemented!();
+ }
}
@@ -23,6 +23,7 @@ pub(crate) struct WindowsDisplay {
pub display_id: DisplayId,
scale_factor: f32,
bounds: Bounds<Pixels>,
+ visible_bounds: Bounds<Pixels>,
physical_bounds: Bounds<DevicePixels>,
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<Self> {
+ 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<Self> {
+ 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<Self> {
@@ -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<dyn PlatformDisplay>
+ .filter_map(|(id, handle)| {
+ Some(Rc::new(
+ WindowsDisplay::new_with_handle_and_id(handle, DisplayId(id as _)).ok()?,
+ ) as Rc<dyn PlatformDisplay>)
})
.collect()
}
@@ -194,6 +221,10 @@ impl PlatformDisplay for WindowsDisplay {
fn bounds(&self) -> Bounds<Pixels> {
self.bounds
}
+
+ fn visible_bounds(&self) -> Bounds<Pixels> {
+ self.visible_bounds
+ }
}
fn available_monitors() -> SmallVec<[HMONITOR; 4]> {
@@ -51,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),
@@ -116,17 +116,16 @@ impl WindowsWindowInner {
}
fn handle_move_msg(&self, handle: HWND, lparam: LPARAM) -> Option<isize> {
- 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
@@ -136,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<isize> {
- 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<isize> {
- 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);
}
@@ -179,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)
@@ -199,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));
}
}
@@ -239,7 +244,7 @@ impl WindowsWindowInner {
fn handle_timer_msg(&self, handle: HWND, wparam: WPARAM) -> Option<isize> {
if wparam.0 == SIZE_MOVE_LOOP_TIMER_ID {
for runnable in self.main_receiver.drain() {
- runnable.run();
+ WindowsDispatcher::execute_runnable(runnable);
}
self.handle_paint_msg(handle)
} else {
@@ -252,17 +257,14 @@ impl WindowsWindowInner {
}
fn handle_close_msg(&self) -> Option<isize> {
- 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<isize> {
- 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();
}
@@ -281,12 +283,10 @@ impl WindowsWindowInner {
fn handle_mouse_move_msg(&self, handle: HWND, lparam: LPARAM, wparam: WPARAM) -> Option<isize> {
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),
@@ -308,32 +308,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<isize> {
- 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_syskeyup_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option<isize> {
- let mut lock = self.state.borrow_mut();
- let input = handle_key_event(wparam, lparam, &mut lock, |keystroke, _| {
+ 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)
@@ -342,11 +342,10 @@ 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, wparam: WPARAM, lparam: LPARAM) -> Option<isize> {
- let mut lock = self.state.borrow_mut();
let Some(input) = handle_key_event(
wparam,
lparam,
- &mut lock,
+ &self.state,
|keystroke, prefer_character_input| {
PlatformInput::KeyDown(KeyDownEvent {
keystroke,
@@ -357,34 +356,31 @@ impl WindowsWindowInner {
) else {
return Some(1);
};
- drop(lock);
- 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 { Some(1) }
}
fn handle_keyup_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option<isize> {
- let mut lock = self.state.borrow_mut();
- let Some(input) = handle_key_event(wparam, lparam, &mut lock, |keystroke, _| {
+ 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) }
}
@@ -405,16 +401,15 @@ impl WindowsWindowInner {
lparam: LPARAM,
) -> Option<isize> {
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,
@@ -424,7 +419,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) }
}
@@ -436,15 +431,14 @@ impl WindowsWindowInner {
lparam: LPARAM,
) -> Option<isize> {
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,
@@ -453,7 +447,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) }
}
@@ -480,26 +474,23 @@ impl WindowsWindowInner {
lparam: LPARAM,
) -> Option<isize> {
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 => {
- self.system_settings
- .borrow()
- .mouse_wheel_settings
- .wheel_scroll_chars
- }
- false => {
- self.system_settings
- .borrow()
- .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;
@@ -524,7 +515,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) }
}
@@ -535,17 +526,15 @@ impl WindowsWindowInner {
wparam: WPARAM,
lparam: LPARAM,
) -> Option<isize> {
- 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_chars = self
- .system_settings
- .borrow()
+ .system_settings()
.mouse_wheel_settings
- .wheel_scroll_chars;
- drop(lock);
+ .wheel_scroll_chars
+ .get();
let wheel_distance =
(-wparam.signed_hiword() as f32 / WHEEL_DELTA as f32) * wheel_scroll_chars as f32;
@@ -564,7 +553,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) }
}
@@ -658,11 +647,11 @@ impl WindowsWindowInner {
wparam: WPARAM,
lparam: LPARAM,
) -> Option<isize> {
- 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;
@@ -677,8 +666,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.system_settings.borrow().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
@@ -707,11 +695,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();
@@ -735,38 +721,64 @@ impl WindowsWindowInner {
lparam: LPARAM,
) -> Option<isize> {
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);
-
- 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();
- }
+ self.state.scale_factor.set(new_scale_factor);
+ self.state.border_offset.update(handle).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::<MONITORINFO>() 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)
@@ -787,7 +799,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;
@@ -804,87 +816,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<isize> {
- if !self.is_movable || self.state.borrow().is_fullscreen() {
+ fn handle_hit_test_msg(&self, handle: HWND, lparam: LPARAM) -> Option<isize> {
+ 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<isize> {
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(),
@@ -897,7 +901,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 }
}
@@ -909,17 +913,15 @@ impl WindowsWindowInner {
wparam: WPARAM,
lparam: LPARAM,
) -> Option<isize> {
- 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,
@@ -930,21 +932,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)
@@ -960,10 +961,8 @@ impl WindowsWindowInner {
wparam: WPARAM,
lparam: LPARAM,
) -> Option<isize> {
- 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(),
@@ -977,16 +976,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
{
@@ -996,7 +994,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() };
@@ -1021,17 +1019,16 @@ impl WindowsWindowInner {
}
fn handle_cursor_changed(&self, lparam: LPARAM) -> Option<isize> {
- 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)
@@ -1054,9 +1051,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(
@@ -1066,13 +1063,12 @@ impl WindowsWindowInner {
lparam: LPARAM,
) -> Option<isize> {
if wparam.0 != 0 {
- let mut lock = self.state.borrow_mut();
- let display = lock.display;
- lock.click_state.system_update(wparam.0);
- lock.border_offset.update(handle).log_err();
- // system settings may emit a window message which wants to take the refcell lock, so drop it
- drop(lock);
- self.system_settings.borrow_mut().update(display, wparam.0);
+ 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)?;
};
@@ -1095,13 +1091,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);
}
}
@@ -1130,10 +1126,14 @@ impl WindowsWindowInner {
}
fn handle_device_lost(&self, lparam: LPARAM) -> Option<isize> {
- let mut lock = self.state.borrow_mut();
let devices = lparam.0 as *const DirectXDevices;
let devices = unsafe { &*devices };
- if let Err(err) = 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)
@@ -1141,29 +1141,36 @@ impl WindowsWindowInner {
#[inline]
fn draw_window(&self, handle: HWND, force_render: bool) -> Option<isize> {
- 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<String> {
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 {
@@ -1175,7 +1182,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())
@@ -1184,9 +1191,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::<TRACKMOUSEEVENT>() as u32,
@@ -1196,10 +1202,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));
}
}
}
@@ -1208,9 +1216,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)
}
@@ -1218,12 +1226,11 @@ impl WindowsWindowInner {
where
F: FnOnce(&mut PlatformInputHandler, f32) -> Option<R>,
{
- 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
}
}
@@ -1231,7 +1238,7 @@ impl WindowsWindowInner {
fn handle_key_event<F>(
wparam: WPARAM,
lparam: LPARAM,
- state: &mut WindowsWindowState,
+ state: &WindowsWindowState,
f: F,
) -> Option<PlatformInput>
where
@@ -1244,11 +1251,12 @@ where
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(),
@@ -1,14 +1,16 @@
use std::{
- cell::RefCell,
+ cell::{Cell, RefCell},
ffi::OsStr,
path::{Path, PathBuf},
rc::{Rc, Weak},
- sync::{Arc, atomic::Ordering},
+ 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;
@@ -37,37 +39,40 @@ pub(crate) struct WindowsPlatform {
text_system: Arc<DirectWriteTextSystem>,
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<AtomicBool>,
handle: HWND,
disable_direct_composition: bool,
}
struct WindowsPlatformInner {
- state: RefCell<WindowsPlatformState>,
+ state: WindowsPlatformState,
raw_window_handles: std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>,
// The below members will never change throughout the entire lifecycle of the app.
validation_number: usize,
- main_receiver: flume::Receiver<Runnable>,
+ main_receiver: flume::Receiver<RunnableVariant>,
dispatcher: Arc<WindowsDispatcher>,
}
pub(crate) struct WindowsPlatformState {
callbacks: PlatformCallbacks,
- menus: Vec<OwnedMenu>,
- jump_list: JumpList,
+ menus: RefCell<Vec<OwnedMenu>>,
+ jump_list: RefCell<JumpList>,
// NOTE: standard cursor handles don't need to close.
- pub(crate) current_cursor: Option<HCURSOR>,
- directx_devices: Option<DirectXDevices>,
+ pub(crate) current_cursor: Cell<Option<HCURSOR>>,
+ directx_devices: RefCell<Option<DirectXDevices>>,
}
#[derive(Default)]
struct PlatformCallbacks {
- open_urls: Option<Box<dyn FnMut(Vec<String>)>>,
- quit: Option<Box<dyn FnMut()>>,
- reopen: Option<Box<dyn FnMut()>>,
- app_menu_action: Option<Box<dyn FnMut(&dyn Action)>>,
- will_open_app_menu: Option<Box<dyn FnMut()>>,
- validate_app_menu_command: Option<Box<dyn FnMut(&dyn Action) -> bool>>,
- keyboard_layout_change: Option<Box<dyn FnMut()>>,
+ open_urls: Cell<Option<Box<dyn FnMut(Vec<String>)>>>,
+ quit: Cell<Option<Box<dyn FnMut()>>>,
+ reopen: Cell<Option<Box<dyn FnMut()>>>,
+ app_menu_action: Cell<Option<Box<dyn FnMut(&dyn Action)>>>,
+ will_open_app_menu: Cell<Option<Box<dyn FnMut()>>>,
+ validate_app_menu_command: Cell<Option<Box<dyn FnMut(&dyn Action) -> bool>>>,
+ keyboard_layout_change: Cell<Option<Box<dyn FnMut()>>>,
}
impl WindowsPlatformState {
@@ -79,10 +84,10 @@ impl WindowsPlatformState {
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::<Runnable>();
+ let (main_sender, main_receiver) = flume::unbounded::<RunnableVariant>();
let validation_number = if usize::BITS == 64 {
rand::random::<u64>() as usize
} else {
@@ -163,6 +168,7 @@ impl WindowsPlatform {
disable_direct_composition,
windows_version,
drop_target_helper,
+ invalidate_devices: Arc::new(AtomicBool::new(false)),
})
}
@@ -188,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().unwrap(),
+ directx_devices: self.inner.state.directx_devices.borrow().clone().unwrap(),
+ invalidate_devices: self.invalidate_devices.clone(),
}
}
@@ -206,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(
@@ -222,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<HWND> {
@@ -243,18 +247,22 @@ impl WindowsPlatform {
}
fn begin_vsync_thread(&self) {
- let mut directx_device = self.inner.state.borrow().directx_devices.clone().unwrap();
+ 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) {
+ 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(),
@@ -323,9 +331,9 @@ impl Platform for WindowsPlatform {
fn on_keyboard_layout_change(&self, callback: Box<dyn FnMut()>) {
self.inner
.state
- .borrow_mut()
.callbacks
- .keyboard_layout_change = Some(callback);
+ .keyboard_layout_change
+ .set(Some(callback));
}
fn run(&self, on_finish_launching: Box<dyn 'static + FnOnce()>) {
@@ -342,9 +350,8 @@ impl Platform for WindowsPlatform {
}
}
- 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) {
@@ -379,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(),
@@ -462,7 +470,7 @@ impl Platform for WindowsPlatform {
}
fn on_open_urls(&self, callback: Box<dyn FnMut(Vec<String>)>) {
- self.inner.state.borrow_mut().callbacks.open_urls = Some(callback);
+ self.inner.state.callbacks.open_urls.set(Some(callback));
}
fn prompt_for_paths(
@@ -532,19 +540,19 @@ impl Platform for WindowsPlatform {
}
fn on_quit(&self, callback: Box<dyn FnMut()>) {
- self.inner.state.borrow_mut().callbacks.quit = Some(callback);
+ self.inner.state.callbacks.quit.set(Some(callback));
}
fn on_reopen(&self, callback: Box<dyn FnMut()>) {
- self.inner.state.borrow_mut().callbacks.reopen = Some(callback);
+ self.inner.state.callbacks.reopen.set(Some(callback));
}
fn set_menus(&self, menus: Vec<Menu>, _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<Vec<OwnedMenu>> {
- Some(self.inner.state.borrow().menus.clone())
+ Some(self.inner.state.menus.borrow().clone())
}
fn set_dock_menu(&self, menus: Vec<MenuItem>, _keymap: &Keymap) {
@@ -552,19 +560,27 @@ impl Platform for WindowsPlatform {
}
fn on_app_menu_action(&self, callback: Box<dyn FnMut(&dyn Action)>) {
- 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<dyn FnMut()>) {
- 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<dyn FnMut(&dyn Action) -> 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<PathBuf> {
@@ -578,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);
}
}
@@ -632,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 {
@@ -702,12 +726,12 @@ impl Platform for WindowsPlatform {
impl WindowsPlatformInner {
fn new(context: &mut PlatformWindowCreateContext) -> Result<Rc<Self>> {
- let state = RefCell::new(WindowsPlatformState::new(
+ 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(),
@@ -724,6 +748,19 @@ impl WindowsPlatformInner {
}))
}
+ /// 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<T>(
+ &self,
+ project: impl Fn(&PlatformCallbacks) -> &Cell<Option<T>>,
+ 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<Self>,
handle: HWND,
@@ -781,24 +818,60 @@ impl WindowsPlatformInner {
#[inline]
fn run_foreground_task(&self) -> Option<isize> {
- loop {
- 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;
+ }
+ match self.main_receiver.try_recv() {
+ Err(_) => break 'timeout_loop,
+ Ok(runnable) => WindowsDispatcher::execute_runnable(runnable),
+ }
}
// 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.
- let dispatcher = self.dispatcher.clone();
-
- dispatcher.wake_posted.store(false, Ordering::Release);
+ self.dispatcher.wake_posted.store(false, Ordering::Release);
match self.main_receiver.try_recv() {
+ Err(_) => break 'tasks,
Ok(runnable) => {
- let _ = dispatcher.wake_posted.swap(true, Ordering::AcqRel);
- runnable.run();
- continue;
- }
- _ => {
- break;
+ self.dispatcher.wake_posted.store(true, Ordering::Release);
+
+ WindowsDispatcher::execute_runnable(runnable);
}
}
}
@@ -807,42 +880,37 @@ impl WindowsPlatformInner {
}
fn handle_dock_action_event(&self, action_idx: usize) -> Option<isize> {
- 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<isize> {
- 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<isize> {
- let mut lock = self.state.borrow_mut();
let directx_devices = lparam.0 as *const DirectXDevices;
let directx_devices = unsafe { &*directx_devices };
- lock.directx_devices.take();
- lock.directx_devices = Some(directx_devices.clone());
+ self.state.directx_devices.borrow_mut().take();
+ *self.state.directx_devices.borrow_mut() = Some(directx_devices.clone());
Some(0)
}
@@ -866,18 +934,21 @@ 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<Runnable>,
+ pub(crate) main_receiver: flume::Receiver<RunnableVariant>,
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<AtomicBool>,
}
struct PlatformWindowCreateContext {
inner: Option<Result<Rc<WindowsPlatformInner>>>,
raw_window_handles: std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>,
validation_number: usize,
- main_sender: Option<flume::Sender<Runnable>>,
- main_receiver: Option<flume::Receiver<Runnable>>,
+ main_sender: Option<flume::Sender<RunnableVariant>>,
+ main_receiver: Option<flume::Receiver<RunnableVariant>>,
directx_devices: Option<DirectXDevices>,
dispatcher: Option<Arc<WindowsDispatcher>>,
}
@@ -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<AutoHideTaskbarPosition>,
+ pub(crate) auto_hide_taskbar_position: Cell<Option<AutoHideTaskbarPosition>>,
}
-#[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<u32>,
/// SEE: SPI_GETWHEELSCROLLLINES
- pub(crate) wheel_scroll_lines: u32,
+ pub(crate) wheel_scroll_lines: Cell<u32>,
}
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);
}
}
}
@@ -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,47 +30,58 @@ use crate::*;
pub(crate) struct WindowsWindow(pub Rc<WindowsWindowInner>);
+impl std::ops::Deref for WindowsWindow {
+ type Target = WindowsWindowInner;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
pub struct WindowsWindowState {
- pub origin: Point<Pixels>,
- pub logical_size: Size<Pixels>,
+ pub origin: Cell<Point<Pixels>>,
+ pub logical_size: Cell<Size<Pixels>>,
pub min_size: Option<Size<Pixels>>,
- pub fullscreen_restore_bounds: Bounds<Pixels>,
+ pub fullscreen_restore_bounds: Cell<Bounds<Pixels>>,
pub border_offset: WindowBorderOffset,
- pub appearance: WindowAppearance,
- pub scale_factor: f32,
- pub restore_from_minimized: Option<Box<dyn FnMut(RequestFrameOptions)>>,
+ pub appearance: Cell<WindowAppearance>,
+ pub scale_factor: Cell<f32>,
+ pub restore_from_minimized: Cell<Option<Box<dyn FnMut(RequestFrameOptions)>>>,
pub callbacks: Callbacks,
- pub input_handler: Option<PlatformInputHandler>,
- pub pending_surrogate: Option<u16>,
- pub last_reported_modifiers: Option<Modifiers>,
- pub last_reported_capslock: Option<Capslock>,
- pub hovered: bool,
+ pub input_handler: Cell<Option<PlatformInputHandler>>,
+ pub pending_surrogate: Cell<Option<u16>>,
+ pub last_reported_modifiers: Cell<Option<Modifiers>>,
+ pub last_reported_capslock: Cell<Option<Capslock>>,
+ pub hovered: Cell<bool>,
- pub renderer: DirectXRenderer,
+ pub renderer: RefCell<DirectXRenderer>,
pub click_state: ClickState,
- pub current_cursor: Option<HCURSOR>,
- pub nc_button_pressed: Option<u32>,
-
- pub display: WindowsDisplay,
- fullscreen: Option<StyleAndBounds>,
- initial_placement: Option<WindowOpenStatus>,
+ pub current_cursor: Cell<Option<HCURSOR>>,
+ pub nc_button_pressed: Cell<Option<u32>>,
+
+ pub display: Cell<WindowsDisplay>,
+ /// 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<AtomicBool>,
+ fullscreen: Cell<Option<StyleAndBounds>>,
+ initial_placement: Cell<Option<WindowOpenStatus>>,
hwnd: HWND,
}
pub(crate) struct WindowsWindowInner {
hwnd: HWND,
drop_target_helper: IDropTargetHelper,
- pub(crate) state: RefCell<WindowsWindowState>,
- pub(crate) system_settings: RefCell<WindowsSystemSettings>,
+ 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<Runnable>,
+ pub(crate) main_receiver: flume::Receiver<RunnableVariant>,
pub(crate) platform_window_handle: HWND,
}
@@ -84,6 +95,7 @@ impl WindowsWindowState {
min_size: Option<Size<Pixels>>,
appearance: WindowAppearance,
disable_direct_composition: bool,
+ invalidate_devices: Arc<AtomicBool>,
) -> Result<Self> {
let scale_factor = {
let monitor_dpi = unsafe { GetDpiForWindow(hwnd) } as f32;
@@ -117,34 +129,35 @@ impl WindowsWindowState {
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,
- 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,
- 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 {
@@ -153,8 +166,8 @@ impl WindowsWindowState {
fn bounds(&self) -> Bounds<Pixels> {
Bounds {
- origin: self.origin,
- size: self.logical_size,
+ origin: self.origin.get(),
+ size: self.logical_size.get(),
}
}
@@ -173,8 +186,8 @@ impl WindowsWindowState {
(
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,
)
@@ -184,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 {
@@ -197,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<Pixels> {
- self.logical_size
+ self.logical_size.get()
}
}
impl WindowsWindowInner {
fn new(context: &mut WindowCreateContext, hwnd: HWND, cs: &CREATESTRUCTW) -> Result<Rc<Self>> {
- let state = RefCell::new(WindowsWindowState::new(
+ let state = WindowsWindowState::new(
hwnd,
&context.directx_devices,
cs,
@@ -212,7 +225,8 @@ impl WindowsWindowInner {
context.min_size,
context.appearance,
context.disable_direct_composition,
- )?);
+ context.invalidate_devices.clone(),
+ )?;
Ok(Rc::new(Self {
hwnd,
@@ -226,7 +240,7 @@ impl WindowsWindowInner {
validation_number: context.validation_number,
main_receiver: context.main_receiver.clone(),
platform_window_handle: context.platform_window_handle,
- system_settings: RefCell::new(WindowsSystemSettings::new(context.display)),
+ system_settings: WindowsSystemSettings::new(context.display),
}))
}
@@ -234,19 +248,17 @@ impl WindowsWindowInner {
let this = self.clone();
self.executor
.spawn(async move {
- let mut lock = this.state.borrow_mut();
let StyleAndBounds {
style,
x,
y,
cx,
cy,
- } = match lock.fullscreen.take() {
+ } = match this.state.fullscreen.take() {
Some(state) => state,
None => {
- let (window_bounds, _) = lock.calculate_window_bounds();
- lock.fullscreen_restore_bounds = window_bounds;
- drop(lock);
+ 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 _);
@@ -254,22 +266,20 @@ impl WindowsWindowInner {
unsafe { GetWindowRect(this.hwnd, &mut rc) }
.context("failed to get window rect")
.log_err();
-
- lock = this.state.borrow_mut();
- let _ = lock.fullscreen.insert(StyleAndBounds {
+ 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 = lock.display.physical_bounds();
+ let physical_bounds = this.state.display.get().physical_bounds();
StyleAndBounds {
style,
x: physical_bounds.left().0,
@@ -279,7 +289,6 @@ impl WindowsWindowInner {
}
}
};
- drop(lock);
unsafe { set_window_long(this.hwnd, GWL_STYLE, style.0 as isize) };
unsafe {
SetWindowPos(
@@ -298,7 +307,7 @@ impl WindowsWindowInner {
}
fn set_window_placement(self: &Rc<Self>) -> Result<()> {
- let Some(open_status) = self.state.borrow_mut().initial_placement.take() else {
+ let Some(open_status) = self.state.initial_placement.take() else {
return Ok(());
};
match open_status.state {
@@ -321,20 +330,24 @@ impl WindowsWindowInner {
}
Ok(())
}
+
+ pub(crate) fn system_settings(&self) -> &WindowsSystemSettings {
+ &self.system_settings
+ }
}
#[derive(Default)]
pub(crate) struct Callbacks {
- pub(crate) request_frame: Option<Box<dyn FnMut(RequestFrameOptions)>>,
- pub(crate) input: Option<Box<dyn FnMut(crate::PlatformInput) -> DispatchEventResult>>,
- pub(crate) active_status_change: Option<Box<dyn FnMut(bool)>>,
- pub(crate) hovered_status_change: Option<Box<dyn FnMut(bool)>>,
- pub(crate) resize: Option<Box<dyn FnMut(Size<Pixels>, f32)>>,
- pub(crate) moved: Option<Box<dyn FnMut()>>,
- pub(crate) should_close: Option<Box<dyn FnMut() -> bool>>,
- pub(crate) close: Option<Box<dyn FnOnce()>>,
- pub(crate) hit_test_window_control: Option<Box<dyn FnMut() -> Option<WindowControlArea>>>,
- pub(crate) appearance_changed: Option<Box<dyn FnMut()>>,
+ pub(crate) request_frame: Cell<Option<Box<dyn FnMut(RequestFrameOptions)>>>,
+ pub(crate) input: Cell<Option<Box<dyn FnMut(crate::PlatformInput) -> DispatchEventResult>>>,
+ pub(crate) active_status_change: Cell<Option<Box<dyn FnMut(bool)>>>,
+ pub(crate) hovered_status_change: Cell<Option<Box<dyn FnMut(bool)>>>,
+ pub(crate) resize: Cell<Option<Box<dyn FnMut(Size<Pixels>, f32)>>>,
+ pub(crate) moved: Cell<Option<Box<dyn FnMut()>>>,
+ pub(crate) should_close: Cell<Option<Box<dyn FnMut() -> bool>>>,
+ pub(crate) close: Cell<Option<Box<dyn FnOnce()>>>,
+ pub(crate) hit_test_window_control: Cell<Option<Box<dyn FnMut() -> Option<WindowControlArea>>>>,
+ pub(crate) appearance_changed: Cell<Option<Box<dyn FnMut()>>>,
}
struct WindowCreateContext {
@@ -349,11 +362,12 @@ struct WindowCreateContext {
windows_version: WindowsVersion,
drop_target_helper: IDropTargetHelper,
validation_number: usize,
- main_receiver: flume::Receiver<Runnable>,
+ main_receiver: flume::Receiver<RunnableVariant>,
platform_window_handle: HWND,
appearance: WindowAppearance,
disable_direct_composition: bool,
directx_devices: DirectXDevices,
+ invalidate_devices: Arc<AtomicBool>,
}
impl WindowsWindow {
@@ -373,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
@@ -433,6 +448,7 @@ impl WindowsWindow {
appearance,
disable_direct_composition,
directx_devices,
+ invalidate_devices,
};
let creation_result = unsafe {
CreateWindowExW(
@@ -459,21 +475,21 @@ impl WindowsWindow {
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))
@@ -516,15 +532,15 @@ impl Drop for WindowsWindow {
impl PlatformWindow for WindowsWindow {
fn bounds(&self) -> Bounds<Pixels> {
- 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.
@@ -532,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<Pixels> {
- self.0.state.borrow().content_size()
+ self.state.content_size()
}
fn resize(&mut self, size: Size<Pixels>) {
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
@@ -562,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<Rc<dyn PlatformDisplay>> {
- Some(Rc::new(self.0.state.borrow().display))
+ Some(Rc::new(self.state.display.get()))
}
fn mouse_position(&self) -> Point<Pixels> {
@@ -595,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<PlatformInputHandler> {
- self.0.state.borrow_mut().input_handler.take()
+ self.state.input_handler.take()
}
fn prompt(
@@ -745,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) {
@@ -757,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);
+ }
}
}
@@ -782,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));
}
}
}
@@ -791,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<dyn FnMut(RequestFrameOptions)>) {
- self.0.state.borrow_mut().callbacks.request_frame = Some(callback);
+ self.state.callbacks.request_frame.set(Some(callback));
}
fn on_input(&self, callback: Box<dyn FnMut(PlatformInput) -> 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<dyn FnMut(bool)>) {
- 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<dyn FnMut(bool)>) {
- 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<dyn FnMut(Size<Pixels>, f32)>) {
- self.0.state.borrow_mut().callbacks.resize = Some(callback);
+ self.state.callbacks.resize.set(Some(callback));
}
fn on_moved(&self, callback: Box<dyn FnMut()>) {
- self.0.state.borrow_mut().callbacks.moved = Some(callback);
+ self.state.callbacks.moved.set(Some(callback));
}
fn on_should_close(&self, callback: Box<dyn FnMut() -> 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<dyn FnOnce()>) {
- 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<dyn FnMut() -> Option<WindowControlArea>>) {
- 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<dyn FnMut()>) {
- 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<dyn PlatformAtlas> {
- self.0.state.borrow().renderer.sprite_atlas()
+ self.state.renderer.borrow().sprite_atlas()
}
fn get_raw_handle(&self) -> HWND {
@@ -853,7 +893,7 @@ impl PlatformWindow for WindowsWindow {
}
fn gpu_specs(&self) -> Option<GpuSpecs> {
- self.0.state.borrow().renderer.gpu_specs().log_err()
+ self.state.renderer.borrow().gpu_specs().log_err()
}
fn update_ime_position(&self, _bounds: Bounds<Pixels>) {
@@ -866,11 +906,9 @@ struct WindowsDragDropHandler(pub Rc<WindowsWindowInner>);
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));
}
}
}
@@ -914,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,
@@ -952,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,
@@ -994,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,
@@ -1008,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<DevicePixels>,
- double_click_spatial_tolerance_width: i32,
- double_click_spatial_tolerance_height: i32,
- double_click_interval: Duration,
- pub(crate) current_count: usize,
+ button: Cell<MouseButton>,
+ last_click: Cell<Instant>,
+ last_position: Cell<Point<DevicePixels>>,
+ double_click_spatial_tolerance_width: Cell<i32>,
+ double_click_spatial_tolerance_height: Cell<i32>,
+ double_click_interval: Cell<Duration>,
+ pub(crate) current_count: Cell<usize>,
}
impl ClickState {
@@ -1026,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<DevicePixels>) -> usize {
- if self.button == button && self.is_double_click(new_position) {
- self.current_count += 1;
+ pub fn update(&self, button: MouseButton, new_position: Point<DevicePixels>) -> 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<DevicePixels>) -> 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,
@@ -1106,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<i32>,
+ pub(crate) height_offset: Cell<i32>,
}
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)?;
@@ -1124,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,
@@ -1246,7 +1284,7 @@ fn register_drag_drop(window: &Rc<WindowsWindowInner>) -> Result<()> {
Ok(())
}
-fn calculate_window_rect(bounds: Bounds<DevicePixels>, border_offset: WindowBorderOffset) -> RECT {
+fn calculate_window_rect(bounds: Bounds<DevicePixels>, border_offset: &WindowBorderOffset) -> RECT {
// NOTE:
// The reason we're not using `AdjustWindowRectEx()` here is
// that the size reported by this function is incorrect.
@@ -1260,10 +1298,10 @@ fn calculate_window_rect(bounds: Bounds<DevicePixels>, 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;
@@ -1273,13 +1311,13 @@ fn calculate_window_rect(bounds: Bounds<DevicePixels>, border_offset: WindowBord
fn calculate_client_rect(
rect: RECT,
- border_offset: WindowBorderOffset,
+ border_offset: &WindowBorderOffset,
scale_factor: f32,
) -> Bounds<Pixels> {
- 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;
@@ -1296,7 +1334,7 @@ fn retrieve_window_placement(
display: WindowsDisplay,
initial_bounds: Bounds<Pixels>,
scale_factor: f32,
- border_offset: WindowBorderOffset,
+ border_offset: &WindowBorderOffset,
) -> Result<WINDOWPLACEMENT> {
let mut placement = WINDOWPLACEMENT {
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
@@ -1314,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<Color>, 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;
}
@@ -1381,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
@@ -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<Instant>,
+}
+
+#[doc(hidden)]
+#[derive(Debug, Clone)]
+pub struct ThreadTaskTimings {
+ pub thread_name: Option<String>,
+ pub thread_id: ThreadId,
+ pub timings: Vec<TaskTiming>,
+}
+
+impl ThreadTaskTimings {
+ pub(crate) fn convert(timings: &[GlobalThreadTimings]) -> Vec<Self> {
+ 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<SerializedTaskTiming<'static>> {
+ 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::<Vec<_>>();
+
+ serialized
+ }
+}
+
+/// Serializable variant of [`ThreadTaskTimings`]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SerializedThreadTaskTimings<'a> {
+ /// Thread name
+ pub thread_name: Option<String>,
+ /// Hash of the thread id
+ pub thread_id: u64,
+ /// Timing records for this thread
+ #[serde(borrow)]
+ pub timings: Vec<SerializedTaskTiming<'a>>,
+}
+
+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::<TaskTiming>();
+
+pub(crate) type TaskTimings = circular_buffer::CircularBuffer<MAX_TASK_TIMINGS, TaskTiming>;
+pub(crate) type GuardedTaskTimings = spin::Mutex<ThreadTimings>;
+
+pub(crate) struct GlobalThreadTimings {
+ pub thread_id: ThreadId,
+ pub timings: std::sync::Weak<GuardedTaskTimings>,
+}
+
+pub(crate) static GLOBAL_THREAD_TIMINGS: spin::Mutex<Vec<GlobalThreadTimings>> =
+ spin::Mutex::new(Vec::new());
+
+thread_local! {
+ pub(crate) static THREAD_TIMINGS: LazyCell<Arc<GuardedTaskTimings>> = 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<String>,
+ pub thread_id: ThreadId,
+ pub timings: Box<TaskTimings>,
+}
+
+impl ThreadTimings {
+ pub(crate) fn new(thread_name: Option<String>, 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);
+ });
+}
@@ -0,0 +1,328 @@
+use std::{
+ fmt,
+ iter::FusedIterator,
+ sync::{Arc, atomic::AtomicUsize},
+};
+
+use rand::{Rng, SeedableRng, rngs::SmallRng};
+
+use crate::Priority;
+
+struct PriorityQueues<T> {
+ high_priority: Vec<T>,
+ medium_priority: Vec<T>,
+ low_priority: Vec<T>,
+}
+
+impl<T> PriorityQueues<T> {
+ fn is_empty(&self) -> bool {
+ self.high_priority.is_empty()
+ && self.medium_priority.is_empty()
+ && self.low_priority.is_empty()
+ }
+}
+
+struct PriorityQueueState<T> {
+ queues: parking_lot::Mutex<PriorityQueues<T>>,
+ condvar: parking_lot::Condvar,
+ receiver_count: AtomicUsize,
+ sender_count: AtomicUsize,
+}
+
+impl<T> PriorityQueueState<T> {
+ fn send(&self, priority: Priority, item: T) -> Result<(), SendError<T>> {
+ 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<parking_lot::MutexGuard<'a, PriorityQueues<T>>, 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<Option<parking_lot::MutexGuard<'a, PriorityQueues<T>>>, 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<T> {
+ state: Arc<PriorityQueueState<T>>,
+}
+
+impl<T> PriorityQueueSender<T> {
+ fn new(state: Arc<PriorityQueueState<T>>) -> Self {
+ Self { state }
+ }
+
+ pub(crate) fn send(&self, priority: Priority, item: T) -> Result<(), SendError<T>> {
+ self.state.send(priority, item)?;
+ Ok(())
+ }
+}
+
+impl<T> Drop for PriorityQueueSender<T> {
+ fn drop(&mut self) {
+ self.state
+ .sender_count
+ .fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
+ }
+}
+
+pub(crate) struct PriorityQueueReceiver<T> {
+ state: Arc<PriorityQueueState<T>>,
+ rand: SmallRng,
+ disconnected: bool,
+}
+
+impl<T> Clone for PriorityQueueReceiver<T> {
+ 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>(T);
+
+impl<T: fmt::Debug> fmt::Debug for SendError<T> {
+ 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<T> PriorityQueueReceiver<T> {
+ pub(crate) fn new() -> (PriorityQueueSender<T>, 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<Option<T>, 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<T, RecvError> {
+ 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<T> {
+ 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<T> {
+ 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<Option<T>, 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<T> Drop for PriorityQueueReceiver<T> {
+ 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<T>(PriorityQueueReceiver<T>);
+impl<T> Iterator for Iter<T> {
+ type Item = T;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ self.0.pop().ok()
+ }
+}
+impl<T> FusedIterator for Iter<T> {}
+
+/// If None is returned there are no more elements in the queue
+pub(crate) struct TryIter<T> {
+ receiver: PriorityQueueReceiver<T>,
+ ended: bool,
+}
+impl<T> Iterator for TryIter<T> {
+ type Item = Result<T, RecvError>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ if self.ended {
+ return None;
+ }
+
+ let res = self.receiver.try_pop();
+ self.ended = res.is_err();
+
+ res.transpose()
+ }
+}
+impl<T> FusedIterator for TryIter<T> {}
+
+#[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::<HashSet<_>>(),
+ [30, 31, 20, 21, 10].into_iter().collect::<HashSet<_>>()
+ )
+ }
+
+ #[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);
+ }
+}
@@ -252,6 +252,7 @@ pub struct Style {
pub box_shadow: Vec<BoxShadow>,
/// The text style of this element
+ #[refineable]
pub text: TextStyleRefinement,
/// The mouse cursor style shown when the mouse pointer is over an element.
@@ -264,6 +265,10 @@ pub struct Style {
/// Equivalent to the Tailwind `grid-cols-<number>`
pub grid_cols: Option<u16>,
+ /// The grid columns with min-content minimum sizing.
+ /// Unlike grid_cols, it won't shrink to width 0 in AvailableSpace::MinContent constraints.
+ pub grid_cols_min_content: Option<u16>,
+
/// The row span of this element
/// Equivalent to the Tailwind `grid-rows-<number>`
pub grid_rows: Option<u16>,
@@ -771,6 +776,7 @@ impl Default for Style {
opacity: None,
grid_rows: None,
grid_cols: None,
+ grid_cols_min_content: None,
grid_location: None,
#[cfg(debug_assertions)]
@@ -1469,4 +1475,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
+ );
+ }
}
@@ -1,8 +1,9 @@
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,
@@ -13,8 +14,9 @@ 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 {
@@ -62,43 +64,33 @@ pub trait Styled: Sized {
/// 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
}
@@ -126,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()
}
@@ -394,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<TextStyleRefinement> {
+ fn text_style(&mut self) -> &mut TextStyleRefinement {
let style: &mut StyleRefinement = self.style();
&mut style.text
}
@@ -403,7 +395,7 @@ pub trait Styled: Sized {
///
/// This value cascades to its child elements.
fn text_color(mut self, color: impl Into<Hsla>) -> Self {
- self.text_style().get_or_insert_with(Default::default).color = Some(color.into());
+ self.text_style().color = Some(color.into());
self
}
@@ -411,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
}
@@ -421,9 +411,7 @@ pub trait Styled: Sized {
///
/// This value cascades to its child elements.
fn text_bg(mut self, bg: impl Into<Hsla>) -> Self {
- self.text_style()
- .get_or_insert_with(Default::default)
- .background_color = Some(bg.into());
+ self.text_style().background_color = Some(bg.into());
self
}
@@ -431,97 +419,77 @@ pub trait Styled: Sized {
///
/// This value cascades to its child elements.
fn text_size(mut self, size: impl Into<AbsoluteLength>) -> 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()
@@ -532,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()
@@ -544,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<Hsla>) -> 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
@@ -561,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
@@ -570,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
@@ -579,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
@@ -588,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
@@ -597,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
@@ -606,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
@@ -615,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
@@ -623,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<SharedString>) -> 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
}
@@ -639,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);
@@ -651,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<DefiniteLength>) -> 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
}
@@ -669,6 +637,13 @@ pub trait Styled: Sized {
self
}
+ /// Sets the grid columns with min-content minimum sizing.
+ /// Unlike grid_cols, it won't shrink to width 0 in AvailableSpace::MinContent constraints.
+ fn grid_cols_min_content(mut self, cols: u16) -> Self {
+ self.style().grid_cols_min_content = Some(cols);
+ self
+ }
+
/// Sets the grid rows of this element.
fn grid_rows(mut self, rows: u16) -> Self {
self.style().grid_rows = Some(rows);
@@ -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),
@@ -8,6 +8,7 @@ use std::{fmt::Debug, ops::Range};
use taffy::{
TaffyTree, TraversePartialTree as _,
geometry::{Point as TaffyPoint, Rect as TaffyRect, Size as TaffySize},
+ prelude::min_content,
style::AvailableSpace as TaffyAvailableSpace,
tree::NodeId,
};
@@ -314,6 +315,14 @@ impl ToTaffy<taffy::style::Style> for Style {
.unwrap_or_default()
}
+ fn to_grid_repeat_min_content<T: taffy::style::CheapCloneStr>(
+ unit: &Option<u16>,
+ ) -> Vec<taffy::GridTemplateComponent<T>> {
+ // grid-template-columns: repeat(<number>, minmax(min-content, 1fr));
+ unit.map(|count| vec![repeat(count, vec![minmax(min_content(), fr(1.0))])])
+ .unwrap_or_default()
+ }
+
taffy::style::Style {
display: self.display.into(),
overflow: self.overflow.into(),
@@ -338,7 +347,11 @@ impl ToTaffy<taffy::style::Style> for Style {
flex_grow: self.flex_grow,
flex_shrink: self.flex_shrink,
grid_template_rows: to_grid_repeat(&self.grid_rows),
- grid_template_columns: to_grid_repeat(&self.grid_cols),
+ grid_template_columns: if self.grid_cols_min_content.is_some() {
+ to_grid_repeat_min_content(&self.grid_cols_min_content)
+ } else {
+ to_grid_repeat(&self.grid_cols)
+ },
grid_row: self
.grid_location
.as_ref()
@@ -69,7 +69,10 @@ pub fn run_test(
std::mem::forget(error);
} else {
if is_multiple_runs {
- eprintln!("failing seed: {}", seed);
+ eprintln!("failing seed: {seed}");
+ eprintln!(
+ "You can rerun from this seed by setting the environmental variable SEED to {seed}"
+ );
}
if let Some(on_fail_fn) = on_fail_fn {
on_fail_fn()
@@ -550,7 +550,6 @@ impl WindowTextSystem {
force_width: Option<Pixels>,
) -> Arc<LineLayout> {
let mut last_run = None::<&TextRun>;
- let mut last_font: Option<FontId> = None;
let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default();
font_runs.clear();
@@ -568,14 +567,13 @@ impl WindowTextSystem {
true
};
+ let font_id = self.resolve_font(&run.font);
if let Some(font_run) = font_runs.last_mut()
- && Some(font_run.font_id) == last_font
+ && font_id == font_run.font_id
&& !decoration_changed
{
font_run.len += run.len;
} else {
- let font_id = self.resolve_font(&run.font);
- last_font = Some(font_id);
font_runs.push(FontRun {
len: run.len,
font_id,
@@ -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,
@@ -54,9 +54,25 @@ pub struct ShapedGlyph {
}
impl LineLayout {
+ /// The index for the character at the given x coordinate
+ pub fn index_for_x(&self, x: Pixels) -> Option<usize> {
+ if x >= self.width {
+ None
+ } else {
+ for run in self.runs.iter().rev() {
+ for glyph in run.glyphs.iter().rev() {
+ if glyph.position.x <= x {
+ return Some(glyph.index);
+ }
+ }
+ }
+ Some(0)
+ }
+ }
+
/// closest_index_for_x returns the character boundary closest to the given x coordinate
/// (e.g. to handle aligning up/down arrow keys)
- pub fn index_for_x(&self, x: Pixels) -> usize {
+ pub fn closest_index_for_x(&self, x: Pixels) -> usize {
let mut prev_index = 0;
let mut prev_x = px(0.);
@@ -262,10 +278,34 @@ impl WrappedLineLayout {
}
/// The index corresponding to a given position in this layout for the given line height.
+ ///
+ /// See also [`Self::closest_index_for_position`].
pub fn index_for_position(
+ &self,
+ position: Point<Pixels>,
+ line_height: Pixels,
+ ) -> Result<usize, usize> {
+ self._index_for_position(position, line_height, false)
+ }
+
+ /// The closest index to a given position in this layout for the given line height.
+ ///
+ /// Closest means the character boundary closest to the given position.
+ ///
+ /// See also [`LineLayout::closest_index_for_x`].
+ pub fn closest_index_for_position(
+ &self,
+ position: Point<Pixels>,
+ line_height: Pixels,
+ ) -> Result<usize, usize> {
+ self._index_for_position(position, line_height, true)
+ }
+
+ fn _index_for_position(
&self,
mut position: Point<Pixels>,
line_height: Pixels,
+ closest: bool,
) -> Result<usize, usize> {
let wrapped_line_ix = (position.y / line_height) as usize;
@@ -305,9 +345,16 @@ impl WrappedLineLayout {
} else if position_in_unwrapped_line.x >= wrapped_line_end_x {
Err(wrapped_line_end_index)
} else {
- Ok(self
- .unwrapped_layout
- .index_for_x(position_in_unwrapped_line.x))
+ if closest {
+ Ok(self
+ .unwrapped_layout
+ .closest_index_for_x(position_in_unwrapped_line.x))
+ } else {
+ Ok(self
+ .unwrapped_layout
+ .index_for_x(position_in_unwrapped_line.x)
+ .unwrap())
+ }
}
}
@@ -182,6 +182,11 @@ impl LineWrapper {
// Cyrillic for Russian, Ukrainian, etc.
// https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
matches!(c, '\u{0400}'..='\u{04FF}') ||
+
+ // Vietnamese (https://vietunicode.sourceforge.net/charset/)
+ matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional
+ matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks
+
// 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`, `a=1`, `Self::new`, etc.
@@ -618,7 +623,12 @@ mod tests {
#[track_caller]
fn assert_word(word: &str) {
for c in word.chars() {
- assert!(LineWrapper::is_word_char(c), "assertion failed for '{}'", c);
+ assert!(
+ LineWrapper::is_word_char(c),
+ "assertion failed for '{}' (unicode 0x{:x})",
+ c,
+ c as u32
+ );
}
}
@@ -661,6 +671,8 @@ mod tests {
assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
// Cyrillic
assert_word("АБВГДЕЖЗИЙКЛМНОП");
+ // Vietnamese (https://github.com/zed-industries/zed/issues/23245)
+ assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng");
// non-word characters
assert_not_word("你好");
@@ -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};
@@ -344,8 +345,8 @@ impl FocusHandle {
}
/// Moves the focus to the element associated with this handle.
- pub fn focus(&self, window: &mut Window) {
- window.focus(self)
+ pub fn focus(&self, window: &mut Window, cx: &mut App) {
+ window.focus(self, cx)
}
/// Obtains whether the element associated with this handle is currently focused.
@@ -596,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.
@@ -624,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.
@@ -909,6 +910,7 @@ struct PendingInput {
keystrokes: SmallVec<[Keystroke; 1]>,
focus: Option<FocusId>,
timer: Option<Task<()>>,
+ needs_timeout: bool,
}
pub(crate) struct ElementStateBox {
@@ -917,86 +919,69 @@ pub(crate) struct ElementStateBox {
pub(crate) type_name: &'static str,
}
-fn default_bounds(display_id: Option<DisplayId>, cx: &mut App) -> Bounds<Pixels> {
- #[cfg(target_os = "macos")]
- {
- const CASCADE_OFFSET: f32 = 25.0;
-
- let display = display_id
- .map(|id| cx.find_display(id))
- .unwrap_or_else(|| cx.primary_display());
-
- let display_bounds = display
- .as_ref()
- .map(|d| d.bounds())
- .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE));
-
- // 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
- let (base_origin, base_size) = cx
- .active_window()
- .and_then(|w| {
- w.update(cx, |_, window, _| {
- let bounds = window.bounds();
- (bounds.origin, bounds.size)
- })
- .ok()
- })
- .unwrap_or_else(|| {
- let default_bounds = display
- .as_ref()
- .map(|d| d.default_bounds())
- .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE));
- (default_bounds.origin, default_bounds.size)
- });
-
- 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,
- };
-
- Bounds::new(final_origin, base_size)
- }
-
- #[cfg(not(target_os = "macos"))]
- {
- const DEFAULT_WINDOW_OFFSET: Point<Pixels> = point(px(0.), px(35.));
-
- // 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());
-
- display
- .as_ref()
- .map(|display| display.default_bounds())
- .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE))
- })
- }
+fn default_bounds(display_id: Option<DisplayId>, cx: &mut App) -> WindowBounds {
+ // TODO, BUG: if you open a window with the currently active window
+ // 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<Pixels>) -> 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
+ .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 {
@@ -1023,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,
@@ -1070,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({
@@ -1455,13 +1436,25 @@ impl Window {
}
/// Move focus to the element associated with the given [`FocusHandle`].
- pub fn focus(&mut self, handle: &FocusHandle) {
+ pub fn focus(&mut self, handle: &FocusHandle, cx: &mut App) {
if !self.focus_enabled || self.focus == Some(handle.id) {
return;
}
self.focus = Some(handle.id);
self.clear_pending_keystrokes();
+
+ // Avoid re-entrant entity updates by deferring observer notifications to the end of the
+ // current effect cycle, and only for this window.
+ let window_handle = self.handle;
+ cx.defer(move |cx| {
+ window_handle
+ .update(cx, |_, window, cx| {
+ window.pending_input_changed(cx);
+ })
+ .ok();
+ });
+
self.refresh();
}
@@ -1482,24 +1475,24 @@ impl Window {
}
/// Move focus to next tab stop.
- pub fn focus_next(&mut self) {
+ pub fn focus_next(&mut self, cx: &mut App) {
if !self.focus_enabled {
return;
}
if let Some(handle) = self.rendered_frame.tab_stops.next(self.focus.as_ref()) {
- self.focus(&handle)
+ self.focus(&handle, cx)
}
}
/// Move focus to previous tab stop.
- pub fn focus_prev(&mut self) {
+ pub fn focus_prev(&mut self, cx: &mut App) {
if !self.focus_enabled {
return;
}
if let Some(handle) = self.rendered_frame.tab_stops.prev(self.focus.as_ref()) {
- self.focus(&handle)
+ self.focus(&handle, cx)
}
}
@@ -1517,7 +1510,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()
@@ -1744,6 +1738,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<AsyncFn, R>(
+ &self,
+ priority: Priority,
+ cx: &App,
+ f: AsyncFn,
+ ) -> Task<R>
+ 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();
@@ -1819,6 +1834,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.
@@ -1957,7 +1973,7 @@ impl Window {
}
/// Determine whether the given action is available along the dispatch path to the currently focused element.
- pub fn is_action_available(&self, action: &dyn Action, cx: &mut App) -> bool {
+ pub fn is_action_available(&self, action: &dyn Action, cx: &App) -> bool {
let node_id =
self.focus_node_id_in_rendered_frame(self.focused(cx).map(|handle| handle.id));
self.rendered_frame
@@ -1965,6 +1981,14 @@ impl Window {
.is_action_available(action, node_id)
}
+ /// Determine whether the given action is available along the dispatch path to the given focus_handle.
+ pub fn is_action_available_in(&self, action: &dyn Action, focus_handle: &FocusHandle) -> bool {
+ let node_id = self.focus_node_id_in_rendered_frame(Some(focus_handle.id));
+ self.rendered_frame
+ .dispatch_tree
+ .is_action_available(action, node_id)
+ }
+
/// The position of the mouse relative to the window.
pub fn mouse_position(&self) -> Point<Pixels> {
self.mouse_position
@@ -2004,7 +2028,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();
@@ -2432,7 +2458,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 {
@@ -2443,7 +2469,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();
@@ -3699,6 +3725,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)
@@ -3895,32 +3924,52 @@ 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 to_replay = window
- .rendered_frame
- .dispatch_tree
- .flush_dispatch(currently_pending.keystrokes, &dispatch_path);
+ let text_input_requires_timeout = event
+ .downcast_ref::<KeyDownEvent>()
+ .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
+ });
- window.pending_input_changed(cx);
- window.replay_pending_input(to_replay, cx)
- })
- .log_err();
- }));
+ currently_pending.needs_timeout |=
+ match_result.pending_has_binding || text_input_requires_timeout;
+
+ 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;
@@ -3983,7 +4032,7 @@ impl Window {
self.dispatch_keystroke_observers(event, None, context_stack, cx);
}
- fn pending_input_changed(&mut self, cx: &mut App) {
+ pub(crate) fn pending_input_changed(&mut self, cx: &mut App) {
self.pending_input_observers
.clone()
.retain(&(), |callback| callback(self, cx));
@@ -4401,6 +4450,13 @@ impl Window {
dispatch_tree.highest_precedence_binding_for_action(action, &context_stack)
}
+ /// Find the bindings that can follow the current input sequence for the current context stack.
+ pub fn possible_bindings_for_input(&self, input: &[Keystroke]) -> Vec<KeyBinding> {
+ self.rendered_frame
+ .dispatch_tree
+ .possible_next_bindings_for_input(input, &self.context_stack())
+ }
+
fn context_stack_for_focus_handle(
&self,
focus_handle: &FocusHandle,
@@ -5060,6 +5116,18 @@ impl From<SharedString> for ElementId {
}
}
+impl From<String> for ElementId {
+ fn from(name: String) -> Self {
+ ElementId::Name(name.into())
+ }
+}
+
+impl From<Arc<str>> for ElementId {
+ fn from(name: Arc<str>) -> Self {
+ ElementId::Name(name.into())
+ }
+}
+
impl From<Arc<std::path::Path>> for ElementId {
fn from(path: Arc<std::path::Path>) -> Self {
ElementId::Path(path)
@@ -44,10 +44,10 @@ impl PromptHandle {
if let Some(sender) = sender.take() {
sender.send(e.0).ok();
window_handle
- .update(cx, |_, window, _cx| {
+ .update(cx, |_, window, cx| {
window.prompt.take();
if let Some(previous_focus) = &previous_focus {
- window.focus(previous_focus);
+ window.focus(previous_focus, cx);
}
})
.ok();
@@ -55,7 +55,7 @@ impl PromptHandle {
})
.detach();
- window.focus(&view.focus_handle(cx));
+ window.focus(&view.focus_handle(cx), cx);
RenderablePromptHandle {
view: Box::new(view),
@@ -62,7 +62,7 @@ pub fn derive_visual_context(input: TokenStream) -> TokenStream {
V: gpui::Focusable,
{
let focus_handle = gpui::Focusable::focus_handle(entity, self.#app_variable);
- self.#window_variable.focus(&focus_handle)
+ self.#window_variable.focus(&focus_handle, self.#app_variable)
}
}
};
@@ -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;
@@ -5,25 +5,48 @@ 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 }
}
}
@@ -40,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();
@@ -62,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();
@@ -28,7 +28,6 @@ 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
@@ -88,17 +88,6 @@ impl From<&'static str> for AsyncBody {
}
}
-impl TryFrom<reqwest::Body> for AsyncBody {
- type Error = anyhow::Error;
-
- fn try_from(value: reqwest::Body) -> Result<Self, Self::Error> {
- value
- .as_bytes()
- .ok_or_else(|| anyhow::anyhow!("Underlying data is a stream"))
- .map(|bytes| Self::from_bytes(Bytes::copy_from_slice(bytes)))
- }
-}
-
impl<T: Into<Self>> From<Option<T>> for AsyncBody {
fn from(body: Option<T>) -> Self {
match body {
@@ -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<dyn HttpClient>,
) -> anyhow::Result<GithubRelease> {
+ 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<dyn HttpClient>,
) -> anyhow::Result<GithubRelease> {
+ 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")?;
@@ -8,15 +8,12 @@ use derive_more::Deref;
use http::HeaderValue;
pub use http::{self, Method, Request, Response, StatusCode, Uri, request::Builder};
-use futures::{
- FutureExt as _,
- future::{self, BoxFuture},
-};
+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};
+use std::{any::type_name, fmt};
pub use url::{Host, Url};
#[derive(Default, Debug, Clone, PartialEq, Eq, Hash)]
@@ -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<AsyncBody>,
@@ -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::<Self>())
}
-
- fn send_multipart_form<'a>(
- &'a self,
- _url: &str,
- _request: reqwest::multipart::Form,
- ) -> BoxFuture<'a, anyhow::Result<Response<AsyncBody>>> {
- 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<Response<AsyncBody>>> {
- self.client.send_multipart_form(url, form)
- }
}
/// An [`HttpClient`] that has a base URL.
+#[derive(Deref)]
pub struct HttpClientWithUrl {
base_url: Mutex<String>,
+ #[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(
@@ -314,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<Response<AsyncBody>>> {
- self.client.send_multipart_form(url, request)
- }
}
pub fn read_proxy_from_env() -> Option<Url> {
@@ -384,10 +341,6 @@ impl HttpClient for BlockedHttpClient {
None
}
- fn type_name(&self) -> &'static str {
- type_name::<Self>()
- }
-
#[cfg(feature = "test-support")]
fn as_fake(&self) -> &FakeHttpClient {
panic!("called as_fake on {}", type_name::<Self>())
@@ -428,6 +381,7 @@ impl FakeHttpClient {
}
pub fn with_404_response() -> Arc<HttpClientWithUrl> {
+ log::warn!("Using fake HTTP client with 404 response");
Self::create(|_| async move {
Ok(Response::builder()
.status(404)
@@ -437,6 +391,7 @@ impl FakeHttpClient {
}
pub fn with_200_response() -> Arc<HttpClientWithUrl> {
+ log::warn!("Using fake HTTP client with 200 response");
Self::create(|_| async move {
Ok(Response::builder()
.status(200)
@@ -482,10 +437,6 @@ impl HttpClient for FakeHttpClient {
None
}
- fn type_name(&self) -> &'static str {
- type_name::<Self>()
- }
-
fn as_fake(&self) -> &FakeHttpClient {
self
}
@@ -34,6 +34,7 @@ pub enum IconName {
ArrowRightLeft,
ArrowUp,
ArrowUpRight,
+ AtSign,
Attach,
AudioOff,
AudioOn,
@@ -44,10 +45,11 @@ pub enum IconName {
BellRing,
Binary,
Blocks,
- BoltOutlined,
BoltFilled,
+ BoltOutlined,
Book,
BookCopy,
+ Box,
CaseSensitive,
Chat,
Check,
@@ -79,13 +81,12 @@ pub enum IconName {
Debug,
DebugBreakpoint,
DebugContinue,
+ DebugDetach,
DebugDisabledBreakpoint,
DebugDisabledLogBreakpoint,
- DebugDetach,
DebugIgnoreBreakpoints,
DebugLogBreakpoint,
DebugPause,
- DebugStepBack,
DebugStepInto,
DebugStepOut,
DebugStepOver,
@@ -136,10 +137,12 @@ pub enum IconName {
GenericRestore,
GitBranch,
GitBranchAlt,
+ GitBranchPlus,
Github,
Hash,
HistoryRerun,
Image,
+ Inception,
Indicator,
Info,
Json,
@@ -147,6 +150,7 @@ pub enum IconName {
Library,
LineHeight,
Link,
+ Linux,
ListCollapse,
ListFilter,
ListTodo,
@@ -172,8 +176,8 @@ pub enum IconName {
PencilUnavailable,
Person,
Pin,
- PlayOutlined,
PlayFilled,
+ PlayOutlined,
Plus,
Power,
Public,
@@ -216,6 +220,7 @@ pub enum IconName {
SupermavenError,
SupermavenInit,
SwatchBook,
+ SweepAi,
Tab,
Terminal,
TerminalAlt,
@@ -255,18 +260,18 @@ pub enum IconName {
XCircle,
XCircleFilled,
ZedAgent,
+ ZedAgentTwo,
ZedAssistant,
ZedBurnMode,
ZedBurnModeOn,
- ZedSrcCustom,
- ZedSrcExtension,
ZedPredict,
ZedPredictDisabled,
ZedPredictDown,
ZedPredictError,
ZedPredictUp,
+ ZedSrcCustom,
+ ZedSrcExtension,
ZedXCopilot,
- Linux,
}
impl IconName {
@@ -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))
}
}
@@ -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| {
@@ -664,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())
@@ -684,7 +686,6 @@ impl CompletionProvider for RustStyleCompletionProvider {
position: language::Anchor,
_text: &str,
_trigger_in_words: bool,
- _menu_is_open: bool,
cx: &mut Context<Editor>,
) -> bool {
completion_replace_range(&buffer.read(cx).snapshot(), &position).is_some()
@@ -33,6 +33,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
app_state.languages.clone(),
app_state.fs.clone(),
None,
+ false,
cx,
);
@@ -159,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);
@@ -173,9 +173,15 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap
}
fn journal_dir(path: &str) -> Option<PathBuf> {
- 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 {
@@ -224,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"));
+ }
+ }
}
@@ -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");
@@ -159,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<LanguageRegistry>,
+ 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::<Vec<_>>();
+
let mut file_associations = serde_json::json!([
{
"fileMatch": [
@@ -211,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)]
@@ -233,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}")
})
}),
);
@@ -249,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)
@@ -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"
}
@@ -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"
}
@@ -41,7 +41,6 @@ tree-sitter-rust.workspace = true
ui_input.workspace = true
ui.workspace = true
util.workspace = true
-vim.workspace = true
workspace.workspace = true
zed_actions.workspace = true
@@ -81,50 +81,61 @@ pub fn init(cx: &mut App) {
let keymap_event_channel = KeymapEventChannel::new();
cx.set_global(keymap_event_channel);
- fn common(filter: Option<String>, cx: &mut App) {
- workspace::with_active_or_new_workspace(cx, move |workspace, window, cx| {
- workspace
- .with_local_workspace(window, cx, move |workspace, window, cx| {
- let existing = workspace
- .active_pane()
- .read(cx)
- .items()
- .find_map(|item| item.downcast::<KeymapEditor>());
-
- 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();
- })
+ fn open_keymap_editor(
+ filter: Option<String>,
+ workspace: &mut Workspace,
+ window: &mut Window,
+ cx: &mut Context<Workspace>,
+ ) {
+ workspace
+ .with_local_workspace(window, cx, |workspace, window, cx| {
+ let existing = workspace
+ .active_pane()
+ .read(cx)
+ .items()
+ .find_map(|item| item.downcast::<KeymapEditor>());
+
+ 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.on_action(|_: &OpenKeymap, cx| common(None, cx));
- cx.on_action(|action: &ChangeKeybinding, cx| common(Some(action.action.clone()), 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::<KeymapEditor>(cx);
}
@@ -184,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,
}
}
@@ -900,7 +911,7 @@ impl KeymapEditor {
.focus_handle(cx)
.contains_focused(window, cx)
{
- window.focus(&self.filter_editor.focus_handle(cx));
+ window.focus(&self.filter_editor.focus_handle(cx), cx);
} else {
self.filter_editor.update(cx, |editor, cx| {
editor.select_all(&Default::default(), window, cx);
@@ -937,7 +948,7 @@ impl KeymapEditor {
if let Some(scroll_strategy) = scroll {
self.scroll_to_item(index, scroll_strategy, cx);
}
- window.focus(&self.focus_handle);
+ window.focus(&self.focus_handle, cx);
cx.notify();
}
}
@@ -958,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",
@@ -985,7 +998,7 @@ impl KeymapEditor {
});
let context_menu_handle = context_menu.focus_handle(cx);
- window.defer(cx, move |window, _cx| window.focus(&context_menu_handle));
+ window.defer(cx, move |window, cx| window.focus(&context_menu_handle, cx));
let subscription = cx.subscribe_in(
&context_menu,
window,
@@ -1001,7 +1014,7 @@ impl KeymapEditor {
fn dismiss_context_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.context_menu.take();
- window.focus(&self.focus_handle);
+ window.focus(&self.focus_handle, cx);
cx.notify();
}
@@ -1217,7 +1230,7 @@ impl KeymapEditor {
window,
cx,
);
- window.focus(&modal.focus_handle(cx));
+ window.focus(&modal.focus_handle(cx), cx);
modal
});
})
@@ -1325,7 +1338,7 @@ impl KeymapEditor {
editor.stop_recording(&StopRecording, window, cx);
editor.clear_keystrokes(&ClearKeystrokes, window, cx);
});
- window.focus(&self.filter_editor.focus_handle(cx));
+ window.focus(&self.filter_editor.focus_handle(cx), cx);
}
}
}
@@ -1598,9 +1611,33 @@ impl Item for KeymapEditor {
impl Render for KeymapEditor {
fn render(&mut self, _window: &mut Window, cx: &mut ui::Context<Self>) -> 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")
@@ -1743,7 +1780,7 @@ impl Render for KeymapEditor {
)
.action(
"Vim Bindings",
- vim::OpenDefaultKeymap.boxed_clone(),
+ zed_actions::vim::OpenDefaultKeymap.boxed_clone(),
)
}))
})
@@ -1784,49 +1821,14 @@ impl Render for KeymapEditor {
)
),
)
- .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,
- 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
)
},
),
@@ -2696,32 +2698,32 @@ impl KeybindingEditorModalFocusState {
.map(|i| i as i32)
}
- fn focus_index(&self, mut index: i32, window: &mut Window) {
+ fn focus_index(&self, mut index: i32, window: &mut Window, cx: &mut App) {
if index < 0 {
index = self.handles.len() as i32 - 1;
}
if index >= self.handles.len() as i32 {
index = 0;
}
- window.focus(&self.handles[index as usize]);
+ window.focus(&self.handles[index as usize], cx);
}
- fn focus_next(&self, window: &mut Window, cx: &App) {
+ fn focus_next(&self, window: &mut Window, cx: &mut App) {
let index_to_focus = if let Some(index) = self.focused_index(window, cx) {
index + 1
} else {
0
};
- self.focus_index(index_to_focus, window);
+ self.focus_index(index_to_focus, window, cx);
}
- fn focus_previous(&self, window: &mut Window, cx: &App) {
+ fn focus_previous(&self, window: &mut Window, cx: &mut App) {
let index_to_focus = if let Some(index) = self.focused_index(window, cx) {
index - 1
} else {
self.handles.len() as i32 - 1
};
- self.focus_index(index_to_focus, window);
+ self.focus_index(index_to_focus, window, cx);
}
}
@@ -2755,7 +2757,7 @@ impl ActionArgumentsEditor {
) -> Self {
let focus_handle = cx.focus_handle();
cx.on_focus_in(&focus_handle, window, |this, window, cx| {
- this.editor.focus_handle(cx).focus(window);
+ this.editor.focus_handle(cx).focus(window, cx);
})
.detach();
let editor = cx.new(|cx| {
@@ -2808,7 +2810,7 @@ impl ActionArgumentsEditor {
this.update_in(cx, |this, window, cx| {
if this.editor.focus_handle(cx).is_focused(window) {
- editor.focus_handle(cx).focus(window);
+ editor.focus_handle(cx).focus(window, cx);
}
this.editor = editor;
this.backup_temp_dir = backup_temp_dir;
@@ -2993,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,
})
@@ -3008,7 +3012,6 @@ impl CompletionProvider for KeyContextCompletionProvider {
_position: language::Anchor,
text: &str,
_trigger_in_words: bool,
- _menu_is_open: bool,
_cx: &mut Context<Editor>,
) -> bool {
text.chars()
@@ -64,6 +64,7 @@ pub struct KeystrokeInput {
clear_close_keystrokes_timer: Option<Task<()>>,
#[cfg(test)]
recording: bool,
+ pub actions_slot: Option<AnyElement>,
}
impl KeystrokeInput {
@@ -94,6 +95,7 @@ impl KeystrokeInput {
clear_close_keystrokes_timer: None,
#[cfg(test)]
recording: false,
+ actions_slot: None,
}
}
@@ -386,7 +388,7 @@ impl KeystrokeInput {
window: &mut Window,
cx: &mut Context<Self>,
) {
- window.focus(&self.inner_focus_handle);
+ window.focus(&self.inner_focus_handle, cx);
self.clear_keystrokes(&ClearKeystrokes, window, cx);
self.previous_modifiers = window.modifiers();
#[cfg(test)]
@@ -405,7 +407,7 @@ impl KeystrokeInput {
if !self.is_recording(window) {
return;
}
- window.focus(&self.outer_focus_handle);
+ window.focus(&self.outer_focus_handle, cx);
if let Some(close_keystrokes_start) = self.close_keystrokes_start.take()
&& close_keystrokes_start < self.keystrokes.len()
{
@@ -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);
+ })),
+ )
+ }),
)
}
}
@@ -21,6 +21,7 @@ test-support = [
"tree-sitter-rust",
"tree-sitter-python",
"tree-sitter-typescript",
+ "tree-sitter-md",
"settings/test-support",
"util/test-support",
]
@@ -31,6 +32,7 @@ async-trait.workspace = true
clock.workspace = true
collections.workspace = true
ec4rs.workspace = true
+encoding_rs.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
@@ -47,6 +49,7 @@ rand = { workspace = true, optional = true }
regex.workspace = true
rpc.workspace = true
schemars.workspace = true
+semver.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
@@ -59,6 +62,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 }
@@ -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,
+ MAX_BYTES_TO_QUERY, SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures,
+ SyntaxMapMatch, SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint,
},
task_context::RunnableRange,
text_diff::text_diff,
+ unified_diff,
};
pub use crate::{
Grammar, Language, LanguageRegistry,
@@ -20,7 +24,8 @@ pub use crate::{
use anyhow::{Context as _, Result};
use clock::Lamport;
pub use clock::ReplicaId;
-use collections::HashMap;
+use collections::{HashMap, HashSet};
+use encoding_rs::Encoding;
use fs::MTime;
use futures::channel::oneshot;
use gpui::{
@@ -126,6 +131,39 @@ pub struct Buffer {
has_unsaved_edits: Cell<(clock::Global, bool)>,
change_bits: Vec<rc::Weak<Cell<bool>>>,
_subscriptions: Vec<gpui::Subscription>,
+ tree_sitter_data: Arc<TreeSitterData>,
+ encoding: &'static Encoding,
+ has_bom: bool,
+}
+
+#[derive(Debug)]
+pub struct TreeSitterData {
+ chunks: RowChunks,
+ brackets_by_chunks: Mutex<Vec<Option<Vec<BracketMatch<usize>>>>>,
+}
+
+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 +187,7 @@ pub struct BufferSnapshot {
remote_selections: TreeMap<ReplicaId, SelectionSet>,
language: Option<Arc<Language>>,
non_text_state_update_count: usize,
+ tree_sitter_data: Arc<TreeSitterData>,
}
/// The kind and amount of indentation in a particular line. For now,
@@ -209,6 +248,8 @@ struct SelectionSet {
pub struct Diagnostic {
/// The name of the service that produced this diagnostic.
pub source: Option<String>,
+ /// The ID provided by the dynamic registration that produced this diagnostic.
+ pub registration_id: Option<SharedString>,
/// A machine-readable code that identifies this diagnostic.
pub code: Option<NumberOrString>,
pub code_description: Option<lsp::Uri>,
@@ -323,7 +364,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.
@@ -717,6 +759,33 @@ pub struct EditPreview {
}
impl EditPreview {
+ pub fn as_unified_diff(&self, edits: &[(Range<Anchor>, impl AsRef<str>)]) -> Option<String> {
+ 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::<String>(),
+ &self
+ .applied_edits_snapshot
+ .text_for_range(start..new_end)
+ .collect::<String>(),
+ ))
+ }
+
pub fn highlight_edits(
&self,
current_snapshot: &BufferSnapshot,
@@ -730,6 +799,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 {
@@ -797,7 +868,19 @@ impl EditPreview {
highlighted_text.build()
}
- fn compute_visible_range<T>(&self, edits: &[(Range<Anchor>, T)]) -> Option<Range<usize>> {
+ pub fn build_result_buffer(&self, cx: &mut App) -> Entity<Buffer> {
+ 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<T>(&self, edits: &[(Range<Anchor>, T)]) -> Option<Range<Point>> {
let (first, _) = edits.first()?;
let (last, _) = edits.last()?;
@@ -814,15 +897,23 @@ 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<usize>,
- pub close_range: Range<usize>,
+pub struct BracketMatch<T> {
+ pub open_range: Range<T>,
+ pub close_range: Range<T>,
pub newline_only: bool,
+ pub syntax_layer_depth: usize,
+ pub color_index: Option<usize>,
+}
+
+impl<T> BracketMatch<T> {
+ pub fn bracket_ranges(self) -> (Range<T>, Range<T>) {
+ (self.open_range, self.close_range)
+ }
}
impl Buffer {
@@ -953,6 +1044,12 @@ impl Buffer {
}
/// Assign a language to the buffer, returning the buffer.
+ pub fn with_language_async(mut self, language: Arc<Language>, cx: &mut Context<Self>) -> 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<Language>, cx: &mut Context<Self>) -> Self {
self.set_language(Some(language), cx);
self
@@ -973,8 +1070,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,
@@ -1004,6 +1103,8 @@ impl Buffer {
has_conflict: false,
change_bits: Default::default(),
_subscriptions: Vec::new(),
+ encoding: encoding_rs::UTF_8,
+ has_bom: false,
}
}
@@ -1024,12 +1125,14 @@ impl Buffer {
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,
}
@@ -1047,9 +1150,11 @@ impl Buffer {
)
.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(),
@@ -1074,9 +1179,11 @@ impl Buffer {
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(),
@@ -1093,9 +1200,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(),
@@ -1123,7 +1237,7 @@ impl Buffer {
}
// Reparse the branch buffer so that we get syntax highlighting immediately.
- branch.reparse(cx);
+ branch.reparse(cx, true);
branch
})
@@ -1274,14 +1388,50 @@ impl Buffer {
self.saved_mtime
}
+ /// Returns the character encoding of the buffer's file.
+ pub fn encoding(&self) -> &'static Encoding {
+ self.encoding
+ }
+
+ /// Sets the character encoding of the buffer.
+ pub fn set_encoding(&mut self, encoding: &'static Encoding) {
+ self.encoding = encoding;
+ }
+
+ /// Returns whether the buffer has a Byte Order Mark.
+ pub fn has_bom(&self) -> bool {
+ self.has_bom
+ }
+
+ /// Sets whether the buffer has a Byte Order Mark.
+ pub fn set_has_bom(&mut self, has_bom: bool) {
+ self.has_bom = has_bom;
+ }
+
/// Assign a language to the buffer.
+ pub fn set_language_async(&mut self, language: Option<Arc<Language>>, cx: &mut Context<Self>) {
+ 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<Arc<Language>>, cx: &mut Context<Self>) {
+ self.set_language_(language, true, cx);
+ }
+
+ fn set_language_(
+ &mut self,
+ language: Option<Arc<Language>>,
+ may_block: bool,
+ cx: &mut Context<Self>,
+ ) {
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
@@ -1513,6 +1663,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.
///
@@ -1523,9 +1683,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
@@ -1536,7 +1696,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<Self>) {
+ pub fn reparse(&mut self, cx: &mut Context<Self>, 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;
}
@@ -1565,42 +1728,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;
- }
- Err(parse_task) => {
- // todo(lw): hot foreground spawn
- 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()
+ 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 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);
- }
- })
- .ok();
- }));
+ };
+ 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();
+ }));
+ }
}
+ } 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 = 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();
+ }));
}
}
@@ -1610,6 +1801,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();
}
@@ -2055,6 +2249,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 {
@@ -2077,7 +2276,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<usize> {
self.text.subscribe()
}
@@ -2495,7 +2694,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);
@@ -3042,15 +3241,22 @@ impl BufferSnapshot {
struct StartPosition {
start: Point,
suffix: SharedString,
+ language: Arc<Language>,
}
// Find the suggested indentation ranges based on the syntax tree.
let start = Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0);
let end = Point::new(row_range.end, 0);
let range = (start..end).to_offset(&self.text);
- let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| {
- Some(&grammar.indents_config.as_ref()?.query)
- });
+ let mut matches = self.syntax.matches_with_options(
+ range.clone(),
+ &self.text,
+ TreeSitterOptions {
+ max_bytes_to_query: Some(MAX_BYTES_TO_QUERY),
+ max_start_depth: None,
+ },
+ |grammar| Some(&grammar.indents_config.as_ref()?.query),
+ );
let indent_configs = matches
.grammars()
.iter()
@@ -3079,6 +3285,7 @@ impl BufferSnapshot {
start_positions.push(StartPosition {
start: Point::from_ts_point(capture.node.start_position()),
suffix: suffix.clone(),
+ language: mat.language.clone(),
});
}
}
@@ -3129,8 +3336,7 @@ impl BufferSnapshot {
// set its end to the outdent position
if let Some(range_to_truncate) = indent_ranges
.iter_mut()
- .filter(|indent_range| indent_range.contains(&outdent_position))
- .next_back()
+ .rfind(|indent_range| indent_range.contains(&outdent_position))
{
range_to_truncate.end = outdent_position;
}
@@ -3140,7 +3346,7 @@ impl BufferSnapshot {
// Find the suggested indentation increases and decreased based on regexes.
let mut regex_outdent_map = HashMap::default();
- let mut last_seen_suffix: HashMap<String, Vec<Point>> = HashMap::default();
+ let mut last_seen_suffix: HashMap<String, Vec<StartPosition>> = HashMap::default();
let mut start_positions_iter = start_positions.iter().peekable();
let mut indent_change_rows = Vec::<(u32, Ordering)>::new();
@@ -3148,14 +3354,21 @@ impl BufferSnapshot {
Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0)
..Point::new(row_range.end, 0),
|row, line| {
- if config
+ let indent_len = self.indent_size_for_line(row).len;
+ let row_language = self.language_at(Point::new(row, indent_len)).cloned();
+ let row_language_config = row_language
+ .as_ref()
+ .map(|lang| lang.config())
+ .unwrap_or(config);
+
+ if row_language_config
.decrease_indent_pattern
.as_ref()
.is_some_and(|regex| regex.is_match(line))
{
indent_change_rows.push((row, Ordering::Less));
}
- if config
+ if row_language_config
.increase_indent_pattern
.as_ref()
.is_some_and(|regex| regex.is_match(line))
@@ -3164,16 +3377,16 @@ impl BufferSnapshot {
}
while let Some(pos) = start_positions_iter.peek() {
if pos.start.row < row {
- let pos = start_positions_iter.next().unwrap();
+ let pos = start_positions_iter.next().unwrap().clone();
last_seen_suffix
.entry(pos.suffix.to_string())
.or_default()
- .push(pos.start);
+ .push(pos);
} else {
break;
}
}
- for rule in &config.decrease_indent_patterns {
+ for rule in &row_language_config.decrease_indent_patterns {
if rule.pattern.as_ref().is_some_and(|r| r.is_match(line)) {
let row_start_column = self.indent_size_for_line(row).len;
let basis_row = rule
@@ -3181,10 +3394,16 @@ impl BufferSnapshot {
.iter()
.filter_map(|valid_suffix| last_seen_suffix.get(valid_suffix))
.flatten()
- .filter(|start_point| start_point.column <= row_start_column)
- .max_by_key(|start_point| start_point.row);
- if let Some(outdent_to_row) = basis_row {
- regex_outdent_map.insert(row, outdent_to_row.row);
+ .filter(|pos| {
+ row_language
+ .as_ref()
+ .or(self.language.as_ref())
+ .is_some_and(|lang| Arc::ptr_eq(lang, &pos.language))
+ })
+ .filter(|pos| pos.start.column <= row_start_column)
+ .max_by_key(|pos| pos.start.row);
+ if let Some(outdent_to) = basis_row {
+ regex_outdent_map.insert(row, outdent_to.start.row);
}
break;
}
@@ -3880,6 +4099,20 @@ impl BufferSnapshot {
})
}
+ pub fn outline_items_as_offsets_containing<T: ToOffset>(
+ &self,
+ range: Range<T>,
+ include_extra_context: bool,
+ theme: Option<&SyntaxTheme>,
+ ) -> Vec<OutlineItem<usize>> {
+ self.outline_items_containing_internal(
+ range,
+ include_extra_context,
+ theme,
+ |buffer, range| range.to_offset(buffer),
+ )
+ }
+
fn outline_items_containing_internal<T: ToOffset, U>(
&self,
range: Range<T>,
@@ -4114,24 +4347,60 @@ 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<usize>,
- ) -> impl Iterator<Item = BracketMatch> + '_ {
- 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::<Vec<_>>();
+ known_chunks: Option<&HashSet<Range<BufferRow>>>,
+ ) -> HashMap<Range<BufferRow>, Vec<BracketMatch<usize>>> {
+ let mut all_bracket_matches = HashMap::default();
+
+ for chunk in self
+ .tree_sitter_data
+ .chunks
+ .applicable_chunks(&[range.to_point(self)])
+ {
+ if known_chunks.is_some_and(|chunks| chunks.contains(&chunk.row_range())) {
+ continue;
+ }
+ let chunk_range = chunk.anchor_range();
+ 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_with_options(
+ chunk_range.clone(),
+ &self.text,
+ TreeSitterOptions {
+ max_bytes_to_query: Some(MAX_BYTES_TO_QUERY),
+ max_start_depth: None,
+ },
+ |grammar| grammar.brackets_config.as_ref().map(|c| &c.query),
+ );
+ let configs = matches
+ .grammars()
+ .iter()
+ .map(|grammar| grammar.brackets_config.as_ref().unwrap())
+ .collect::<Vec<_>>();
- 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 {
@@ -4148,25 +4417,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 `<Element/>` 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<usize>,
+ ) -> impl Iterator<Item = BracketMatch<usize>> {
+ 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<T: ToOffset>(
&self,
range: Range<T>,
- ) -> impl Iterator<Item = BracketMatch> + '_ {
+ ) -> impl Iterator<Item = BracketMatch<usize>> + '_ {
// 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)
@@ -4312,11 +4639,19 @@ impl BufferSnapshot {
pub fn enclosing_bracket_ranges<T: ToOffset>(
&self,
range: Range<T>,
- ) -> impl Iterator<Item = BracketMatch> + '_ {
+ ) -> impl Iterator<Item = BracketMatch<usize>> + '_ {
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
})
}
@@ -4803,6 +5138,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,
}
}
@@ -5117,6 +5453,7 @@ impl Default for Diagnostic {
is_unnecessary: false,
underline: true,
data: None,
+ registration_id: None,
}
}
}
@@ -0,0 +1,119 @@
+//! 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 text::{Anchor, OffsetRangeExt as _, Point};
+use util::RangeExt;
+
+use crate::BufferRow;
+
+/// An range of rows, exclusive as [`lsp::Range`] and
+/// <https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#range>
+/// 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
+/// <https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#inlayHintParams>
+#[derive(Clone)]
+pub struct RowChunks {
+ chunks: Arc<[RowChunk]>,
+ version: clock::Global,
+}
+
+impl std::fmt::Debug for RowChunks {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("RowChunks")
+ .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)
+ .collect::<Vec<_>>();
+ let last_chunk_id = chunks.len() - 1;
+ let chunks = chunks
+ .into_iter()
+ .enumerate()
+ .map(|(id, chunk_start)| {
+ let start = Point::new(chunk_start, 0);
+ let end_exclusive = (chunk_start + max_rows_per_chunk).min(last_row);
+ let end = if id == last_chunk_id {
+ Point::new(end_exclusive, snapshot.line_len(end_exclusive))
+ } else {
+ Point::new(end_exclusive, 0)
+ };
+ RowChunk {
+ id,
+ start: chunk_start,
+ end_exclusive,
+ start_anchor: snapshot.anchor_before(start),
+ end_anchor: snapshot.anchor_after(end),
+ }
+ })
+ .collect::<Vec<_>>();
+ Self {
+ chunks: Arc::from(chunks),
+ version: snapshot.version().clone(),
+ }
+ }
+
+ pub fn version(&self) -> &clock::Global {
+ &self.version
+ }
+
+ pub fn len(&self) -> usize {
+ self.chunks.len()
+ }
+
+ pub fn applicable_chunks(&self, ranges: &[Range<Point>]) -> impl Iterator<Item = RowChunk> {
+ let row_ranges = ranges
+ .iter()
+ // 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::<Vec<_>>();
+ 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 previous_chunk(&self, chunk: RowChunk) -> Option<RowChunk> {
+ 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,
+ pub start_anchor: Anchor,
+ pub end_anchor: Anchor,
+}
+
+impl RowChunk {
+ pub fn row_range(&self) -> Range<BufferRow> {
+ self.start..self.end_exclusive
+ }
+
+ pub fn anchor_range(&self) -> Range<Anchor> {
+ self.start_anchor..self.end_anchor
+ }
+}
@@ -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);
@@ -151,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()
@@ -173,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()
@@ -608,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();
@@ -735,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
});
@@ -783,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::<String>()))
+ ))
.collect::<Vec<_>>(),
&[
- ("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())),
]
);
@@ -840,6 +860,11 @@ async fn test_outline(cx: &mut gpui::TestAppContext) {
]
);
+ fn minimize_space(text: &str) -> String {
+ static WHITESPACE: LazyLock<Regex> = LazyLock::new(|| Regex::new("[\\n\\s]+").unwrap());
+ WHITESPACE.replace_all(text, " ").trim().to_string()
+ }
+
async fn search<'a>(
outline: &'a Outline<Anchor>,
query: &'a str,
@@ -865,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!(
@@ -945,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!(
@@ -993,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
@@ -1068,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
@@ -1087,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
@@ -1105,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! {"
@@ -1130,6 +1163,7 @@ fn test_enclosing_bracket_ranges(cx: &mut App) {
}
«}»
let foo = 1;"}],
+ cx,
);
assert(
@@ -1156,6 +1190,7 @@ fn test_enclosing_bracket_ranges(cx: &mut App) {
}
let foo = 1;"},
],
+ cx,
);
assert(
@@ -1182,6 +1217,7 @@ fn test_enclosing_bracket_ranges(cx: &mut App) {
}
let foo = 1;"},
],
+ cx,
);
assert(
@@ -1199,6 +1235,7 @@ fn test_enclosing_bracket_ranges(cx: &mut App) {
}
«}»
let foo = 1;"}],
+ cx,
);
assert(
@@ -1209,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.
@@ -1221,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(
@@ -1261,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!(
@@ -1313,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}");
@@ -1355,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}");
@@ -1404,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.
@@ -1545,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(
@@ -1608,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(
@@ -1667,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(
&"
@@ -1717,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),
@@ -1743,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),
@@ -1780,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
@@ -1863,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)];
@@ -1915,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
@@ -2006,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(
[
@@ -2020,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 {
@@ -2214,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);
@@ -2672,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);
@@ -2714,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());
@@ -3113,7 +3157,7 @@ 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()
@@ -3420,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::<Vec<_>>()))
.collect::<Vec<_>>();
let expected_remote_selections = active_selections
@@ -3455,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
@@ -3505,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
@@ -3665,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: "<!--".into(),
prefix: "".into(),
@@ -3730,78 +3793,6 @@ fn erb_lang() -> Language {
.unwrap()
}
-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()
- .with_brackets_query(
- r#"
- ("{" @open "}" @close)
- "#,
- )
- .unwrap()
- .with_text_object_query(
- r#"
- (function_item
- body: (_
- "{"
- (_)* @function.inside
- "}" )) @function.around
-
- (line_comment)+ @comment.around
-
- (block_comment) @comment.around
- "#,
- )
- .unwrap()
- .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()
-}
-
fn json_lang() -> Language {
Language::new(
LanguageConfig {
@@ -3839,32 +3830,6 @@ fn javascript_lang() -> Language {
.unwrap()
}
-pub fn markdown_lang() -> Language {
- Language::new(
- LanguageConfig {
- name: "Markdown".into(),
- matcher: LanguageMatcher {
- path_suffixes: vec!["md".into()],
- ..Default::default()
- },
- ..Default::default()
- },
- Some(tree_sitter_md::LANGUAGE.into()),
- )
- .with_injection_query(
- r#"
- (fenced_code_block
- (info_string
- (language) @injection.language)
- (code_fence_content) @injection.content)
-
- ((inline) @injection.content
- (#set! injection.language "markdown-inline"))
- "#,
- )
- .unwrap()
-}
-
pub fn markdown_inline_lang() -> Language {
Language::new(
LanguageConfig {
@@ -3891,12 +3856,11 @@ fn get_tree_sexp(buffer: &Entity<Buffer>, cx: &mut gpui::TestAppContext) -> Stri
fn assert_bracket_pairs(
selection_text: &'static str,
bracket_pair_texts: Vec<&'static str>,
- language: Language,
+ language: Arc<Language>,
cx: &mut App,
) {
let (expected_text, selection_ranges) = marked_text_ranges(selection_text, false);
- let buffer =
- cx.new(|cx| Buffer::local(expected_text.clone(), cx).with_language(Arc::new(language), cx));
+ let buffer = cx.new(|cx| Buffer::local(expected_text.clone(), cx).with_language(language, cx));
let buffer = buffer.update(cx, |buffer, _cx| buffer.snapshot());
let selection_range = selection_ranges[0].clone();
@@ -51,6 +51,9 @@ impl HighlightMap {
}
impl HighlightId {
+ pub const TABSTOP_INSERT_ID: HighlightId = HighlightId(u32::MAX - 1);
+ pub const TABSTOP_REPLACE_ID: HighlightId = HighlightId(u32::MAX - 2);
+
pub(crate) fn is_default(&self) -> bool {
*self == DEFAULT_SYNTAX_HIGHLIGHT_ID
}
@@ -28,17 +28,22 @@ use anyhow::{Context as _, Result};
use async_trait::async_trait;
use collections::{HashMap, HashSet, IndexSet};
use futures::Future;
+use futures::future::LocalBoxFuture;
+use futures::lock::OwnedMutexGuard;
use gpui::{App, AsyncApp, Entity, SharedString};
pub use highlight_map::HighlightMap;
use http_client::HttpClient;
pub use language_registry::{
LanguageName, LanguageServerStatusUpdate, LoadedLanguage, ServerHealth,
};
-use lsp::{CodeActionKind, InitializeParams, LanguageServerBinary, LanguageServerBinaryOptions};
+use lsp::{
+ CodeActionKind, InitializeParams, LanguageServerBinary, LanguageServerBinaryOptions, Uri,
+};
pub use manifest::{ManifestDelegate, ManifestName, ManifestProvider, ManifestQuery};
use parking_lot::Mutex;
use regex::Regex;
use schemars::{JsonSchema, SchemaGenerator, json_schema};
+use semver::Version;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use serde_json::Value;
use settings::WorktreeId;
@@ -51,7 +56,6 @@ use std::{
mem,
ops::{DerefMut, Range},
path::{Path, PathBuf},
- pin::Pin,
str,
sync::{
Arc, LazyLock,
@@ -63,6 +67,7 @@ use task::RunnableTag;
pub use task_context::{ContextLocation, ContextProvider, RunnableRange};
pub use text_diff::{
DiffOptions, apply_diff_patch, line_diff, text_diff, text_diff_with_options, unified_diff,
+ word_diff_ranges,
};
use theme::SyntaxTheme;
pub use toolchain::{
@@ -132,6 +137,46 @@ pub static PLAIN_TEXT: LazyLock<Arc<Language>> = LazyLock::new(|| {
path_suffixes: vec!["txt".to_owned()],
first_line_pattern: None,
},
+ brackets: BracketPairConfig {
+ pairs: vec![
+ BracketPair {
+ start: "(".to_string(),
+ end: ")".to_string(),
+ close: true,
+ surround: true,
+ newline: false,
+ },
+ BracketPair {
+ start: "[".to_string(),
+ end: "]".to_string(),
+ close: true,
+ surround: true,
+ newline: false,
+ },
+ BracketPair {
+ start: "{".to_string(),
+ end: "}".to_string(),
+ close: true,
+ surround: true,
+ newline: false,
+ },
+ BracketPair {
+ start: "\"".to_string(),
+ end: "\"".to_string(),
+ close: true,
+ surround: true,
+ newline: false,
+ },
+ BracketPair {
+ start: "'".to_string(),
+ end: "'".to_string(),
+ close: true,
+ surround: true,
+ newline: false,
+ },
+ ],
+ disabled_scopes_by_bracket_ix: Default::default(),
+ },
..Default::default()
},
None,
@@ -152,7 +197,14 @@ pub struct Location {
}
type ServerBinaryCache = futures::lock::Mutex<Option<(bool, LanguageServerBinary)>>;
-
+type DownloadableLanguageServerBinary = LocalBoxFuture<'static, Result<LanguageServerBinary>>;
+pub type LanguageServerBinaryLocations = LocalBoxFuture<
+ 'static,
+ (
+ Result<LanguageServerBinary>,
+ Option<DownloadableLanguageServerBinary>,
+ ),
+>;
/// Represents a Language Server, with certain cached sync properties.
/// Uses [`LspAdapter`] under the hood, but calls all 'static' methods
/// once at startup, and caches the results.
@@ -162,7 +214,7 @@ pub struct CachedLspAdapter {
pub disk_based_diagnostics_progress_token: Option<String>,
language_ids: HashMap<LanguageName, String>,
pub adapter: Arc<dyn LspAdapter>,
- cached_binary: ServerBinaryCache,
+ cached_binary: Arc<ServerBinaryCache>,
}
impl Debug for CachedLspAdapter {
@@ -209,18 +261,15 @@ impl CachedLspAdapter {
toolchains: Option<Toolchain>,
binary_options: LanguageServerBinaryOptions,
cx: &mut AsyncApp,
- ) -> Result<LanguageServerBinary> {
- let mut cached_binary = self.cached_binary.lock().await;
- self.adapter
- .clone()
- .get_language_server_command(
- delegate,
- toolchains,
- binary_options,
- &mut cached_binary,
- cx,
- )
- .await
+ ) -> LanguageServerBinaryLocations {
+ let cached_binary = self.cached_binary.clone().lock_owned().await;
+ self.adapter.clone().get_language_server_command(
+ delegate,
+ toolchains,
+ binary_options,
+ cached_binary,
+ cx.clone(),
+ )
}
pub fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
@@ -281,6 +330,10 @@ impl CachedLspAdapter {
.cloned()
.unwrap_or_else(|| language_name.lsp_id())
}
+
+ pub fn process_prompt_response(&self, context: &PromptResponseContext, cx: &mut AsyncApp) {
+ self.adapter.process_prompt_response(context, cx)
+ }
}
/// [`LspAdapterDelegate`] allows [`LspAdapter]` implementations to interface with the application
@@ -291,6 +344,7 @@ pub trait LspAdapterDelegate: Send + Sync {
fn http_client(&self) -> Arc<dyn HttpClient>;
fn worktree_id(&self) -> WorktreeId;
fn worktree_root_path(&self) -> &Path;
+ fn resolve_executable_path(&self, path: PathBuf) -> PathBuf;
fn update_status(&self, language: LanguageServerName, status: BinaryStatus);
fn registered_lsp_adapters(&self) -> Vec<Arc<dyn LspAdapter>>;
async fn language_server_download_dir(&self, name: &LanguageServerName) -> Option<Arc<Path>>;
@@ -298,13 +352,24 @@ pub trait LspAdapterDelegate: Send + Sync {
async fn npm_package_installed_version(
&self,
package_name: &str,
- ) -> Result<Option<(PathBuf, String)>>;
+ ) -> Result<Option<(PathBuf, Version)>>;
async fn which(&self, command: &OsStr) -> Option<PathBuf>;
async fn shell_env(&self) -> HashMap<String, String>;
async fn read_text_file(&self, path: &RelPath) -> Result<String>;
async fn try_exec(&self, binary: LanguageServerBinary) -> Result<()>;
}
+/// Context provided to LSP adapters when a user responds to a ShowMessageRequest prompt.
+/// This allows adapters to intercept preference selections (like "Always" or "Never")
+/// and potentially persist them to Zed's settings.
+#[derive(Debug, Clone)]
+pub struct PromptResponseContext {
+ /// The original message shown to the user
+ pub message: String,
+ /// The action (button) the user selected
+ pub selected_action: lsp::MessageActionItem,
+}
+
#[async_trait(?Send)]
pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller {
fn name(&self) -> LanguageServerName;
@@ -400,6 +465,7 @@ pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller {
self: Arc<Self>,
_: &Arc<dyn LspAdapterDelegate>,
_: Option<Toolchain>,
+ _: Option<Uri>,
_cx: &mut AsyncApp,
) -> Result<Value> {
Ok(serde_json::json!({}))
@@ -460,6 +526,11 @@ pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller {
fn is_extension(&self) -> bool {
false
}
+
+ /// Called when a user responds to a ShowMessageRequest from this language server.
+ /// This allows adapters to intercept preference selections (like "Always" or "Never")
+ /// for settings that should be persisted to Zed's settings file.
+ fn process_prompt_response(&self, _context: &PromptResponseContext, _cx: &mut AsyncApp) {}
}
pub trait LspInstaller {
@@ -485,7 +556,7 @@ pub trait LspInstaller {
_version: &Self::BinaryVersion,
_container_dir: &PathBuf,
_delegate: &dyn LspAdapterDelegate,
- ) -> impl Future<Output = Option<LanguageServerBinary>> {
+ ) -> impl Send + Future<Output = Option<LanguageServerBinary>> {
async { None }
}
@@ -494,7 +565,7 @@ pub trait LspInstaller {
latest_version: Self::BinaryVersion,
container_dir: PathBuf,
delegate: &dyn LspAdapterDelegate,
- ) -> impl Future<Output = Result<LanguageServerBinary>>;
+ ) -> impl Send + Future<Output = Result<LanguageServerBinary>>;
fn cached_server_binary(
&self,
@@ -512,19 +583,20 @@ pub trait DynLspInstaller {
pre_release: bool,
cx: &mut AsyncApp,
) -> Result<LanguageServerBinary>;
- fn get_language_server_command<'a>(
+ fn get_language_server_command(
self: Arc<Self>,
delegate: Arc<dyn LspAdapterDelegate>,
toolchains: Option<Toolchain>,
binary_options: LanguageServerBinaryOptions,
- cached_binary: &'a mut Option<(bool, LanguageServerBinary)>,
- cx: &'a mut AsyncApp,
- ) -> Pin<Box<dyn 'a + Future<Output = Result<LanguageServerBinary>>>>;
+ cached_binary: OwnedMutexGuard<Option<(bool, LanguageServerBinary)>>,
+ cx: AsyncApp,
+ ) -> LanguageServerBinaryLocations;
}
#[async_trait(?Send)]
impl<LI, BinaryVersion> DynLspInstaller for LI
where
+ BinaryVersion: Send + Sync,
LI: LspInstaller<BinaryVersion = BinaryVersion> + LspAdapter,
{
async fn try_fetch_server_binary(
@@ -543,8 +615,13 @@ where
.fetch_latest_server_version(delegate.as_ref(), pre_release, cx)
.await?;
- if let Some(binary) = self
- .check_if_version_installed(&latest_version, &container_dir, delegate.as_ref())
+ if let Some(binary) = cx
+ .background_executor()
+ .await_on_background(self.check_if_version_installed(
+ &latest_version,
+ &container_dir,
+ delegate.as_ref(),
+ ))
.await
{
log::debug!("language server {:?} is already installed", name.0);
@@ -553,23 +630,29 @@ where
} else {
log::debug!("downloading language server {:?}", name.0);
delegate.update_status(name.clone(), BinaryStatus::Downloading);
- let binary = self
- .fetch_server_binary(latest_version, container_dir, delegate.as_ref())
+ let binary = cx
+ .background_executor()
+ .await_on_background(self.fetch_server_binary(
+ latest_version,
+ container_dir,
+ delegate.as_ref(),
+ ))
.await;
delegate.update_status(name.clone(), BinaryStatus::None);
binary
}
}
- fn get_language_server_command<'a>(
+ fn get_language_server_command(
self: Arc<Self>,
delegate: Arc<dyn LspAdapterDelegate>,
toolchain: Option<Toolchain>,
binary_options: LanguageServerBinaryOptions,
- cached_binary: &'a mut Option<(bool, LanguageServerBinary)>,
- cx: &'a mut AsyncApp,
- ) -> Pin<Box<dyn 'a + Future<Output = Result<LanguageServerBinary>>>> {
+ mut cached_binary: OwnedMutexGuard<Option<(bool, LanguageServerBinary)>>,
+ mut cx: AsyncApp,
+ ) -> LanguageServerBinaryLocations {
async move {
+ let cached_binary_deref = cached_binary.deref_mut();
// First we check whether the adapter can give us a user-installed binary.
// If so, we do *not* want to cache that, because each worktree might give us a different
// binary:
@@ -583,7 +666,7 @@ where
// for each worktree we might have open.
if binary_options.allow_path_lookup
&& let Some(binary) = self
- .check_if_user_installed(delegate.as_ref(), toolchain, cx)
+ .check_if_user_installed(delegate.as_ref(), toolchain, &mut cx)
.await
{
log::info!(
@@ -592,62 +675,77 @@ where
binary.path,
binary.arguments
);
- return Ok(binary);
+ return (Ok(binary), None);
}
- anyhow::ensure!(
- binary_options.allow_binary_download,
- "downloading language servers disabled"
- );
+ if !binary_options.allow_binary_download {
+ return (
+ Err(anyhow::anyhow!("downloading language servers disabled")),
+ None,
+ );
+ }
- if let Some((pre_release, cached_binary)) = cached_binary
+ if let Some((pre_release, cached_binary)) = cached_binary_deref
&& *pre_release == binary_options.pre_release
{
- return Ok(cached_binary.clone());
+ return (Ok(cached_binary.clone()), None);
}
let Some(container_dir) = delegate.language_server_download_dir(&self.name()).await
else {
- anyhow::bail!("no language server download dir defined")
+ return (
+ Err(anyhow::anyhow!("no language server download dir defined")),
+ None,
+ );
};
- let mut binary = self
- .try_fetch_server_binary(
- &delegate,
- container_dir.to_path_buf(),
- binary_options.pre_release,
- cx,
- )
- .await;
+ let last_downloaded_binary = self
+ .cached_server_binary(container_dir.to_path_buf(), delegate.as_ref())
+ .await
+ .context(
+ "did not find existing language server binary, falling back to downloading",
+ );
+ let download_binary = async move {
+ let mut binary = self
+ .try_fetch_server_binary(
+ &delegate,
+ container_dir.to_path_buf(),
+ binary_options.pre_release,
+ &mut cx,
+ )
+ .await;
+
+ if let Err(error) = binary.as_ref() {
+ if let Some(prev_downloaded_binary) = self
+ .cached_server_binary(container_dir.to_path_buf(), delegate.as_ref())
+ .await
+ {
+ log::info!(
+ "failed to fetch newest version of language server {:?}. \
+ error: {:?}, falling back to using {:?}",
+ self.name(),
+ error,
+ prev_downloaded_binary.path
+ );
+ binary = Ok(prev_downloaded_binary);
+ } else {
+ delegate.update_status(
+ self.name(),
+ BinaryStatus::Failed {
+ error: format!("{error:?}"),
+ },
+ );
+ }
+ }
- if let Err(error) = binary.as_ref() {
- if let Some(prev_downloaded_binary) = self
- .cached_server_binary(container_dir.to_path_buf(), delegate.as_ref())
- .await
- {
- log::info!(
- "failed to fetch newest version of language server {:?}. \
- error: {:?}, falling back to using {:?}",
- self.name(),
- error,
- prev_downloaded_binary.path
- );
- binary = Ok(prev_downloaded_binary);
- } else {
- delegate.update_status(
- self.name(),
- BinaryStatus::Failed {
- error: format!("{error:?}"),
- },
- );
+ if let Ok(binary) = &binary {
+ *cached_binary = Some((binary_options.pre_release, binary.clone()));
}
- }
- if let Ok(binary) = &binary {
- *cached_binary = Some((binary_options.pre_release, binary.clone()));
+ binary
}
-
- binary
+ .boxed_local();
+ (last_downloaded_binary, Some(download_binary))
}
.boxed_local()
}
@@ -956,7 +1054,7 @@ impl<T> Override<T> {
impl Default for LanguageConfig {
fn default() -> Self {
Self {
- name: LanguageName::new(""),
+ name: LanguageName::new_static(""),
code_fence_block_name: None,
grammar: None,
matcher: LanguageMatcher::default(),
@@ -1322,6 +1420,7 @@ struct BracketsConfig {
#[derive(Clone, Debug, Default)]
struct BracketsPatternConfig {
newline_only: bool,
+ rainbow_exclude: bool,
}
pub struct DebugVariablesConfig {
@@ -1684,9 +1783,13 @@ impl Language {
.map(|ix| {
let mut config = BracketsPatternConfig::default();
for setting in query.property_settings(ix) {
- if setting.key.as_ref() == "newline.only" {
+ let setting_key = setting.key.as_ref();
+ if setting_key == "newline.only" {
config.newline_only = true
}
+ if setting_key == "rainbow.exclude" {
+ config.rainbow_exclude = true
+ }
}
config
})
@@ -2343,7 +2446,10 @@ impl CodeLabel {
"invalid filter range"
);
runs.iter().for_each(|(range, _)| {
- assert!(text.get(range.clone()).is_some(), "invalid run range");
+ assert!(
+ text.get(range.clone()).is_some(),
+ "invalid run range with inputs. Requested range {range:?} in text '{text}'",
+ );
});
Self {
runs,
@@ -2616,47 +2722,80 @@ pub fn rust_lang() -> Arc<Language> {
outline: Some(Cow::from(include_str!(
"../../languages/src/rust/outline.scm"
))),
- indents: Some(Cow::from(
- r#"
-[
- ((where_clause) _ @end)
- (field_expression)
- (call_expression)
- (assignment_expression)
- (let_declaration)
- (let_chain)
- (await_expression)
-] @indent
-
-(_ "[" "]" @end) @indent
-(_ "<" ">" @end) @indent
-(_ "{" "}" @end) @indent
-(_ "(" ")" @end) @indent"#,
- )),
- brackets: Some(Cow::from(
- r#"
-("(" @open ")" @close)
-("[" @open "]" @close)
-("{" @open "}" @close)
-("<" @open ">" @close)
-("\"" @open "\"" @close)
-(closure_parameters "|" @open "|" @close)"#,
- )),
- text_objects: Some(Cow::from(
- r#"
-(function_item
- body: (_
- "{"
- (_)* @function.inside
- "}" )) @function.around
- "#,
- )),
- ..LanguageQueries::default()
+ indents: Some(Cow::from(include_str!(
+ "../../languages/src/rust/indents.scm"
+ ))),
+ brackets: Some(Cow::from(include_str!(
+ "../../languages/src/rust/brackets.scm"
+ ))),
+ text_objects: Some(Cow::from(include_str!(
+ "../../languages/src/rust/textobjects.scm"
+ ))),
+ highlights: Some(Cow::from(include_str!(
+ "../../languages/src/rust/highlights.scm"
+ ))),
+ embedding: Some(Cow::from(include_str!(
+ "../../languages/src/rust/embedding.scm"
+ ))),
+ injections: Some(Cow::from(include_str!(
+ "../../languages/src/rust/injections.scm"
+ ))),
+ overrides: Some(Cow::from(include_str!(
+ "../../languages/src/rust/overrides.scm"
+ ))),
+ redactions: None,
+ runnables: Some(Cow::from(include_str!(
+ "../../languages/src/rust/runnables.scm"
+ ))),
+ debugger: Some(Cow::from(include_str!(
+ "../../languages/src/rust/debugger.scm"
+ ))),
+ imports: Some(Cow::from(include_str!(
+ "../../languages/src/rust/imports.scm"
+ ))),
})
.expect("Could not parse queries");
Arc::new(language)
}
+#[doc(hidden)]
+#[cfg(any(test, feature = "test-support"))]
+pub fn markdown_lang() -> Arc<Language> {
+ use std::borrow::Cow;
+
+ let language = Language::new(
+ LanguageConfig {
+ name: "Markdown".into(),
+ matcher: LanguageMatcher {
+ path_suffixes: vec!["md".into()],
+ ..Default::default()
+ },
+ ..LanguageConfig::default()
+ },
+ Some(tree_sitter_md::LANGUAGE.into()),
+ )
+ .with_queries(LanguageQueries {
+ brackets: Some(Cow::from(include_str!(
+ "../../languages/src/markdown/brackets.scm"
+ ))),
+ injections: Some(Cow::from(include_str!(
+ "../../languages/src/markdown/injections.scm"
+ ))),
+ highlights: Some(Cow::from(include_str!(
+ "../../languages/src/markdown/highlights.scm"
+ ))),
+ indents: Some(Cow::from(include_str!(
+ "../../languages/src/markdown/indents.scm"
+ ))),
+ outline: Some(Cow::from(include_str!(
+ "../../languages/src/markdown/outline.scm"
+ ))),
+ ..LanguageQueries::default()
+ })
+ .expect("Could not parse markdown queries");
+ Arc::new(language)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -2692,9 +2831,9 @@ mod tests {
assert_eq!(
languages.language_names(),
&[
- LanguageName::new("JSON"),
- LanguageName::new("Plain Text"),
- LanguageName::new("Rust"),
+ LanguageName::new_static("JSON"),
+ LanguageName::new_static("Plain Text"),
+ LanguageName::new_static("Rust"),
]
);
@@ -2705,9 +2844,9 @@ mod tests {
assert_eq!(
languages.language_names(),
&[
- LanguageName::new("JSON"),
- LanguageName::new("Plain Text"),
- LanguageName::new("Rust"),
+ LanguageName::new_static("JSON"),
+ LanguageName::new_static("Plain Text"),
+ LanguageName::new_static("Rust"),
]
);
@@ -2718,9 +2857,9 @@ mod tests {
assert_eq!(
languages.language_names(),
&[
- LanguageName::new("JSON"),
- LanguageName::new("Plain Text"),
- LanguageName::new("Rust"),
+ LanguageName::new_static("JSON"),
+ LanguageName::new_static("Plain Text"),
+ LanguageName::new_static("Rust"),
]
);
@@ -43,12 +43,18 @@ impl LanguageName {
Self(SharedString::new(s))
}
+ pub fn new_static(s: &'static str) -> Self {
+ Self(SharedString::new_static(s))
+ }
+
pub fn from_proto(s: String) -> Self {
Self(SharedString::from(s))
}
+
pub fn to_proto(&self) -> String {
self.0.to_string()
}
+
pub fn lsp_id(&self) -> String {
match self.0.as_ref() {
"Plain Text" => "plaintext".to_string(),
@@ -87,9 +93,9 @@ impl std::fmt::Display for LanguageName {
}
}
-impl<'a> From<&'a str> for LanguageName {
- fn from(str: &'a str) -> LanguageName {
- LanguageName(SharedString::new(str))
+impl From<&'static str> for LanguageName {
+ fn from(str: &'static str) -> Self {
+ Self(SharedString::new_static(str))
}
}
@@ -437,26 +443,14 @@ impl LanguageRegistry {
language_name: impl Into<LanguageName>,
mut adapter: crate::FakeLspAdapter,
) -> futures::channel::mpsc::UnboundedReceiver<lsp::FakeLanguageServer> {
- let language_name = language_name.into();
let adapter_name = LanguageServerName(adapter.name.into());
let capabilities = adapter.capabilities.clone();
let initializer = adapter.initializer.take();
- let adapter = CachedLspAdapter::new(Arc::new(adapter));
- {
- let mut state = self.state.write();
- state
- .lsp_adapters
- .entry(language_name)
- .or_default()
- .push(adapter.clone());
- state.all_lsp_adapters.insert(adapter.name(), adapter);
- }
-
- self.register_fake_language_server(adapter_name, capabilities, initializer)
+ self.register_fake_lsp_adapter(language_name, adapter);
+ self.register_fake_lsp_server(adapter_name, capabilities, initializer)
}
/// Register a fake lsp adapter (without the language server)
- /// The returned channel receives a new instance of the language server every time it is started
#[cfg(any(feature = "test-support", test))]
pub fn register_fake_lsp_adapter(
&self,
@@ -479,7 +473,7 @@ impl LanguageRegistry {
/// Register a fake language server (without the adapter)
/// The returned channel receives a new instance of the language server every time it is started
#[cfg(any(feature = "test-support", test))]
- pub fn register_fake_language_server(
+ pub fn register_fake_lsp_server(
&self,
lsp_name: LanguageServerName,
capabilities: lsp::ServerCapabilities,
@@ -757,7 +751,7 @@ impl LanguageRegistry {
self: &Arc<Self>,
path: &Path,
content: Option<&Rope>,
- user_file_types: Option<&FxHashMap<Arc<str>, GlobSet>>,
+ user_file_types: Option<&FxHashMap<Arc<str>, (GlobSet, Vec<String>)>>,
) -> Option<AvailableLanguage> {
let filename = path.file_name().and_then(|filename| filename.to_str());
// `Path.extension()` returns None for files with a leading '.'
@@ -800,7 +794,7 @@ impl LanguageRegistry {
let path_matches_custom_suffix = || {
user_file_types
.and_then(|types| types.get(language_name.as_ref()))
- .map_or(None, |custom_suffixes| {
+ .map_or(None, |(custom_suffixes, _)| {
path_suffixes
.iter()
.find(|(_, candidate)| custom_suffixes.is_match_candidate(candidate))
@@ -51,17 +51,17 @@ pub struct AllLanguageSettings {
pub edit_predictions: EditPredictionSettings,
pub defaults: LanguageSettings,
languages: HashMap<LanguageName, LanguageSettings>,
- pub(crate) file_types: FxHashMap<Arc<str>, GlobSet>,
+ pub file_types: FxHashMap<Arc<str>, (GlobSet, Vec<String>)>,
}
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
pub struct WhitespaceMap {
pub space: SharedString,
pub tab: SharedString,
}
/// The settings for a particular language.
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
pub struct LanguageSettings {
/// How many columns a tab should occupy.
pub tab_size: NonZeroU32,
@@ -153,9 +153,18 @@ pub struct LanguageSettings {
pub completions: CompletionSettings,
/// Preferred debuggers for this language.
pub debuggers: Vec<String>,
+ /// 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`
+ pub word_diff_enabled: bool,
+ /// Whether to use tree-sitter bracket queries to detect and colorize the brackets in the editor.
+ pub colorize_brackets: bool,
}
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
pub struct CompletionSettings {
/// Controls how words are completed.
/// For large documents, not all words may be fetched for completion.
@@ -207,7 +216,7 @@ pub struct IndentGuideSettings {
pub background_coloring: settings::IndentGuideBackgroundColoring,
}
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
pub struct LanguageTaskSettings {
/// Extra task variables to set for a particular language.
pub variables: HashMap<String, String>,
@@ -225,7 +234,7 @@ pub struct LanguageTaskSettings {
/// Allows to enable/disable formatting with Prettier
/// and configure default Prettier, used when no project-level Prettier installation is found.
/// Prettier formatting is disabled by default.
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
pub struct PrettierSettings {
/// Enables or disables formatting with Prettier for a given language.
pub allowed: bool,
@@ -364,6 +373,8 @@ impl InlayHintSettings {
pub struct EditPredictionSettings {
/// The provider that supplies edit predictions.
pub provider: settings::EditPredictionProvider,
+ /// Whether to use the experimental edit prediction context retrieval system.
+ pub use_context: bool,
/// A list of globs representing files that edit predictions should be disabled for.
/// This list adds to a pre-existing, sensible default set of globs.
/// Any additional ones you add are combined with them.
@@ -584,6 +595,7 @@ impl settings::Settings for AllLanguageSettings {
},
show_completions_on_input: settings.show_completions_on_input.unwrap(),
show_completion_documentation: settings.show_completion_documentation.unwrap(),
+ colorize_brackets: settings.colorize_brackets.unwrap(),
completions: CompletionSettings {
words: completions.words.unwrap(),
words_min_length: completions.words_min_length.unwrap() as usize,
@@ -592,6 +604,7 @@ impl settings::Settings for AllLanguageSettings {
lsp_insert_mode: completions.lsp_insert_mode.unwrap(),
},
debuggers: settings.debuggers.unwrap(),
+ word_diff_enabled: settings.word_diff_enabled.unwrap(),
}
}
@@ -611,6 +624,11 @@ impl settings::Settings for AllLanguageSettings {
.features
.as_ref()
.and_then(|f| f.edit_prediction_provider);
+ let use_edit_prediction_context = all_languages
+ .features
+ .as_ref()
+ .and_then(|f| f.experimental_edit_prediction_context_retrieval)
+ .unwrap_or_default();
let edit_predictions = all_languages.edit_predictions.clone().unwrap();
let edit_predictions_mode = edit_predictions.mode.unwrap();
@@ -638,7 +656,7 @@ impl settings::Settings for AllLanguageSettings {
let enabled_in_text_threads = edit_predictions.enabled_in_text_threads.unwrap();
- let mut file_types: FxHashMap<Arc<str>, GlobSet> = FxHashMap::default();
+ let mut file_types: FxHashMap<Arc<str>, (GlobSet, Vec<String>)> = FxHashMap::default();
for (language, patterns) in all_languages.file_types.iter().flatten() {
let mut builder = GlobSetBuilder::new();
@@ -647,7 +665,10 @@ impl settings::Settings for AllLanguageSettings {
builder.add(Glob::new(pattern).unwrap());
}
- file_types.insert(language.clone(), builder.build().unwrap());
+ file_types.insert(
+ language.clone(),
+ (builder.build().unwrap(), patterns.0.clone()),
+ );
}
Self {
@@ -657,6 +678,7 @@ impl settings::Settings for AllLanguageSettings {
} else {
EditPredictionProvider::None
},
+ use_context: use_edit_prediction_context,
disabled_globs: disabled_globs
.iter()
.filter_map(|g| {
@@ -1,4 +1,4 @@
-use crate::{BufferSnapshot, Point, ToPoint};
+use crate::{BufferSnapshot, Point, ToPoint, ToTreeSitterPoint};
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{BackgroundExecutor, HighlightStyle};
use std::ops::Range;
@@ -48,6 +48,54 @@ impl<T: ToPoint> OutlineItem<T> {
.map(|r| r.start.to_point(buffer)..r.end.to_point(buffer)),
}
}
+
+ pub fn body_range(&self, buffer: &BufferSnapshot) -> Option<Range<Point>> {
+ if let Some(range) = self.body_range.as_ref() {
+ return Some(range.start.to_point(buffer)..range.end.to_point(buffer));
+ }
+
+ let range = self.range.start.to_point(buffer)..self.range.end.to_point(buffer);
+ let start_indent = buffer.indent_size_for_line(range.start.row);
+ let node = buffer.syntax_ancestor(range.clone())?;
+
+ let mut cursor = node.walk();
+ loop {
+ let node = cursor.node();
+ if node.start_position() >= range.start.to_ts_point()
+ && node.end_position() <= range.end.to_ts_point()
+ {
+ break;
+ }
+ cursor.goto_first_child_for_point(range.start.to_ts_point());
+ }
+
+ if !cursor.goto_last_child() {
+ return None;
+ }
+ let body_node = loop {
+ let node = cursor.node();
+ if node.child_count() > 0 {
+ break node;
+ }
+ if !cursor.goto_previous_sibling() {
+ return None;
+ }
+ };
+
+ let mut start_row = body_node.start_position().row as u32;
+ let mut end_row = body_node.end_position().row as u32;
+
+ while start_row < end_row && buffer.indent_size_for_line(start_row) == start_indent {
+ start_row += 1;
+ }
+ while start_row < end_row && buffer.indent_size_for_line(end_row - 1) == start_indent {
+ end_row -= 1;
+ }
+ if start_row < end_row {
+ return Some(Point::new(start_row, 0)..Point::new(end_row, 0));
+ }
+ None
+ }
}
impl<T> Outline<T> {
@@ -3,6 +3,7 @@
use crate::{CursorShape, Diagnostic, DiagnosticSourceKind, diagnostic_set::DiagnosticEntry};
use anyhow::{Context as _, Result};
use clock::ReplicaId;
+use gpui::SharedString;
use lsp::{DiagnosticSeverity, LanguageServerId};
use rpc::proto;
use serde_json::Value;
@@ -239,6 +240,11 @@ pub fn serialize_diagnostics<'a>(
is_disk_based: entry.diagnostic.is_disk_based,
is_unnecessary: entry.diagnostic.is_unnecessary,
data: entry.diagnostic.data.as_ref().map(|data| data.to_string()),
+ registration_id: entry
+ .diagnostic
+ .registration_id
+ .as_ref()
+ .map(ToString::to_string),
})
.collect()
}
@@ -457,6 +463,7 @@ pub fn deserialize_diagnostics(
is_disk_based: diagnostic.is_disk_based,
is_unnecessary: diagnostic.is_unnecessary,
underline: diagnostic.underline,
+ registration_id: diagnostic.registration_id.map(SharedString::from),
source_kind: match proto::diagnostic::SourceKind::from_i32(
diagnostic.source_kind,
)? {
@@ -21,6 +21,8 @@ use sum_tree::{Bias, Dimensions, SeekTarget, SumTree};
use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point, Rope, ToOffset, ToPoint};
use tree_sitter::{Node, Query, QueryCapture, QueryCaptures, QueryCursor, QueryMatches, Tree};
+pub const MAX_BYTES_TO_QUERY: usize = 16 * 1024;
+
pub struct SyntaxMap {
snapshot: SyntaxSnapshot,
language_registry: Option<Arc<LanguageRegistry>>,
@@ -279,6 +281,13 @@ impl SyntaxSnapshot {
self.layers.is_empty()
}
+ pub fn root_language(&self) -> Option<Arc<Language>> {
+ match &self.layers.first()?.content {
+ SyntaxLayerContent::Parsed { language, .. } => Some(language.clone()),
+ SyntaxLayerContent::Pending { .. } => None,
+ }
+ }
+
pub fn update_count(&self) -> usize {
self.update_count
}
@@ -323,7 +332,7 @@ impl SyntaxSnapshot {
let slice = cursor.slice(
&SyntaxLayerPosition {
depth: depth + 1,
- range: Anchor::MIN..Anchor::MAX,
+ range: Anchor::min_max_range_for_buffer(text.remote_id()),
language: None,
},
Bias::Left,
@@ -486,7 +495,7 @@ impl SyntaxSnapshot {
start_point: Point::zero().to_ts_point(),
end_point: text.max_point().to_ts_point(),
}],
- range: Anchor::MIN..Anchor::MAX,
+ range: Anchor::min_max_range_for_buffer(text.remote_id()),
mode: ParseMode::Single,
});
@@ -508,7 +517,7 @@ impl SyntaxSnapshot {
} else {
SyntaxLayerPosition {
depth: max_depth + 1,
- range: Anchor::MAX..Anchor::MAX,
+ range: Anchor::min_max_range_for_buffer(text.remote_id()),
language: None,
}
};
@@ -1089,12 +1098,15 @@ impl<'a> SyntaxMapCaptures<'a> {
#[derive(Default)]
pub struct TreeSitterOptions {
- max_start_depth: Option<u32>,
+ pub max_start_depth: Option<u32>,
+ pub max_bytes_to_query: Option<usize>,
}
+
impl TreeSitterOptions {
pub fn max_start_depth(max_start_depth: u32) -> Self {
Self {
max_start_depth: Some(max_start_depth),
+ max_bytes_to_query: None,
}
}
}
@@ -1128,6 +1140,14 @@ impl<'a> SyntaxMapMatches<'a> {
};
cursor.set_max_start_depth(options.max_start_depth);
+ if let Some(max_bytes_to_query) = options.max_bytes_to_query {
+ let midpoint = (range.start + range.end) / 2;
+ let containing_range_start = midpoint.saturating_sub(max_bytes_to_query / 2);
+ let containing_range_end =
+ containing_range_start.saturating_add(max_bytes_to_query);
+ cursor.set_containing_byte_range(containing_range_start..containing_range_end);
+ }
+
cursor.set_byte_range(range.clone());
let matches = cursor.matches(query, layer.node(), TextProvider(text));
let grammar_index = result
@@ -1208,6 +1228,19 @@ impl<'a> SyntaxMapMatches<'a> {
true
}
+
+ // pub fn set_byte_range(&mut self, range: Range<usize>) {
+ // for layer in &mut self.layers {
+ // layer.matches.set_byte_range(range.clone());
+ // layer.advance();
+ // }
+ // self.layers.sort_unstable_by_key(|layer| layer.sort_key());
+ // self.active_layer_count = self
+ // .layers
+ // .iter()
+ // .position(|layer| !layer.has_next)
+ // .unwrap_or(self.layers.len());
+ // }
}
impl SyntaxMapCapturesLayer<'_> {
@@ -1622,6 +1655,10 @@ impl<'a> SyntaxLayer<'a> {
let mut query_cursor = QueryCursorHandle::new();
query_cursor.set_byte_range(offset.saturating_sub(1)..offset.saturating_add(1));
+ query_cursor.set_containing_byte_range(
+ offset.saturating_sub(MAX_BYTES_TO_QUERY / 2)
+ ..offset.saturating_add(MAX_BYTES_TO_QUERY / 2),
+ );
let mut smallest_match: Option<(u32, Range<usize>)> = None;
let mut matches = query_cursor.matches(&config.query, self.node(), text);
@@ -1908,6 +1945,8 @@ impl Drop for QueryCursorHandle {
let mut cursor = self.0.take().unwrap();
cursor.set_byte_range(0..usize::MAX);
cursor.set_point_range(Point::zero().to_ts_point()..Point::MAX.to_ts_point());
+ cursor.set_containing_byte_range(0..usize::MAX);
+ cursor.set_containing_point_range(Point::zero().to_ts_point()..Point::MAX.to_ts_point());
QUERY_CURSORS.lock().push(cursor)
}
}
@@ -1,9 +1,9 @@
use super::*;
use crate::{
- LanguageConfig, LanguageMatcher,
- buffer_tests::{markdown_inline_lang, markdown_lang},
+ LanguageConfig, LanguageMatcher, buffer_tests::markdown_inline_lang, markdown_lang, rust_lang,
};
use gpui::App;
+use pretty_assertions::assert_eq;
use rand::rngs::StdRng;
use std::{env, ops::Range, sync::Arc};
use text::{Buffer, BufferId, ReplicaId};
@@ -84,7 +84,7 @@ fn test_splice_included_ranges() {
#[gpui::test]
fn test_syntax_map_layers_for_range(cx: &mut App) {
let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
- let language = Arc::new(rust_lang());
+ let language = rust_lang();
registry.add(language.clone());
let mut buffer = Buffer::new(
@@ -181,11 +181,11 @@ fn test_syntax_map_layers_for_range(cx: &mut App) {
#[gpui::test]
fn test_dynamic_language_injection(cx: &mut App) {
let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
- let markdown = Arc::new(markdown_lang());
+ let markdown = markdown_lang();
let markdown_inline = Arc::new(markdown_inline_lang());
registry.add(markdown.clone());
registry.add(markdown_inline.clone());
- registry.add(Arc::new(rust_lang()));
+ registry.add(rust_lang());
registry.add(Arc::new(ruby_lang()));
let mut buffer = Buffer::new(
@@ -291,7 +291,7 @@ fn test_typing_multiple_new_injections(cx: &mut App) {
assert_capture_ranges(
&syntax_map,
&buffer,
- &["field"],
+ &["property"],
"fn a() { test_macro!(b.«c»(vec![d.«e»])) }",
);
}
@@ -329,16 +329,16 @@ fn test_pasting_new_injection_line_between_others(cx: &mut App) {
assert_capture_ranges(
&syntax_map,
&buffer,
- &["struct"],
+ &["type"],
"
fn a() {
- b!(«B {}»);
- c!(«C {}»);
- d!(«D {}»);
- h!(«H {}»);
- e!(«E {}»);
- f!(«F {}»);
- g!(«G {}»);
+ b!(«B» {});
+ c!(«C» {});
+ d!(«D» {});
+ h!(«H» {});
+ e!(«E» {});
+ f!(«F» {});
+ g!(«G» {});
}
",
);
@@ -376,7 +376,7 @@ fn test_joining_injections_with_child_injections(cx: &mut App) {
assert_capture_ranges(
&syntax_map,
&buffer,
- &["field"],
+ &["property"],
"
fn a() {
b!(
@@ -900,7 +900,7 @@ fn test_random_syntax_map_edits_rust_macros(rng: StdRng, cx: &mut App) {
.repeat(2);
let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
- let language = Arc::new(rust_lang());
+ let language = rust_lang();
registry.add(language.clone());
test_random_edits(text, registry, language, rng);
@@ -1133,8 +1133,8 @@ fn check_interpolation(
check_node_edits(
depth,
range,
- old_node.child(i).unwrap(),
- new_node.child(i).unwrap(),
+ old_node.child(i as u32).unwrap(),
+ new_node.child(i as u32).unwrap(),
old_buffer,
new_buffer,
edits,
@@ -1147,11 +1147,11 @@ fn test_edit_sequence(language_name: &str, steps: &[&str], cx: &mut App) -> (Buf
let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
registry.add(Arc::new(elixir_lang()));
registry.add(Arc::new(heex_lang()));
- registry.add(Arc::new(rust_lang()));
+ registry.add(rust_lang());
registry.add(Arc::new(ruby_lang()));
registry.add(Arc::new(html_lang()));
registry.add(Arc::new(erb_lang()));
- registry.add(Arc::new(markdown_lang()));
+ registry.add(markdown_lang());
registry.add(Arc::new(markdown_inline_lang()));
let language = registry
@@ -1287,35 +1287,6 @@ fn erb_lang() -> Language {
.unwrap()
}
-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(
- r#"
- (field_identifier) @field
- (struct_expression) @struct
- "#,
- )
- .unwrap()
- .with_injection_query(
- r#"
- (macro_invocation
- (token_tree) @injection.content
- (#set! injection.language "rust"))
- "#,
- )
- .unwrap()
-}
-
fn elixir_lang() -> Language {
Language::new(
LanguageConfig {
@@ -1425,6 +1396,7 @@ fn assert_capture_ranges(
actual_ranges.push(capture.node.byte_range());
}
}
+ actual_ranges.dedup();
let (text, expected_ranges) = marked_text_ranges(&marked_string.unindent(), false);
assert_eq!(text, buffer.text());
@@ -44,6 +44,47 @@ pub fn text_diff(old_text: &str, new_text: &str) -> Vec<(Range<usize>, Arc<str>)
text_diff_with_options(old_text, new_text, DiffOptions::default())
}
+/// Computes word-level diff ranges between two strings.
+///
+/// Returns a tuple of (old_ranges, new_ranges) where each vector contains
+/// the byte ranges of changed words in the respective text.
+pub fn word_diff_ranges(
+ old_text: &str,
+ new_text: &str,
+ options: DiffOptions,
+) -> (Vec<Range<usize>>, Vec<Range<usize>>) {
+ let mut input: InternedInput<&str> = InternedInput::default();
+ input.update_before(tokenize(old_text, options.language_scope.clone()));
+ input.update_after(tokenize(new_text, options.language_scope));
+
+ let mut old_ranges: Vec<Range<usize>> = Vec::new();
+ let mut new_ranges: Vec<Range<usize>> = Vec::new();
+
+ diff_internal(&input, |old_byte_range, new_byte_range, _, _| {
+ if !old_byte_range.is_empty() {
+ if let Some(last) = old_ranges.last_mut()
+ && last.end >= old_byte_range.start
+ {
+ last.end = old_byte_range.end;
+ } else {
+ old_ranges.push(old_byte_range);
+ }
+ }
+
+ if !new_byte_range.is_empty() {
+ if let Some(last) = new_ranges.last_mut()
+ && last.end >= new_byte_range.start
+ {
+ last.end = new_byte_range.end;
+ } else {
+ new_ranges.push(new_byte_range);
+ }
+ }
+ });
+
+ (old_ranges, new_ranges)
+}
+
pub struct DiffOptions {
pub language_scope: Option<LanguageScope>,
pub max_word_diff_len: usize,
@@ -1,21 +1,20 @@
use std::ops::Range;
-use std::path::PathBuf;
-use std::pin::Pin;
+use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context as _, Result};
use async_trait::async_trait;
use collections::{HashMap, HashSet};
use extension::{Extension, ExtensionLanguageServerProxy, WorktreeDelegate};
-use futures::{Future, FutureExt, future::join_all};
+use futures::{FutureExt, future::join_all, lock::OwnedMutexGuard};
use gpui::{App, AppContext, AsyncApp, Task};
use language::{
- BinaryStatus, CodeLabel, DynLspInstaller, HighlightId, Language, LanguageName, LspAdapter,
- LspAdapterDelegate, Toolchain,
+ BinaryStatus, CodeLabel, DynLspInstaller, HighlightId, Language, LanguageName,
+ LanguageServerBinaryLocations, LspAdapter, LspAdapterDelegate, Toolchain,
};
use lsp::{
CodeActionKind, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerName,
- LanguageServerSelector,
+ LanguageServerSelector, Uri,
};
use serde::Serialize;
use serde_json::Value;
@@ -155,47 +154,101 @@ impl ExtensionLspAdapter {
#[async_trait(?Send)]
impl DynLspInstaller for ExtensionLspAdapter {
- fn get_language_server_command<'a>(
+ fn get_language_server_command(
self: Arc<Self>,
delegate: Arc<dyn LspAdapterDelegate>,
_: Option<Toolchain>,
_: LanguageServerBinaryOptions,
- _: &'a mut Option<(bool, LanguageServerBinary)>,
- _: &'a mut AsyncApp,
- ) -> Pin<Box<dyn 'a + Future<Output = Result<LanguageServerBinary>>>> {
+ _: OwnedMutexGuard<Option<(bool, LanguageServerBinary)>>,
+ _: AsyncApp,
+ ) -> LanguageServerBinaryLocations {
async move {
- let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
- let command = self
- .extension
- .language_server_command(
- self.language_server_id.clone(),
- self.language_name.clone(),
- delegate,
- )
- .await?;
-
- let path = self.extension.path_from_extension(command.command.as_ref());
-
- // TODO: This should now be done via the `zed::make_file_executable` function in
- // Zed extension API, but we're leaving these existing usages in place temporarily
- // to avoid any compatibility issues between Zed and the extension versions.
- //
- // We can remove once the following extension versions no longer see any use:
- // - toml@0.0.2
- // - zig@0.0.1
- if ["toml", "zig"].contains(&self.extension.manifest().id.as_ref())
- && path.starts_with(&self.extension.work_dir())
- {
- make_file_executable(&path)
- .await
- .context("failed to set file permissions")?;
- }
+ let ret = maybe!(async move {
+ let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
+ let command = self
+ .extension
+ .language_server_command(
+ self.language_server_id.clone(),
+ self.language_name.clone(),
+ delegate,
+ )
+ .await?;
+
+ // on windows, extensions might produce weird paths
+ // that start with a leading slash due to WASI
+ // requiring that for PWD and friends so account for
+ // that here and try to transform those paths back
+ // to windows paths
+ //
+ // if we don't do this, std will interpret the path as relative,
+ // which changes join behavior
+ let command_path: &Path = if cfg!(windows)
+ && let Some(command) = command.command.to_str()
+ {
+ let mut chars = command.chars();
+ if chars.next().is_some_and(|c| c == '/')
+ && chars.next().is_some_and(|c| c.is_ascii_alphabetic())
+ && chars.next().is_some_and(|c| c == ':')
+ && chars.next().is_some_and(|c| c == '\\' || c == '/')
+ {
+ // looks like a windows path with a leading slash, so strip it
+ command.strip_prefix('/').unwrap().as_ref()
+ } else {
+ command.as_ref()
+ }
+ } else {
+ command.command.as_ref()
+ };
+ let path = self.extension.path_from_extension(command_path);
+
+ // TODO: This should now be done via the `zed::make_file_executable` function in
+ // Zed extension API, but we're leaving these existing usages in place temporarily
+ // to avoid any compatibility issues between Zed and the extension versions.
+ //
+ // We can remove once the following extension versions no longer see any use:
+ // - toml@0.0.2
+ // - zig@0.0.1
+ if ["toml", "zig"].contains(&self.extension.manifest().id.as_ref())
+ && path.starts_with(&self.extension.work_dir())
+ {
+ make_file_executable(&path)
+ .await
+ .context("failed to set file permissions")?;
+ }
- Ok(LanguageServerBinary {
- path,
- arguments: command.args.into_iter().map(|arg| arg.into()).collect(),
- env: Some(command.env.into_iter().collect()),
+ Ok(LanguageServerBinary {
+ path,
+ arguments: command
+ .args
+ .into_iter()
+ .map(|arg| {
+ // on windows, extensions might produce weird paths
+ // that start with a leading slash due to WASI
+ // requiring that for PWD and friends so account for
+ // that here and try to transform those paths back
+ // to windows paths
+ if cfg!(windows) {
+ let mut chars = arg.chars();
+ if chars.next().is_some_and(|c| c == '/')
+ && chars.next().is_some_and(|c| c.is_ascii_alphabetic())
+ && chars.next().is_some_and(|c| c == ':')
+ && chars.next().is_some_and(|c| c == '\\' || c == '/')
+ {
+ // looks like a windows path with a leading slash, so strip it
+ arg.strip_prefix('/').unwrap().into()
+ } else {
+ arg.into()
+ }
+ } else {
+ arg.into()
+ }
+ })
+ .collect(),
+ env: Some(command.env.into_iter().collect()),
+ })
})
+ .await;
+ (ret, None)
}
.boxed_local()
}
@@ -242,7 +295,7 @@ impl LspAdapter for ExtensionLspAdapter {
// We can remove once the following extension versions no longer see any use:
// - php@0.0.1
if self.extension.manifest().id.as_ref() == "php" {
- return HashMap::from_iter([(LanguageName::new("PHP"), "php".into())]);
+ return HashMap::from_iter([(LanguageName::new_static("PHP"), "php".into())]);
}
self.extension
@@ -279,6 +332,7 @@ impl LspAdapter for ExtensionLspAdapter {
self: Arc<Self>,
delegate: &Arc<dyn LspAdapterDelegate>,
_: Option<Toolchain>,
+ _: Option<Uri>,
_cx: &mut AsyncApp,
) -> Result<Value> {
let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
@@ -18,6 +18,7 @@ test-support = []
[dependencies]
anthropic = { workspace = true, features = ["schemars"] }
anyhow.workspace = true
+credentials_provider.workspace = true
base64.workspace = true
client.workspace = true
cloud_api_types.workspace = true
@@ -29,6 +30,7 @@ http_client.workspace = true
icons.workspace = true
image.workspace = true
log.workspace = true
+open_ai = { workspace = true, features = ["schemars"] }
open_router.workspace = true
parking_lot.workspace = true
proto.workspace = true
@@ -37,9 +39,9 @@ serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
-telemetry_events.workspace = true
thiserror.workspace = true
util.workspace = true
+zed_env_vars.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
@@ -2,7 +2,6 @@ use anyhow::{Result, anyhow};
use credentials_provider::CredentialsProvider;
use futures::{FutureExt, future};
use gpui::{AsyncApp, Context, SharedString, Task};
-use language_model::AuthenticateError;
use std::{
fmt::{Display, Formatter},
sync::Arc,
@@ -10,13 +9,16 @@ use std::{
use util::ResultExt as _;
use zed_env_vars::EnvVar;
+use crate::AuthenticateError;
+
/// Manages a single API key for a language model provider. API keys either come from environment
/// variables or the system keychain.
///
/// Keys from the system keychain are associated with a provider URL, and this ensures that they are
/// only used with that URL.
pub struct ApiKeyState {
- url: SharedString,
+ pub url: SharedString,
+ env_var: EnvVar,
load_status: LoadStatus,
load_task: Option<future::Shared<Task<()>>>,
}
@@ -35,9 +37,10 @@ pub struct ApiKey {
}
impl ApiKeyState {
- pub fn new(url: SharedString) -> Self {
+ pub fn new(url: SharedString, env_var: EnvVar) -> Self {
Self {
url,
+ env_var,
load_status: LoadStatus::NotPresent,
load_task: None,
}
@@ -47,6 +50,10 @@ impl ApiKeyState {
matches!(self.load_status, LoadStatus::Loaded { .. })
}
+ pub fn env_var_name(&self) -> &SharedString {
+ &self.env_var.name
+ }
+
pub fn is_from_env_var(&self) -> bool {
match &self.load_status {
LoadStatus::Loaded(ApiKey {
@@ -136,14 +143,13 @@ impl ApiKeyState {
pub fn handle_url_change<Ent: 'static>(
&mut self,
url: SharedString,
- env_var: &EnvVar,
get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static,
cx: &mut Context<Ent>,
) {
if url != self.url {
if !self.is_from_env_var() {
// loading will continue even though this result task is dropped
- let _task = self.load_if_needed(url, env_var, get_this, cx);
+ let _task = self.load_if_needed(url, get_this, cx);
}
}
}
@@ -156,7 +162,6 @@ impl ApiKeyState {
pub fn load_if_needed<Ent: 'static>(
&mut self,
url: SharedString,
- env_var: &EnvVar,
get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static,
cx: &mut Context<Ent>,
) -> Task<Result<(), AuthenticateError>> {
@@ -166,10 +171,10 @@ impl ApiKeyState {
return Task::ready(Ok(()));
}
- if let Some(key) = &env_var.value
+ if let Some(key) = &self.env_var.value
&& !key.is_empty()
{
- let api_key = ApiKey::from_env(env_var.name.clone(), key);
+ let api_key = ApiKey::from_env(self.env_var.name.clone(), key);
self.url = url;
self.load_status = LoadStatus::Loaded(api_key);
self.load_task = None;
@@ -1,3 +1,4 @@
+mod api_key;
mod model;
mod rate_limiter;
mod registry;
@@ -12,7 +13,7 @@ pub mod fake_provider;
use anthropic::{AnthropicError, parse_prompt_too_long};
use anyhow::{Result, anyhow};
use client::Client;
-use cloud_llm_client::{CompletionMode, CompletionRequestStatus};
+use cloud_llm_client::{CompletionMode, CompletionRequestStatus, UsageLimit};
use futures::FutureExt;
use futures::{StreamExt, future::BoxFuture, stream::BoxStream};
use gpui::{AnyView, App, AsyncApp, SharedString, Task, Window};
@@ -30,6 +31,7 @@ use std::{fmt, io};
use thiserror::Error;
use util::serde::is_default;
+pub use crate::api_key::{ApiKey, ApiKeyState};
pub use crate::model::*;
pub use crate::rate_limiter::*;
pub use crate::registry::*;
@@ -37,6 +39,7 @@ pub use crate::request::*;
pub use crate::role::*;
pub use crate::telemetry::*;
pub use crate::tool_schema::LanguageModelToolSchemaFormat;
+pub use zed_env_vars::{EnvVar, env_var};
pub const ANTHROPIC_PROVIDER_ID: LanguageModelProviderId =
LanguageModelProviderId::new("anthropic");
@@ -70,7 +73,15 @@ pub fn init_settings(cx: &mut App) {
/// A completion event from a language model.
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub enum LanguageModelCompletionEvent {
- StatusUpdate(CompletionRequestStatus),
+ Queued {
+ position: usize,
+ },
+ Started,
+ UsageUpdated {
+ amount: usize,
+ limit: UsageLimit,
+ },
+ ToolUseLimitReached,
Stop(StopReason),
Text(String),
Thinking {
@@ -90,9 +101,41 @@ pub enum LanguageModelCompletionEvent {
StartMessage {
message_id: String,
},
+ ReasoningDetails(serde_json::Value),
UsageUpdate(TokenUsage),
}
+impl LanguageModelCompletionEvent {
+ pub fn from_completion_request_status(
+ status: CompletionRequestStatus,
+ upstream_provider: LanguageModelProviderName,
+ ) -> Result<Self, LanguageModelCompletionError> {
+ match status {
+ CompletionRequestStatus::Queued { position } => {
+ Ok(LanguageModelCompletionEvent::Queued { position })
+ }
+ CompletionRequestStatus::Started => Ok(LanguageModelCompletionEvent::Started),
+ CompletionRequestStatus::UsageUpdated { amount, limit } => {
+ Ok(LanguageModelCompletionEvent::UsageUpdated { amount, limit })
+ }
+ CompletionRequestStatus::ToolUseLimitReached => {
+ Ok(LanguageModelCompletionEvent::ToolUseLimitReached)
+ }
+ CompletionRequestStatus::Failed {
+ code,
+ message,
+ request_id: _,
+ retry_after,
+ } => Err(LanguageModelCompletionError::from_cloud_failure(
+ upstream_provider,
+ code,
+ message,
+ retry_after.map(Duration::from_secs_f64),
+ )),
+ }
+ }
+}
+
#[derive(Error, Debug)]
pub enum LanguageModelCompletionError {
#[error("prompt too large for context window")]
@@ -138,7 +181,7 @@ pub enum LanguageModelCompletionError {
provider: LanguageModelProviderName,
message: String,
},
- #[error("permission error with {provider}'s API: {message}")]
+ #[error("Permission error with {provider}'s API: {message}")]
PermissionError {
provider: LanguageModelProviderName,
message: String,
@@ -345,6 +388,27 @@ impl From<anthropic::ApiError> for LanguageModelCompletionError {
}
}
+impl From<open_ai::RequestError> for LanguageModelCompletionError {
+ fn from(error: open_ai::RequestError) -> Self {
+ match error {
+ open_ai::RequestError::HttpResponseError {
+ provider,
+ status_code,
+ body,
+ headers,
+ } => {
+ let retry_after = headers
+ .get(http::header::RETRY_AFTER)
+ .and_then(|val| val.to_str().ok()?.parse::<u64>().ok())
+ .map(Duration::from_secs);
+
+ Self::from_http_status(provider.into(), status_code, body, retry_after)
+ }
+ open_ai::RequestError::Other(e) => Self::Other(e),
+ }
+ }
+}
+
impl From<OpenRouterError> for LanguageModelCompletionError {
fn from(error: OpenRouterError) -> Self {
let provider = LanguageModelProviderName::new("OpenRouter");
@@ -494,6 +558,9 @@ pub struct LanguageModelToolUse {
pub raw_input: String,
pub input: serde_json::Value,
pub is_input_complete: bool,
+ /// Thought signature the model sent us. Some models require that this
+ /// signature be preserved and sent back in conversation history for validation.
+ pub thought_signature: Option<String>,
}
pub struct LanguageModelTextStream {
@@ -545,6 +612,11 @@ pub trait LanguageModel: Send + Sync {
false
}
+ /// Returns whether this model or provider supports streaming tool calls;
+ fn supports_streaming_tools(&self) -> bool {
+ false
+ }
+
fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
LanguageModelToolSchemaFormat::JsonSchema
}
@@ -609,11 +681,15 @@ pub trait LanguageModel: Send + Sync {
let last_token_usage = last_token_usage.clone();
async move {
match result {
- Ok(LanguageModelCompletionEvent::StatusUpdate { .. }) => None,
+ Ok(LanguageModelCompletionEvent::Queued { .. }) => None,
+ Ok(LanguageModelCompletionEvent::Started) => None,
+ Ok(LanguageModelCompletionEvent::UsageUpdated { .. }) => None,
+ Ok(LanguageModelCompletionEvent::ToolUseLimitReached) => None,
Ok(LanguageModelCompletionEvent::StartMessage { .. }) => None,
Ok(LanguageModelCompletionEvent::Text(text)) => Some(Ok(text)),
Ok(LanguageModelCompletionEvent::Thinking { .. }) => None,
Ok(LanguageModelCompletionEvent::RedactedThinking { .. }) => None,
+ Ok(LanguageModelCompletionEvent::ReasoningDetails(_)) => None,
Ok(LanguageModelCompletionEvent::Stop(_)) => None,
Ok(LanguageModelCompletionEvent::ToolUse(_)) => None,
Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
@@ -639,6 +715,40 @@ pub trait LanguageModel: Send + Sync {
.boxed()
}
+ fn stream_completion_tool(
+ &self,
+ request: LanguageModelRequest,
+ cx: &AsyncApp,
+ ) -> BoxFuture<'static, Result<LanguageModelToolUse, LanguageModelCompletionError>> {
+ let future = self.stream_completion(request, cx);
+
+ async move {
+ let events = future.await?;
+ let mut events = events.fuse();
+
+ // Iterate through events until we find a complete ToolUse
+ while let Some(event) = events.next().await {
+ match event {
+ Ok(LanguageModelCompletionEvent::ToolUse(tool_use))
+ if tool_use.is_input_complete =>
+ {
+ return Ok(tool_use);
+ }
+ Err(err) => {
+ return Err(err);
+ }
+ _ => {}
+ }
+ }
+
+ // Stream ended without a complete tool use
+ Err(LanguageModelCompletionError::Other(anyhow::anyhow!(
+ "Stream ended without receiving a complete tool use"
+ )))
+ }
+ .boxed()
+ }
+
fn cache_configuration(&self) -> Option<LanguageModelCacheConfiguration> {
None
}
@@ -661,6 +771,21 @@ pub trait LanguageModelExt: LanguageModel {
}
impl LanguageModelExt for dyn LanguageModel {}
+impl std::fmt::Debug for dyn LanguageModel {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("<dyn LanguageModel>")
+ .field("id", &self.id())
+ .field("name", &self.name())
+ .field("provider_id", &self.provider_id())
+ .field("provider_name", &self.provider_name())
+ .field("upstream_provider_name", &self.upstream_provider_name())
+ .field("upstream_provider_id", &self.upstream_provider_id())
+ .field("upstream_provider_id", &self.upstream_provider_id())
+ .field("supports_streaming_tools", &self.supports_streaming_tools())
+ .finish()
+ }
+}
+
/// An error that occurred when trying to authenticate the language model provider.
#[derive(Debug, Error)]
pub enum AuthenticateError {
@@ -900,4 +1025,85 @@ mod tests {
),
}
}
+
+ #[test]
+ fn test_language_model_tool_use_serializes_with_signature() {
+ use serde_json::json;
+
+ let tool_use = LanguageModelToolUse {
+ id: LanguageModelToolUseId::from("test_id"),
+ name: "test_tool".into(),
+ raw_input: json!({"arg": "value"}).to_string(),
+ input: json!({"arg": "value"}),
+ is_input_complete: true,
+ thought_signature: Some("test_signature".to_string()),
+ };
+
+ let serialized = serde_json::to_value(&tool_use).unwrap();
+
+ assert_eq!(serialized["id"], "test_id");
+ assert_eq!(serialized["name"], "test_tool");
+ assert_eq!(serialized["thought_signature"], "test_signature");
+ }
+
+ #[test]
+ fn test_language_model_tool_use_deserializes_with_missing_signature() {
+ use serde_json::json;
+
+ let json = json!({
+ "id": "test_id",
+ "name": "test_tool",
+ "raw_input": "{\"arg\":\"value\"}",
+ "input": {"arg": "value"},
+ "is_input_complete": true
+ });
+
+ let tool_use: LanguageModelToolUse = serde_json::from_value(json).unwrap();
+
+ assert_eq!(tool_use.id, LanguageModelToolUseId::from("test_id"));
+ assert_eq!(tool_use.name.as_ref(), "test_tool");
+ assert_eq!(tool_use.thought_signature, None);
+ }
+
+ #[test]
+ fn test_language_model_tool_use_round_trip_with_signature() {
+ use serde_json::json;
+
+ let original = LanguageModelToolUse {
+ id: LanguageModelToolUseId::from("round_trip_id"),
+ name: "round_trip_tool".into(),
+ raw_input: json!({"key": "value"}).to_string(),
+ input: json!({"key": "value"}),
+ is_input_complete: true,
+ thought_signature: Some("round_trip_sig".to_string()),
+ };
+
+ let serialized = serde_json::to_value(&original).unwrap();
+ let deserialized: LanguageModelToolUse = serde_json::from_value(serialized).unwrap();
+
+ assert_eq!(deserialized.id, original.id);
+ assert_eq!(deserialized.name, original.name);
+ assert_eq!(deserialized.thought_signature, original.thought_signature);
+ }
+
+ #[test]
+ fn test_language_model_tool_use_round_trip_without_signature() {
+ use serde_json::json;
+
+ let original = LanguageModelToolUse {
+ id: LanguageModelToolUseId::from("no_sig_id"),
+ name: "no_sig_tool".into(),
+ raw_input: json!({"arg": "value"}).to_string(),
+ input: json!({"arg": "value"}),
+ is_input_complete: true,
+ thought_signature: None,
+ };
+
+ let serialized = serde_json::to_value(&original).unwrap();
+ let deserialized: LanguageModelToolUse = serde_json::from_value(serialized).unwrap();
+
+ assert_eq!(deserialized.id, original.id);
+ assert_eq!(deserialized.name, original.name);
+ assert_eq!(deserialized.thought_signature, None);
+ }
}
@@ -135,6 +135,11 @@ impl LanguageModelRegistry {
fake_provider
}
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn fake_model(&self) -> Arc<dyn LanguageModel> {
+ self.default_model.as_ref().unwrap().model.clone()
+ }
+
pub fn register_provider<T: LanguageModelProvider + LanguageModelProviderState>(
&mut self,
provider: Arc<T>,
@@ -19,7 +19,8 @@ use crate::{LanguageModelToolUse, LanguageModelToolUseId};
pub struct LanguageModelImage {
/// A base64-encoded PNG image.
pub source: SharedString,
- pub size: Size<DevicePixels>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub size: Option<Size<DevicePixels>>,
}
impl LanguageModelImage {
@@ -61,7 +62,7 @@ impl LanguageModelImage {
}
Some(Self {
- size: size(DevicePixels(width?), DevicePixels(height?)),
+ size: Some(size(DevicePixels(width?), DevicePixels(height?))),
source: SharedString::from(source.to_string()),
})
}
@@ -83,7 +84,7 @@ impl LanguageModelImage {
pub fn empty() -> Self {
Self {
source: "".into(),
- size: size(DevicePixels(0), DevicePixels(0)),
+ size: None,
}
}
@@ -139,15 +140,18 @@ impl LanguageModelImage {
let source = unsafe { String::from_utf8_unchecked(base64_image) };
Some(LanguageModelImage {
- size: image_size,
+ size: Some(image_size),
source: source.into(),
})
})
}
pub fn estimate_tokens(&self) -> usize {
- let width = self.size.width.0.unsigned_abs() as usize;
- let height = self.size.height.0.unsigned_abs() as usize;
+ let Some(size) = self.size.as_ref() else {
+ return 0;
+ };
+ let width = size.width.0.unsigned_abs() as usize;
+ let height = size.height.0.unsigned_abs() as usize;
// From: https://docs.anthropic.com/en/docs/build-with-claude/vision#calculate-image-costs
// Note that are a lot of conditions on Anthropic's API, and OpenAI doesn't use this,
@@ -357,6 +361,8 @@ pub struct LanguageModelRequestMessage {
pub role: Role,
pub content: Vec<MessageContent>,
pub cache: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reasoning_details: Option<serde_json::Value>,
}
impl LanguageModelRequestMessage {
@@ -461,8 +467,9 @@ mod tests {
match result {
LanguageModelToolResultContent::Image(image) => {
assert_eq!(image.source.as_ref(), "base64encodedimagedata");
- assert_eq!(image.size.width.0, 100);
- assert_eq!(image.size.height.0, 200);
+ let size = image.size.expect("size");
+ assert_eq!(size.width.0, 100);
+ assert_eq!(size.height.0, 200);
}
_ => panic!("Expected Image variant"),
}
@@ -481,8 +488,9 @@ mod tests {
match result {
LanguageModelToolResultContent::Image(image) => {
assert_eq!(image.source.as_ref(), "wrappedimagedata");
- assert_eq!(image.size.width.0, 50);
- assert_eq!(image.size.height.0, 75);
+ let size = image.size.expect("size");
+ assert_eq!(size.width.0, 50);
+ assert_eq!(size.height.0, 75);
}
_ => panic!("Expected Image variant"),
}
@@ -501,8 +509,9 @@ mod tests {
match result {
LanguageModelToolResultContent::Image(image) => {
assert_eq!(image.source.as_ref(), "caseinsensitive");
- assert_eq!(image.size.width.0, 30);
- assert_eq!(image.size.height.0, 40);
+ let size = image.size.expect("size");
+ assert_eq!(size.width.0, 30);
+ assert_eq!(size.height.0, 40);
}
_ => panic!("Expected Image variant"),
}
@@ -539,8 +548,9 @@ mod tests {
match result {
LanguageModelToolResultContent::Image(image) => {
assert_eq!(image.source.as_ref(), "directimage");
- assert_eq!(image.size.width.0, 200);
- assert_eq!(image.size.height.0, 300);
+ let size = image.size.expect("size");
+ assert_eq!(size.width.0, 200);
+ assert_eq!(size.height.0, 300);
}
_ => panic!("Expected Image variant"),
}
@@ -1,41 +1,101 @@
use crate::ANTHROPIC_PROVIDER_ID;
use anthropic::ANTHROPIC_API_URL;
use anyhow::{Context as _, anyhow};
-use client::telemetry::Telemetry;
use gpui::BackgroundExecutor;
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
use std::env;
use std::sync::Arc;
-use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
use util::ResultExt;
-pub fn report_assistant_event(
- event: AssistantEventData,
- telemetry: Option<Arc<Telemetry>>,
- client: Arc<dyn HttpClient>,
- model_api_key: Option<String>,
- executor: &BackgroundExecutor,
+#[derive(Clone, Debug)]
+pub struct AnthropicEventData {
+ pub completion_type: AnthropicCompletionType,
+ pub event: AnthropicEventType,
+ pub language_name: Option<String>,
+ pub message_id: Option<String>,
+}
+
+#[derive(Clone, Debug)]
+pub enum AnthropicCompletionType {
+ Editor,
+ Terminal,
+ Panel,
+}
+
+#[derive(Clone, Debug)]
+pub enum AnthropicEventType {
+ Invoked,
+ Response,
+ Accept,
+ Reject,
+}
+
+impl AnthropicCompletionType {
+ fn as_str(&self) -> &'static str {
+ match self {
+ Self::Editor => "natural_language_completion_in_editor",
+ Self::Terminal => "natural_language_completion_in_terminal",
+ Self::Panel => "conversation_message",
+ }
+ }
+}
+
+impl AnthropicEventType {
+ fn as_str(&self) -> &'static str {
+ match self {
+ Self::Invoked => "invoke",
+ Self::Response => "response",
+ Self::Accept => "accept",
+ Self::Reject => "reject",
+ }
+ }
+}
+
+pub fn report_anthropic_event(
+ model: &Arc<dyn crate::LanguageModel>,
+ event: AnthropicEventData,
+ cx: &gpui::App,
) {
- if let Some(telemetry) = telemetry.as_ref() {
- telemetry.report_assistant_event(event.clone());
- if telemetry.metrics_enabled() && event.model_provider == ANTHROPIC_PROVIDER_ID.0 {
- if let Some(api_key) = model_api_key {
- executor
- .spawn(async move {
- report_anthropic_event(event, client, api_key)
- .await
- .log_err();
- })
- .detach();
- } else {
- log::error!("Cannot send Anthropic telemetry because API key is missing");
- }
+ let reporter = AnthropicEventReporter::new(model, cx);
+ reporter.report(event);
+}
+
+#[derive(Clone)]
+pub struct AnthropicEventReporter {
+ http_client: Arc<dyn HttpClient>,
+ executor: BackgroundExecutor,
+ api_key: Option<String>,
+ is_anthropic: bool,
+}
+
+impl AnthropicEventReporter {
+ pub fn new(model: &Arc<dyn crate::LanguageModel>, cx: &gpui::App) -> Self {
+ Self {
+ http_client: cx.http_client(),
+ executor: cx.background_executor().clone(),
+ api_key: model.api_key(cx),
+ is_anthropic: model.provider_id() == ANTHROPIC_PROVIDER_ID,
}
}
+
+ pub fn report(&self, event: AnthropicEventData) {
+ if !self.is_anthropic {
+ return;
+ }
+ let Some(api_key) = self.api_key.clone() else {
+ return;
+ };
+ let client = self.http_client.clone();
+ self.executor
+ .spawn(async move {
+ send_anthropic_event(event, client, api_key).await.log_err();
+ })
+ .detach();
+ }
}
-async fn report_anthropic_event(
- event: AssistantEventData,
+async fn send_anthropic_event(
+ event: AnthropicEventData,
client: Arc<dyn HttpClient>,
api_key: String,
) -> anyhow::Result<()> {
@@ -45,18 +105,10 @@ async fn report_anthropic_event(
.uri(uri)
.header("X-Api-Key", api_key)
.header("Content-Type", "application/json");
- let serialized_event: serde_json::Value = serde_json::json!({
- "completion_type": match event.kind {
- AssistantKind::Inline => "natural_language_completion_in_editor",
- AssistantKind::InlineTerminal => "natural_language_completion_in_terminal",
- AssistantKind::Panel => "conversation_message",
- },
- "event": match event.phase {
- AssistantPhase::Response => "response",
- AssistantPhase::Invoked => "invoke",
- AssistantPhase::Accepted => "accept",
- AssistantPhase::Rejected => "reject",
- },
+
+ let serialized_event = serde_json::json!({
+ "completion_type": event.completion_type.as_str(),
+ "event": event.event.as_str(),
"metadata": {
"language_name": event.language_name,
"message_id": event.message_id,
@@ -46,6 +46,7 @@ open_router = { workspace = true, features = ["schemars"] }
partial-json-fixer.workspace = true
release_channel.workspace = true
schemars.workspace = true
+semver.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
@@ -59,7 +60,6 @@ ui_input.workspace = true
util.workspace = true
vercel = { workspace = true, features = ["schemars"] }
x_ai = { workspace = true, features = ["schemars"] }
-zed_env_vars.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }
@@ -7,10 +7,8 @@ use gpui::{App, Context, Entity};
use language_model::{LanguageModelProviderId, LanguageModelRegistry};
use provider::deepseek::DeepSeekLanguageModelProvider;
-mod api_key;
pub mod provider;
mod settings;
-pub mod ui;
use crate::provider::anthropic::AnthropicLanguageModelProvider;
use crate::provider::bedrock::BedrockLanguageModelProvider;
@@ -1,6 +1,6 @@
use anthropic::{
- ANTHROPIC_API_URL, AnthropicError, AnthropicModelMode, ContentDelta, Event, ResponseContent,
- ToolResultContent, ToolResultPart, Usage,
+ ANTHROPIC_API_URL, AnthropicError, AnthropicModelMode, ContentDelta, CountTokensRequest, Event,
+ ResponseContent, ToolResultContent, ToolResultPart, Usage,
};
use anyhow::{Result, anyhow};
use collections::{BTreeMap, HashMap};
@@ -8,25 +8,21 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::B
use gpui::{AnyView, App, AsyncApp, Context, Entity, Task};
use http_client::HttpClient;
use language_model::{
- AuthenticateError, ConfigurationViewTargetAgent, LanguageModel,
- LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelId,
- LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
- LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
- LanguageModelToolResultContent, MessageContent, RateLimiter, Role,
+ ApiKeyState, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModel,
+ LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent,
+ LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+ LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
+ LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
+ RateLimiter, Role, StopReason, env_var,
};
-use language_model::{LanguageModelCompletionEvent, LanguageModelToolUse, StopReason};
use settings::{Settings, SettingsStore};
use std::pin::Pin;
use std::str::FromStr;
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::api_key::ApiKeyState;
-use crate::ui::{ConfiguredApiCard, InstructionListItem};
pub use settings::AnthropicAvailableModel as AvailableModel;
@@ -65,12 +61,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = AnthropicLanguageModelProvider::api_url(cx);
- self.api_key_state.load_if_needed(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- )
+ self.api_key_state
+ .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -79,17 +71,13 @@ impl AnthropicLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
- this.api_key_state.handle_url_change(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- );
+ this.api_key_state
+ .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
- api_key_state: ApiKeyState::new(Self::api_url(cx)),
+ api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
});
@@ -231,68 +219,215 @@ pub struct AnthropicModel {
request_limiter: RateLimiter,
}
-pub fn count_anthropic_tokens(
+/// Convert a LanguageModelRequest to an Anthropic CountTokensRequest.
+pub fn into_anthropic_count_tokens_request(
request: LanguageModelRequest,
- cx: &App,
-) -> BoxFuture<'static, Result<u64>> {
- cx.background_spawn(async move {
- let messages = request.messages;
- let mut tokens_from_images = 0;
- let mut string_messages = Vec::with_capacity(messages.len());
-
- for message in messages {
- use language_model::MessageContent;
-
- let mut string_contents = String::new();
-
- for content in message.content {
- match content {
- MessageContent::Text(text) => {
- string_contents.push_str(&text);
- }
- MessageContent::Thinking { .. } => {
- // Thinking blocks are not included in the input token count.
- }
- MessageContent::RedactedThinking(_) => {
- // Thinking blocks are not included in the input token count.
- }
- MessageContent::Image(image) => {
- tokens_from_images += image.estimate_tokens();
- }
- MessageContent::ToolUse(_tool_use) => {
- // TODO: Estimate token usage from tool uses.
- }
- MessageContent::ToolResult(tool_result) => match &tool_result.content {
- LanguageModelToolResultContent::Text(text) => {
- string_contents.push_str(text);
+ model: String,
+ mode: AnthropicModelMode,
+) -> CountTokensRequest {
+ let mut new_messages: Vec<anthropic::Message> = Vec::new();
+ let mut system_message = String::new();
+
+ for message in request.messages {
+ if message.contents_empty() {
+ continue;
+ }
+
+ match message.role {
+ Role::User | Role::Assistant => {
+ let anthropic_message_content: Vec<anthropic::RequestContent> = message
+ .content
+ .into_iter()
+ .filter_map(|content| match content {
+ MessageContent::Text(text) => {
+ let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) {
+ text.trim_end().to_string()
+ } else {
+ text
+ };
+ if !text.is_empty() {
+ Some(anthropic::RequestContent::Text {
+ text,
+ cache_control: None,
+ })
+ } else {
+ None
+ }
}
- LanguageModelToolResultContent::Image(image) => {
- tokens_from_images += image.estimate_tokens();
+ MessageContent::Thinking {
+ text: thinking,
+ signature,
+ } => {
+ if !thinking.is_empty() {
+ Some(anthropic::RequestContent::Thinking {
+ thinking,
+ signature: signature.unwrap_or_default(),
+ cache_control: None,
+ })
+ } else {
+ None
+ }
+ }
+ MessageContent::RedactedThinking(data) => {
+ if !data.is_empty() {
+ Some(anthropic::RequestContent::RedactedThinking { data })
+ } else {
+ None
+ }
+ }
+ MessageContent::Image(image) => Some(anthropic::RequestContent::Image {
+ source: anthropic::ImageSource {
+ source_type: "base64".to_string(),
+ media_type: "image/png".to_string(),
+ data: image.source.to_string(),
+ },
+ cache_control: None,
+ }),
+ MessageContent::ToolUse(tool_use) => {
+ Some(anthropic::RequestContent::ToolUse {
+ id: tool_use.id.to_string(),
+ name: tool_use.name.to_string(),
+ input: tool_use.input,
+ cache_control: None,
+ })
}
- },
+ MessageContent::ToolResult(tool_result) => {
+ Some(anthropic::RequestContent::ToolResult {
+ tool_use_id: tool_result.tool_use_id.to_string(),
+ is_error: tool_result.is_error,
+ content: match tool_result.content {
+ LanguageModelToolResultContent::Text(text) => {
+ ToolResultContent::Plain(text.to_string())
+ }
+ LanguageModelToolResultContent::Image(image) => {
+ ToolResultContent::Multipart(vec![ToolResultPart::Image {
+ source: anthropic::ImageSource {
+ source_type: "base64".to_string(),
+ media_type: "image/png".to_string(),
+ data: image.source.to_string(),
+ },
+ }])
+ }
+ },
+ cache_control: None,
+ })
+ }
+ })
+ .collect();
+ let anthropic_role = match message.role {
+ Role::User => anthropic::Role::User,
+ Role::Assistant => anthropic::Role::Assistant,
+ Role::System => unreachable!("System role should never occur here"),
+ };
+ if let Some(last_message) = new_messages.last_mut()
+ && last_message.role == anthropic_role
+ {
+ last_message.content.extend(anthropic_message_content);
+ continue;
}
- }
- if !string_contents.is_empty() {
- string_messages.push(tiktoken_rs::ChatCompletionRequestMessage {
- role: match message.role {
- Role::User => "user".into(),
- Role::Assistant => "assistant".into(),
- Role::System => "system".into(),
- },
- content: Some(string_contents),
- name: None,
- function_call: None,
+ new_messages.push(anthropic::Message {
+ role: anthropic_role,
+ content: anthropic_message_content,
});
}
+ Role::System => {
+ if !system_message.is_empty() {
+ system_message.push_str("\n\n");
+ }
+ system_message.push_str(&message.string_contents());
+ }
+ }
+ }
+
+ CountTokensRequest {
+ model,
+ messages: new_messages,
+ system: if system_message.is_empty() {
+ None
+ } else {
+ Some(anthropic::StringOrContents::String(system_message))
+ },
+ thinking: if request.thinking_allowed
+ && let AnthropicModelMode::Thinking { budget_tokens } = mode
+ {
+ Some(anthropic::Thinking::Enabled { budget_tokens })
+ } else {
+ None
+ },
+ tools: request
+ .tools
+ .into_iter()
+ .map(|tool| anthropic::Tool {
+ name: tool.name,
+ description: tool.description,
+ input_schema: tool.input_schema,
+ })
+ .collect(),
+ tool_choice: request.tool_choice.map(|choice| match choice {
+ LanguageModelToolChoice::Auto => anthropic::ToolChoice::Auto,
+ LanguageModelToolChoice::Any => anthropic::ToolChoice::Any,
+ LanguageModelToolChoice::None => anthropic::ToolChoice::None,
+ }),
+ }
+}
+
+/// Estimate tokens using tiktoken. Used as a fallback when the API is unavailable,
+/// or by providers (like Zed Cloud) that don't have direct Anthropic API access.
+pub fn count_anthropic_tokens_with_tiktoken(request: LanguageModelRequest) -> Result<u64> {
+ let messages = request.messages;
+ let mut tokens_from_images = 0;
+ let mut string_messages = Vec::with_capacity(messages.len());
+
+ for message in messages {
+ let mut string_contents = String::new();
+
+ for content in message.content {
+ match content {
+ MessageContent::Text(text) => {
+ string_contents.push_str(&text);
+ }
+ MessageContent::Thinking { .. } => {
+ // Thinking blocks are not included in the input token count.
+ }
+ MessageContent::RedactedThinking(_) => {
+ // Thinking blocks are not included in the input token count.
+ }
+ MessageContent::Image(image) => {
+ tokens_from_images += image.estimate_tokens();
+ }
+ MessageContent::ToolUse(_tool_use) => {
+ // TODO: Estimate token usage from tool uses.
+ }
+ MessageContent::ToolResult(tool_result) => match &tool_result.content {
+ LanguageModelToolResultContent::Text(text) => {
+ string_contents.push_str(text);
+ }
+ LanguageModelToolResultContent::Image(image) => {
+ tokens_from_images += image.estimate_tokens();
+ }
+ },
+ }
}
- // Tiktoken doesn't yet support these models, so we manually use the
- // same tokenizer as GPT-4.
- tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages)
- .map(|tokens| (tokens + tokens_from_images) as u64)
- })
- .boxed()
+ if !string_contents.is_empty() {
+ string_messages.push(tiktoken_rs::ChatCompletionRequestMessage {
+ role: match message.role {
+ Role::User => "user".into(),
+ Role::Assistant => "assistant".into(),
+ Role::System => "system".into(),
+ },
+ content: Some(string_contents),
+ name: None,
+ function_call: None,
+ });
+ }
+ }
+
+ // Tiktoken doesn't yet support these models, so we manually use the
+ // same tokenizer as GPT-4.
+ tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages)
+ .map(|tokens| (tokens + tokens_from_images) as u64)
}
impl AnthropicModel {
@@ -362,6 +497,10 @@ impl LanguageModel for AnthropicModel {
true
}
+ fn supports_streaming_tools(&self) -> bool {
+ true
+ }
+
fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
match choice {
LanguageModelToolChoice::Auto
@@ -394,7 +533,40 @@ impl LanguageModel for AnthropicModel {
request: LanguageModelRequest,
cx: &App,
) -> BoxFuture<'static, Result<u64>> {
- count_anthropic_tokens(request, cx)
+ let http_client = self.http_client.clone();
+ let model_id = self.model.request_id().to_string();
+ let mode = self.model.mode();
+
+ let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
+ let api_url = AnthropicLanguageModelProvider::api_url(cx);
+ (
+ state.api_key_state.key(&api_url).map(|k| k.to_string()),
+ api_url.to_string(),
+ )
+ });
+
+ async move {
+ // If no API key, fall back to tiktoken estimation
+ let Some(api_key) = api_key else {
+ return count_anthropic_tokens_with_tiktoken(request);
+ };
+
+ let count_request =
+ into_anthropic_count_tokens_request(request.clone(), model_id, mode);
+
+ match anthropic::count_tokens(http_client.as_ref(), &api_url, &api_key, count_request)
+ .await
+ {
+ Ok(response) => Ok(response.input_tokens),
+ Err(err) => {
+ log::error!(
+ "Anthropic count_tokens API failed, falling back to tiktoken: {err:?}"
+ );
+ count_anthropic_tokens_with_tiktoken(request)
+ }
+ }
+ }
+ .boxed()
}
fn stream_completion(
@@ -711,6 +883,7 @@ impl AnthropicEventMapper {
is_input_complete: false,
raw_input: tool_use.input_json.clone(),
input,
+ thought_signature: None,
},
))];
}
@@ -734,6 +907,7 @@ impl AnthropicEventMapper {
is_input_complete: true,
input,
raw_input: tool_use.input_json.clone(),
+ thought_signature: None,
},
)),
Err(json_parse_err) => {
@@ -935,14 +1109,12 @@ impl Render for ConfigurationView {
.child(
List::new()
.child(
- InstructionListItem::new(
- "Create one by visiting",
- Some("Anthropic's settings"),
- Some("https://console.anthropic.com/settings/keys")
- )
+ ListBulletItem::new("")
+ .child(Label::new("Create one by visiting"))
+ .child(ButtonLink::new("Anthropic's settings", "https://console.anthropic.com/settings/keys"))
)
.child(
- InstructionListItem::text_only("Paste your API key below and hit enter to start using the agent")
+ ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
)
)
.child(self.api_key_editor.clone())
@@ -951,7 +1123,8 @@ impl Render for ConfigurationView {
format!("You can also assign the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."),
)
.size(LabelSize::Small)
- .color(Color::Muted),
+ .color(Color::Muted)
+ .mt_0p5(),
)
.into_any_element()
} else {
@@ -987,6 +1160,7 @@ mod tests {
MessageContent::Image(language_model::LanguageModelImage::empty()),
],
cache: true,
+ reasoning_details: None,
}],
thread_id: None,
prompt_id: None,
@@ -2,11 +2,10 @@ use std::pin::Pin;
use std::str::FromStr;
use std::sync::Arc;
-use crate::ui::{ConfiguredApiCard, InstructionListItem};
use anyhow::{Context as _, Result, anyhow};
use aws_config::stalled_stream_protection::StalledStreamProtectionConfig;
use aws_config::{BehaviorVersion, Region};
-use aws_credential_types::Credentials;
+use aws_credential_types::{Credentials, Token};
use aws_http_client::AwsHttpClient;
use bedrock::bedrock_client::Client as BedrockClient;
use bedrock::bedrock_client::config::timeout::TimeoutConfig;
@@ -24,38 +23,84 @@ use bedrock::{
use collections::{BTreeMap, HashMap};
use credentials_provider::CredentialsProvider;
use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, FontWeight, Subscription, Task};
+use gpui::{
+ AnyView, App, AsyncApp, Context, Entity, FocusHandle, FontWeight, Subscription, Task, Window,
+ actions,
+};
use gpui_tokio::Tokio;
use http_client::HttpClient;
use language_model::{
- AuthenticateError, LanguageModel, LanguageModelCacheConfiguration,
+ AuthenticateError, EnvVar, LanguageModel, LanguageModelCacheConfiguration,
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role,
- TokenUsage,
+ TokenUsage, env_var,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use settings::{BedrockAvailableModel as AvailableModel, Settings, SettingsStore};
use smol::lock::OnceCell;
+use std::sync::LazyLock;
use strum::{EnumIter, IntoEnumIterator, IntoStaticStr};
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use crate::AllLanguageModelSettings;
+actions!(bedrock, [Tab, TabPrev]);
+
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("amazon-bedrock");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Amazon Bedrock");
+/// Credentials stored in the keychain for static authentication.
+/// Region is handled separately since it's orthogonal to auth method.
#[derive(Default, Clone, Deserialize, Serialize, PartialEq, Debug)]
pub struct BedrockCredentials {
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: Option<String>,
- pub region: String,
+ pub bearer_token: Option<String>,
+}
+
+/// Resolved authentication configuration for Bedrock.
+/// Settings take priority over UX-provided credentials.
+#[derive(Clone, Debug, PartialEq)]
+pub enum BedrockAuth {
+ /// Use default AWS credential provider chain (IMDSv2, PodIdentity, env vars, etc.)
+ Automatic,
+ /// Use AWS named profile from ~/.aws/credentials or ~/.aws/config
+ NamedProfile { profile_name: String },
+ /// Use AWS SSO profile
+ SingleSignOn { profile_name: String },
+ /// Use IAM credentials (access key + secret + optional session token)
+ IamCredentials {
+ access_key_id: String,
+ secret_access_key: String,
+ session_token: Option<String>,
+ },
+ /// Use Bedrock API Key (bearer token authentication)
+ ApiKey { api_key: String },
+}
+
+impl BedrockCredentials {
+ /// Convert stored credentials to the appropriate auth variant.
+ /// Prefers API key if present, otherwise uses IAM credentials.
+ fn into_auth(self) -> Option<BedrockAuth> {
+ if let Some(api_key) = self.bearer_token.filter(|t| !t.is_empty()) {
+ Some(BedrockAuth::ApiKey { api_key })
+ } else if !self.access_key_id.is_empty() && !self.secret_access_key.is_empty() {
+ Some(BedrockAuth::IamCredentials {
+ access_key_id: self.access_key_id,
+ secret_access_key: self.secret_access_key,
+ session_token: self.session_token.filter(|t| !t.is_empty()),
+ })
+ } else {
+ None
+ }
+ }
}
#[derive(Default, Clone, Debug, PartialEq)]
@@ -66,6 +111,7 @@ pub struct AmazonBedrockSettings {
pub profile_name: Option<String>,
pub role_arn: Option<String>,
pub authentication_method: Option<BedrockAuthMethod>,
+ pub allow_global: Option<bool>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, EnumIter, IntoStaticStr, JsonSchema)]
@@ -74,6 +120,8 @@ pub enum BedrockAuthMethod {
NamedProfile,
#[serde(rename = "sso")]
SingleSignOn,
+ #[serde(rename = "api_key")]
+ ApiKey,
/// IMDSv2, PodIdentity, env vars, etc.
#[serde(rename = "default")]
Automatic,
@@ -85,6 +133,7 @@ impl From<settings::BedrockAuthMethodContent> for BedrockAuthMethod {
settings::BedrockAuthMethodContent::SingleSignOn => BedrockAuthMethod::SingleSignOn,
settings::BedrockAuthMethodContent::Automatic => BedrockAuthMethod::Automatic,
settings::BedrockAuthMethodContent::NamedProfile => BedrockAuthMethod::NamedProfile,
+ settings::BedrockAuthMethodContent::ApiKey => BedrockAuthMethod::ApiKey,
}
}
}
@@ -125,23 +174,26 @@ impl From<BedrockModelMode> for ModelMode {
const AMAZON_AWS_URL: &str = "https://amazonaws.com";
// These environment variables all use a `ZED_` prefix because we don't want to overwrite the user's AWS credentials.
-const ZED_BEDROCK_ACCESS_KEY_ID_VAR: &str = "ZED_ACCESS_KEY_ID";
-const ZED_BEDROCK_SECRET_ACCESS_KEY_VAR: &str = "ZED_SECRET_ACCESS_KEY";
-const ZED_BEDROCK_SESSION_TOKEN_VAR: &str = "ZED_SESSION_TOKEN";
-const ZED_AWS_PROFILE_VAR: &str = "ZED_AWS_PROFILE";
-const ZED_BEDROCK_REGION_VAR: &str = "ZED_AWS_REGION";
-const ZED_AWS_CREDENTIALS_VAR: &str = "ZED_AWS_CREDENTIALS";
-const ZED_AWS_ENDPOINT_VAR: &str = "ZED_AWS_ENDPOINT";
+static ZED_BEDROCK_ACCESS_KEY_ID_VAR: LazyLock<EnvVar> = env_var!("ZED_ACCESS_KEY_ID");
+static ZED_BEDROCK_SECRET_ACCESS_KEY_VAR: LazyLock<EnvVar> = env_var!("ZED_SECRET_ACCESS_KEY");
+static ZED_BEDROCK_SESSION_TOKEN_VAR: LazyLock<EnvVar> = env_var!("ZED_SESSION_TOKEN");
+static ZED_AWS_PROFILE_VAR: LazyLock<EnvVar> = env_var!("ZED_AWS_PROFILE");
+static ZED_BEDROCK_REGION_VAR: LazyLock<EnvVar> = env_var!("ZED_AWS_REGION");
+static ZED_AWS_ENDPOINT_VAR: LazyLock<EnvVar> = env_var!("ZED_AWS_ENDPOINT");
+static ZED_BEDROCK_BEARER_TOKEN_VAR: LazyLock<EnvVar> = env_var!("ZED_BEDROCK_BEARER_TOKEN");
pub struct State {
- credentials: Option<BedrockCredentials>,
+ /// The resolved authentication method. Settings take priority over UX credentials.
+ auth: Option<BedrockAuth>,
+ /// Raw settings from settings.json
settings: Option<AmazonBedrockSettings>,
+ /// Whether credentials came from environment variables (only relevant for static credentials)
credentials_from_env: bool,
_subscription: Subscription,
}
impl State {
- fn reset_credentials(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
+ fn reset_auth(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
let credentials_provider = <dyn CredentialsProvider>::global(cx);
cx.spawn(async move |this, cx| {
credentials_provider
@@ -149,19 +201,19 @@ impl State {
.await
.log_err();
this.update(cx, |this, cx| {
- this.credentials = None;
+ this.auth = None;
this.credentials_from_env = false;
- this.settings = None;
cx.notify();
})
})
}
- fn set_credentials(
+ fn set_static_credentials(
&mut self,
credentials: BedrockCredentials,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
+ let auth = credentials.clone().into_auth();
let credentials_provider = <dyn CredentialsProvider>::global(cx);
cx.spawn(async move |this, cx| {
credentials_provider
@@ -173,50 +225,131 @@ impl State {
)
.await?;
this.update(cx, |this, cx| {
- this.credentials = Some(credentials);
+ this.auth = auth;
+ this.credentials_from_env = false;
cx.notify();
})
})
}
fn is_authenticated(&self) -> bool {
- let derived = self
- .settings
- .as_ref()
- .and_then(|s| s.authentication_method.as_ref());
- let creds = self.credentials.as_ref();
-
- derived.is_some() || creds.is_some()
+ self.auth.is_some()
}
+ /// Resolve authentication. Settings take priority over UX-provided credentials.
fn authenticate(&self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
if self.is_authenticated() {
return Task::ready(Ok(()));
}
+ // Step 1: Check if settings specify an auth method (enterprise control)
+ if let Some(settings) = &self.settings {
+ if let Some(method) = &settings.authentication_method {
+ let profile_name = settings
+ .profile_name
+ .clone()
+ .unwrap_or_else(|| "default".to_string());
+
+ let auth = match method {
+ BedrockAuthMethod::Automatic => BedrockAuth::Automatic,
+ BedrockAuthMethod::NamedProfile => BedrockAuth::NamedProfile { profile_name },
+ BedrockAuthMethod::SingleSignOn => BedrockAuth::SingleSignOn { profile_name },
+ BedrockAuthMethod::ApiKey => {
+ // ApiKey method means "use static credentials from keychain/env"
+ // Fall through to load them below
+ return self.load_static_credentials(cx);
+ }
+ };
+
+ return cx.spawn(async move |this, cx| {
+ this.update(cx, |this, cx| {
+ this.auth = Some(auth);
+ this.credentials_from_env = false;
+ cx.notify();
+ })?;
+ Ok(())
+ });
+ }
+ }
+
+ // Step 2: No settings auth method - try to load static credentials
+ self.load_static_credentials(cx)
+ }
+
+ /// Load static credentials from environment variables or keychain.
+ fn load_static_credentials(
+ &self,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<(), AuthenticateError>> {
let credentials_provider = <dyn CredentialsProvider>::global(cx);
cx.spawn(async move |this, cx| {
- let (credentials, from_env) =
- if let Ok(credentials) = std::env::var(ZED_AWS_CREDENTIALS_VAR) {
- (credentials, true)
- } else {
- let (_, credentials) = credentials_provider
- .read_credentials(AMAZON_AWS_URL, cx)
- .await?
- .ok_or_else(|| AuthenticateError::CredentialsNotFound)?;
+ // Try environment variables first
+ let (auth, from_env) = if let Some(bearer_token) = &ZED_BEDROCK_BEARER_TOKEN_VAR.value {
+ if !bearer_token.is_empty() {
(
- String::from_utf8(credentials)
- .context("invalid {PROVIDER_NAME} credentials")?,
- false,
+ Some(BedrockAuth::ApiKey {
+ api_key: bearer_token.to_string(),
+ }),
+ true,
)
- };
+ } else {
+ (None, false)
+ }
+ } else if let Some(access_key_id) = &ZED_BEDROCK_ACCESS_KEY_ID_VAR.value {
+ if let Some(secret_access_key) = &ZED_BEDROCK_SECRET_ACCESS_KEY_VAR.value {
+ if !access_key_id.is_empty() && !secret_access_key.is_empty() {
+ let session_token = ZED_BEDROCK_SESSION_TOKEN_VAR
+ .value
+ .as_deref()
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string());
+ (
+ Some(BedrockAuth::IamCredentials {
+ access_key_id: access_key_id.to_string(),
+ secret_access_key: secret_access_key.to_string(),
+ session_token,
+ }),
+ true,
+ )
+ } else {
+ (None, false)
+ }
+ } else {
+ (None, false)
+ }
+ } else {
+ (None, false)
+ };
+
+ // If we got auth from env vars, use it
+ if let Some(auth) = auth {
+ this.update(cx, |this, cx| {
+ this.auth = Some(auth);
+ this.credentials_from_env = from_env;
+ cx.notify();
+ })?;
+ return Ok(());
+ }
+
+ // Try keychain
+ let (_, credentials_bytes) = credentials_provider
+ .read_credentials(AMAZON_AWS_URL, cx)
+ .await?
+ .ok_or(AuthenticateError::CredentialsNotFound)?;
+
+ let credentials_str = String::from_utf8(credentials_bytes)
+ .context("invalid {PROVIDER_NAME} credentials")?;
let credentials: BedrockCredentials =
- serde_json::from_str(&credentials).context("failed to parse credentials")?;
+ serde_json::from_str(&credentials_str).context("failed to parse credentials")?;
+
+ let auth = credentials
+ .into_auth()
+ .ok_or(AuthenticateError::CredentialsNotFound)?;
this.update(cx, |this, cx| {
- this.credentials = Some(credentials);
- this.credentials_from_env = from_env;
+ this.auth = Some(auth);
+ this.credentials_from_env = false;
cx.notify();
})?;
@@ -224,15 +357,26 @@ impl State {
})
}
+ /// Get the resolved region. Checks env var, then settings, then defaults to us-east-1.
fn get_region(&self) -> String {
- // Get region - from credentials or directly from settings
- let credentials_region = self.credentials.as_ref().map(|s| s.region.clone());
- let settings_region = self.settings.as_ref().and_then(|s| s.region.clone());
-
- // Use credentials region if available, otherwise use settings region, finally fall back to default
- credentials_region
- .or(settings_region)
- .unwrap_or(String::from("us-east-1"))
+ // Priority: env var > settings > default
+ if let Some(region) = ZED_BEDROCK_REGION_VAR.value.as_deref() {
+ if !region.is_empty() {
+ return region.to_string();
+ }
+ }
+
+ self.settings
+ .as_ref()
+ .and_then(|s| s.region.clone())
+ .unwrap_or_else(|| "us-east-1".to_string())
+ }
+
+ fn get_allow_global(&self) -> bool {
+ self.settings
+ .as_ref()
+ .and_then(|s| s.allow_global)
+ .unwrap_or(false)
}
}
@@ -245,7 +389,7 @@ pub struct BedrockLanguageModelProvider {
impl BedrockLanguageModelProvider {
pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
let state = cx.new(|cx| State {
- credentials: None,
+ auth: None,
settings: Some(AllLanguageModelSettings::get_global(cx).bedrock.clone()),
credentials_from_env: false,
_subscription: cx.observe_global::<SettingsStore>(|_, cx| {
@@ -254,7 +398,7 @@ impl BedrockLanguageModelProvider {
});
Self {
- http_client: AwsHttpClient::new(http_client.clone()),
+ http_client: AwsHttpClient::new(http_client),
handle: Tokio::handle(cx),
state,
}
@@ -300,7 +444,6 @@ impl LanguageModelProvider for BedrockLanguageModelProvider {
for model in bedrock::Model::iter() {
if !matches!(model, bedrock::Model::Custom { .. }) {
- // TODO: Sonnet 3.7 vs. 3.7 Thinking bug is here.
models.insert(model.id().to_string(), model);
}
}
@@ -354,8 +497,7 @@ impl LanguageModelProvider for BedrockLanguageModelProvider {
}
fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
- self.state
- .update(cx, |state, cx| state.reset_credentials(cx))
+ self.state.update(cx, |state, cx| state.reset_auth(cx))
}
}
@@ -381,25 +523,11 @@ impl BedrockModel {
fn get_or_init_client(&self, cx: &AsyncApp) -> anyhow::Result<&BedrockClient> {
self.client
.get_or_try_init_blocking(|| {
- let (auth_method, credentials, endpoint, region, settings) =
- cx.read_entity(&self.state, |state, _cx| {
- let auth_method = state
- .settings
- .as_ref()
- .and_then(|s| s.authentication_method.clone());
-
- let endpoint = state.settings.as_ref().and_then(|s| s.endpoint.clone());
-
- let region = state.get_region();
-
- (
- auth_method,
- state.credentials.clone(),
- endpoint,
- region,
- state.settings.clone(),
- )
- })?;
+ let (auth, endpoint, region) = cx.read_entity(&self.state, |state, _cx| {
+ let endpoint = state.settings.as_ref().and_then(|s| s.endpoint.clone());
+ let region = state.get_region();
+ (state.auth.clone(), endpoint, region)
+ })?;
let mut config_builder = aws_config::defaults(BehaviorVersion::latest())
.stalled_stream_protection(StalledStreamProtectionConfig::disabled())
@@ -413,37 +541,39 @@ impl BedrockModel {
config_builder = config_builder.endpoint_url(endpoint_url);
}
- match auth_method {
- None => {
- if let Some(creds) = credentials {
- let aws_creds = Credentials::new(
- creds.access_key_id,
- creds.secret_access_key,
- creds.session_token,
- None,
- "zed-bedrock-provider",
- );
- config_builder = config_builder.credentials_provider(aws_creds);
- }
+ match auth {
+ Some(BedrockAuth::Automatic) | None => {
+ // Use default AWS credential provider chain
}
- Some(BedrockAuthMethod::NamedProfile)
- | Some(BedrockAuthMethod::SingleSignOn) => {
- // Currently NamedProfile and SSO behave the same way but only the instructions change
- // Until we support BearerAuth through SSO, this will not change.
- let profile_name = settings
- .and_then(|s| s.profile_name)
- .unwrap_or_else(|| "default".to_string());
-
+ Some(BedrockAuth::NamedProfile { profile_name })
+ | Some(BedrockAuth::SingleSignOn { profile_name }) => {
if !profile_name.is_empty() {
config_builder = config_builder.profile_name(profile_name);
}
}
- Some(BedrockAuthMethod::Automatic) => {
- // Use default credential provider chain
+ Some(BedrockAuth::IamCredentials {
+ access_key_id,
+ secret_access_key,
+ session_token,
+ }) => {
+ let aws_creds = Credentials::new(
+ access_key_id,
+ secret_access_key,
+ session_token,
+ None,
+ "zed-bedrock-provider",
+ );
+ config_builder = config_builder.credentials_provider(aws_creds);
+ }
+ Some(BedrockAuth::ApiKey { api_key }) => {
+ config_builder = config_builder
+ .auth_scheme_preference(["httpBearerAuth".into()]) // https://github.com/smithy-lang/smithy-rs/pull/4241
+ .token_provider(Token::new(api_key, None));
}
}
let config = self.handle.block_on(config_builder.load());
+
anyhow::Ok(BedrockClient::new(&config))
})
.context("initializing Bedrock client")?;
@@ -540,11 +670,13 @@ impl LanguageModel for BedrockModel {
LanguageModelCompletionError,
>,
> {
- let Ok(region) = cx.read_entity(&self.state, |state, _cx| state.get_region()) else {
+ let Ok((region, allow_global)) = cx.read_entity(&self.state, |state, _cx| {
+ (state.get_region(), state.get_allow_global())
+ }) else {
return async move { Err(anyhow::anyhow!("App State Dropped").into()) }.boxed();
};
- let model_id = match self.model.cross_region_inference_id(®ion) {
+ let model_id = match self.model.cross_region_inference_id(®ion, allow_global) {
Ok(s) => s,
Err(e) => {
return async move { Err(e.into()) }.boxed();
@@ -965,6 +1097,7 @@ pub fn map_to_language_model_completion_events(
is_input_complete: true,
raw_input: tool_use.input_json,
input,
+ thought_signature: None,
},
))
}),
@@ -1009,9 +1142,10 @@ struct ConfigurationView {
access_key_id_editor: Entity<InputField>,
secret_access_key_editor: Entity<InputField>,
session_token_editor: Entity<InputField>,
- region_editor: Entity<InputField>,
+ bearer_token_editor: Entity<InputField>,
state: Entity<State>,
load_credentials_task: Option<Task<()>>,
+ focus_handle: FocusHandle,
}
impl ConfigurationView {
@@ -1019,14 +1153,44 @@ impl ConfigurationView {
const PLACEHOLDER_SECRET_ACCESS_KEY_TEXT: &'static str =
"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
const PLACEHOLDER_SESSION_TOKEN_TEXT: &'static str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
- const PLACEHOLDER_REGION: &'static str = "us-east-1";
+ const PLACEHOLDER_BEARER_TOKEN_TEXT: &'static str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
fn new(state: Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+ let focus_handle = cx.focus_handle();
+
cx.observe(&state, |_, _, cx| {
cx.notify();
})
.detach();
+ let access_key_id_editor = cx.new(|cx| {
+ InputField::new(window, cx, Self::PLACEHOLDER_ACCESS_KEY_ID_TEXT)
+ .label("Access Key ID")
+ .tab_index(0)
+ .tab_stop(true)
+ });
+
+ let secret_access_key_editor = cx.new(|cx| {
+ InputField::new(window, cx, Self::PLACEHOLDER_SECRET_ACCESS_KEY_TEXT)
+ .label("Secret Access Key")
+ .tab_index(1)
+ .tab_stop(true)
+ });
+
+ let session_token_editor = cx.new(|cx| {
+ InputField::new(window, cx, Self::PLACEHOLDER_SESSION_TOKEN_TEXT)
+ .label("Session Token (Optional)")
+ .tab_index(2)
+ .tab_stop(true)
+ });
+
+ let bearer_token_editor = cx.new(|cx| {
+ InputField::new(window, cx, Self::PLACEHOLDER_BEARER_TOKEN_TEXT)
+ .label("Bedrock API Key")
+ .tab_index(3)
+ .tab_stop(true)
+ });
+
let load_credentials_task = Some(cx.spawn({
let state = state.clone();
async move |this, cx| {
@@ -1046,22 +1210,13 @@ impl ConfigurationView {
}));
Self {
- access_key_id_editor: cx.new(|cx| {
- InputField::new(window, cx, Self::PLACEHOLDER_ACCESS_KEY_ID_TEXT)
- .label("Access Key ID")
- }),
- secret_access_key_editor: cx.new(|cx| {
- InputField::new(window, cx, Self::PLACEHOLDER_SECRET_ACCESS_KEY_TEXT)
- .label("Secret Access Key")
- }),
- session_token_editor: cx.new(|cx| {
- InputField::new(window, cx, Self::PLACEHOLDER_SESSION_TOKEN_TEXT)
- .label("Session Token (Optional)")
- }),
- region_editor: cx
- .new(|cx| InputField::new(window, cx, Self::PLACEHOLDER_REGION).label("Region")),
+ access_key_id_editor,
+ secret_access_key_editor,
+ session_token_editor,
+ bearer_token_editor,
state,
load_credentials_task,
+ focus_handle,
}
}
@@ -1094,25 +1249,30 @@ impl ConfigurationView {
} else {
Some(session_token)
};
- let region = self.region_editor.read(cx).text(cx).trim().to_string();
- let region = if region.is_empty() {
- "us-east-1".to_string()
+ let bearer_token = self
+ .bearer_token_editor
+ .read(cx)
+ .text(cx)
+ .trim()
+ .to_string();
+ let bearer_token = if bearer_token.is_empty() {
+ None
} else {
- region
+ Some(bearer_token)
};
let state = self.state.clone();
cx.spawn(async move |_, cx| {
state
.update(cx, |state, cx| {
- let credentials: BedrockCredentials = BedrockCredentials {
- region: region.clone(),
- access_key_id: access_key_id.clone(),
- secret_access_key: secret_access_key.clone(),
- session_token: session_token.clone(),
+ let credentials = BedrockCredentials {
+ access_key_id,
+ secret_access_key,
+ session_token,
+ bearer_token,
};
- state.set_credentials(credentials, cx)
+ state.set_static_credentials(credentials, cx)
})?
.await
})
@@ -1126,28 +1286,39 @@ impl ConfigurationView {
.update(cx, |editor, cx| editor.set_text("", window, cx));
self.session_token_editor
.update(cx, |editor, cx| editor.set_text("", window, cx));
- self.region_editor
+ self.bearer_token_editor
.update(cx, |editor, cx| editor.set_text("", window, cx));
let state = self.state.clone();
- cx.spawn(async move |_, cx| {
- state
- .update(cx, |state, cx| state.reset_credentials(cx))?
- .await
- })
- .detach_and_log_err(cx);
+ cx.spawn(async move |_, cx| state.update(cx, |state, cx| state.reset_auth(cx))?.await)
+ .detach_and_log_err(cx);
}
fn should_render_editor(&self, cx: &Context<Self>) -> bool {
self.state.read(cx).is_authenticated()
}
+
+ fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
+ window.focus_next(cx);
+ }
+
+ fn on_tab_prev(
+ &mut self,
+ _: &menu::SelectPrevious,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ window.focus_prev(cx);
+ }
}
impl Render for ConfigurationView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let env_var_set = self.state.read(cx).credentials_from_env;
- let bedrock_settings = self.state.read(cx).settings.as_ref();
- let bedrock_method = bedrock_settings
+ let state = self.state.read(cx);
+ let env_var_set = state.credentials_from_env;
+ let auth = state.auth.clone();
+ let settings_auth_method = state
+ .settings
.as_ref()
.and_then(|s| s.authentication_method.clone());
@@ -1155,34 +1326,62 @@ impl Render for ConfigurationView {
return div().child(Label::new("Loading credentials...")).into_any();
}
- let configured_label = if env_var_set {
- format!(
- "Access Key ID is set in {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, Secret Key is set in {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR}, Region is set in {ZED_BEDROCK_REGION_VAR} environment variables."
- )
- } else {
- match bedrock_method {
- Some(BedrockAuthMethod::Automatic) => "You are using automatic credentials.".into(),
- Some(BedrockAuthMethod::NamedProfile) => "You are using named profile.".into(),
- Some(BedrockAuthMethod::SingleSignOn) => {
- "You are using a single sign on profile.".into()
- }
- None => "You are using static credentials.".into(),
+ let configured_label = match &auth {
+ Some(BedrockAuth::Automatic) => {
+ "Using automatic credentials (AWS default chain)".into()
+ }
+ Some(BedrockAuth::NamedProfile { profile_name }) => {
+ format!("Using AWS profile: {profile_name}")
+ }
+ Some(BedrockAuth::SingleSignOn { profile_name }) => {
+ format!("Using AWS SSO profile: {profile_name}")
+ }
+ Some(BedrockAuth::IamCredentials { .. }) if env_var_set => {
+ format!(
+ "Using IAM credentials from {} and {} environment variables",
+ ZED_BEDROCK_ACCESS_KEY_ID_VAR.name, ZED_BEDROCK_SECRET_ACCESS_KEY_VAR.name
+ )
}
+ Some(BedrockAuth::IamCredentials { .. }) => "Using IAM credentials".into(),
+ Some(BedrockAuth::ApiKey { .. }) if env_var_set => {
+ format!(
+ "Using Bedrock API Key from {} environment variable",
+ ZED_BEDROCK_BEARER_TOKEN_VAR.name
+ )
+ }
+ Some(BedrockAuth::ApiKey { .. }) => "Using Bedrock API Key".into(),
+ None => "Not authenticated".into(),
};
+ // Determine if credentials can be reset
+ // Settings-derived auth (non-ApiKey) cannot be reset from UI
+ let is_settings_derived = matches!(
+ settings_auth_method,
+ Some(BedrockAuthMethod::Automatic)
+ | Some(BedrockAuthMethod::NamedProfile)
+ | Some(BedrockAuthMethod::SingleSignOn)
+ );
+
let tooltip_label = if env_var_set {
Some(format!(
- "To reset your credentials, unset the {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR}, and {ZED_BEDROCK_REGION_VAR} environment variables."
+ "To reset your credentials, unset the {}, {}, and {} or {} environment variables.",
+ ZED_BEDROCK_ACCESS_KEY_ID_VAR.name,
+ ZED_BEDROCK_SECRET_ACCESS_KEY_VAR.name,
+ ZED_BEDROCK_SESSION_TOKEN_VAR.name,
+ ZED_BEDROCK_BEARER_TOKEN_VAR.name
))
- } else if bedrock_method.is_some() {
- Some("You cannot reset credentials as they're being derived, check Zed settings to understand how.".to_string())
+ } else if is_settings_derived {
+ Some(
+ "Authentication method is configured in settings. Edit settings.json to change."
+ .to_string(),
+ )
} else {
None
};
if self.should_render_editor(cx) {
return ConfiguredApiCard::new(configured_label)
- .disabled(env_var_set || bedrock_method.is_some())
+ .disabled(env_var_set || is_settings_derived)
.on_click(cx.listener(|this, _, window, cx| this.reset_credentials(window, cx)))
.when_some(tooltip_label, |this, label| this.tooltip_label(label))
.into_any_element();
@@ -1190,30 +1389,29 @@ impl Render for ConfigurationView {
v_flex()
.size_full()
+ .track_focus(&self.focus_handle)
+ .on_action(cx.listener(Self::on_tab))
+ .on_action(cx.listener(Self::on_tab_prev))
.on_action(cx.listener(ConfigurationView::save_credentials))
.child(Label::new("To use Zed's agent with Bedrock, you can set a custom authentication strategy through the settings.json, or use static credentials."))
.child(Label::new("But, to access models on AWS, you need to:").mt_1())
.child(
List::new()
.child(
- InstructionListItem::new(
- "Grant permissions to the strategy you'll use according to the:",
- Some("Prerequisites"),
- Some("https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"),
- )
+ ListBulletItem::new("")
+ .child(Label::new("Grant permissions to the strategy you'll use according to the:"))
+ .child(ButtonLink::new("Prerequisites", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"))
)
.child(
- InstructionListItem::new(
- "Select the models you would like access to:",
- Some("Bedrock Model Catalog"),
- Some("https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess"),
- )
+ ListBulletItem::new("")
+ .child(Label::new("Select the models you would like access to:"))
+ .child(ButtonLink::new("Bedrock Model Catalog", "https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess"))
)
)
.child(self.render_static_credentials_ui())
.child(
Label::new(
- format!("You can also assign the {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR} AND {ZED_BEDROCK_REGION_VAR} environment variables and restart Zed."),
+ format!("You can also assign the {}, {} AND {} environment variables (or {} for Bedrock API Key authentication) and restart Zed.", ZED_BEDROCK_ACCESS_KEY_ID_VAR.name, ZED_BEDROCK_SECRET_ACCESS_KEY_VAR.name, ZED_BEDROCK_REGION_VAR.name, ZED_BEDROCK_BEARER_TOKEN_VAR.name),
)
.size(LabelSize::Small)
.color(Color::Muted)
@@ -1221,7 +1419,7 @@ impl Render for ConfigurationView {
)
.child(
Label::new(
- format!("Optionally, if your environment uses AWS CLI profiles, you can set {ZED_AWS_PROFILE_VAR}; if it requires a custom endpoint, you can set {ZED_AWS_ENDPOINT_VAR}; and if it requires a Session Token, you can set {ZED_BEDROCK_SESSION_TOKEN_VAR}."),
+ format!("Optionally, if your environment uses AWS CLI profiles, you can set {}; if it requires a custom endpoint, you can set {}; and if it requires a Session Token, you can set {}.", ZED_AWS_PROFILE_VAR.name, ZED_AWS_ENDPOINT_VAR.name, ZED_BEDROCK_SESSION_TOKEN_VAR.name),
)
.size(LabelSize::Small)
.color(Color::Muted),
@@ -1234,6 +1432,7 @@ impl ConfigurationView {
fn render_static_credentials_ui(&self) -> impl IntoElement {
v_flex()
.my_2()
+ .tab_group()
.gap_1p5()
.child(
Label::new("Static Keys")
@@ -1242,31 +1441,47 @@ impl ConfigurationView {
)
.child(
Label::new(
- "This method uses your AWS access key ID and secret access key directly.",
+ "This method uses your AWS access key ID and secret access key, or a Bedrock API Key.",
)
)
.child(
List::new()
- .child(InstructionListItem::new(
- "Create an IAM user in the AWS console with programmatic access",
- Some("IAM Console"),
- Some("https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users"),
- ))
- .child(InstructionListItem::new(
- "Attach the necessary Bedrock permissions to this ",
- Some("user"),
- Some("https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"),
- ))
- .child(InstructionListItem::text_only(
- "Copy the access key ID and secret access key when provided",
- ))
- .child(InstructionListItem::text_only(
- "Enter these credentials below",
- )),
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("For access keys: Create an IAM user in the AWS console with programmatic access"))
+ .child(ButtonLink::new("IAM Console", "https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users"))
+ )
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("For Bedrock API Keys: Generate an API key from the"))
+ .child(ButtonLink::new("Bedrock Console", "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-use.html"))
+ )
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("Attach the necessary Bedrock permissions to this"))
+ .child(ButtonLink::new("user", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"))
+ )
+ .child(
+ ListBulletItem::new("Enter either access keys OR a Bedrock API Key below (not both)")
+ ),
)
.child(self.access_key_id_editor.clone())
.child(self.secret_access_key_editor.clone())
.child(self.session_token_editor.clone())
- .child(self.region_editor.clone())
+ .child(
+ Label::new("OR")
+ .size(LabelSize::Default)
+ .weight(FontWeight::BOLD)
+ .my_1(),
+ )
+ .child(self.bearer_token_editor.clone())
+ .child(
+ Label::new(
+ format!("Region is configured via {} environment variable or settings.json (defaults to us-east-1).", ZED_BEDROCK_REGION_VAR.name),
+ )
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .mt_2(),
+ )
}
}
@@ -15,9 +15,7 @@ use futures::{
AsyncBufReadExt, FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream,
};
use google_ai::GoogleModelMode;
-use gpui::{
- AnyElement, AnyView, App, AsyncApp, Context, Entity, SemanticVersion, Subscription, Task,
-};
+use gpui::{AnyElement, AnyView, App, AsyncApp, Context, Entity, Subscription, Task};
use http_client::http::{HeaderMap, HeaderValue};
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Response, StatusCode};
use language_model::{
@@ -30,6 +28,7 @@ use language_model::{
};
use release_channel::AppVersion;
use schemars::JsonSchema;
+use semver::Version;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use settings::SettingsStore;
pub use settings::ZedDotDevAvailableModel as AvailableModel;
@@ -43,7 +42,9 @@ use thiserror::Error;
use ui::{TintColor, prelude::*};
use util::{ResultExt as _, maybe};
-use crate::provider::anthropic::{AnthropicEventMapper, count_anthropic_tokens, into_anthropic};
+use crate::provider::anthropic::{
+ AnthropicEventMapper, count_anthropic_tokens_with_tiktoken, into_anthropic,
+};
use crate::provider::google::{GoogleEventMapper, into_google};
use crate::provider::open_ai::{OpenAiEventMapper, count_open_ai_tokens, into_open_ai};
use crate::provider::x_ai::count_xai_tokens;
@@ -384,7 +385,7 @@ impl CloudLanguageModel {
async fn perform_llm_completion(
client: Arc<Client>,
llm_api_token: LlmApiToken,
- app_version: Option<SemanticVersion>,
+ app_version: Option<Version>,
body: CompletionBody,
) -> Result<PerformLlmCompletionResponse> {
let http_client = &client.http_client();
@@ -396,7 +397,7 @@ impl CloudLanguageModel {
let request = http_client::Request::builder()
.method(Method::POST)
.uri(http_client.build_zed_llm_url("/completions", &[])?.as_ref())
- .when_some(app_version, |builder, app_version| {
+ .when_some(app_version.as_ref(), |builder, app_version| {
builder.header(ZED_VERSION_HEADER_NAME, app_version.to_string())
})
.header("Content-Type", "application/json")
@@ -603,6 +604,10 @@ impl LanguageModel for CloudLanguageModel {
self.model.supports_images
}
+ fn supports_streaming_tools(&self) -> bool {
+ self.model.supports_streaming_tools
+ }
+
fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
match choice {
LanguageModelToolChoice::Auto
@@ -664,9 +669,9 @@ impl LanguageModel for CloudLanguageModel {
cx: &App,
) -> BoxFuture<'static, Result<u64>> {
match self.model.provider {
- cloud_llm_client::LanguageModelProvider::Anthropic => {
- count_anthropic_tokens(request, cx)
- }
+ cloud_llm_client::LanguageModelProvider::Anthropic => cx
+ .background_spawn(async move { count_anthropic_tokens_with_tiktoken(request) })
+ .boxed(),
cloud_llm_client::LanguageModelProvider::OpenAi => {
let model = match open_ai::Model::from_id(&self.model.id.0) {
Ok(model) => model,
@@ -752,6 +757,7 @@ impl LanguageModel for CloudLanguageModel {
let mode = request.mode;
let app_version = cx.update(|cx| AppVersion::global(cx)).ok();
let thinking_allowed = request.thinking_allowed;
+ let provider_name = provider_name(&self.model.provider);
match self.model.provider {
cloud_llm_client::LanguageModelProvider::Anthropic => {
let request = into_anthropic(
@@ -801,8 +807,9 @@ impl LanguageModel for CloudLanguageModel {
Box::pin(
response_lines(response, includes_status_messages)
.chain(usage_updated_event(usage))
- .chain(tool_use_limit_reached_event(tool_use_limit_reached)),
+ .chain(tool_use_limit_reached_event(tool_use_limit_reached)), // .map(|_| {}),
),
+ &provider_name,
move |event| mapper.map_event(event),
))
});
@@ -849,6 +856,7 @@ impl LanguageModel for CloudLanguageModel {
.chain(usage_updated_event(usage))
.chain(tool_use_limit_reached_event(tool_use_limit_reached)),
),
+ &provider_name,
move |event| mapper.map_event(event),
))
});
@@ -895,6 +903,7 @@ impl LanguageModel for CloudLanguageModel {
.chain(usage_updated_event(usage))
.chain(tool_use_limit_reached_event(tool_use_limit_reached)),
),
+ &provider_name,
move |event| mapper.map_event(event),
))
});
@@ -935,6 +944,7 @@ impl LanguageModel for CloudLanguageModel {
.chain(usage_updated_event(usage))
.chain(tool_use_limit_reached_event(tool_use_limit_reached)),
),
+ &provider_name,
move |event| mapper.map_event(event),
))
});
@@ -946,6 +956,7 @@ impl LanguageModel for CloudLanguageModel {
fn map_cloud_completion_events<T, F>(
stream: Pin<Box<dyn Stream<Item = Result<CompletionEvent<T>>> + Send>>,
+ provider: &LanguageModelProviderName,
mut map_callback: F,
) -> BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
where
@@ -954,6 +965,7 @@ where
+ Send
+ 'static,
{
+ let provider = provider.clone();
stream
.flat_map(move |event| {
futures::stream::iter(match event {
@@ -961,7 +973,12 @@ where
vec![Err(LanguageModelCompletionError::from(error))]
}
Ok(CompletionEvent::Status(event)) => {
- vec![Ok(LanguageModelCompletionEvent::StatusUpdate(event))]
+ vec![
+ LanguageModelCompletionEvent::from_completion_request_status(
+ event,
+ provider.clone(),
+ ),
+ ]
}
Ok(CompletionEvent::Event(event)) => map_callback(event),
})
@@ -969,6 +986,17 @@ where
.boxed()
}
+fn provider_name(provider: &cloud_llm_client::LanguageModelProvider) -> LanguageModelProviderName {
+ match provider {
+ cloud_llm_client::LanguageModelProvider::Anthropic => {
+ language_model::ANTHROPIC_PROVIDER_NAME
+ }
+ cloud_llm_client::LanguageModelProvider::OpenAi => language_model::OPEN_AI_PROVIDER_NAME,
+ cloud_llm_client::LanguageModelProvider::Google => language_model::GOOGLE_PROVIDER_NAME,
+ cloud_llm_client::LanguageModelProvider::XAi => language_model::X_AI_PROVIDER_NAME,
+ }
+}
+
fn usage_updated_event<T>(
usage: Option<ModelRequestUsage>,
) -> impl Stream<Item = Result<CompletionEvent<T>>> {
@@ -14,7 +14,7 @@ use copilot::{Copilot, Status};
use futures::future::BoxFuture;
use futures::stream::BoxStream;
use futures::{FutureExt, Stream, StreamExt};
-use gpui::{Action, AnyView, App, AsyncApp, Entity, Render, Subscription, Task, svg};
+use gpui::{AnyView, App, AsyncApp, Entity, Subscription, Task};
use http_client::StatusCode;
use language::language_settings::all_language_settings;
use language_model::{
@@ -26,11 +26,9 @@ use language_model::{
StopReason, TokenUsage,
};
use settings::SettingsStore;
-use ui::{CommonAnimationExt, prelude::*};
+use ui::prelude::*;
use util::debug_panic;
-use crate::ui::ConfiguredApiCard;
-
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat");
const PROVIDER_NAME: LanguageModelProviderName =
LanguageModelProviderName::new("GitHub Copilot Chat");
@@ -143,9 +141,11 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
};
let Some(copilot) = Copilot::global(cx) else {
- return Task::ready( Err(anyhow!(
- "Copilot must be enabled for Copilot Chat to work. Please enable Copilot and try again."
- ).into()));
+ return Task::ready(Err(anyhow!(concat!(
+ "Copilot must be enabled for Copilot Chat to work. ",
+ "Please enable Copilot and try again."
+ ))
+ .into()));
};
let err = match copilot.read(cx).status() {
@@ -177,8 +177,18 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
_: &mut Window,
cx: &mut App,
) -> AnyView {
- let state = self.state.clone();
- cx.new(|cx| ConfigurationView::new(state, cx)).into()
+ cx.new(|cx| {
+ copilot::ConfigurationView::new(
+ |cx| {
+ CopilotChat::global(cx)
+ .map(|m| m.read(cx).is_authenticated())
+ .unwrap_or(false)
+ },
+ copilot::ConfigurationMode::Chat,
+ cx,
+ )
+ })
+ .into()
}
fn reset_credentials(&self, _cx: &mut App) -> Task<Result<()>> {
@@ -359,17 +369,22 @@ pub fn map_to_language_model_completion_events(
id: String,
name: String,
arguments: String,
+ thought_signature: Option<String>,
}
struct State {
events: Pin<Box<dyn Send + Stream<Item = Result<ResponseEvent>>>>,
tool_calls_by_index: HashMap<usize, RawToolCall>,
+ reasoning_opaque: Option<String>,
+ reasoning_text: Option<String>,
}
futures::stream::unfold(
State {
events,
tool_calls_by_index: HashMap::default(),
+ reasoning_opaque: None,
+ reasoning_text: None,
},
move |mut state| async move {
if let Some(event) = state.events.next().await {
@@ -400,6 +415,14 @@ pub fn map_to_language_model_completion_events(
events.push(Ok(LanguageModelCompletionEvent::Text(content)));
}
+ // Capture reasoning data from the delta (e.g. for Gemini 3)
+ if let Some(opaque) = delta.reasoning_opaque.clone() {
+ state.reasoning_opaque = Some(opaque);
+ }
+ if let Some(text) = delta.reasoning_text.clone() {
+ state.reasoning_text = Some(text);
+ }
+
for (index, tool_call) in delta.tool_calls.iter().enumerate() {
let tool_index = tool_call.index.unwrap_or(index);
let entry = state.tool_calls_by_index.entry(tool_index).or_default();
@@ -416,6 +439,11 @@ pub fn map_to_language_model_completion_events(
if let Some(arguments) = function.arguments.clone() {
entry.arguments.push_str(&arguments);
}
+
+ if let Some(thought_signature) = function.thought_signature.clone()
+ {
+ entry.thought_signature = Some(thought_signature);
+ }
}
}
@@ -437,6 +465,32 @@ pub fn map_to_language_model_completion_events(
)));
}
Some("tool_calls") => {
+ // Gemini 3 models send reasoning_opaque/reasoning_text that must
+ // be preserved and sent back in subsequent requests. Emit as
+ // ReasoningDetails so the agent stores it in the message.
+ if state.reasoning_opaque.is_some()
+ || state.reasoning_text.is_some()
+ {
+ let mut details = serde_json::Map::new();
+ if let Some(opaque) = state.reasoning_opaque.take() {
+ details.insert(
+ "reasoning_opaque".to_string(),
+ serde_json::Value::String(opaque),
+ );
+ }
+ if let Some(text) = state.reasoning_text.take() {
+ details.insert(
+ "reasoning_text".to_string(),
+ serde_json::Value::String(text),
+ );
+ }
+ events.push(Ok(
+ LanguageModelCompletionEvent::ReasoningDetails(
+ serde_json::Value::Object(details),
+ ),
+ ));
+ }
+
events.extend(state.tool_calls_by_index.drain().map(
|(_, tool_call)| {
// The model can output an empty string
@@ -456,6 +510,7 @@ pub fn map_to_language_model_completion_events(
is_input_complete: true,
input,
raw_input: tool_call.arguments,
+ thought_signature: tool_call.thought_signature,
},
)),
Err(error) => Ok(
@@ -547,6 +602,7 @@ impl CopilotResponsesEventMapper {
call_id,
name,
arguments,
+ thought_signature,
..
} => {
let mut events = Vec::new();
@@ -558,6 +614,7 @@ impl CopilotResponsesEventMapper {
is_input_complete: true,
input,
raw_input: arguments.clone(),
+ thought_signature,
},
))),
Err(error) => {
@@ -772,6 +829,7 @@ fn into_copilot_chat(
function: copilot::copilot_chat::FunctionContent {
name: tool_use.name.to_string(),
arguments: serde_json::to_string(&tool_use.input)?,
+ thought_signature: tool_use.thought_signature.clone(),
},
},
});
@@ -795,6 +853,22 @@ fn into_copilot_chat(
buffer
};
+ // Extract reasoning_opaque and reasoning_text from reasoning_details
+ let (reasoning_opaque, reasoning_text) =
+ if let Some(details) = &message.reasoning_details {
+ let opaque = details
+ .get("reasoning_opaque")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ let text = details
+ .get("reasoning_text")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ (opaque, text)
+ } else {
+ (None, None)
+ };
+
messages.push(ChatMessage::Assistant {
content: if text_content.is_empty() {
ChatMessageContent::empty()
@@ -802,6 +876,8 @@ fn into_copilot_chat(
text_content.into()
},
tool_calls,
+ reasoning_opaque,
+ reasoning_text,
});
}
Role::System => messages.push(ChatMessage::System {
@@ -946,6 +1022,7 @@ fn into_copilot_responses(
name: tool_use.name.to_string(),
arguments: tool_use.raw_input.clone(),
status: None,
+ thought_signature: tool_use.thought_signature.clone(),
});
}
}
@@ -1118,6 +1195,7 @@ mod tests {
name: "do_it".into(),
arguments: "{\"x\":1}".into(),
status: None,
+ thought_signature: None,
},
}];
@@ -1143,6 +1221,7 @@ mod tests {
name: "do_it".into(),
arguments: "{not json}".into(),
status: None,
+ thought_signature: None,
},
}];
@@ -1246,6 +1325,7 @@ mod tests {
name: "do_it".into(),
arguments: "{}".into(),
status: None,
+ thought_signature: None,
},
},
responses::StreamEvent::Completed {
@@ -1301,93 +1381,104 @@ mod tests {
other => panic!("expected HttpResponseError, got {:?}", other),
}
}
-}
-struct ConfigurationView {
- copilot_status: Option<copilot::Status>,
- state: Entity<State>,
- _subscription: Option<Subscription>,
-}
-impl ConfigurationView {
- pub fn new(state: Entity<State>, cx: &mut Context<Self>) -> Self {
- let copilot = Copilot::global(cx);
+ #[test]
+ fn chat_completions_stream_maps_reasoning_data() {
+ use copilot::copilot_chat::ResponseEvent;
- Self {
- copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()),
- state,
- _subscription: copilot.as_ref().map(|copilot| {
- cx.observe(copilot, |this, model, cx| {
- this.copilot_status = Some(model.read(cx).status());
- cx.notify();
- })
- }),
- }
- }
-}
+ let events = vec![
+ ResponseEvent {
+ choices: vec![copilot::copilot_chat::ResponseChoice {
+ index: Some(0),
+ finish_reason: None,
+ delta: Some(copilot::copilot_chat::ResponseDelta {
+ content: None,
+ role: Some(copilot::copilot_chat::Role::Assistant),
+ tool_calls: vec![copilot::copilot_chat::ToolCallChunk {
+ index: Some(0),
+ id: Some("call_abc123".to_string()),
+ function: Some(copilot::copilot_chat::FunctionChunk {
+ name: Some("list_directory".to_string()),
+ arguments: Some("{\"path\":\"test\"}".to_string()),
+ thought_signature: None,
+ }),
+ }],
+ reasoning_opaque: Some("encrypted_reasoning_token_xyz".to_string()),
+ reasoning_text: Some("Let me check the directory".to_string()),
+ }),
+ message: None,
+ }],
+ id: "chatcmpl-123".to_string(),
+ usage: None,
+ },
+ ResponseEvent {
+ choices: vec![copilot::copilot_chat::ResponseChoice {
+ index: Some(0),
+ finish_reason: Some("tool_calls".to_string()),
+ delta: Some(copilot::copilot_chat::ResponseDelta {
+ content: None,
+ role: None,
+ tool_calls: vec![],
+ reasoning_opaque: None,
+ reasoning_text: None,
+ }),
+ message: None,
+ }],
+ id: "chatcmpl-123".to_string(),
+ usage: None,
+ },
+ ];
-impl Render for ConfigurationView {
- fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- if self.state.read(cx).is_authenticated(cx) {
- ConfiguredApiCard::new("Authorized")
- .button_label("Sign Out")
- .on_click(|_, window, cx| {
- window.dispatch_action(copilot::SignOut.boxed_clone(), cx);
- })
- .into_any_element()
- } else {
- let loading_icon = Icon::new(IconName::ArrowCircle).with_rotate_animation(4);
-
- const ERROR_LABEL: &str = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different Assistant provider.";
-
- match &self.copilot_status {
- Some(status) => match status {
- Status::Starting { task: _ } => h_flex()
- .gap_2()
- .child(loading_icon)
- .child(Label::new("Starting Copilot…"))
- .into_any_element(),
- Status::SigningIn { prompt: _ }
- | Status::SignedOut {
- awaiting_signing_in: true,
- } => h_flex()
- .gap_2()
- .child(loading_icon)
- .child(Label::new("Signing into Copilot…"))
- .into_any_element(),
- Status::Error(_) => {
- const LABEL: &str = "Copilot had issues starting. Please try restarting it. If the issue persists, try reinstalling Copilot.";
- v_flex()
- .gap_6()
- .child(Label::new(LABEL))
- .child(svg().size_8().path(IconName::CopilotError.path()))
- .into_any_element()
- }
- _ => {
- const LABEL: &str = "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.";
-
- v_flex()
- .gap_2()
- .child(Label::new(LABEL))
- .child(
- Button::new("sign_in", "Sign in to use GitHub Copilot")
- .full_width()
- .style(ButtonStyle::Outlined)
- .icon_color(Color::Muted)
- .icon(IconName::Github)
- .icon_position(IconPosition::Start)
- .icon_size(IconSize::Small)
- .on_click(|_, window, cx| {
- copilot::initiate_sign_in(window, cx)
- }),
- )
- .into_any_element()
- }
- },
- None => v_flex()
- .gap_6()
- .child(Label::new(ERROR_LABEL))
- .into_any_element(),
+ let mapped = futures::executor::block_on(async {
+ map_to_language_model_completion_events(
+ Box::pin(futures::stream::iter(events.into_iter().map(Ok))),
+ true,
+ )
+ .collect::<Vec<_>>()
+ .await
+ });
+
+ let mut has_reasoning_details = false;
+ let mut has_tool_use = false;
+ let mut reasoning_opaque_value: Option<String> = None;
+ let mut reasoning_text_value: Option<String> = None;
+
+ for event_result in mapped {
+ match event_result {
+ Ok(LanguageModelCompletionEvent::ReasoningDetails(details)) => {
+ has_reasoning_details = true;
+ reasoning_opaque_value = details
+ .get("reasoning_opaque")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ reasoning_text_value = details
+ .get("reasoning_text")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ }
+ Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => {
+ has_tool_use = true;
+ assert_eq!(tool_use.id.to_string(), "call_abc123");
+ assert_eq!(tool_use.name.as_ref(), "list_directory");
+ }
+ _ => {}
}
}
+
+ assert!(
+ has_reasoning_details,
+ "Should emit ReasoningDetails event for Gemini 3 reasoning"
+ );
+ assert!(has_tool_use, "Should emit ToolUse event");
+ assert_eq!(
+ reasoning_opaque_value,
+ Some("encrypted_reasoning_token_xyz".to_string()),
+ "Should capture reasoning_opaque"
+ );
+ assert_eq!(
+ reasoning_text_value,
+ Some("Let me check the directory".to_string()),
+ "Should capture reasoning_text"
+ );
}
}
@@ -7,11 +7,11 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture, stream::BoxStream
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
use http_client::HttpClient;
use language_model::{
- AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
- LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
- LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
- LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
- RateLimiter, Role, StopReason, TokenUsage,
+ ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+ LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+ LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
+ LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
};
pub use settings::DeepseekAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore};
@@ -19,13 +19,9 @@ use std::pin::Pin;
use std::str::FromStr;
use std::sync::{Arc, LazyLock};
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::ui::ConfiguredApiCard;
-use crate::{api_key::ApiKeyState, ui::InstructionListItem};
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("deepseek");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("DeepSeek");
@@ -67,12 +63,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = DeepSeekLanguageModelProvider::api_url(cx);
- self.api_key_state.load_if_needed(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- )
+ self.api_key_state
+ .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -81,17 +73,13 @@ impl DeepSeekLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
- this.api_key_state.handle_url_change(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- );
+ this.api_key_state
+ .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
- api_key_state: ApiKeyState::new(Self::api_url(cx)),
+ api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
});
@@ -332,9 +320,11 @@ pub fn into_deepseek(
model: &deepseek::Model,
max_output_tokens: Option<u64>,
) -> deepseek::Request {
- let is_reasoner = *model == deepseek::Model::Reasoner;
+ let is_reasoner = model == &deepseek::Model::Reasoner;
let mut messages = Vec::new();
+ let mut current_reasoning: Option<String> = None;
+
for message in request.messages {
for content in message.content {
match content {
@@ -343,10 +333,14 @@ pub fn into_deepseek(
Role::Assistant => deepseek::RequestMessage::Assistant {
content: Some(text),
tool_calls: Vec::new(),
+ reasoning_content: current_reasoning.take(),
},
Role::System => deepseek::RequestMessage::System { content: text },
}),
- MessageContent::Thinking { .. } => {}
+ MessageContent::Thinking { text, .. } => {
+ // Accumulate reasoning content for next assistant message
+ current_reasoning.get_or_insert_default().push_str(&text);
+ }
MessageContent::RedactedThinking(_) => {}
MessageContent::Image(_) => {}
MessageContent::ToolUse(tool_use) => {
@@ -369,6 +363,7 @@ pub fn into_deepseek(
messages.push(deepseek::RequestMessage::Assistant {
content: None,
tool_calls: vec![tool_call],
+ reasoning_content: current_reasoning.take(),
});
}
}
@@ -501,6 +496,7 @@ impl DeepSeekEventMapper {
is_input_complete: true,
input,
raw_input: tool_call.arguments.clone(),
+ thought_signature: None,
},
)),
Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
@@ -624,12 +620,15 @@ impl Render for ConfigurationView {
.child(Label::new("To use DeepSeek in Zed, you need an API key:"))
.child(
List::new()
- .child(InstructionListItem::new(
- "Get your API key from the",
- Some("DeepSeek console"),
- Some("https://platform.deepseek.com/api_keys"),
- ))
- .child(InstructionListItem::text_only(
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("Get your API key from the"))
+ .child(ButtonLink::new(
+ "DeepSeek console",
+ "https://platform.deepseek.com/api_keys",
+ )),
+ )
+ .child(ListBulletItem::new(
"Paste your API key below and hit enter to start using the assistant",
)),
)
@@ -9,7 +9,7 @@ use google_ai::{
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
use http_client::HttpClient;
use language_model::{
- AuthenticateError, ConfigurationViewTargetAgent, LanguageModelCompletionError,
+ AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat,
LanguageModelToolUse, LanguageModelToolUseId, MessageContent, StopReason,
};
@@ -28,14 +28,11 @@ use std::sync::{
atomic::{self, AtomicU64},
};
use strum::IntoEnumIterator;
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
-use zed_env_vars::EnvVar;
-use crate::api_key::ApiKey;
-use crate::api_key::ApiKeyState;
-use crate::ui::{ConfiguredApiCard, InstructionListItem};
+use language_model::{ApiKey, ApiKeyState};
const PROVIDER_ID: LanguageModelProviderId = language_model::GOOGLE_PROVIDER_ID;
const PROVIDER_NAME: LanguageModelProviderName = language_model::GOOGLE_PROVIDER_NAME;
@@ -87,12 +84,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = GoogleLanguageModelProvider::api_url(cx);
- self.api_key_state.load_if_needed(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- )
+ self.api_key_state
+ .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -101,17 +94,13 @@ impl GoogleLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
- this.api_key_state.handle_url_change(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- );
+ this.api_key_state
+ .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
- api_key_state: ApiKeyState::new(Self::api_url(cx)),
+ api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
});
@@ -439,11 +428,15 @@ pub fn into_google(
})]
}
language_model::MessageContent::ToolUse(tool_use) => {
+ // Normalize empty string signatures to None
+ let thought_signature = tool_use.thought_signature.filter(|s| !s.is_empty());
+
vec![Part::FunctionCallPart(google_ai::FunctionCallPart {
function_call: google_ai::FunctionCall {
name: tool_use.name.to_string(),
args: tool_use.input,
},
+ thought_signature,
})]
}
language_model::MessageContent::ToolResult(tool_result) => {
@@ -655,6 +648,11 @@ impl GoogleEventMapper {
let id: LanguageModelToolUseId =
format!("{}-{}", name, next_tool_id).into();
+ // Normalize empty string signatures to None
+ let thought_signature = function_call_part
+ .thought_signature
+ .filter(|s| !s.is_empty());
+
events.push(Ok(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id,
@@ -662,6 +660,7 @@ impl GoogleEventMapper {
is_input_complete: true,
raw_input: function_call_part.function_call.args.to_string(),
input: function_call_part.function_call.args,
+ thought_signature,
},
)));
}
@@ -863,14 +862,14 @@ impl Render for ConfigurationView {
})))
.child(
List::new()
- .child(InstructionListItem::new(
- "Create one by visiting",
- Some("Google AI's console"),
- Some("https://aistudio.google.com/app/apikey"),
- ))
- .child(InstructionListItem::text_only(
- "Paste your API key below and hit enter to start using the assistant",
- )),
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("Create one by visiting"))
+ .child(ButtonLink::new("Google AI's console", "https://aistudio.google.com/app/apikey"))
+ )
+ .child(
+ ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
+ )
)
.child(self.api_key_editor.clone())
.child(
@@ -891,3 +890,428 @@ impl Render for ConfigurationView {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use google_ai::{
+ Content, FunctionCall, FunctionCallPart, GenerateContentCandidate, GenerateContentResponse,
+ Part, Role as GoogleRole, TextPart,
+ };
+ use language_model::{LanguageModelToolUseId, MessageContent, Role};
+ use serde_json::json;
+
+ #[test]
+ fn test_function_call_with_signature_creates_tool_use_with_signature() {
+ let mut mapper = GoogleEventMapper::new();
+
+ let response = GenerateContentResponse {
+ candidates: Some(vec![GenerateContentCandidate {
+ index: Some(0),
+ content: Content {
+ parts: vec![Part::FunctionCallPart(FunctionCallPart {
+ function_call: FunctionCall {
+ name: "test_function".to_string(),
+ args: json!({"arg": "value"}),
+ },
+ thought_signature: Some("test_signature_123".to_string()),
+ })],
+ role: GoogleRole::Model,
+ },
+ finish_reason: None,
+ finish_message: None,
+ safety_ratings: None,
+ citation_metadata: None,
+ }]),
+ prompt_feedback: None,
+ usage_metadata: None,
+ };
+
+ let events = mapper.map_event(response);
+
+ assert_eq!(events.len(), 2); // ToolUse event + Stop event
+
+ if let Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) = &events[0] {
+ assert_eq!(tool_use.name.as_ref(), "test_function");
+ assert_eq!(
+ tool_use.thought_signature.as_deref(),
+ Some("test_signature_123")
+ );
+ } else {
+ panic!("Expected ToolUse event");
+ }
+ }
+
+ #[test]
+ fn test_function_call_without_signature_has_none() {
+ let mut mapper = GoogleEventMapper::new();
+
+ let response = GenerateContentResponse {
+ candidates: Some(vec![GenerateContentCandidate {
+ index: Some(0),
+ content: Content {
+ parts: vec![Part::FunctionCallPart(FunctionCallPart {
+ function_call: FunctionCall {
+ name: "test_function".to_string(),
+ args: json!({"arg": "value"}),
+ },
+ thought_signature: None,
+ })],
+ role: GoogleRole::Model,
+ },
+ finish_reason: None,
+ finish_message: None,
+ safety_ratings: None,
+ citation_metadata: None,
+ }]),
+ prompt_feedback: None,
+ usage_metadata: None,
+ };
+
+ let events = mapper.map_event(response);
+
+ if let Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) = &events[0] {
+ assert_eq!(tool_use.thought_signature, None);
+ } else {
+ panic!("Expected ToolUse event");
+ }
+ }
+
+ #[test]
+ fn test_empty_string_signature_normalized_to_none() {
+ let mut mapper = GoogleEventMapper::new();
+
+ let response = GenerateContentResponse {
+ candidates: Some(vec![GenerateContentCandidate {
+ index: Some(0),
+ content: Content {
+ parts: vec![Part::FunctionCallPart(FunctionCallPart {
+ function_call: FunctionCall {
+ name: "test_function".to_string(),
+ args: json!({"arg": "value"}),
+ },
+ thought_signature: Some("".to_string()),
+ })],
+ role: GoogleRole::Model,
+ },
+ finish_reason: None,
+ finish_message: None,
+ safety_ratings: None,
+ citation_metadata: None,
+ }]),
+ prompt_feedback: None,
+ usage_metadata: None,
+ };
+
+ let events = mapper.map_event(response);
+
+ if let Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) = &events[0] {
+ assert_eq!(tool_use.thought_signature, None);
+ } else {
+ panic!("Expected ToolUse event");
+ }
+ }
+
+ #[test]
+ fn test_parallel_function_calls_preserve_signatures() {
+ let mut mapper = GoogleEventMapper::new();
+
+ let response = GenerateContentResponse {
+ candidates: Some(vec![GenerateContentCandidate {
+ index: Some(0),
+ content: Content {
+ parts: vec![
+ Part::FunctionCallPart(FunctionCallPart {
+ function_call: FunctionCall {
+ name: "function_1".to_string(),
+ args: json!({"arg": "value1"}),
+ },
+ thought_signature: Some("signature_1".to_string()),
+ }),
+ Part::FunctionCallPart(FunctionCallPart {
+ function_call: FunctionCall {
+ name: "function_2".to_string(),
+ args: json!({"arg": "value2"}),
+ },
+ thought_signature: None,
+ }),
+ ],
+ role: GoogleRole::Model,
+ },
+ finish_reason: None,
+ finish_message: None,
+ safety_ratings: None,
+ citation_metadata: None,
+ }]),
+ prompt_feedback: None,
+ usage_metadata: None,
+ };
+
+ let events = mapper.map_event(response);
+
+ assert_eq!(events.len(), 3); // 2 ToolUse events + Stop event
+
+ if let Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) = &events[0] {
+ assert_eq!(tool_use.name.as_ref(), "function_1");
+ assert_eq!(tool_use.thought_signature.as_deref(), Some("signature_1"));
+ } else {
+ panic!("Expected ToolUse event for function_1");
+ }
+
+ if let Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) = &events[1] {
+ assert_eq!(tool_use.name.as_ref(), "function_2");
+ assert_eq!(tool_use.thought_signature, None);
+ } else {
+ panic!("Expected ToolUse event for function_2");
+ }
+ }
+
+ #[test]
+ fn test_tool_use_with_signature_converts_to_function_call_part() {
+ let tool_use = language_model::LanguageModelToolUse {
+ id: LanguageModelToolUseId::from("test_id"),
+ name: "test_function".into(),
+ raw_input: json!({"arg": "value"}).to_string(),
+ input: json!({"arg": "value"}),
+ is_input_complete: true,
+ thought_signature: Some("test_signature_456".to_string()),
+ };
+
+ let request = super::into_google(
+ LanguageModelRequest {
+ messages: vec![language_model::LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec![MessageContent::ToolUse(tool_use)],
+ cache: false,
+ reasoning_details: None,
+ }],
+ ..Default::default()
+ },
+ "gemini-2.5-flash".to_string(),
+ GoogleModelMode::Default,
+ );
+
+ assert_eq!(request.contents[0].parts.len(), 1);
+ if let Part::FunctionCallPart(fc_part) = &request.contents[0].parts[0] {
+ assert_eq!(fc_part.function_call.name, "test_function");
+ assert_eq!(
+ fc_part.thought_signature.as_deref(),
+ Some("test_signature_456")
+ );
+ } else {
+ panic!("Expected FunctionCallPart");
+ }
+ }
+
+ #[test]
+ fn test_tool_use_without_signature_omits_field() {
+ let tool_use = language_model::LanguageModelToolUse {
+ id: LanguageModelToolUseId::from("test_id"),
+ name: "test_function".into(),
+ raw_input: json!({"arg": "value"}).to_string(),
+ input: json!({"arg": "value"}),
+ is_input_complete: true,
+ thought_signature: None,
+ };
+
+ let request = super::into_google(
+ LanguageModelRequest {
+ messages: vec![language_model::LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec![MessageContent::ToolUse(tool_use)],
+ cache: false,
+ reasoning_details: None,
+ }],
+ ..Default::default()
+ },
+ "gemini-2.5-flash".to_string(),
+ GoogleModelMode::Default,
+ );
+
+ assert_eq!(request.contents[0].parts.len(), 1);
+ if let Part::FunctionCallPart(fc_part) = &request.contents[0].parts[0] {
+ assert_eq!(fc_part.thought_signature, None);
+ } else {
+ panic!("Expected FunctionCallPart");
+ }
+ }
+
+ #[test]
+ fn test_empty_signature_in_tool_use_normalized_to_none() {
+ let tool_use = language_model::LanguageModelToolUse {
+ id: LanguageModelToolUseId::from("test_id"),
+ name: "test_function".into(),
+ raw_input: json!({"arg": "value"}).to_string(),
+ input: json!({"arg": "value"}),
+ is_input_complete: true,
+ thought_signature: Some("".to_string()),
+ };
+
+ let request = super::into_google(
+ LanguageModelRequest {
+ messages: vec![language_model::LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec![MessageContent::ToolUse(tool_use)],
+ cache: false,
+ reasoning_details: None,
+ }],
+ ..Default::default()
+ },
+ "gemini-2.5-flash".to_string(),
+ GoogleModelMode::Default,
+ );
+
+ if let Part::FunctionCallPart(fc_part) = &request.contents[0].parts[0] {
+ assert_eq!(fc_part.thought_signature, None);
+ } else {
+ panic!("Expected FunctionCallPart");
+ }
+ }
+
+ #[test]
+ fn test_round_trip_preserves_signature() {
+ let mut mapper = GoogleEventMapper::new();
+
+ // Simulate receiving a response from Google with a signature
+ let response = GenerateContentResponse {
+ candidates: Some(vec![GenerateContentCandidate {
+ index: Some(0),
+ content: Content {
+ parts: vec![Part::FunctionCallPart(FunctionCallPart {
+ function_call: FunctionCall {
+ name: "test_function".to_string(),
+ args: json!({"arg": "value"}),
+ },
+ thought_signature: Some("round_trip_sig".to_string()),
+ })],
+ role: GoogleRole::Model,
+ },
+ finish_reason: None,
+ finish_message: None,
+ safety_ratings: None,
+ citation_metadata: None,
+ }]),
+ prompt_feedback: None,
+ usage_metadata: None,
+ };
+
+ let events = mapper.map_event(response);
+
+ let tool_use = if let Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) = &events[0] {
+ tool_use.clone()
+ } else {
+ panic!("Expected ToolUse event");
+ };
+
+ // Convert back to Google format
+ let request = super::into_google(
+ LanguageModelRequest {
+ messages: vec![language_model::LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec![MessageContent::ToolUse(tool_use)],
+ cache: false,
+ reasoning_details: None,
+ }],
+ ..Default::default()
+ },
+ "gemini-2.5-flash".to_string(),
+ GoogleModelMode::Default,
+ );
+
+ // Verify signature is preserved
+ if let Part::FunctionCallPart(fc_part) = &request.contents[0].parts[0] {
+ assert_eq!(fc_part.thought_signature.as_deref(), Some("round_trip_sig"));
+ } else {
+ panic!("Expected FunctionCallPart");
+ }
+ }
+
+ #[test]
+ fn test_mixed_text_and_function_call_with_signature() {
+ let mut mapper = GoogleEventMapper::new();
+
+ let response = GenerateContentResponse {
+ candidates: Some(vec![GenerateContentCandidate {
+ index: Some(0),
+ content: Content {
+ parts: vec![
+ Part::TextPart(TextPart {
+ text: "I'll help with that.".to_string(),
+ }),
+ Part::FunctionCallPart(FunctionCallPart {
+ function_call: FunctionCall {
+ name: "helper_function".to_string(),
+ args: json!({"query": "help"}),
+ },
+ thought_signature: Some("mixed_sig".to_string()),
+ }),
+ ],
+ role: GoogleRole::Model,
+ },
+ finish_reason: None,
+ finish_message: None,
+ safety_ratings: None,
+ citation_metadata: None,
+ }]),
+ prompt_feedback: None,
+ usage_metadata: None,
+ };
+
+ let events = mapper.map_event(response);
+
+ assert_eq!(events.len(), 3); // Text event + ToolUse event + Stop event
+
+ if let Ok(LanguageModelCompletionEvent::Text(text)) = &events[0] {
+ assert_eq!(text, "I'll help with that.");
+ } else {
+ panic!("Expected Text event");
+ }
+
+ if let Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) = &events[1] {
+ assert_eq!(tool_use.name.as_ref(), "helper_function");
+ assert_eq!(tool_use.thought_signature.as_deref(), Some("mixed_sig"));
+ } else {
+ panic!("Expected ToolUse event");
+ }
+ }
+
+ #[test]
+ fn test_special_characters_in_signature_preserved() {
+ let mut mapper = GoogleEventMapper::new();
+
+ let signature_with_special_chars = "sig<>\"'&%$#@!{}[]".to_string();
+
+ let response = GenerateContentResponse {
+ candidates: Some(vec![GenerateContentCandidate {
+ index: Some(0),
+ content: Content {
+ parts: vec![Part::FunctionCallPart(FunctionCallPart {
+ function_call: FunctionCall {
+ name: "test_function".to_string(),
+ args: json!({"arg": "value"}),
+ },
+ thought_signature: Some(signature_with_special_chars.clone()),
+ })],
+ role: GoogleRole::Model,
+ },
+ finish_reason: None,
+ finish_message: None,
+ safety_ratings: None,
+ citation_metadata: None,
+ }]),
+ prompt_feedback: None,
+ usage_metadata: None,
+ };
+
+ let events = mapper.map_event(response);
+
+ if let Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) = &events[0] {
+ assert_eq!(
+ tool_use.thought_signature.as_deref(),
+ Some(signature_with_special_chars.as_str())
+ );
+ } else {
+ panic!("Expected ToolUse event");
+ }
+ }
+}
@@ -20,11 +20,10 @@ use settings::{Settings, SettingsStore};
use std::pin::Pin;
use std::str::FromStr;
use std::{collections::BTreeMap, sync::Arc};
-use ui::{ButtonLike, Indicator, List, prelude::*};
+use ui::{ButtonLike, Indicator, List, ListBulletItem, prelude::*};
use util::ResultExt;
use crate::AllLanguageModelSettings;
-use crate::ui::InstructionListItem;
const LMSTUDIO_DOWNLOAD_URL: &str = "https://lmstudio.ai/download";
const LMSTUDIO_CATALOG_URL: &str = "https://lmstudio.ai/models";
@@ -569,6 +568,7 @@ impl LmStudioEventMapper {
is_input_complete: true,
input,
raw_input: tool_call.arguments,
+ thought_signature: None,
},
)),
Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
@@ -685,12 +685,14 @@ impl Render for ConfigurationView {
.child(
v_flex().gap_1().child(Label::new(lmstudio_intro)).child(
List::new()
- .child(InstructionListItem::text_only(
+ .child(ListBulletItem::new(
"LM Studio needs to be running with at least one model downloaded.",
))
- .child(InstructionListItem::text_only(
- "To get your first model, try running `lms get qwen2.5-coder-7b`",
- )),
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("To get your first model, try running"))
+ .child(Label::new("lms get qwen2.5-coder-7b").inline_code(cx)),
+ ),
),
)
.child(
@@ -1,31 +1,27 @@
use anyhow::{Result, anyhow};
use collections::BTreeMap;
-use fs::Fs;
+
use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::BoxStream};
use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window};
use http_client::HttpClient;
use language_model::{
- AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
- LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
- LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
- LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
- RateLimiter, Role, StopReason, TokenUsage,
+ ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+ LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+ LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
+ LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
};
-use mistral::{CODESTRAL_API_URL, MISTRAL_API_URL, StreamResponse};
+pub use mistral::{CODESTRAL_API_URL, MISTRAL_API_URL, StreamResponse};
pub use settings::MistralAvailableModel as AvailableModel;
-use settings::{EditPredictionProvider, Settings, SettingsStore, update_settings_file};
+use settings::{Settings, SettingsStore};
use std::collections::HashMap;
use std::pin::Pin;
use std::str::FromStr;
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::ui::ConfiguredApiCard;
-use crate::{api_key::ApiKeyState, ui::InstructionListItem};
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("mistral");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Mistral");
@@ -44,12 +40,26 @@ pub struct MistralSettings {
pub struct MistralLanguageModelProvider {
http_client: Arc<dyn HttpClient>,
- state: Entity<State>,
+ pub state: Entity<State>,
}
pub struct State {
api_key_state: ApiKeyState,
- codestral_api_key_state: ApiKeyState,
+ codestral_api_key_state: Entity<ApiKeyState>,
+}
+
+struct CodestralApiKey(Entity<ApiKeyState>);
+impl Global for CodestralApiKey {}
+
+pub fn codestral_api_key(cx: &mut App) -> Entity<ApiKeyState> {
+ if cx.has_global::<CodestralApiKey>() {
+ cx.global::<CodestralApiKey>().0.clone()
+ } else {
+ let api_key_state = cx
+ .new(|_| ApiKeyState::new(CODESTRAL_API_URL.into(), CODESTRAL_API_KEY_ENV_VAR.clone()));
+ cx.set_global(CodestralApiKey(api_key_state.clone()));
+ api_key_state
+ }
}
impl State {
@@ -63,39 +73,19 @@ impl State {
.store(api_url, api_key, |this| &mut this.api_key_state, cx)
}
- fn set_codestral_api_key(
- &mut self,
- api_key: Option<String>,
- cx: &mut Context<Self>,
- ) -> Task<Result<()>> {
- self.codestral_api_key_state.store(
- CODESTRAL_API_URL.into(),
- api_key,
- |this| &mut this.codestral_api_key_state,
- cx,
- )
- }
-
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = MistralLanguageModelProvider::api_url(cx);
- self.api_key_state.load_if_needed(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- )
+ self.api_key_state
+ .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
fn authenticate_codestral(
&mut self,
cx: &mut Context<Self>,
) -> Task<Result<(), AuthenticateError>> {
- self.codestral_api_key_state.load_if_needed(
- CODESTRAL_API_URL.into(),
- &CODESTRAL_API_KEY_ENV_VAR,
- |this| &mut this.codestral_api_key_state,
- cx,
- )
+ self.codestral_api_key_state.update(cx, |state, cx| {
+ state.load_if_needed(CODESTRAL_API_URL.into(), |state| state, cx)
+ })
}
}
@@ -116,18 +106,14 @@ impl MistralLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
- this.api_key_state.handle_url_change(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- );
+ this.api_key_state
+ .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
- api_key_state: ApiKeyState::new(Self::api_url(cx)),
- codestral_api_key_state: ApiKeyState::new(CODESTRAL_API_URL.into()),
+ api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+ codestral_api_key_state: codestral_api_key(cx),
}
});
@@ -142,7 +128,11 @@ impl MistralLanguageModelProvider {
}
pub fn codestral_api_key(&self, url: &str, cx: &App) -> Option<Arc<str>> {
- self.state.read(cx).codestral_api_key_state.key(url)
+ self.state
+ .read(cx)
+ .codestral_api_key_state
+ .read(cx)
+ .key(url)
}
fn create_language_model(&self, model: mistral::Model) -> Arc<dyn LanguageModel> {
@@ -159,7 +149,7 @@ impl MistralLanguageModelProvider {
&crate::AllLanguageModelSettings::get_global(cx).mistral
}
- fn api_url(cx: &App) -> SharedString {
+ pub fn api_url(cx: &App) -> SharedString {
let api_url = &Self::settings(cx).api_url;
if api_url.is_empty() {
mistral::MISTRAL_API_URL.into()
@@ -720,6 +710,7 @@ impl MistralEventMapper {
is_input_complete: true,
input,
raw_input: tool_call.arguments,
+ thought_signature: None,
},
))),
Err(error) => {
@@ -746,7 +737,6 @@ struct RawToolCall {
struct ConfigurationView {
api_key_editor: Entity<InputField>,
- codestral_api_key_editor: Entity<InputField>,
state: Entity<State>,
load_credentials_task: Option<Task<()>>,
}
@@ -755,8 +745,6 @@ impl ConfigurationView {
fn new(state: Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let api_key_editor =
cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"));
- let codestral_api_key_editor =
- cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"));
cx.observe(&state, |_, _, cx| {
cx.notify();
@@ -773,12 +761,6 @@ impl ConfigurationView {
// We don't log an error, because "not signed in" is also an error.
let _ = task.await;
}
- if let Some(task) = state
- .update(cx, |state, cx| state.authenticate_codestral(cx))
- .log_err()
- {
- let _ = task.await;
- }
this.update(cx, |this, cx| {
this.load_credentials_task = None;
@@ -790,7 +772,6 @@ impl ConfigurationView {
Self {
api_key_editor,
- codestral_api_key_editor,
state,
load_credentials_task,
}
@@ -828,110 +809,9 @@ impl ConfigurationView {
.detach_and_log_err(cx);
}
- fn save_codestral_api_key(
- &mut self,
- _: &menu::Confirm,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- let api_key = self
- .codestral_api_key_editor
- .read(cx)
- .text(cx)
- .trim()
- .to_string();
- if api_key.is_empty() {
- return;
- }
-
- // url changes can cause the editor to be displayed again
- self.codestral_api_key_editor
- .update(cx, |editor, cx| editor.set_text("", window, cx));
-
- let state = self.state.clone();
- cx.spawn_in(window, async move |_, cx| {
- state
- .update(cx, |state, cx| {
- state.set_codestral_api_key(Some(api_key), cx)
- })?
- .await?;
- cx.update(|_window, cx| {
- set_edit_prediction_provider(EditPredictionProvider::Codestral, cx)
- })
- })
- .detach_and_log_err(cx);
- }
-
- fn reset_codestral_api_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- self.codestral_api_key_editor
- .update(cx, |editor, cx| editor.set_text("", window, cx));
-
- let state = self.state.clone();
- cx.spawn_in(window, async move |_, cx| {
- state
- .update(cx, |state, cx| state.set_codestral_api_key(None, cx))?
- .await?;
- cx.update(|_window, cx| set_edit_prediction_provider(EditPredictionProvider::Zed, cx))
- })
- .detach_and_log_err(cx);
- }
-
fn should_render_api_key_editor(&self, cx: &mut Context<Self>) -> bool {
!self.state.read(cx).is_authenticated()
}
-
- fn render_codestral_api_key_editor(&mut self, cx: &mut Context<Self>) -> AnyElement {
- let key_state = &self.state.read(cx).codestral_api_key_state;
- let should_show_editor = !key_state.has_key();
- let env_var_set = key_state.is_from_env_var();
- let configured_card_label = if env_var_set {
- format!("API key set in {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable")
- } else {
- "Codestral API key configured".to_string()
- };
-
- if should_show_editor {
- v_flex()
- .id("codestral")
- .size_full()
- .mt_2()
- .on_action(cx.listener(Self::save_codestral_api_key))
- .child(Label::new(
- "To use Codestral as an edit prediction provider, \
- you need to add a Codestral-specific API key. Follow these steps:",
- ))
- .child(
- List::new()
- .child(InstructionListItem::new(
- "Create one by visiting",
- Some("the Codestral section of Mistral's console"),
- Some("https://console.mistral.ai/codestral"),
- ))
- .child(InstructionListItem::text_only("Paste your API key below and hit enter")),
- )
- .child(self.codestral_api_key_editor.clone())
- .child(
- Label::new(
- format!("You can also assign the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable and restart Zed."),
- )
- .size(LabelSize::Small).color(Color::Muted),
- ).into_any()
- } else {
- ConfiguredApiCard::new(configured_card_label)
- .disabled(env_var_set)
- .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
- .when(env_var_set, |this| {
- this.tooltip_label(format!(
- "To reset your API key, \
- unset the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable."
- ))
- })
- .on_click(
- cx.listener(|this, _, window, cx| this.reset_codestral_api_key(window, cx)),
- )
- .into_any_element()
- }
- }
}
impl Render for ConfigurationView {
@@ -957,17 +837,17 @@ impl Render for ConfigurationView {
.child(Label::new("To use Zed's agent with Mistral, you need to add an API key. Follow these steps:"))
.child(
List::new()
- .child(InstructionListItem::new(
- "Create one by visiting",
- Some("Mistral's console"),
- Some("https://console.mistral.ai/api-keys"),
- ))
- .child(InstructionListItem::text_only(
- "Ensure your Mistral account has credits",
- ))
- .child(InstructionListItem::text_only(
- "Paste your API key below and hit enter to start using the assistant",
- )),
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("Create one by visiting"))
+ .child(ButtonLink::new("Mistral's console", "https://console.mistral.ai/api-keys"))
+ )
+ .child(
+ ListBulletItem::new("Ensure your Mistral account has credits")
+ )
+ .child(
+ ListBulletItem::new("Paste your API key below and hit enter to start using the assistant")
+ ),
)
.child(self.api_key_editor.clone())
.child(
@@ -976,7 +856,6 @@ impl Render for ConfigurationView {
)
.size(LabelSize::Small).color(Color::Muted),
)
- .child(self.render_codestral_api_key_editor(cx))
.into_any()
} else {
v_flex()
@@ -993,24 +872,11 @@ impl Render for ConfigurationView {
))
}),
)
- .child(self.render_codestral_api_key_editor(cx))
.into_any()
}
}
}
-fn set_edit_prediction_provider(provider: EditPredictionProvider, cx: &mut App) {
- let fs = <dyn Fs>::global(cx);
- update_settings_file(fs, cx, move |settings, _| {
- settings
- .project
- .all_languages
- .features
- .get_or_insert_default()
- .edit_prediction_provider = Some(provider);
- });
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -1024,11 +890,13 @@ mod tests {
role: Role::System,
content: vec![MessageContent::Text("System prompt".into())],
cache: false,
+ reasoning_details: None,
},
LanguageModelRequestMessage {
role: Role::User,
content: vec![MessageContent::Text("Hello".into())],
cache: false,
+ reasoning_details: None,
},
],
temperature: Some(0.5),
@@ -1059,10 +927,11 @@ mod tests {
MessageContent::Text("What's in this image?".into()),
MessageContent::Image(LanguageModelImage {
source: "base64data".into(),
- size: Default::default(),
+ size: None,
}),
],
cache: false,
+ reasoning_details: None,
}],
tools: vec![],
tool_choice: None,
@@ -5,11 +5,11 @@ use futures::{Stream, TryFutureExt, stream};
use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task};
use http_client::HttpClient;
use language_model::{
- AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
- LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
- LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
- LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse,
- LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage,
+ ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+ LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+ LanguageModelRequest, LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse,
+ LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
};
use menu;
use ollama::{
@@ -22,13 +22,13 @@ use std::pin::Pin;
use std::sync::LazyLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::{collections::HashMap, sync::Arc};
-use ui::{ButtonLike, ElevationIndex, List, Tooltip, prelude::*};
+use ui::{
+ ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip,
+ prelude::*,
+};
use ui_input::InputField;
-use zed_env_vars::{EnvVar, env_var};
use crate::AllLanguageModelSettings;
-use crate::api_key::ApiKeyState;
-use crate::ui::{ConfiguredApiCard, InstructionListItem};
const OLLAMA_DOWNLOAD_URL: &str = "https://ollama.com/download";
const OLLAMA_LIBRARY_URL: &str = "https://ollama.com/library";
@@ -43,6 +43,7 @@ static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
#[derive(Default, Debug, Clone, PartialEq)]
pub struct OllamaSettings {
pub api_url: String,
+ pub auto_discover: bool,
pub available_models: Vec<AvailableModel>,
}
@@ -80,12 +81,9 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = OllamaLanguageModelProvider::api_url(cx);
- let task = self.api_key_state.load_if_needed(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- );
+ let task = self
+ .api_key_state
+ .load_if_needed(api_url, |this| &mut this.api_key_state, cx);
// Always try to fetch models - if no API key is needed (local Ollama), it will work
// If API key is needed and provided, it will work
@@ -185,7 +183,7 @@ impl OllamaLanguageModelProvider {
http_client,
fetched_models: Default::default(),
fetch_model_task: None,
- api_key_state: ApiKeyState::new(Self::api_url(cx)),
+ api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
}),
};
@@ -241,10 +239,13 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let mut models: HashMap<String, ollama::Model> = HashMap::new();
+ let settings = OllamaLanguageModelProvider::settings(cx);
// Add models from the Ollama API
- for model in self.state.read(cx).fetched_models.iter() {
- models.insert(model.name.clone(), model.clone());
+ if settings.auto_discover {
+ for model in self.state.read(cx).fetched_models.iter() {
+ models.insert(model.name.clone(), model.clone());
+ }
}
// Override with available models from settings
@@ -381,10 +382,13 @@ impl OllamaLanguageModel {
thinking = Some(text)
}
MessageContent::ToolUse(tool_use) => {
- tool_calls.push(OllamaToolCall::Function(OllamaFunctionCall {
- name: tool_use.name.to_string(),
- arguments: tool_use.input,
- }));
+ tool_calls.push(OllamaToolCall {
+ id: Some(tool_use.id.to_string()),
+ function: OllamaFunctionCall {
+ name: tool_use.name.to_string(),
+ arguments: tool_use.input,
+ },
+ });
}
_ => (),
}
@@ -575,25 +579,24 @@ fn map_to_language_model_completion_events(
}
if let Some(tool_call) = tool_calls.and_then(|v| v.into_iter().next()) {
- match tool_call {
- OllamaToolCall::Function(function) => {
- let tool_id = format!(
- "{}-{}",
- &function.name,
- TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed)
- );
- let event =
- LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
- id: LanguageModelToolUseId::from(tool_id),
- name: Arc::from(function.name),
- raw_input: function.arguments.to_string(),
- input: function.arguments,
- is_input_complete: true,
- });
- events.push(Ok(event));
- state.used_tools = true;
- }
- }
+ let OllamaToolCall { id, function } = tool_call;
+ let id = id.unwrap_or_else(|| {
+ format!(
+ "{}-{}",
+ &function.name,
+ TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed)
+ )
+ });
+ let event = LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
+ id: LanguageModelToolUseId::from(id),
+ name: Arc::from(function.name),
+ raw_input: function.arguments.to_string(),
+ input: function.arguments,
+ is_input_complete: true,
+ thought_signature: None,
+ });
+ events.push(Ok(event));
+ state.used_tools = true;
} else if !content.is_empty() {
events.push(Ok(LanguageModelCompletionEvent::Text(content)));
}
@@ -721,7 +724,7 @@ impl ConfigurationView {
cx.notify();
}
- fn render_instructions() -> Div {
+ fn render_instructions(cx: &mut Context<Self>) -> Div {
v_flex()
.gap_2()
.child(Label::new(
@@ -731,15 +734,17 @@ impl ConfigurationView {
.child(Label::new("To use local Ollama:"))
.child(
List::new()
- .child(InstructionListItem::new(
- "Download and install Ollama from",
- Some("ollama.com"),
- Some("https://ollama.com/download"),
- ))
- .child(InstructionListItem::text_only(
- "Start Ollama and download a model: `ollama run gpt-oss:20b`",
- ))
- .child(InstructionListItem::text_only(
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("Download and install Ollama from"))
+ .child(ButtonLink::new("ollama.com", "https://ollama.com/download")),
+ )
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("Start Ollama and download a model:"))
+ .child(Label::new("ollama run gpt-oss:20b").inline_code(cx)),
+ )
+ .child(ListBulletItem::new(
"Click 'Connect' below to start using Ollama in Zed",
)),
)
@@ -828,7 +833,7 @@ impl Render for ConfigurationView {
v_flex()
.gap_2()
- .child(Self::render_instructions())
+ .child(Self::render_instructions(cx))
.child(self.render_api_url_editor(cx))
.child(self.render_api_key_editor(cx))
.child(
@@ -5,11 +5,11 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
use http_client::HttpClient;
use language_model::{
- AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
- LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
- LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
- LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
- RateLimiter, Role, StopReason, TokenUsage,
+ ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+ LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+ LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
+ LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
};
use menu;
use open_ai::{
@@ -20,13 +20,9 @@ use std::pin::Pin;
use std::str::FromStr as _;
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::ui::ConfiguredApiCard;
-use crate::{api_key::ApiKeyState, ui::InstructionListItem};
const PROVIDER_ID: LanguageModelProviderId = language_model::OPEN_AI_PROVIDER_ID;
const PROVIDER_NAME: LanguageModelProviderName = language_model::OPEN_AI_PROVIDER_NAME;
@@ -62,12 +58,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = OpenAiLanguageModelProvider::api_url(cx);
- self.api_key_state.load_if_needed(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- )
+ self.api_key_state
+ .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -76,17 +68,13 @@ impl OpenAiLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
- this.api_key_state.handle_url_change(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- );
+ this.api_key_state
+ .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
- api_key_state: ApiKeyState::new(Self::api_url(cx)),
+ api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
});
@@ -226,12 +214,17 @@ impl OpenAiLanguageModel {
};
let future = self.request_limiter.stream(async move {
+ let provider = PROVIDER_NAME;
let Some(api_key) = api_key else {
- return Err(LanguageModelCompletionError::NoApiKey {
- provider: PROVIDER_NAME,
- });
+ return Err(LanguageModelCompletionError::NoApiKey { provider });
};
- let request = stream_completion(http_client.as_ref(), &api_url, &api_key, request);
+ let request = stream_completion(
+ http_client.as_ref(),
+ provider.0.as_str(),
+ &api_url,
+ &api_key,
+ request,
+ );
let response = request.await?;
Ok(response)
});
@@ -272,6 +265,8 @@ impl LanguageModel for OpenAiLanguageModel {
| Model::Five
| Model::FiveMini
| Model::FiveNano
+ | Model::FivePointOne
+ | Model::FivePointTwo
| Model::O1
| Model::O3
| Model::O4Mini => true,
@@ -432,7 +427,7 @@ pub fn into_open_ai(
messages,
stream,
stop: request.stop,
- temperature: request.temperature.unwrap_or(1.0),
+ temperature: request.temperature.or(Some(1.0)),
max_completion_tokens: max_output_tokens,
parallel_tool_calls: if supports_parallel_tool_calls && !request.tools.is_empty() {
// Disable parallel tool calls, as the Agent currently expects a maximum of one per turn.
@@ -581,6 +576,7 @@ impl OpenAiEventMapper {
is_input_complete: true,
input,
raw_input: tool_call.arguments.clone(),
+ thought_signature: None,
},
)),
Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
@@ -638,7 +634,6 @@ pub fn count_open_ai_tokens(
) -> BoxFuture<'static, Result<u64>> {
cx.background_spawn(async move {
let messages = collect_tiktoken_messages(request);
-
match model {
Model::Custom { max_tokens, .. } => {
let model = if max_tokens >= 100_000 {
@@ -666,10 +661,13 @@ pub fn count_open_ai_tokens(
| Model::O1
| Model::O3
| Model::O3Mini
- | Model::O4Mini => tiktoken_rs::num_tokens_from_messages(model.id(), &messages),
- // GPT-5 models don't have tiktoken support yet; fall back on gpt-4o tokenizer
- Model::Five | Model::FiveMini | Model::FiveNano => {
- tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages)
+ | Model::O4Mini
+ | Model::Five
+ | Model::FiveMini
+ | Model::FiveNano => tiktoken_rs::num_tokens_from_messages(model.id(), &messages),
+ // GPT-5.1 and 5.2 don't have dedicated tiktoken support; use gpt-5 tokenizer
+ Model::FivePointOne | Model::FivePointTwo => {
+ tiktoken_rs::num_tokens_from_messages("gpt-5", &messages)
}
}
.map(|tokens| tokens as u64)
@@ -780,17 +778,17 @@ impl Render for ConfigurationView {
.child(Label::new("To use Zed's agent with OpenAI, you need to add an API key. Follow these steps:"))
.child(
List::new()
- .child(InstructionListItem::new(
- "Create one by visiting",
- Some("OpenAI's console"),
- Some("https://platform.openai.com/api-keys"),
- ))
- .child(InstructionListItem::text_only(
- "Ensure your OpenAI account has credits",
- ))
- .child(InstructionListItem::text_only(
- "Paste your API key below and hit enter to start using the assistant",
- )),
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("Create one by visiting"))
+ .child(ButtonLink::new("OpenAI's console", "https://platform.openai.com/api-keys"))
+ )
+ .child(
+ ListBulletItem::new("Ensure your OpenAI account has credits")
+ )
+ .child(
+ ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
+ ),
)
.child(self.api_key_editor.clone())
.child(
@@ -876,6 +874,7 @@ mod tests {
role: Role::User,
content: vec![MessageContent::Text("message".into())],
cache: false,
+ reasoning_details: None,
}],
tools: vec![],
tool_choice: None,
@@ -4,10 +4,10 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
use http_client::HttpClient;
use language_model::{
- AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
- LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
- LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
- LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
+ ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+ LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+ LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
};
use menu;
use open_ai::{ResponseStreamEvent, stream_completion};
@@ -16,9 +16,7 @@ use std::sync::Arc;
use ui::{ElevationIndex, Tooltip, prelude::*};
use ui_input::InputField;
use util::ResultExt;
-use zed_env_vars::EnvVar;
-use crate::api_key::ApiKeyState;
use crate::provider::open_ai::{OpenAiEventMapper, into_open_ai};
pub use settings::OpenAiCompatibleAvailableModel as AvailableModel;
pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities;
@@ -38,7 +36,6 @@ pub struct OpenAiCompatibleLanguageModelProvider {
pub struct State {
id: Arc<str>,
- api_key_env_var: EnvVar,
api_key_state: ApiKeyState,
settings: OpenAiCompatibleSettings,
}
@@ -56,12 +53,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = SharedString::new(self.settings.api_url.clone());
- self.api_key_state.load_if_needed(
- api_url,
- &self.api_key_env_var,
- |this| &mut this.api_key_state,
- cx,
- )
+ self.api_key_state
+ .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -83,7 +76,6 @@ impl OpenAiCompatibleLanguageModelProvider {
let api_url = SharedString::new(settings.api_url.as_str());
this.api_key_state.handle_url_change(
api_url,
- &this.api_key_env_var,
|this| &mut this.api_key_state,
cx,
);
@@ -95,8 +87,10 @@ impl OpenAiCompatibleLanguageModelProvider {
let settings = resolve_settings(&id, cx).cloned().unwrap_or_default();
State {
id: id.clone(),
- api_key_env_var: EnvVar::new(api_key_env_var_name),
- api_key_state: ApiKeyState::new(SharedString::new(settings.api_url.as_str())),
+ api_key_state: ApiKeyState::new(
+ SharedString::new(settings.api_url.as_str()),
+ EnvVar::new(api_key_env_var_name),
+ ),
settings,
}
});
@@ -205,8 +199,13 @@ impl OpenAiCompatibleLanguageModel {
&self,
request: open_ai::Request,
cx: &AsyncApp,
- ) -> BoxFuture<'static, Result<futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>>>
- {
+ ) -> BoxFuture<
+ 'static,
+ Result<
+ futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>,
+ LanguageModelCompletionError,
+ >,
+ > {
let http_client = self.http_client.clone();
let Ok((api_key, api_url)) = self.state.read_with(cx, |state, _cx| {
@@ -216,7 +215,7 @@ impl OpenAiCompatibleLanguageModel {
state.settings.api_url.clone(),
)
}) else {
- return future::ready(Err(anyhow!("App state dropped"))).boxed();
+ return future::ready(Err(anyhow!("App state dropped").into())).boxed();
};
let provider = self.provider_name.clone();
@@ -224,7 +223,13 @@ impl OpenAiCompatibleLanguageModel {
let Some(api_key) = api_key else {
return Err(LanguageModelCompletionError::NoApiKey { provider });
};
- let request = stream_completion(http_client.as_ref(), &api_url, &api_key, request);
+ let request = stream_completion(
+ http_client.as_ref(),
+ provider.0.as_str(),
+ &api_url,
+ &api_key,
+ request,
+ );
let response = request.await?;
Ok(response)
});
@@ -426,7 +431,7 @@ impl Render for ConfigurationView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let state = self.state.read(cx);
let env_var_set = state.api_key_state.is_from_env_var();
- let env_var_name = &state.api_key_env_var.name;
+ let env_var_name = state.api_key_state.env_var_name();
let api_key_section = if self.should_render_editor(cx) {
v_flex()
@@ -4,11 +4,12 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task};
use http_client::HttpClient;
use language_model::{
- AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
- LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
- LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
- LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
- LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage,
+ ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+ LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+ LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
+ LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role,
+ StopReason, TokenUsage, env_var,
};
use open_router::{
Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ResponseStreamEvent, list_models,
@@ -17,13 +18,9 @@ use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsSto
use std::pin::Pin;
use std::str::FromStr as _;
use std::sync::{Arc, LazyLock};
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::ui::ConfiguredApiCard;
-use crate::{api_key::ApiKeyState, ui::InstructionListItem};
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openrouter");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenRouter");
@@ -62,12 +59,9 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = OpenRouterLanguageModelProvider::api_url(cx);
- let task = self.api_key_state.load_if_needed(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- );
+ let task = self
+ .api_key_state
+ .load_if_needed(api_url, |this| &mut this.api_key_state, cx);
cx.spawn(async move |this, cx| {
let result = task.await;
@@ -135,7 +129,7 @@ impl OpenRouterLanguageModelProvider {
})
.detach();
State {
- api_key_state: ApiKeyState::new(Self::api_url(cx)),
+ api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
http_client: http_client.clone(),
available_models: Vec::new(),
fetch_models_task: None,
@@ -393,6 +387,7 @@ pub fn into_open_router(
) -> open_router::Request {
let mut messages = Vec::new();
for message in request.messages {
+ let reasoning_details = message.reasoning_details.clone();
for content in message.content {
match content {
MessageContent::Text(text) => add_message_content_part(
@@ -419,18 +414,26 @@ pub fn into_open_router(
name: tool_use.name.to_string(),
arguments: serde_json::to_string(&tool_use.input)
.unwrap_or_default(),
+ thought_signature: tool_use.thought_signature.clone(),
},
},
};
- if let Some(open_router::RequestMessage::Assistant { tool_calls, .. }) =
- messages.last_mut()
+ if let Some(open_router::RequestMessage::Assistant {
+ tool_calls,
+ reasoning_details: existing_reasoning,
+ ..
+ }) = messages.last_mut()
{
tool_calls.push(tool_call);
+ if existing_reasoning.is_none() && reasoning_details.is_some() {
+ *existing_reasoning = reasoning_details.clone();
+ }
} else {
messages.push(open_router::RequestMessage::Assistant {
content: None,
tool_calls: vec![tool_call],
+ reasoning_details: reasoning_details.clone(),
});
}
}
@@ -529,6 +532,7 @@ fn add_message_content_part(
Role::Assistant => open_router::RequestMessage::Assistant {
content: Some(open_router::MessageContent::from(vec![new_part])),
tool_calls: Vec::new(),
+ reasoning_details: None,
},
Role::System => open_router::RequestMessage::System {
content: open_router::MessageContent::from(vec![new_part]),
@@ -540,12 +544,14 @@ fn add_message_content_part(
pub struct OpenRouterEventMapper {
tool_calls_by_index: HashMap<usize, RawToolCall>,
+ reasoning_details: Option<serde_json::Value>,
}
impl OpenRouterEventMapper {
pub fn new() -> Self {
Self {
tool_calls_by_index: HashMap::default(),
+ reasoning_details: None,
}
}
@@ -577,6 +583,15 @@ impl OpenRouterEventMapper {
};
let mut events = Vec::new();
+
+ if let Some(details) = choice.delta.reasoning_details.clone() {
+ // Emit reasoning_details immediately
+ events.push(Ok(LanguageModelCompletionEvent::ReasoningDetails(
+ details.clone(),
+ )));
+ self.reasoning_details = Some(details);
+ }
+
if let Some(reasoning) = choice.delta.reasoning.clone() {
events.push(Ok(LanguageModelCompletionEvent::Thinking {
text: reasoning,
@@ -608,6 +623,10 @@ impl OpenRouterEventMapper {
if let Some(arguments) = function.arguments.clone() {
entry.arguments.push_str(&arguments);
}
+
+ if let Some(signature) = function.thought_signature.clone() {
+ entry.thought_signature = Some(signature);
+ }
}
}
}
@@ -623,6 +642,7 @@ impl OpenRouterEventMapper {
match choice.finish_reason.as_deref() {
Some("stop") => {
+ // Don't emit reasoning_details here - already emitted immediately when captured
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
}
Some("tool_calls") => {
@@ -635,6 +655,7 @@ impl OpenRouterEventMapper {
is_input_complete: true,
input,
raw_input: tool_call.arguments.clone(),
+ thought_signature: tool_call.thought_signature.clone(),
},
)),
Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
@@ -646,10 +667,12 @@ impl OpenRouterEventMapper {
}
}));
+ // Don't emit reasoning_details here - already emitted immediately when captured
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
}
Some(stop_reason) => {
log::error!("Unexpected OpenRouter stop_reason: {stop_reason:?}",);
+ // Don't emit reasoning_details here - already emitted immediately when captured
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
}
None => {}
@@ -664,6 +687,7 @@ struct RawToolCall {
id: String,
name: String,
arguments: String,
+ thought_signature: Option<String>,
}
pub fn count_open_router_tokens(
@@ -800,17 +824,15 @@ impl Render for ConfigurationView {
.child(Label::new("To use Zed's agent with OpenRouter, you need to add an API key. Follow these steps:"))
.child(
List::new()
- .child(InstructionListItem::new(
- "Create an API key by visiting",
- Some("OpenRouter's console"),
- Some("https://openrouter.ai/keys"),
- ))
- .child(InstructionListItem::text_only(
- "Ensure your OpenRouter account has credits",
- ))
- .child(InstructionListItem::text_only(
- "Paste your API key below and hit enter to start using the assistant",
- )),
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("Create an API key by visiting"))
+ .child(ButtonLink::new("OpenRouter's console", "https://openrouter.ai/keys"))
+ )
+ .child(ListBulletItem::new("Ensure your OpenRouter account has credits")
+ )
+ .child(ListBulletItem::new("Paste your API key below and hit enter to start using the assistant")
+ ),
)
.child(self.api_key_editor.clone())
.child(
@@ -831,3 +853,235 @@ impl Render for ConfigurationView {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use open_router::{ChoiceDelta, FunctionChunk, ResponseMessageDelta, ToolCallChunk};
+
+ #[gpui::test]
+ async fn test_reasoning_details_preservation_with_tool_calls() {
+ // This test verifies that reasoning_details are properly captured and preserved
+ // when a model uses tool calling with reasoning/thinking tokens.
+ //
+ // The key regression this prevents:
+ // - OpenRouter sends multiple reasoning_details updates during streaming
+ // - First with actual content (encrypted reasoning data)
+ // - Then with empty array on completion
+ // - We must NOT overwrite the real data with the empty array
+
+ let mut mapper = OpenRouterEventMapper::new();
+
+ // Simulate the streaming events as they come from OpenRouter/Gemini
+ let events = vec![
+ // Event 1: Initial reasoning details with text
+ ResponseStreamEvent {
+ id: Some("response_123".into()),
+ created: 1234567890,
+ model: "google/gemini-3-pro-preview".into(),
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: ResponseMessageDelta {
+ role: None,
+ content: None,
+ reasoning: None,
+ tool_calls: None,
+ reasoning_details: Some(serde_json::json!([
+ {
+ "type": "reasoning.text",
+ "text": "Let me analyze this request...",
+ "format": "google-gemini-v1",
+ "index": 0
+ }
+ ])),
+ },
+ finish_reason: None,
+ }],
+ usage: None,
+ },
+ // Event 2: More reasoning details
+ ResponseStreamEvent {
+ id: Some("response_123".into()),
+ created: 1234567890,
+ model: "google/gemini-3-pro-preview".into(),
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: ResponseMessageDelta {
+ role: None,
+ content: None,
+ reasoning: None,
+ tool_calls: None,
+ reasoning_details: Some(serde_json::json!([
+ {
+ "type": "reasoning.encrypted",
+ "data": "EtgDCtUDAdHtim9OF5jm4aeZSBAtl/randomized123",
+ "format": "google-gemini-v1",
+ "index": 0,
+ "id": "tool_call_abc123"
+ }
+ ])),
+ },
+ finish_reason: None,
+ }],
+ usage: None,
+ },
+ // Event 3: Tool call starts
+ ResponseStreamEvent {
+ id: Some("response_123".into()),
+ created: 1234567890,
+ model: "google/gemini-3-pro-preview".into(),
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: ResponseMessageDelta {
+ role: None,
+ content: None,
+ reasoning: None,
+ tool_calls: Some(vec![ToolCallChunk {
+ index: 0,
+ id: Some("tool_call_abc123".into()),
+ function: Some(FunctionChunk {
+ name: Some("list_directory".into()),
+ arguments: Some("{\"path\":\"test\"}".into()),
+ thought_signature: Some("sha256:test_signature_xyz789".into()),
+ }),
+ }]),
+ reasoning_details: None,
+ },
+ finish_reason: None,
+ }],
+ usage: None,
+ },
+ // Event 4: Empty reasoning_details on tool_calls finish
+ // This is the critical event - we must not overwrite with this empty array!
+ ResponseStreamEvent {
+ id: Some("response_123".into()),
+ created: 1234567890,
+ model: "google/gemini-3-pro-preview".into(),
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: ResponseMessageDelta {
+ role: None,
+ content: None,
+ reasoning: None,
+ tool_calls: None,
+ reasoning_details: Some(serde_json::json!([])),
+ },
+ finish_reason: Some("tool_calls".into()),
+ }],
+ usage: None,
+ },
+ ];
+
+ // Process all events
+ let mut collected_events = Vec::new();
+ for event in events {
+ let mapped = mapper.map_event(event);
+ collected_events.extend(mapped);
+ }
+
+ // Verify we got the expected events
+ let mut has_tool_use = false;
+ let mut reasoning_details_events = Vec::new();
+ let mut thought_signature_value = None;
+
+ for event_result in collected_events {
+ match event_result {
+ Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => {
+ has_tool_use = true;
+ assert_eq!(tool_use.id.to_string(), "tool_call_abc123");
+ assert_eq!(tool_use.name.as_ref(), "list_directory");
+ thought_signature_value = tool_use.thought_signature.clone();
+ }
+ Ok(LanguageModelCompletionEvent::ReasoningDetails(details)) => {
+ reasoning_details_events.push(details);
+ }
+ _ => {}
+ }
+ }
+
+ // Assertions
+ assert!(has_tool_use, "Should have emitted ToolUse event");
+ assert!(
+ !reasoning_details_events.is_empty(),
+ "Should have emitted ReasoningDetails events"
+ );
+
+ // We should have received multiple reasoning_details events (text, encrypted, empty)
+ // The agent layer is responsible for keeping only the first non-empty one
+ assert!(
+ reasoning_details_events.len() >= 2,
+ "Should have multiple reasoning_details events from streaming"
+ );
+
+ // Verify at least one contains the encrypted data
+ let has_encrypted = reasoning_details_events.iter().any(|details| {
+ if let serde_json::Value::Array(arr) = details {
+ arr.iter().any(|item| {
+ item["type"] == "reasoning.encrypted"
+ && item["data"]
+ .as_str()
+ .map_or(false, |s| s.contains("EtgDCtUDAdHtim9OF5jm4aeZSBAtl"))
+ })
+ } else {
+ false
+ }
+ });
+ assert!(
+ has_encrypted,
+ "Should have at least one reasoning_details with encrypted data"
+ );
+
+ // Verify thought_signature was captured
+ assert!(
+ thought_signature_value.is_some(),
+ "Tool use should have thought_signature"
+ );
+ assert_eq!(
+ thought_signature_value.unwrap(),
+ "sha256:test_signature_xyz789"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_agent_prevents_empty_reasoning_details_overwrite() {
+ // This test verifies that the agent layer prevents empty reasoning_details
+ // from overwriting non-empty ones, even though the mapper emits all events.
+
+ // Simulate what the agent does when it receives multiple ReasoningDetails events
+ let mut agent_reasoning_details: Option<serde_json::Value> = None;
+
+ let events = vec![
+ // First event: non-empty reasoning_details
+ serde_json::json!([
+ {
+ "type": "reasoning.encrypted",
+ "data": "real_data_here",
+ "format": "google-gemini-v1"
+ }
+ ]),
+ // Second event: empty array (should not overwrite)
+ serde_json::json!([]),
+ ];
+
+ for details in events {
+ // This mimics the agent's logic: only store if we don't already have it
+ if agent_reasoning_details.is_none() {
+ agent_reasoning_details = Some(details);
+ }
+ }
+
+ // Verify the agent kept the first non-empty reasoning_details
+ assert!(agent_reasoning_details.is_some());
+ let final_details = agent_reasoning_details.unwrap();
+ if let serde_json::Value::Array(arr) = &final_details {
+ assert!(
+ !arr.is_empty(),
+ "Agent should have kept the non-empty reasoning_details"
+ );
+ assert_eq!(arr[0]["data"], "real_data_here");
+ } else {
+ panic!("Expected array");
+ }
+ }
+}
@@ -4,26 +4,20 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
use http_client::HttpClient;
use language_model::{
- AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
- LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
- LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
- LanguageModelToolChoice, RateLimiter, Role,
+ ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+ LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+ LanguageModelRequest, LanguageModelToolChoice, RateLimiter, Role, env_var,
};
use open_ai::ResponseStreamEvent;
pub use settings::VercelAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore};
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use vercel::{Model, VERCEL_API_URL};
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::{
- api_key::ApiKeyState,
- ui::{ConfiguredApiCard, InstructionListItem},
-};
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("vercel");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Vercel");
@@ -59,12 +53,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = VercelLanguageModelProvider::api_url(cx);
- self.api_key_state.load_if_needed(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- )
+ self.api_key_state
+ .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -73,17 +63,13 @@ impl VercelLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
- this.api_key_state.handle_url_change(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- );
+ this.api_key_state
+ .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
- api_key_state: ApiKeyState::new(Self::api_url(cx)),
+ api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
});
@@ -220,13 +206,17 @@ impl VercelLanguageModel {
};
let future = self.request_limiter.stream(async move {
+ let provider = PROVIDER_NAME;
let Some(api_key) = api_key else {
- return Err(LanguageModelCompletionError::NoApiKey {
- provider: PROVIDER_NAME,
- });
+ return Err(LanguageModelCompletionError::NoApiKey { provider });
};
- let request =
- open_ai::stream_completion(http_client.as_ref(), &api_url, &api_key, request);
+ let request = open_ai::stream_completion(
+ http_client.as_ref(),
+ provider.0.as_str(),
+ &api_url,
+ &api_key,
+ request,
+ );
let response = request.await?;
Ok(response)
});
@@ -468,14 +458,14 @@ impl Render for ConfigurationView {
.child(Label::new("To use Zed's agent with Vercel v0, you need to add an API key. Follow these steps:"))
.child(
List::new()
- .child(InstructionListItem::new(
- "Create one by visiting",
- Some("Vercel v0's console"),
- Some("https://v0.dev/chat/settings/keys"),
- ))
- .child(InstructionListItem::text_only(
- "Paste your API key below and hit enter to start using the agent",
- )),
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("Create one by visiting"))
+ .child(ButtonLink::new("Vercel v0's console", "https://v0.dev/chat/settings/keys"))
+ )
+ .child(
+ ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
+ ),
)
.child(self.api_key_editor.clone())
.child(
@@ -4,26 +4,21 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window};
use http_client::HttpClient;
use language_model::{
- AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
- LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
- LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
- LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, Role,
+ ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+ LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+ LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
+ Role, env_var,
};
use open_ai::ResponseStreamEvent;
pub use settings::XaiAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore};
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use x_ai::{Model, XAI_API_URL};
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::{
- api_key::ApiKeyState,
- ui::{ConfiguredApiCard, InstructionListItem},
-};
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("xAI");
@@ -59,12 +54,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = XAiLanguageModelProvider::api_url(cx);
- self.api_key_state.load_if_needed(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- )
+ self.api_key_state
+ .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -73,17 +64,13 @@ impl XAiLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
- this.api_key_state.handle_url_change(
- api_url,
- &API_KEY_ENV_VAR,
- |this| &mut this.api_key_state,
- cx,
- );
+ this.api_key_state
+ .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
- api_key_state: ApiKeyState::new(Self::api_url(cx)),
+ api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
});
@@ -211,25 +198,34 @@ impl XAiLanguageModel {
&self,
request: open_ai::Request,
cx: &AsyncApp,
- ) -> BoxFuture<'static, Result<futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>>>
- {
+ ) -> BoxFuture<
+ 'static,
+ Result<
+ futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>,
+ LanguageModelCompletionError,
+ >,
+ > {
let http_client = self.http_client.clone();
let Ok((api_key, api_url)) = self.state.read_with(cx, |state, cx| {
let api_url = XAiLanguageModelProvider::api_url(cx);
(state.api_key_state.key(&api_url), api_url)
}) else {
- return future::ready(Err(anyhow!("App state dropped"))).boxed();
+ return future::ready(Err(anyhow!("App state dropped").into())).boxed();
};
let future = self.request_limiter.stream(async move {
+ let provider = PROVIDER_NAME;
let Some(api_key) = api_key else {
- return Err(LanguageModelCompletionError::NoApiKey {
- provider: PROVIDER_NAME,
- });
+ return Err(LanguageModelCompletionError::NoApiKey { provider });
};
- let request =
- open_ai::stream_completion(http_client.as_ref(), &api_url, &api_key, request);
+ let request = open_ai::stream_completion(
+ http_client.as_ref(),
+ provider.0.as_str(),
+ &api_url,
+ &api_key,
+ request,
+ );
let response = request.await?;
Ok(response)
});
@@ -465,14 +461,14 @@ impl Render for ConfigurationView {
.child(Label::new("To use Zed's agent with xAI, you need to add an API key. Follow these steps:"))
.child(
List::new()
- .child(InstructionListItem::new(
- "Create one by visiting",
- Some("xAI console"),
- Some("https://console.x.ai/team/default/api-keys"),
- ))
- .child(InstructionListItem::text_only(
- "Paste your API key below and hit enter to start using the agent",
- )),
+ .child(
+ ListBulletItem::new("")
+ .child(Label::new("Create one by visiting"))
+ .child(ButtonLink::new("xAI console", "https://console.x.ai/team/default/api-keys"))
+ )
+ .child(
+ ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
+ ),
)
.child(self.api_key_editor.clone())
.child(
@@ -58,6 +58,7 @@ impl settings::Settings for AllLanguageModelSettings {
profile_name: bedrock.profile,
role_arn: None, // todo(was never a setting for this...)
authentication_method: bedrock.authentication_method.map(Into::into),
+ allow_global: bedrock.allow_global,
},
deepseek: DeepSeekSettings {
api_url: deepseek.api_url.unwrap(),
@@ -77,6 +78,7 @@ impl settings::Settings for AllLanguageModelSettings {
},
ollama: OllamaSettings {
api_url: ollama.api_url.unwrap(),
+ auto_discover: ollama.auto_discover.unwrap_or(true),
available_models: ollama.available_models.unwrap_or_default(),
},
open_router: OpenRouterSettings {
@@ -1,4 +0,0 @@
-pub mod configured_api_card;
-pub mod instruction_list_item;
-pub use configured_api_card::ConfiguredApiCard;
-pub use instruction_list_item::InstructionListItem;
@@ -1,69 +0,0 @@
-use gpui::{AnyElement, IntoElement, ParentElement, SharedString};
-use ui::{ListItem, prelude::*};
-
-/// A reusable list item component for adding LLM provider configuration instructions
-pub struct InstructionListItem {
- label: SharedString,
- button_label: Option<SharedString>,
- button_link: Option<String>,
-}
-
-impl InstructionListItem {
- pub fn new(
- label: impl Into<SharedString>,
- button_label: Option<impl Into<SharedString>>,
- button_link: Option<impl Into<String>>,
- ) -> Self {
- Self {
- label: label.into(),
- button_label: button_label.map(|l| l.into()),
- button_link: button_link.map(|l| l.into()),
- }
- }
-
- pub fn text_only(label: impl Into<SharedString>) -> Self {
- Self {
- label: label.into(),
- button_label: None,
- button_link: None,
- }
- }
-}
-
-impl IntoElement for InstructionListItem {
- type Element = AnyElement;
-
- fn into_element(self) -> Self::Element {
- let item_content = if let (Some(button_label), Some(button_link)) =
- (self.button_label, self.button_link)
- {
- let link = button_link;
- let unique_id = SharedString::from(format!("{}-button", self.label));
-
- h_flex()
- .flex_wrap()
- .child(Label::new(self.label))
- .child(
- Button::new(unique_id, button_label)
- .style(ButtonStyle::Subtle)
- .icon(IconName::ArrowUpRight)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .on_click(move |_, _window, cx| cx.open_url(&link)),
- )
- .into_any_element()
- } else {
- Label::new(self.label).into_any_element()
- };
-
- ListItem::new("list-item")
- .selectable(false)
- .start_slot(
- Icon::new(IconName::Dash)
- .size(IconSize::XSmall)
- .color(Color::Hidden),
- )
- .child(div().w_full().child(item_content))
- .into_any_element()
- }
-}
@@ -39,5 +39,6 @@ zed_actions.workspace = true
editor = { workspace = true, features = ["test-support"] }
release_channel.workspace = true
gpui = { workspace = true, features = ["test-support"] }
+semver.workspace = true
util = { workspace = true, features = ["test-support"] }
zlog.workspace = true
@@ -94,7 +94,7 @@ struct LanguageServerBinaryStatus {
#[derive(Debug)]
struct ServerInfo {
name: LanguageServerName,
- id: Option<LanguageServerId>,
+ id: LanguageServerId,
health: Option<ServerHealth>,
binary_status: Option<LanguageServerBinaryStatus>,
message: Option<SharedString>,
@@ -102,9 +102,7 @@ struct ServerInfo {
impl ServerInfo {
fn server_selector(&self) -> LanguageServerSelector {
- self.id
- .map(LanguageServerSelector::Id)
- .unwrap_or_else(|| LanguageServerSelector::Name(self.name.clone()))
+ LanguageServerSelector::Id(self.id)
}
}
@@ -214,7 +212,6 @@ impl LanguageServerState {
let Some(server_info) = item.server_info() else {
continue;
};
-
let server_selector = server_info.server_selector();
let is_remote = self
.lsp_store
@@ -430,7 +427,7 @@ enum ServerData<'a> {
binary_status: Option<&'a LanguageServerBinaryStatus>,
},
WithBinaryStatus {
- server_id: Option<LanguageServerId>,
+ server_id: LanguageServerId,
server_name: &'a LanguageServerName,
binary_status: &'a LanguageServerBinaryStatus,
},
@@ -444,7 +441,7 @@ enum LspMenuItem {
binary_status: Option<LanguageServerBinaryStatus>,
},
WithBinaryStatus {
- server_id: Option<LanguageServerId>,
+ server_id: LanguageServerId,
server_name: LanguageServerName,
binary_status: LanguageServerBinaryStatus,
},
@@ -469,7 +466,7 @@ impl LspMenuItem {
..
} => Some(ServerInfo {
name: health.name.clone(),
- id: Some(*server_id),
+ id: *server_id,
health: health.health(),
binary_status: binary_status.clone(),
message: health.message(),
@@ -591,6 +588,8 @@ impl LspButton {
};
let mut updated = false;
+ // TODO `LspStore` is global and reports status from all language servers, even from the other windows.
+ // Also, we do not get "LSP removed" events so LSPs are never removed.
match e {
LspStoreEvent::LanguageServerUpdate {
language_server_id,
@@ -758,7 +757,6 @@ impl LspButton {
.ok();
let mut servers_per_worktree = BTreeMap::<SharedString, Vec<ServerData>>::new();
- let mut servers_without_worktree = Vec::<ServerData>::new();
let mut servers_with_health_checks = HashSet::default();
for (server_id, health) in &state.language_servers.health_statuses {
@@ -780,12 +778,11 @@ impl LspButton {
health,
binary_status,
};
- match worktree_name {
- Some(worktree_name) => servers_per_worktree
+ if let Some(worktree_name) = worktree_name {
+ servers_per_worktree
.entry(worktree_name.clone())
.or_default()
- .push(server_data),
- None => servers_without_worktree.push(server_data),
+ .push(server_data);
}
}
@@ -822,42 +819,25 @@ impl LspButton {
BinaryStatus::Failed { .. } => {}
}
- match server_names_to_worktrees.get(server_name) {
- Some(worktrees_for_name) => {
- match worktrees_for_name
- .iter()
- .find(|(worktree, _)| active_worktrees.contains(worktree))
- .or_else(|| worktrees_for_name.iter().next())
- {
- Some((worktree, server_id)) => {
- let worktree_name =
- SharedString::new(worktree.read(cx).root_name_str());
- servers_per_worktree
- .entry(worktree_name.clone())
- .or_default()
- .push(ServerData::WithBinaryStatus {
- server_name,
- binary_status,
- server_id: Some(*server_id),
- });
- }
- None => servers_without_worktree.push(ServerData::WithBinaryStatus {
- server_name,
- binary_status,
- server_id: None,
- }),
- }
- }
- None => servers_without_worktree.push(ServerData::WithBinaryStatus {
- server_name,
- binary_status,
- server_id: None,
- }),
+ if let Some(worktrees_for_name) = server_names_to_worktrees.get(server_name)
+ && let Some((worktree, server_id)) = worktrees_for_name
+ .iter()
+ .find(|(worktree, _)| active_worktrees.contains(worktree))
+ .or_else(|| worktrees_for_name.iter().next())
+ {
+ let worktree_name = SharedString::new(worktree.read(cx).root_name_str());
+ servers_per_worktree
+ .entry(worktree_name.clone())
+ .or_default()
+ .push(ServerData::WithBinaryStatus {
+ server_name,
+ binary_status,
+ server_id: *server_id,
+ });
}
}
- let mut new_lsp_items =
- Vec::with_capacity(servers_per_worktree.len() + servers_without_worktree.len() + 2);
+ let mut new_lsp_items = Vec::with_capacity(servers_per_worktree.len() + 1);
for (worktree_name, worktree_servers) in servers_per_worktree {
if worktree_servers.is_empty() {
continue;
@@ -868,17 +848,6 @@ impl LspButton {
});
new_lsp_items.extend(worktree_servers.into_iter().map(ServerData::into_lsp_item));
}
- if !servers_without_worktree.is_empty() {
- new_lsp_items.push(LspMenuItem::Header {
- header: Some(SharedString::from("Unknown worktree")),
- separator: false,
- });
- new_lsp_items.extend(
- servers_without_worktree
- .into_iter()
- .map(ServerData::into_lsp_item),
- );
- }
if !new_lsp_items.is_empty() {
if can_stop_all {
new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: true });
@@ -1053,11 +1022,16 @@ impl Render for LspButton {
(None, "All Servers Operational")
};
- let lsp_button = cx.entity();
+ let lsp_button = cx.weak_entity();
div().child(
PopoverMenu::new("lsp-tool")
- .menu(move |_, cx| lsp_button.read(cx).lsp_menu.clone())
+ .menu(move |_, cx| {
+ lsp_button
+ .read_with(cx, |lsp_button, _| lsp_button.lsp_menu.clone())
+ .ok()
+ .flatten()
+ })
.anchor(Corner::BottomLeft)
.with_handle(self.popover_menu_handle.clone())
.trigger_with_tooltip(
@@ -1,18 +1,18 @@
use collections::VecDeque;
use copilot::Copilot;
-use editor::{Editor, EditorEvent, actions::MoveToEnd, scroll::Autoscroll};
+use editor::{Editor, EditorEvent, MultiBufferOffset, actions::MoveToEnd, scroll::Autoscroll};
use gpui::{
- AnyView, App, Context, Corner, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
- ParentElement, Render, Styled, Subscription, Task, WeakEntity, Window, actions, div,
+ App, Context, Corner, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement,
+ Render, Styled, Subscription, Task, WeakEntity, Window, actions, div,
};
-use itertools::Itertools;
+use itertools::Itertools as _;
use language::{LanguageServerId, language_settings::SoftWrap};
use lsp::{
- LanguageServer, LanguageServerBinary, LanguageServerName, LanguageServerSelector, MessageType,
- SetTraceParams, TraceValue, notification::SetTrace,
+ LanguageServer, LanguageServerName, LanguageServerSelector, MessageType, SetTraceParams,
+ TraceValue, notification::SetTrace,
};
use project::{
- Project,
+ LanguageServerStatus, Project,
lsp_store::log_store::{self, Event, LanguageServerKind, LogKind, LogStore, Message},
search::SearchQuery,
};
@@ -125,7 +125,7 @@ pub fn init(on_headless_host: bool, cx: &mut App) {
let server_id = server.server_id();
let weak_lsp_store = cx.weak_entity();
log_store.copilot_log_subscription =
- Some(server.on_notification::<copilot::request::LogMessage, _>(
+ Some(server.on_notification::<lsp::notification::LogMessage, _>(
move |params, cx| {
weak_lsp_store
.update(cx, |lsp_store, cx| {
@@ -231,7 +231,7 @@ impl LspLogView {
let last_offset = editor.buffer().read(cx).len(cx);
let newest_cursor_is_at_end = editor
.selections
- .newest::<usize>(&editor.display_snapshot(cx))
+ .newest::<MultiBufferOffset>(&editor.display_snapshot(cx))
.start
>= last_offset;
editor.edit(
@@ -241,13 +241,15 @@ impl LspLogView {
],
cx,
);
- if text.len() > 1024
- && let Some((fold_offset, _)) =
- text.char_indices().dropping(1024).next()
- && fold_offset < text.len()
- {
+ if text.len() > 1024 {
+ let b = editor.buffer().read(cx).as_singleton().unwrap().read(cx);
+ let fold_offset =
+ b.as_rope().ceil_char_boundary(last_offset.0 + 1024);
editor.fold_ranges(
- vec![last_offset + fold_offset..last_offset + text.len()],
+ vec![
+ MultiBufferOffset(fold_offset)
+ ..MultiBufferOffset(b.as_rope().len()),
+ ],
false,
window,
cx,
@@ -267,7 +269,7 @@ impl LspLogView {
let focus_handle = cx.focus_handle();
let focus_subscription = cx.on_focus(&focus_handle, window, |log_view, window, cx| {
- window.focus(&log_view.editor.focus_handle(cx));
+ window.focus(&log_view.editor.focus_handle(cx), cx);
});
cx.on_release(|log_view, cx| {
@@ -336,16 +338,24 @@ impl LspLogView {
* Capabilities: {CAPABILITIES}
* Configuration: {CONFIGURATION}",
- NAME = info.name,
+ NAME = info.status.name,
ID = info.id,
BINARY = info
+ .status
.binary
.as_ref()
- .map_or_else(|| "Unknown".to_string(), |binary| format!("{binary:#?}")),
- WORKSPACE_FOLDERS = info.workspace_folders.join(", "),
+ .map_or_else(|| "Unknown".to_string(), |binary| format!("{:#?}", binary)),
+ WORKSPACE_FOLDERS = info
+ .status
+ .workspace_folders
+ .iter()
+ .filter_map(|uri| uri.to_file_path().ok())
+ .map(|path| path.to_string_lossy().into_owned())
+ .join(", "),
CAPABILITIES = serde_json::to_string_pretty(&info.capabilities)
.unwrap_or_else(|e| format!("Failed to serialize capabilities: {e}")),
CONFIGURATION = info
+ .status
.configuration
.map(|configuration| serde_json::to_string_pretty(&configuration))
.transpose()
@@ -452,7 +462,7 @@ impl LspLogView {
self.editor_subscriptions = editor_subscriptions;
cx.notify();
}
- self.editor.read(cx).focus_handle(cx).focus(window);
+ self.editor.read(cx).focus_handle(cx).focus(window, cx);
self.log_store.update(cx, |log_store, cx| {
let state = log_store.get_language_server_state(server_id)?;
state.toggled_log_kind = Some(LogKind::Logs);
@@ -484,7 +494,7 @@ impl LspLogView {
cx.notify();
}
- self.editor.read(cx).focus_handle(cx).focus(window);
+ self.editor.read(cx).focus_handle(cx).focus(window, cx);
}
fn show_trace_for_server(
@@ -518,7 +528,7 @@ impl LspLogView {
});
cx.notify();
}
- self.editor.read(cx).focus_handle(cx).focus(window);
+ self.editor.read(cx).focus_handle(cx).focus(window, cx);
}
fn show_rpc_trace_for_server(
@@ -562,7 +572,7 @@ impl LspLogView {
cx.notify();
}
- self.editor.read(cx).focus_handle(cx).focus(window);
+ self.editor.read(cx).focus_handle(cx).focus(window, cx);
}
fn toggle_rpc_trace_for_server(
@@ -632,17 +642,12 @@ impl LspLogView {
.or_else(move || {
let capabilities =
lsp_store.lsp_server_capabilities.get(&server_id)?.clone();
- let name = lsp_store
- .language_server_statuses
- .get(&server_id)
- .map(|status| status.name.clone())?;
+ let status = lsp_store.language_server_statuses.get(&server_id)?.clone();
+
Some(ServerInfo {
id: server_id,
capabilities,
- binary: None,
- name,
- workspace_folders: Vec::new(),
- configuration: None,
+ status,
})
})
})
@@ -655,7 +660,7 @@ impl LspLogView {
self.editor = editor;
self.editor_subscriptions = editor_subscriptions;
cx.notify();
- self.editor.read(cx).focus_handle(cx).focus(window);
+ self.editor.read(cx).focus_handle(cx).focus(window, cx);
self.log_store.update(cx, |log_store, cx| {
let state = log_store.get_language_server_state(server_id)?;
if let Some(log_kind) = state.toggled_log_kind.take() {
@@ -739,7 +744,11 @@ impl Item for LspLogView {
None
}
- fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(
+ &self,
+ handle: &Entity<Self>,
+ _: &App,
+ ) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(handle.clone()))
}
@@ -748,11 +757,11 @@ impl Item for LspLogView {
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
- ) -> Option<AnyView> {
+ ) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
- Some(self_handle.to_any())
+ Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
- Some(self.editor.to_any())
+ Some(self.editor.clone().into())
} else {
None
}
@@ -796,11 +805,13 @@ impl SearchableItem for LspLogView {
fn update_matches(
&mut self,
matches: &[Self::Match],
+ active_match_index: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
- 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<Self>) -> String {
@@ -812,13 +823,11 @@ impl SearchableItem for LspLogView {
&mut self,
index: usize,
matches: &[Self::Match],
- collapse: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
- self.editor.update(cx, |e, cx| {
- e.activate_match(index, matches, collapse, window, cx)
- })
+ self.editor
+ .update(cx, |e, cx| e.activate_match(index, matches, window, cx))
}
fn select_matches(
@@ -930,7 +939,7 @@ impl Render for LspLogToolbarItemView {
})
.collect();
- let log_toolbar_view = cx.entity();
+ let log_toolbar_view = cx.weak_entity();
let lsp_menu = PopoverMenu::new("LspLogView")
.anchor(Corner::TopLeft)
@@ -959,7 +968,7 @@ impl Render for LspLogToolbarItemView {
for (server_id, name, worktree_root, active_entry_kind) in
available_language_servers.iter()
{
- let label = format!("{} ({})", name, worktree_root);
+ let label = format!("{name} ({worktree_root})");
let server_id = *server_id;
let active_entry_kind = *active_entry_kind;
menu = menu.entry(
@@ -1014,7 +1023,7 @@ impl Render for LspLogToolbarItemView {
.icon_color(Color::Muted),
)
.menu(move |window, cx| {
- let log_toolbar_view = log_toolbar_view.clone();
+ let log_toolbar_view = log_toolbar_view.upgrade()?;
let log_view = log_view.clone();
Some(ContextMenu::build(window, cx, move |this, window, _| {
this.entry(
@@ -1305,7 +1314,7 @@ impl LspLogToolbarItemView {
log_view.show_rpc_trace_for_server(id, window, cx);
cx.notify();
}
- window.focus(&log_view.focus_handle);
+ window.focus(&log_view.focus_handle, cx);
});
}
cx.notify();
@@ -1315,10 +1324,7 @@ impl LspLogToolbarItemView {
struct ServerInfo {
id: LanguageServerId,
capabilities: lsp::ServerCapabilities,
- binary: Option<LanguageServerBinary>,
- name: LanguageServerName,
- workspace_folders: Vec<String>,
- configuration: Option<serde_json::Value>,
+ status: LanguageServerStatus,
}
impl ServerInfo {
@@ -1326,18 +1332,16 @@ impl ServerInfo {
Self {
id: server.server_id(),
capabilities: server.capabilities(),
- binary: Some(server.binary().clone()),
- name: server.name(),
- workspace_folders: server
- .workspace_folders()
- .into_iter()
- .filter_map(|path| {
- path.to_file_path()
- .ok()
- .map(|path| path.to_string_lossy().into_owned())
- })
- .collect::<Vec<_>>(),
- configuration: Some(server.configuration().clone()),
+ status: LanguageServerStatus {
+ name: server.name(),
+ pending_work: Default::default(),
+ has_pending_diagnostic_updates: false,
+ progress_tokens: Default::default(),
+ worktree: None,
+ binary: Some(server.binary().clone()),
+ configuration: Some(server.configuration().clone()),
+ workspace_folders: server.workspace_folders(),
+ },
}
}
}
@@ -4,7 +4,7 @@ use crate::lsp_log_view::LogMenuItem;
use super::*;
use futures::StreamExt;
-use gpui::{AppContext as _, SemanticVersion, TestAppContext, VisualTestContext};
+use gpui::{AppContext as _, TestAppContext, VisualTestContext};
use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
use lsp::LanguageServerName;
use project::{
@@ -110,6 +110,6 @@ fn init_test(cx: &mut gpui::TestAppContext) {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
theme::init(theme::LoadThemes::JustBase, cx);
- release_channel::init(SemanticVersion::default(), cx);
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
});
}
@@ -1,5 +1,5 @@
use command_palette_hooks::CommandPaletteFilter;
-use editor::{Anchor, Editor, ExcerptId, SelectionEffects, scroll::Autoscroll};
+use editor::{Anchor, Editor, ExcerptId, MultiBufferOffset, SelectionEffects, scroll::Autoscroll};
use gpui::{
App, AppContext as _, Context, Div, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
Hsla, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent,
@@ -254,7 +254,7 @@ impl SyntaxTreeView {
let (buffer, range, excerpt_id) = editor_state.editor.update(cx, |editor, cx| {
let selection_range = editor
.selections
- .last::<usize>(&editor.display_snapshot(cx))
+ .last::<MultiBufferOffset>(&editor.display_snapshot(cx))
.range();
let multi_buffer = editor.buffer().read(cx);
let (buffer, range, excerpt_id) = snapshot
@@ -308,8 +308,8 @@ impl SyntaxTreeView {
// Within the active layer, find the syntax node under the cursor,
// and scroll to it.
let mut cursor = layer.node().walk();
- while cursor.goto_first_child_for_byte(range.start).is_some() {
- if !range.is_empty() && cursor.node().end_byte() == range.start {
+ while cursor.goto_first_child_for_byte(range.start.0).is_some() {
+ if !range.is_empty() && cursor.node().end_byte() == range.start.0 {
cursor.goto_next_sibling();
}
}
@@ -317,7 +317,7 @@ impl SyntaxTreeView {
// Ascend to the smallest ancestor that contains the range.
loop {
let node_range = cursor.node().byte_range();
- if node_range.start <= range.start && node_range.end >= range.end {
+ if node_range.start <= range.start.0 && node_range.end >= range.end.0 {
break;
}
if !cursor.goto_parent() {
@@ -459,7 +459,7 @@ impl SyntaxTreeView {
editor.clear_background_highlights::<Self>(cx);
editor.highlight_background::<Self>(
&[range],
- |theme| {
+ |_, theme| {
theme
.colors()
.editor_document_highlight_write_background
@@ -507,11 +507,11 @@ impl Render for SyntaxTreeView {
}),
)
.size_full()
- .track_scroll(self.list_scroll_handle.clone())
+ .track_scroll(&self.list_scroll_handle)
.text_bg(cx.theme().colors().background)
.into_any_element(),
)
- .vertical_scrollbar_for(self.list_scroll_handle.clone(), window, cx)
+ .vertical_scrollbar_for(&self.list_scroll_handle, window, cx)
.into_any_element()
} else {
let inner_content = v_flex()
@@ -614,13 +614,14 @@ impl SyntaxTreeToolbarItemView {
let active_layer = buffer_state.active_layer.clone()?;
let active_buffer = buffer_state.buffer.read(cx).snapshot();
- let view = cx.entity();
+ let view = cx.weak_entity();
Some(
PopoverMenu::new("Syntax Tree")
.trigger(Self::render_header(&active_layer))
.menu(move |window, cx| {
- ContextMenu::build(window, cx, |mut menu, window, _| {
+ ContextMenu::build(window, cx, |mut menu, _, _| {
for (layer_ix, layer) in active_buffer.syntax_layers().enumerate() {
+ let view = view.clone();
menu = menu.entry(
format!(
"{} {}",
@@ -628,9 +629,12 @@ impl SyntaxTreeToolbarItemView {
format_node_range(layer.node())
),
None,
- window.handler_for(&view, move |view, window, cx| {
- view.select_layer(layer_ix, window, cx);
- }),
+ move |window, cx| {
+ view.update(cx, |view, cx| {
+ view.select_layer(layer_ix, window, cx);
+ })
+ .ok();
+ },
);
}
menu
@@ -655,7 +659,7 @@ impl SyntaxTreeToolbarItemView {
buffer_state.active_layer = Some(layer.to_owned());
view.selected_descendant_ix = None;
cx.notify();
- view.focus_handle.focus(window);
+ view.focus_handle.focus(window, cx);
Some(())
})
}
@@ -42,10 +42,11 @@ async-trait.workspace = true
chrono.workspace = true
collections.workspace = true
futures.workspace = true
+globset.workspace = true
gpui.workspace = true
http_client.workspace = true
-json_schema_store.workspace = true
itertools.workspace = true
+json_schema_store.workspace = true
language.workspace = true
log.workspace = true
lsp.workspace = true
@@ -66,8 +67,10 @@ serde.workspace = true
serde_json.workspace = true
serde_json_lenient.workspace = true
settings.workspace = true
+smallvec.workspace = true
+semver.workspace = true
smol.workspace = true
-url.workspace = true
+snippet.workspace = true
task.workspace = true
terminal.workspace = true
theme.workspace = true
@@ -90,6 +93,7 @@ tree-sitter-regex = { workspace = true, optional = true }
tree-sitter-rust = { workspace = true, optional = true }
tree-sitter-typescript = { workspace = true, optional = true }
tree-sitter-yaml = { workspace = true, optional = true }
+url.workspace = true
util.workspace = true
[dev-dependencies]
@@ -98,6 +102,7 @@ text.workspace = true
theme = { workspace = true, features = ["test-support"] }
tree-sitter-bash.workspace = true
tree-sitter-c.workspace = true
+tree-sitter-cpp.workspace = true
tree-sitter-css.workspace = true
tree-sitter-go.workspace = true
tree-sitter-python.workspace = true
@@ -44,7 +44,11 @@ mod tests {
let expect_indents_to =
|buffer: &mut Buffer, cx: &mut Context<Buffer>, input: &str, expected: &str| {
- buffer.edit( [(0..buffer.len(), input)], Some(AutoindentMode::EachLine), cx, );
+ buffer.edit(
+ [(0..buffer.len(), input)],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
assert_eq!(buffer.text(), expected);
};
@@ -1,12 +1,12 @@
("(" @open ")" @close)
("[" @open "]" @close)
("{" @open "}" @close)
-("\"" @open "\"" @close)
-("`" @open "`" @close)
-(("do" @open "done" @close) (#set! newline.only))
-((case_statement ("in" @open "esac" @close)) (#set! newline.only))
-((if_statement (elif_clause ("then" @open)) (else_clause ("else" @close))) (#set! newline.only))
-((if_statement (else_clause ("else" @open)) "fi" @close) (#set! newline.only))
-((if_statement ("then" @open) (elif_clause ("elif" @close))) (#set! newline.only))
-((if_statement ("then" @open) (else_clause ("else" @close))) (#set! newline.only))
-((if_statement ("then" @open "fi" @close)) (#set! newline.only))
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
+(("`" @open "`" @close) (#set! rainbow.exclude))
+(("do" @open "done" @close) (#set! newline.only) (#set! rainbow.exclude))
+((case_statement ("in" @open "esac" @close)) (#set! newline.only) (#set! rainbow.exclude))
+((if_statement (elif_clause ("then" @open)) (else_clause ("else" @close))) (#set! newline.only) (#set! rainbow.exclude))
+((if_statement (else_clause ("else" @open)) "fi" @close) (#set! newline.only) (#set! rainbow.exclude))
+((if_statement ("then" @open) (elif_clause ("elif" @close))) (#set! newline.only) (#set! rainbow.exclude))
+((if_statement ("then" @open) (else_clause ("else" @close))) (#set! newline.only) (#set! rainbow.exclude))
+((if_statement ("then" @open "fi" @close)) (#set! newline.only) (#set! rainbow.exclude))
@@ -395,10 +395,10 @@ mod tests {
use language::{AutoindentMode, Buffer};
use settings::SettingsStore;
use std::num::NonZeroU32;
+ use unindent::Unindent;
#[gpui::test]
- async fn test_c_autoindent(cx: &mut TestAppContext) {
- // cx.executor().set_block_on_ticks(usize::MAX..=usize::MAX);
+ async fn test_c_autoindent_basic(cx: &mut TestAppContext) {
cx.update(|cx| {
let test_settings = SettingsStore::test(cx);
cx.set_global(test_settings);
@@ -413,23 +413,229 @@ mod tests {
cx.new(|cx| {
let mut buffer = Buffer::local("", cx).with_language(language, cx);
- // empty function
buffer.edit([(0..0, "int main() {}")], None, cx);
- // indent inside braces
let ix = buffer.len() - 1;
buffer.edit([(ix..ix, "\n\n")], Some(AutoindentMode::EachLine), cx);
- assert_eq!(buffer.text(), "int main() {\n \n}");
+ assert_eq!(
+ buffer.text(),
+ "int main() {\n \n}",
+ "content inside braces should be indented"
+ );
- // indent body of single-statement if statement
- let ix = buffer.len() - 2;
- buffer.edit([(ix..ix, "if (a)\nb;")], Some(AutoindentMode::EachLine), cx);
- assert_eq!(buffer.text(), "int main() {\n if (a)\n b;\n}");
+ buffer
+ });
+ }
- // indent inside field expression
- let ix = buffer.len() - 3;
+ #[gpui::test]
+ async fn test_c_autoindent_if_else(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let test_settings = SettingsStore::test(cx);
+ cx.set_global(test_settings);
+ cx.update_global::<SettingsStore, _>(|store, cx| {
+ store.update_user_settings(cx, |s| {
+ s.project.all_languages.defaults.tab_size = NonZeroU32::new(2);
+ });
+ });
+ });
+ let language = crate::language("c", tree_sitter_c::LANGUAGE.into());
+
+ cx.new(|cx| {
+ let mut buffer = Buffer::local("", cx).with_language(language, cx);
+
+ buffer.edit(
+ [(
+ 0..0,
+ r#"
+ int main() {
+ if (a)
+ b;
+ }
+ "#
+ .unindent(),
+ )],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a)
+ b;
+ }
+ "#
+ .unindent(),
+ "body of if-statement without braces should be indented"
+ );
+
+ let ix = buffer.len() - 4;
buffer.edit([(ix..ix, "\n.c")], Some(AutoindentMode::EachLine), cx);
- assert_eq!(buffer.text(), "int main() {\n if (a)\n b\n .c;\n}");
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a)
+ b
+ .c;
+ }
+ "#
+ .unindent(),
+ "field expression (.c) should be indented further than the statement body"
+ );
+
+ buffer.edit([(0..buffer.len(), "")], Some(AutoindentMode::EachLine), cx);
+ buffer.edit(
+ [(
+ 0..0,
+ r#"
+ int main() {
+ if (a) a++;
+ else b++;
+ }
+ "#
+ .unindent(),
+ )],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a) a++;
+ else b++;
+ }
+ "#
+ .unindent(),
+ "single-line if/else without braces should align at the same level"
+ );
+
+ buffer.edit([(0..buffer.len(), "")], Some(AutoindentMode::EachLine), cx);
+ buffer.edit(
+ [(
+ 0..0,
+ r#"
+ int main() {
+ if (a)
+ b++;
+ else
+ c++;
+ }
+ "#
+ .unindent(),
+ )],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a)
+ b++;
+ else
+ c++;
+ }
+ "#
+ .unindent(),
+ "multi-line if/else without braces should indent statement bodies"
+ );
+
+ buffer.edit([(0..buffer.len(), "")], Some(AutoindentMode::EachLine), cx);
+ buffer.edit(
+ [(
+ 0..0,
+ r#"
+ int main() {
+ if (a)
+ if (b)
+ c++;
+ }
+ "#
+ .unindent(),
+ )],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a)
+ if (b)
+ c++;
+ }
+ "#
+ .unindent(),
+ "nested if statements without braces should indent properly"
+ );
+
+ buffer.edit([(0..buffer.len(), "")], Some(AutoindentMode::EachLine), cx);
+ buffer.edit(
+ [(
+ 0..0,
+ r#"
+ int main() {
+ if (a)
+ b++;
+ else if (c)
+ d++;
+ else
+ f++;
+ }
+ "#
+ .unindent(),
+ )],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a)
+ b++;
+ else if (c)
+ d++;
+ else
+ f++;
+ }
+ "#
+ .unindent(),
+ "else-if chains should align all conditions at same level with indented bodies"
+ );
+
+ buffer.edit([(0..buffer.len(), "")], Some(AutoindentMode::EachLine), cx);
+ buffer.edit(
+ [(
+ 0..0,
+ r#"
+ int main() {
+ if (a) {
+ b++;
+ } else
+ c++;
+ }
+ "#
+ .unindent(),
+ )],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a) {
+ b++;
+ } else
+ c++;
+ }
+ "#
+ .unindent(),
+ "mixed braces should indent properly"
+ );
buffer
});
@@ -1,5 +1,5 @@
("(" @open ")" @close)
("[" @open "]" @close)
("{" @open "}" @close)
-("\"" @open "\"" @close)
-("'" @open "'" @close)
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
+(("'" @open "'" @close) (#set! rainbow.exclude))
@@ -4,7 +4,7 @@ path_suffixes = ["c"]
line_comments = ["// "]
decrease_indent_patterns = [
{ pattern = "^\\s*\\{.*\\}?\\s*$", valid_after = ["if", "for", "while", "do", "switch", "else"] },
- { pattern = "^\\s*else\\s*$", valid_after = ["if"] }
+ { pattern = "^\\s*else\\b", valid_after = ["if"] }
]
autoclose_before = ";:.,=}])>"
brackets = [
@@ -36,7 +36,7 @@
"#ifndef"
"#include"
(preproc_directive)
-] @keyword
+] @keyword.directive
[
"="
@@ -0,0 +1,252 @@
+#[cfg(test)]
+mod tests {
+ use gpui::{AppContext as _, BorrowAppContext, TestAppContext};
+ use language::{AutoindentMode, Buffer};
+ use settings::SettingsStore;
+ use std::num::NonZeroU32;
+ use unindent::Unindent;
+
+ #[gpui::test]
+ async fn test_cpp_autoindent_basic(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let test_settings = SettingsStore::test(cx);
+ cx.set_global(test_settings);
+ cx.update_global::<SettingsStore, _>(|store, cx| {
+ store.update_user_settings(cx, |s| {
+ s.project.all_languages.defaults.tab_size = NonZeroU32::new(2);
+ });
+ });
+ });
+ let language = crate::language("cpp", tree_sitter_cpp::LANGUAGE.into());
+
+ cx.new(|cx| {
+ let mut buffer = Buffer::local("", cx).with_language(language, cx);
+
+ buffer.edit([(0..0, "int main() {}")], None, cx);
+
+ let ix = buffer.len() - 1;
+ buffer.edit([(ix..ix, "\n\n")], Some(AutoindentMode::EachLine), cx);
+ assert_eq!(
+ buffer.text(),
+ "int main() {\n \n}",
+ "content inside braces should be indented"
+ );
+
+ buffer
+ });
+ }
+
+ #[gpui::test]
+ async fn test_cpp_autoindent_if_else(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let test_settings = SettingsStore::test(cx);
+ cx.set_global(test_settings);
+ cx.update_global::<SettingsStore, _>(|store, cx| {
+ store.update_user_settings(cx, |s| {
+ s.project.all_languages.defaults.tab_size = NonZeroU32::new(2);
+ });
+ });
+ });
+ let language = crate::language("cpp", tree_sitter_cpp::LANGUAGE.into());
+
+ cx.new(|cx| {
+ let mut buffer = Buffer::local("", cx).with_language(language, cx);
+
+ buffer.edit(
+ [(
+ 0..0,
+ r#"
+ int main() {
+ if (a)
+ b;
+ }
+ "#
+ .unindent(),
+ )],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a)
+ b;
+ }
+ "#
+ .unindent(),
+ "body of if-statement without braces should be indented"
+ );
+
+ let ix = buffer.len() - 4;
+ buffer.edit([(ix..ix, "\n.c")], Some(AutoindentMode::EachLine), cx);
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a)
+ b
+ .c;
+ }
+ "#
+ .unindent(),
+ "field expression (.c) should be indented further than the statement body"
+ );
+
+ buffer.edit([(0..buffer.len(), "")], Some(AutoindentMode::EachLine), cx);
+ buffer.edit(
+ [(
+ 0..0,
+ r#"
+ int main() {
+ if (a) a++;
+ else b++;
+ }
+ "#
+ .unindent(),
+ )],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a) a++;
+ else b++;
+ }
+ "#
+ .unindent(),
+ "single-line if/else without braces should align at the same level"
+ );
+
+ buffer.edit([(0..buffer.len(), "")], Some(AutoindentMode::EachLine), cx);
+ buffer.edit(
+ [(
+ 0..0,
+ r#"
+ int main() {
+ if (a)
+ b++;
+ else
+ c++;
+ }
+ "#
+ .unindent(),
+ )],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a)
+ b++;
+ else
+ c++;
+ }
+ "#
+ .unindent(),
+ "multi-line if/else without braces should indent statement bodies"
+ );
+
+ buffer.edit([(0..buffer.len(), "")], Some(AutoindentMode::EachLine), cx);
+ buffer.edit(
+ [(
+ 0..0,
+ r#"
+ int main() {
+ if (a)
+ if (b)
+ c++;
+ }
+ "#
+ .unindent(),
+ )],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a)
+ if (b)
+ c++;
+ }
+ "#
+ .unindent(),
+ "nested if statements without braces should indent properly"
+ );
+
+ buffer.edit([(0..buffer.len(), "")], Some(AutoindentMode::EachLine), cx);
+ buffer.edit(
+ [(
+ 0..0,
+ r#"
+ int main() {
+ if (a)
+ b++;
+ else if (c)
+ d++;
+ else
+ f++;
+ }
+ "#
+ .unindent(),
+ )],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a)
+ b++;
+ else if (c)
+ d++;
+ else
+ f++;
+ }
+ "#
+ .unindent(),
+ "else-if chains should align all conditions at same level with indented bodies"
+ );
+
+ buffer.edit([(0..buffer.len(), "")], Some(AutoindentMode::EachLine), cx);
+ buffer.edit(
+ [(
+ 0..0,
+ r#"
+ int main() {
+ if (a) {
+ b++;
+ } else
+ c++;
+ }
+ "#
+ .unindent(),
+ )],
+ Some(AutoindentMode::EachLine),
+ cx,
+ );
+ assert_eq!(
+ buffer.text(),
+ r#"
+ int main() {
+ if (a) {
+ b++;
+ } else
+ c++;
+ }
+ "#
+ .unindent(),
+ "mixed braces should indent properly"
+ );
+
+ buffer
+ });
+ }
+}
@@ -1,5 +1,6 @@
("(" @open ")" @close)
("[" @open "]" @close)
("{" @open "}" @close)
-("\"" @open "\"" @close)
-("'" @open "'" @close)
+("<" @open ">" @close)
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
+(("'" @open "'" @close) (#set! rainbow.exclude))
@@ -1,10 +1,10 @@
name = "C++"
grammar = "cpp"
-path_suffixes = ["cc", "hh", "cpp", "h", "hpp", "cxx", "hxx", "c++", "ipp", "inl", "ino", "ixx", "cu", "cuh", "C", "H"]
+path_suffixes = ["cc", "hh", "cpp", "h", "hpp", "cxx", "hxx", "c++", "h++", "ipp", "inl", "ino", "ixx", "cu", "cuh", "C", "H"]
line_comments = ["// ", "/// ", "//! "]
decrease_indent_patterns = [
{ pattern = "^\\s*\\{.*\\}?\\s*$", valid_after = ["if", "for", "while", "do", "switch", "else"] },
- { pattern = "^\\s*else\\s*$", valid_after = ["if"] }
+ { pattern = "^\\s*else\\b", valid_after = ["if"] }
]
autoclose_before = ";:.,=}])>"
brackets = [
@@ -1,13 +1,12 @@
-use anyhow::{Context as _, Result};
+use anyhow::Result;
use async_trait::async_trait;
-use futures::StreamExt;
use gpui::AsyncApp;
use language::{LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain};
-use lsp::{LanguageServerBinary, LanguageServerName};
+use lsp::{LanguageServerBinary, LanguageServerName, Uri};
use node_runtime::{NodeRuntime, VersionStrategy};
use project::lsp_store::language_server_settings;
+use semver::Version;
use serde_json::json;
-use smol::fs;
use std::{
ffi::OsString,
path::{Path, PathBuf},
@@ -34,14 +33,14 @@ impl CssLspAdapter {
}
impl LspInstaller for CssLspAdapter {
- type BinaryVersion = String;
+ type BinaryVersion = Version;
async fn fetch_latest_server_version(
&self,
_: &dyn LspAdapterDelegate,
_: bool,
_: &mut AsyncApp,
- ) -> Result<String> {
+ ) -> Result<Self::BinaryVersion> {
self.node
.npm_package_latest_version("vscode-langservers-extracted")
.await
@@ -67,11 +66,12 @@ impl LspInstaller for CssLspAdapter {
async fn fetch_server_binary(
&self,
- latest_version: String,
+ latest_version: Self::BinaryVersion,
container_dir: PathBuf,
_: &dyn LspAdapterDelegate,
) -> Result<LanguageServerBinary> {
let server_path = container_dir.join(SERVER_PATH);
+ let latest_version = latest_version.to_string();
self.node
.npm_install_packages(
@@ -89,7 +89,7 @@ impl LspInstaller for CssLspAdapter {
async fn check_if_version_installed(
&self,
- version: &String,
+ version: &Self::BinaryVersion,
container_dir: &PathBuf,
_: &dyn LspAdapterDelegate,
) -> Option<LanguageServerBinary> {
@@ -144,6 +144,7 @@ impl LspAdapter for CssLspAdapter {
self: Arc<Self>,
delegate: &Arc<dyn LspAdapterDelegate>,
_: Option<Toolchain>,
+ _: Option<Uri>,
cx: &mut AsyncApp,
) -> Result<serde_json::Value> {
let mut default_config = json!({
@@ -176,19 +177,10 @@ async fn get_cached_server_binary(
node: &NodeRuntime,
) -> Option<LanguageServerBinary> {
maybe!(async {
- let mut last_version_dir = None;
- let mut entries = fs::read_dir(&container_dir).await?;
- while let Some(entry) = entries.next().await {
- let entry = entry?;
- if entry.file_type().await?.is_dir() {
- last_version_dir = Some(entry.path());
- }
- }
- let last_version_dir = last_version_dir.context("no cached binary")?;
- let server_path = last_version_dir.join(SERVER_PATH);
+ let server_path = container_dir.join(SERVER_PATH);
anyhow::ensure!(
server_path.exists(),
- "missing executable in directory {last_version_dir:?}"
+ "missing executable in directory {server_path:?}"
);
Ok(LanguageServerBinary {
path: node.binary_path().await?,
@@ -1,5 +1,5 @@
("(" @open ")" @close)
("[" @open "]" @close)
("{" @open "}" @close)
-("\"" @open "\"" @close)
-("'" @open "'" @close)
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
+(("'" @open "'" @close) (#set! rainbow.exclude))
@@ -3,12 +3,14 @@
[
(addition)
(new_file)
-] @diff.plus
+] @string
+;; TODO: This should eventually be `@diff.plus` with a fallback of `@string`
[
(deletion)
(old_file)
-] @diff.minus
+] @keyword
+;; TODO: This should eventually be `@diff.minus` with a fallback of `@keyword`
(commit) @constant
@@ -18,8 +20,6 @@
"diff" @function
(argument) @variable.parameter)
-(filename) @string.special.path
-
(mode) @number
([
@@ -0,0 +1,772 @@
+use anyhow::{Context as _, Result};
+use async_trait::async_trait;
+use gpui::AsyncApp;
+use http_client::{
+ github::{AssetKind, GitHubLspBinaryVersion, build_asset_url},
+ github_download::download_server_binary,
+};
+use language::{LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain};
+use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri};
+use node_runtime::NodeRuntime;
+use project::lsp_store::language_server_settings_for;
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+use settings::SettingsLocation;
+use smol::{fs, stream::StreamExt};
+use std::{
+ ffi::OsString,
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+use util::merge_json_value_into;
+use util::{fs::remove_matching, rel_path::RelPath};
+
+fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
+ vec![
+ "--max-old-space-size=8192".into(),
+ server_path.into(),
+ "--stdio".into(),
+ ]
+}
+
+pub struct EsLintLspAdapter {
+ node: NodeRuntime,
+}
+
+impl EsLintLspAdapter {
+ const CURRENT_VERSION: &'static str = "2.4.4";
+ const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
+
+ #[cfg(not(windows))]
+ const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
+ #[cfg(windows)]
+ const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
+
+ const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
+ const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
+
+ const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
+ "eslint.config.js",
+ "eslint.config.mjs",
+ "eslint.config.cjs",
+ "eslint.config.ts",
+ "eslint.config.cts",
+ "eslint.config.mts",
+ ];
+
+ pub fn new(node: NodeRuntime) -> Self {
+ EsLintLspAdapter { node }
+ }
+
+ fn build_destination_path(container_dir: &Path) -> PathBuf {
+ container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
+ }
+}
+
+impl LspInstaller for EsLintLspAdapter {
+ type BinaryVersion = GitHubLspBinaryVersion;
+
+ async fn fetch_latest_server_version(
+ &self,
+ _delegate: &dyn LspAdapterDelegate,
+ _: bool,
+ _: &mut AsyncApp,
+ ) -> Result<GitHubLspBinaryVersion> {
+ let url = build_asset_url(
+ "zed-industries/vscode-eslint",
+ Self::CURRENT_VERSION_TAG_NAME,
+ Self::GITHUB_ASSET_KIND,
+ )?;
+
+ Ok(GitHubLspBinaryVersion {
+ name: Self::CURRENT_VERSION.into(),
+ digest: None,
+ url,
+ })
+ }
+
+ async fn fetch_server_binary(
+ &self,
+ version: GitHubLspBinaryVersion,
+ container_dir: PathBuf,
+ delegate: &dyn LspAdapterDelegate,
+ ) -> Result<LanguageServerBinary> {
+ let destination_path = Self::build_destination_path(&container_dir);
+ let server_path = destination_path.join(Self::SERVER_PATH);
+
+ if fs::metadata(&server_path).await.is_err() {
+ remove_matching(&container_dir, |_| true).await;
+
+ download_server_binary(
+ &*delegate.http_client(),
+ &version.url,
+ None,
+ &destination_path,
+ Self::GITHUB_ASSET_KIND,
+ )
+ .await?;
+
+ let mut dir = fs::read_dir(&destination_path).await?;
+ let first = dir.next().await.context("missing first file")??;
+ let repo_root = destination_path.join("vscode-eslint");
+ fs::rename(first.path(), &repo_root).await?;
+
+ #[cfg(target_os = "windows")]
+ {
+ handle_symlink(
+ repo_root.join("$shared"),
+ repo_root.join("client").join("src").join("shared"),
+ )
+ .await?;
+ handle_symlink(
+ repo_root.join("$shared"),
+ repo_root.join("server").join("src").join("shared"),
+ )
+ .await?;
+ }
+
+ self.node
+ .run_npm_subcommand(Some(&repo_root), "install", &[])
+ .await?;
+
+ self.node
+ .run_npm_subcommand(Some(&repo_root), "run-script", &["compile"])
+ .await?;
+ }
+
+ Ok(LanguageServerBinary {
+ path: self.node.binary_path().await?,
+ env: None,
+ arguments: eslint_server_binary_arguments(&server_path),
+ })
+ }
+
+ async fn cached_server_binary(
+ &self,
+ container_dir: PathBuf,
+ _: &dyn LspAdapterDelegate,
+ ) -> Option<LanguageServerBinary> {
+ let server_path =
+ Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
+ Some(LanguageServerBinary {
+ path: self.node.binary_path().await.ok()?,
+ env: None,
+ arguments: eslint_server_binary_arguments(&server_path),
+ })
+ }
+}
+
+#[async_trait(?Send)]
+impl LspAdapter for EsLintLspAdapter {
+ fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
+ Some(vec![
+ CodeActionKind::QUICKFIX,
+ CodeActionKind::new("source.fixAll.eslint"),
+ ])
+ }
+
+ async fn workspace_configuration(
+ self: Arc<Self>,
+ delegate: &Arc<dyn LspAdapterDelegate>,
+ _: Option<Toolchain>,
+ requested_uri: Option<Uri>,
+ cx: &mut AsyncApp,
+ ) -> Result<Value> {
+ let worktree_root = delegate.worktree_root_path();
+ let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
+ .iter()
+ .any(|file| worktree_root.join(file).is_file());
+
+ let mut default_workspace_configuration = json!({
+ "validate": "on",
+ "rulesCustomizations": [],
+ "run": "onType",
+ "nodePath": null,
+ "workingDirectory": {
+ "mode": "auto"
+ },
+ "workspaceFolder": {
+ "uri": worktree_root,
+ "name": worktree_root.file_name()
+ .unwrap_or(worktree_root.as_os_str())
+ .to_string_lossy(),
+ },
+ "problems": {},
+ "codeActionOnSave": {
+ // We enable this, but without also configuring code_actions_on_format
+ // in the Zed configuration, it doesn't have an effect.
+ "enable": true,
+ },
+ "codeAction": {
+ "disableRuleComment": {
+ "enable": true,
+ "location": "separateLine",
+ },
+ "showDocumentation": {
+ "enable": true
+ }
+ },
+ "experimental": {
+ "useFlatConfig": use_flat_config,
+ }
+ });
+
+ let file_path = requested_uri
+ .as_ref()
+ .and_then(|uri| {
+ (uri.scheme() == "file")
+ .then(|| uri.to_file_path().ok())
+ .flatten()
+ })
+ .and_then(|abs_path| {
+ abs_path
+ .strip_prefix(&worktree_root)
+ .ok()
+ .map(ToOwned::to_owned)
+ });
+ let file_path = file_path
+ .and_then(|p| RelPath::unix(&p).ok().map(ToOwned::to_owned))
+ .unwrap_or_else(|| RelPath::empty().to_owned());
+ let override_options = cx.update(|cx| {
+ language_server_settings_for(
+ SettingsLocation {
+ worktree_id: delegate.worktree_id(),
+ path: &file_path,
+ },
+ &Self::SERVER_NAME,
+ cx,
+ )
+ .and_then(|s| s.settings.clone())
+ })?;
+
+ if let Some(override_options) = override_options {
+ let working_directories = override_options.get("workingDirectories").and_then(|wd| {
+ serde_json::from_value::<WorkingDirectories>(wd.clone())
+ .ok()
+ .and_then(|wd| wd.0)
+ });
+
+ merge_json_value_into(override_options, &mut default_workspace_configuration);
+
+ let working_directory = working_directories
+ .zip(requested_uri)
+ .and_then(|(wd, uri)| {
+ determine_working_directory(uri, wd, worktree_root.to_owned())
+ });
+
+ if let Some(working_directory) = working_directory
+ && let Some(wd) = default_workspace_configuration.get_mut("workingDirectory")
+ {
+ *wd = serde_json::to_value(working_directory)?;
+ }
+ }
+
+ Ok(json!({
+ "": default_workspace_configuration
+ }))
+ }
+
+ fn name(&self) -> LanguageServerName {
+ Self::SERVER_NAME
+ }
+}
+
+/// On Windows, converts Unix-style separators (/) to Windows-style (\).
+/// On Unix, returns the path unchanged
+fn normalize_path_separators(path: &str) -> String {
+ #[cfg(windows)]
+ {
+ path.replace('/', "\\")
+ }
+ #[cfg(not(windows))]
+ {
+ path.to_string()
+ }
+}
+
+fn determine_working_directory(
+ uri: Uri,
+ working_directories: Vec<WorkingDirectory>,
+ workspace_folder_path: PathBuf,
+) -> Option<ResultWorkingDirectory> {
+ let mut working_directory = None;
+
+ for item in working_directories {
+ let mut directory: Option<String> = None;
+ let mut pattern: Option<String> = None;
+ let mut no_cwd = false;
+ match item {
+ WorkingDirectory::String(contents) => {
+ directory = Some(normalize_path_separators(&contents));
+ }
+ WorkingDirectory::LegacyDirectoryItem(legacy_directory_item) => {
+ directory = Some(normalize_path_separators(&legacy_directory_item.directory));
+ no_cwd = !legacy_directory_item.change_process_cwd;
+ }
+ WorkingDirectory::DirectoryItem(directory_item) => {
+ directory = Some(normalize_path_separators(&directory_item.directory));
+ if let Some(not_cwd) = directory_item.not_cwd {
+ no_cwd = not_cwd;
+ }
+ }
+ WorkingDirectory::PatternItem(pattern_item) => {
+ pattern = Some(normalize_path_separators(&pattern_item.pattern));
+ if let Some(not_cwd) = pattern_item.not_cwd {
+ no_cwd = not_cwd;
+ }
+ }
+ WorkingDirectory::ModeItem(mode_item) => {
+ working_directory = Some(ResultWorkingDirectory::ModeItem(mode_item));
+ continue;
+ }
+ }
+
+ let mut item_value: Option<String> = None;
+ if directory.is_some() || pattern.is_some() {
+ let file_path: Option<PathBuf> = (uri.scheme() == "file")
+ .then(|| uri.to_file_path().ok())
+ .flatten();
+ if let Some(file_path) = file_path {
+ if let Some(mut directory) = directory {
+ if Path::new(&directory).is_relative() {
+ directory = workspace_folder_path
+ .join(directory)
+ .to_string_lossy()
+ .to_string();
+ }
+ if !directory.ends_with(std::path::MAIN_SEPARATOR) {
+ directory.push(std::path::MAIN_SEPARATOR);
+ }
+ if file_path.starts_with(&directory) {
+ item_value = Some(directory);
+ }
+ } else if let Some(mut pattern) = pattern
+ && !pattern.is_empty()
+ {
+ if Path::new(&pattern).is_relative() {
+ pattern = workspace_folder_path
+ .join(pattern)
+ .to_string_lossy()
+ .to_string();
+ }
+ if !pattern.ends_with(std::path::MAIN_SEPARATOR) {
+ pattern.push(std::path::MAIN_SEPARATOR);
+ }
+ if let Some(matched) = match_glob_pattern(&pattern, &file_path) {
+ item_value = Some(matched);
+ }
+ }
+ }
+ }
+ if let Some(item_value) = item_value {
+ if working_directory
+ .as_ref()
+ .is_none_or(|wd| matches!(wd, ResultWorkingDirectory::ModeItem(_)))
+ {
+ working_directory = Some(ResultWorkingDirectory::DirectoryItem(DirectoryItem {
+ directory: item_value,
+ not_cwd: Some(no_cwd),
+ }));
+ } else if let Some(ResultWorkingDirectory::DirectoryItem(item)) = &mut working_directory
+ && item.directory.len() < item_value.len()
+ {
+ item.directory = item_value;
+ item.not_cwd = Some(no_cwd);
+ }
+ }
+ }
+
+ working_directory
+}
+
+fn match_glob_pattern(pattern: &str, file_path: &Path) -> Option<String> {
+ use globset::GlobBuilder;
+
+ let glob = GlobBuilder::new(pattern)
+ .literal_separator(true)
+ .build()
+ .ok()?
+ .compile_matcher();
+
+ let mut current = file_path.to_path_buf();
+ let mut matched: Option<String> = None;
+
+ while let Some(parent) = current.parent() {
+ let mut prefix = parent.to_string_lossy().to_string();
+ if !prefix.ends_with(std::path::MAIN_SEPARATOR) {
+ prefix.push(std::path::MAIN_SEPARATOR);
+ }
+ if glob.is_match(&prefix) {
+ matched = Some(prefix);
+ }
+ current = parent.to_path_buf();
+ }
+
+ matched
+}
+
+#[cfg(target_os = "windows")]
+async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
+ anyhow::ensure!(
+ fs::metadata(&src_dir).await.is_ok(),
+ "Directory {src_dir:?} is not present"
+ );
+ if fs::metadata(&dest_dir).await.is_ok() {
+ fs::remove_file(&dest_dir).await?;
+ }
+ fs::create_dir_all(&dest_dir).await?;
+ let mut entries = fs::read_dir(&src_dir).await?;
+ while let Some(entry) = entries.try_next().await? {
+ let entry_path = entry.path();
+ let entry_name = entry.file_name();
+ let dest_path = dest_dir.join(&entry_name);
+ fs::copy(&entry_path, &dest_path).await?;
+ }
+ Ok(())
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct LegacyDirectoryItem {
+ directory: String,
+ #[serde(rename = "changeProcessCWD")]
+ change_process_cwd: bool,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct DirectoryItem {
+ directory: String,
+ #[serde(rename = "!cwd")]
+ not_cwd: Option<bool>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct PatternItem {
+ pattern: String,
+ #[serde(rename = "!cwd")]
+ not_cwd: Option<bool>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct ModeItem {
+ mode: ModeEnum,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+#[serde(rename_all = "lowercase")]
+enum ModeEnum {
+ Auto,
+ Location,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+#[serde(untagged)]
+enum WorkingDirectory {
+ String(String),
+ LegacyDirectoryItem(LegacyDirectoryItem),
+ DirectoryItem(DirectoryItem),
+ PatternItem(PatternItem),
+ ModeItem(ModeItem),
+}
+#[derive(Serialize, Deserialize)]
+struct WorkingDirectories(Option<Vec<WorkingDirectory>>);
+
+#[derive(Serialize, Deserialize, Debug)]
+#[serde(untagged)]
+enum ResultWorkingDirectory {
+ ModeItem(ModeItem),
+ DirectoryItem(DirectoryItem),
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ mod glob_patterns {
+ use super::*;
+
+ #[test]
+ fn test_match_glob_pattern() {
+ let pattern = unix_path_to_platform("/test/*/");
+ let file_path = PathBuf::from(unix_path_to_platform("/test/foo/bar/file.txt"));
+ let matched = match_glob_pattern(&pattern, &file_path);
+ assert_eq!(matched, Some(unix_path_to_platform("/test/foo/")));
+ }
+
+ #[test]
+ fn test_match_glob_pattern_globstar() {
+ let pattern = unix_path_to_platform("/workspace/**/src/");
+ let file_path = PathBuf::from(unix_path_to_platform(
+ "/workspace/packages/core/src/index.ts",
+ ));
+ let matched = match_glob_pattern(&pattern, &file_path);
+ assert_eq!(
+ matched,
+ Some(unix_path_to_platform("/workspace/packages/core/src/"))
+ );
+ }
+
+ #[test]
+ fn test_match_glob_pattern_no_match() {
+ let pattern = unix_path_to_platform("/other/*/");
+ let file_path = PathBuf::from(unix_path_to_platform("/test/foo/bar/file.txt"));
+ let matched = match_glob_pattern(&pattern, &file_path);
+ assert_eq!(matched, None);
+ }
+ }
+
+ mod unix_style_paths {
+ use super::*;
+
+ #[test]
+ fn test_working_directory_string() {
+ let uri = make_uri("/workspace/packages/foo/src/file.ts");
+ let working_directories = vec![WorkingDirectory::String("packages/foo".to_string())];
+ let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
+
+ let result = determine_working_directory(uri, working_directories, workspace_folder);
+ assert_directory_result(
+ result,
+ &unix_path_to_platform("/workspace/packages/foo/"),
+ false,
+ );
+ }
+
+ #[test]
+ fn test_working_directory_absolute_path() {
+ let uri = make_uri("/workspace/packages/foo/src/file.ts");
+ let working_directories = vec![WorkingDirectory::String(unix_path_to_platform(
+ "/workspace/packages/foo",
+ ))];
+ let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
+
+ let result = determine_working_directory(uri, working_directories, workspace_folder);
+ assert_directory_result(
+ result,
+ &unix_path_to_platform("/workspace/packages/foo/"),
+ false,
+ );
+ }
+
+ #[test]
+ fn test_working_directory_directory_item() {
+ let uri = make_uri("/workspace/packages/foo/src/file.ts");
+ let working_directories = vec![WorkingDirectory::DirectoryItem(DirectoryItem {
+ directory: "packages/foo".to_string(),
+ not_cwd: Some(true),
+ })];
+ let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
+
+ let result = determine_working_directory(uri, working_directories, workspace_folder);
+ assert_directory_result(
+ result,
+ &unix_path_to_platform("/workspace/packages/foo/"),
+ true,
+ );
+ }
+
+ #[test]
+ fn test_working_directory_legacy_item() {
+ let uri = make_uri("/workspace/packages/foo/src/file.ts");
+ let working_directories =
+ vec![WorkingDirectory::LegacyDirectoryItem(LegacyDirectoryItem {
+ directory: "packages/foo".to_string(),
+ change_process_cwd: false,
+ })];
+ let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
+
+ let result = determine_working_directory(uri, working_directories, workspace_folder);
+ assert_directory_result(
+ result,
+ &unix_path_to_platform("/workspace/packages/foo/"),
+ true,
+ );
+ }
+
+ #[test]
+ fn test_working_directory_pattern_item() {
+ let uri = make_uri("/workspace/packages/foo/src/file.ts");
+ let working_directories = vec![WorkingDirectory::PatternItem(PatternItem {
+ pattern: "packages/*/".to_string(),
+ not_cwd: Some(false),
+ })];
+ let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
+
+ let result = determine_working_directory(uri, working_directories, workspace_folder);
+ assert_directory_result(
+ result,
+ &unix_path_to_platform("/workspace/packages/foo/"),
+ false,
+ );
+ }
+
+ #[test]
+ fn test_working_directory_multiple_patterns() {
+ let uri = make_uri("/workspace/apps/web/src/file.ts");
+ let working_directories = vec![
+ WorkingDirectory::PatternItem(PatternItem {
+ pattern: "packages/*/".to_string(),
+ not_cwd: None,
+ }),
+ WorkingDirectory::PatternItem(PatternItem {
+ pattern: "apps/*/".to_string(),
+ not_cwd: None,
+ }),
+ ];
+ let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
+
+ let result = determine_working_directory(uri, working_directories, workspace_folder);
+ assert_directory_result(
+ result,
+ &unix_path_to_platform("/workspace/apps/web/"),
+ false,
+ );
+ }
+ }
+
+ #[cfg(windows)]
+ mod windows_style_paths {
+ use super::*;
+
+ #[test]
+ fn test_working_directory_string() {
+ let uri = make_uri("/workspace/packages/foo/src/file.ts");
+ let working_directories = vec![WorkingDirectory::String("packages\\foo".to_string())];
+ let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
+
+ let result = determine_working_directory(uri, working_directories, workspace_folder);
+ assert_directory_result(
+ result,
+ &unix_path_to_platform("/workspace/packages/foo/"),
+ false,
+ );
+ }
+
+ #[test]
+ fn test_working_directory_absolute_path() {
+ let uri = make_uri("/workspace/packages/foo/src/file.ts");
+ let working_directories = vec![WorkingDirectory::String(
+ unix_path_to_platform("/workspace/packages/foo").replace('/', "\\"),
+ )];
+ let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
+
+ let result = determine_working_directory(uri, working_directories, workspace_folder);
+ assert_directory_result(
+ result,
+ &unix_path_to_platform("/workspace/packages/foo/"),
+ false,
+ );
+ }
+
+ #[test]
+ fn test_working_directory_directory_item() {
+ let uri = make_uri("/workspace/packages/foo/src/file.ts");
+ let working_directories = vec![WorkingDirectory::DirectoryItem(DirectoryItem {
+ directory: "packages\\foo".to_string(),
+ not_cwd: Some(true),
+ })];
+ let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
+
+ let result = determine_working_directory(uri, working_directories, workspace_folder);
+ assert_directory_result(
+ result,
+ &unix_path_to_platform("/workspace/packages/foo/"),
+ true,
+ );
+ }
+
+ #[test]
+ fn test_working_directory_legacy_item() {
+ let uri = make_uri("/workspace/packages/foo/src/file.ts");
+ let working_directories =
+ vec![WorkingDirectory::LegacyDirectoryItem(LegacyDirectoryItem {
+ directory: "packages\\foo".to_string(),
+ change_process_cwd: false,
+ })];
+ let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
+
+ let result = determine_working_directory(uri, working_directories, workspace_folder);
+ assert_directory_result(
+ result,
+ &unix_path_to_platform("/workspace/packages/foo/"),
+ true,
+ );
+ }
+
+ #[test]
+ fn test_working_directory_pattern_item() {
+ let uri = make_uri("/workspace/packages/foo/src/file.ts");
+ let working_directories = vec![WorkingDirectory::PatternItem(PatternItem {
+ pattern: "packages\\*\\".to_string(),
+ not_cwd: Some(false),
+ })];
+ let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
+
+ let result = determine_working_directory(uri, working_directories, workspace_folder);
+ assert_directory_result(
+ result,
+ &unix_path_to_platform("/workspace/packages/foo/"),
+ false,
+ );
+ }
+
+ #[test]
+ fn test_working_directory_multiple_patterns() {
+ let uri = make_uri("/workspace/apps/web/src/file.ts");
+ let working_directories = vec![
+ WorkingDirectory::PatternItem(PatternItem {
+ pattern: "packages\\*\\".to_string(),
+ not_cwd: None,
+ }),
+ WorkingDirectory::PatternItem(PatternItem {
+ pattern: "apps\\*\\".to_string(),
+ not_cwd: None,
+ }),
+ ];
+ let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
+
+ let result = determine_working_directory(uri, working_directories, workspace_folder);
+ assert_directory_result(
+ result,
+ &unix_path_to_platform("/workspace/apps/web/"),
+ false,
+ );
+ }
+ }
+
+ /// Converts a Unix-style path to a platform-specific path.
+ /// On Windows, converts "/workspace/foo/bar" to "C:\workspace\foo\bar"
+ /// On Unix, returns the path unchanged.
+ fn unix_path_to_platform(path: &str) -> String {
+ #[cfg(windows)]
+ {
+ if path.starts_with('/') {
+ format!("C:{}", path.replace('/', "\\"))
+ } else {
+ path.replace('/', "\\")
+ }
+ }
+ #[cfg(not(windows))]
+ {
+ path.to_string()
+ }
+ }
+
+ fn make_uri(path: &str) -> Uri {
+ let platform_path = unix_path_to_platform(path);
+ Uri::from_file_path(&platform_path).unwrap()
+ }
+
+ fn assert_directory_result(
+ result: Option<ResultWorkingDirectory>,
+ expected_directory: &str,
+ expected_not_cwd: bool,
+ ) {
+ match result {
+ Some(ResultWorkingDirectory::DirectoryItem(item)) => {
+ assert_eq!(item.directory, expected_directory);
+ assert_eq!(item.not_cwd, Some(expected_not_cwd));
+ }
+ other => panic!("Expected DirectoryItem, got {:?}", other),
+ }
+ }
+}
@@ -73,7 +73,9 @@ impl LspInstaller for GoLspAdapter {
delegate.show_notification(NOTIFICATION_MESSAGE, cx);
})?
}
- anyhow::bail!("cannot install gopls");
+ anyhow::bail!(
+ "Could not install the Go language server `gopls`, because `go` was not found."
+ );
}
let release =
@@ -654,7 +656,7 @@ impl ContextProvider for GoContextProvider {
"-v".into(),
"-run".into(),
format!(
- "\\^{}\\$/\\^{}\\$",
+ "'^{}$/^{}$'",
VariableName::Symbol.template_value(),
GO_SUBTEST_NAME_TASK_VARIABLE.template_value(),
),
@@ -1,6 +1,6 @@
("(" @open ")" @close)
("[" @open "]" @close)
("{" @open "}" @close)
-("\"" @open "\"" @close)
-("`" @open "`" @close)
-((rune_literal) @open @close)
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
+(("`" @open "`" @close) (#set! rainbow.exclude))
+((rune_literal) @open @close (#set! rainbow.exclude))
@@ -19,360 +19,717 @@
; INJECT SQL
(
- [
- ; var, const or short declaration of raw or interpreted string literal
- ((comment) @comment
- .
- (expression_list
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a literal element (to struct field eg.)
- ((comment) @comment
- .
- (literal_element
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a function parameter
- ((comment) @comment
- .
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content)
- ]
+ [
+ (const_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (var_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (assignment_statement
+ left: (expression_list)
+ "="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
- (#match? @comment "^\\/\\*\\s*sql\\s*\\*\\/") ; /* sql */ or /*sql*/
- (#set! injection.language "sql")
+ (short_var_declaration
+ left: (expression_list)
+ ":="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (composite_literal
+ body: (literal_value
+ (keyed_element
+ (comment) @_comment
+ value: (literal_element
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))))
+
+ (expression_statement
+ (call_expression
+ (argument_list
+ (comment) @_comment
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ )))
+ ]
+ (#match? @_comment "^\\/\\*\\s*sql\\s*\\*\\/$")
+ (#set! injection.language "sql")
)
; INJECT JSON
(
- [
- ; var, const or short declaration of raw or interpreted string literal
- ((comment) @comment
- .
- (expression_list
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a literal element (to struct field eg.)
- ((comment) @comment
- .
- (literal_element
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a function parameter
- ((comment) @comment
- .
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content)
- ]
+ [
+ (const_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
- (#match? @comment "^\\/\\*\\s*json\\s*\\*\\/") ; /* json */ or /*json*/
+ (var_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (assignment_statement
+ left: (expression_list)
+ "="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (short_var_declaration
+ left: (expression_list)
+ ":="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (composite_literal
+ body: (literal_value
+ (keyed_element
+ (comment) @_comment
+ value: (literal_element
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))))
+
+ (expression_statement
+ (call_expression
+ (argument_list
+ (comment) @_comment
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ )))
+ ]
+ (#match? @_comment "^\\/\\*\\s*json\\s*\\*\\/") ; /* json */ or /*json*/
(#set! injection.language "json")
)
; INJECT YAML
(
- [
- ; var, const or short declaration of raw or interpreted string literal
- ((comment) @comment
- .
- (expression_list
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a literal element (to struct field eg.)
- ((comment) @comment
- .
- (literal_element
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a function parameter
- ((comment) @comment
- .
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content)
- ]
+ [
+ (const_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (var_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (assignment_statement
+ left: (expression_list)
+ "="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (short_var_declaration
+ left: (expression_list)
+ ":="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
- (#match? @comment "^\\/\\*\\s*yaml\\s*\\*\\/") ; /* yaml */ or /*yaml*/
+ (composite_literal
+ body: (literal_value
+ (keyed_element
+ (comment) @_comment
+ value: (literal_element
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))))
+
+ (expression_statement
+ (call_expression
+ (argument_list
+ (comment) @_comment
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ )))
+ ]
+ (#match? @_comment "^\\/\\*\\s*yaml\\s*\\*\\/") ; /* yaml */ or /*yaml*/
(#set! injection.language "yaml")
)
; INJECT XML
(
- [
- ; var, const or short declaration of raw or interpreted string literal
- ((comment) @comment
- .
- (expression_list
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a literal element (to struct field eg.)
- ((comment) @comment
- .
- (literal_element
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a function parameter
- ((comment) @comment
- .
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content)
- ]
+ [
+ (const_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (var_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (assignment_statement
+ left: (expression_list)
+ "="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (short_var_declaration
+ left: (expression_list)
+ ":="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
- (#match? @comment "^\\/\\*\\s*xml\\s*\\*\\/") ; /* xml */ or /*xml*/
+ (composite_literal
+ body: (literal_value
+ (keyed_element
+ (comment) @_comment
+ value: (literal_element
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))))
+
+ (expression_statement
+ (call_expression
+ (argument_list
+ (comment) @_comment
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ )))
+ ]
+ (#match? @_comment "^\\/\\*\\s*xml\\s*\\*\\/") ; /* xml */ or /*xml*/
(#set! injection.language "xml")
)
; INJECT HTML
(
- [
- ; var, const or short declaration of raw or interpreted string literal
- ((comment) @comment
- .
- (expression_list
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a literal element (to struct field eg.)
- ((comment) @comment
- .
- (literal_element
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a function parameter
- ((comment) @comment
- .
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content)
- ]
+ [
+ (const_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
- (#match? @comment "^\\/\\*\\s*html\\s*\\*\\/") ; /* html */ or /*html*/
+ (var_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (assignment_statement
+ left: (expression_list)
+ "="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (short_var_declaration
+ left: (expression_list)
+ ":="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (composite_literal
+ body: (literal_value
+ (keyed_element
+ (comment) @_comment
+ value: (literal_element
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))))
+
+ (expression_statement
+ (call_expression
+ (argument_list
+ (comment) @_comment
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ )))
+ ]
+ (#match? @_comment "^\\/\\*\\s*html\\s*\\*\\/") ; /* html */ or /*html*/
(#set! injection.language "html")
)
; INJECT JS
(
- [
- ; var, const or short declaration of raw or interpreted string literal
- ((comment) @comment
- .
- (expression_list
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a literal element (to struct field eg.)
- ((comment) @comment
- .
- (literal_element
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a function parameter
- ((comment) @comment
- .
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content)
- ]
+ [
+ (const_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (var_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (assignment_statement
+ left: (expression_list)
+ "="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (short_var_declaration
+ left: (expression_list)
+ ":="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
- (#match? @comment "^\\/\\*\\s*js\\s*\\*\\/") ; /* js */ or /*js*/
+ (composite_literal
+ body: (literal_value
+ (keyed_element
+ (comment) @_comment
+ value: (literal_element
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))))
+
+ (expression_statement
+ (call_expression
+ (argument_list
+ (comment) @_comment
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ )))
+ ]
+ (#match? @_comment "^\\/\\*\\s*js\\s*\\*\\/") ; /* js */ or /*js*/
(#set! injection.language "javascript")
)
+
; INJECT CSS
(
- [
- ; var, const or short declaration of raw or interpreted string literal
- ((comment) @comment
- .
- (expression_list
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a literal element (to struct field eg.)
- ((comment) @comment
- .
- (literal_element
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a function parameter
- ((comment) @comment
- .
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content)
- ]
+ [
+ (const_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (var_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (assignment_statement
+ left: (expression_list)
+ "="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
- (#match? @comment "^\\/\\*\\s*css\\s*\\*\\/") ; /* css */ or /*css*/
+ (short_var_declaration
+ left: (expression_list)
+ ":="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (composite_literal
+ body: (literal_value
+ (keyed_element
+ (comment) @_comment
+ value: (literal_element
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))))
+
+ (expression_statement
+ (call_expression
+ (argument_list
+ (comment) @_comment
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ )))
+ ]
+ (#match? @_comment "^\\/\\*\\s*css\\s*\\*\\/") ; /* css */ or /*css*/
(#set! injection.language "css")
)
+
; INJECT LUA
(
- [
- ; var, const or short declaration of raw or interpreted string literal
- ((comment) @comment
- .
- (expression_list
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a literal element (to struct field eg.)
- ((comment) @comment
- .
- (literal_element
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a function parameter
- ((comment) @comment
- .
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content)
- ]
+ [
+ (const_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (var_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (assignment_statement
+ left: (expression_list)
+ "="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (short_var_declaration
+ left: (expression_list)
+ ":="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
- (#match? @comment "^\\/\\*\\s*lua\\s*\\*\\/") ; /* lua */ or /*lua*/
+ (composite_literal
+ body: (literal_value
+ (keyed_element
+ (comment) @_comment
+ value: (literal_element
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))))
+
+ (expression_statement
+ (call_expression
+ (argument_list
+ (comment) @_comment
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ )))
+ ]
+ (#match? @_comment "^\\/\\*\\s*lua\\s*\\*\\/") ; /* lua */ or /*lua*/
(#set! injection.language "lua")
)
; INJECT BASH
(
- [
- ; var, const or short declaration of raw or interpreted string literal
- ((comment) @comment
- .
- (expression_list
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a literal element (to struct field eg.)
- ((comment) @comment
- .
- (literal_element
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a function parameter
- ((comment) @comment
- .
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content)
- ]
+ [
+ (const_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (var_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (assignment_statement
+ left: (expression_list)
+ "="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (short_var_declaration
+ left: (expression_list)
+ ":="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
- (#match? @comment "^\\/\\*\\s*bash\\s*\\*\\/") ; /* bash */ or /*bash*/
+ (composite_literal
+ body: (literal_value
+ (keyed_element
+ (comment) @_comment
+ value: (literal_element
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))))
+
+ (expression_statement
+ (call_expression
+ (argument_list
+ (comment) @_comment
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ )))
+ ]
+ (#match? @_comment "^\\/\\*\\s*bash\\s*\\*\\/") ; /* bash */ or /*bash*/
(#set! injection.language "bash")
)
; INJECT CSV
(
- [
- ; var, const or short declaration of raw or interpreted string literal
- ((comment) @comment
- .
- (expression_list
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a literal element (to struct field eg.)
- ((comment) @comment
- .
- (literal_element
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content
- ))
-
- ; when passing as a function parameter
- ((comment) @comment
- .
- [
- (interpreted_string_literal)
- (raw_string_literal)
- ] @injection.content)
- ]
+ [
+ (const_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
- (#match? @comment "^\\/\\*\\s*csv\\s*\\*\\/") ; /* csv */ or /*csv*/
+ (var_spec
+ name: (identifier)
+ "="
+ (comment) @_comment
+ value: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (assignment_statement
+ left: (expression_list)
+ "="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (short_var_declaration
+ left: (expression_list)
+ ":="
+ (comment) @_comment
+ right: (expression_list
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ ((comment) @_comment
+ value: (literal_element
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ ))
+
+ (argument_list
+ (comment) @_comment
+ [
+ (interpreted_string_literal (interpreted_string_literal_content) @injection.content)
+ (raw_string_literal (raw_string_literal_content) @injection.content)
+ ]
+ )
+ ]
+ (#match? @_comment "^\\/\\*\\s*csv\\s*\\*\\/") ; /* csv */ or /*csv */
(#set! injection.language "csv")
)
@@ -4,6 +4,6 @@
("<" @open ">" @close)
("<" @open "/>" @close)
("</" @open ">" @close)
-("\"" @open "\"" @close)
-("'" @open "'" @close)
-("`" @open "`" @close)
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
+(("'" @open "'" @close) (#set! rainbow.exclude))
+(("`" @open "`" @close) (#set! rainbow.exclude))
@@ -2,6 +2,40 @@
(identifier) @variable
+(call_expression
+ function: (member_expression
+ object: (identifier) @type.builtin
+ (#any-of?
+ @type.builtin
+ "Promise"
+ "Array"
+ "Object"
+ "Map"
+ "Set"
+ "WeakMap"
+ "WeakSet"
+ "Date"
+ "Error"
+ "TypeError"
+ "RangeError"
+ "SyntaxError"
+ "ReferenceError"
+ "EvalError"
+ "URIError"
+ "RegExp"
+ "Function"
+ "Number"
+ "String"
+ "Boolean"
+ "Symbol"
+ "BigInt"
+ "Proxy"
+ "ArrayBuffer"
+ "DataView"
+ )
+ )
+)
+
; Properties
(property_identifier) @property
@@ -18,6 +52,12 @@
function: (member_expression
property: [(property_identifier) (private_property_identifier)] @function.method))
+(new_expression
+ constructor: (identifier) @type)
+
+(nested_type_identifier
+ module: (identifier) @type)
+
; Function and method definitions
(function_expression
@@ -47,10 +87,45 @@
left: (identifier) @function
right: [(function_expression) (arrow_function)])
+; Parameters
+
+(required_parameter
+ (identifier) @variable.parameter)
+
+(required_parameter
+ (_
+ ([
+ (identifier)
+ (shorthand_property_identifier_pattern)
+ ]) @variable.parameter))
+
+(optional_parameter
+ (identifier) @variable.parameter)
+
+(optional_parameter
+ (_
+ ([
+ (identifier)
+ (shorthand_property_identifier_pattern)
+ ]) @variable.parameter))
+
+(catch_clause
+ parameter: (identifier) @variable.parameter)
+
+(index_signature
+ name: (identifier) @variable.parameter)
+
+(arrow_function
+ parameter: (identifier) @variable.parameter)
+
; Special identifiers
+;
+(class_declaration
+ (type_identifier) @type.class)
+
+(extends_clause
+ value: (identifier) @type.class)
-((identifier) @type
- (#match? @type "^[A-Z]"))
(type_identifier) @type
(predefined_type) @type.builtin
@@ -251,6 +326,34 @@
(jsx_closing_element (identifier) @tag.jsx (#match? @tag.jsx "^[a-z][^.]*$"))
(jsx_self_closing_element (identifier) @tag.jsx (#match? @tag.jsx "^[a-z][^.]*$"))
+(jsx_opening_element
+ [
+ (identifier) @type
+ (member_expression
+ object: (identifier) @type
+ property: (property_identifier) @type
+ )
+ ]
+)
+(jsx_closing_element
+ [
+ (identifier) @type
+ (member_expression
+ object: (identifier) @type
+ property: (property_identifier) @type
+ )
+ ]
+)
+(jsx_self_closing_element
+ [
+ (identifier) @type
+ (member_expression
+ object: (identifier) @type
+ property: (property_identifier) @type
+ )
+ ]
+)
+
(jsx_attribute (property_identifier) @attribute.jsx)
(jsx_opening_element (["<" ">"]) @punctuation.bracket.jsx)
(jsx_closing_element (["</" ">"]) @punctuation.bracket.jsx)
@@ -83,3 +83,46 @@
arguments: (arguments (template_string (string_fragment) @injection.content
(#set! injection.language "isograph")))
)
+
+; Parse the contents of strings and tagged template
+; literals with leading ECMAScript comments:
+; '/* html */' or '/*html*/'
+(
+ ((comment) @_ecma_comment [
+ (string (string_fragment) @injection.content)
+ (template_string (string_fragment) @injection.content)
+ ])
+ (#match? @_ecma_comment "^\\/\\*\\s*html\\s*\\*\\/")
+ (#set! injection.language "html")
+)
+
+; '/* sql */' or '/*sql*/'
+(
+ ((comment) @_ecma_comment [
+ (string (string_fragment) @injection.content)
+ (template_string (string_fragment) @injection.content)
+ ])
+ (#match? @_ecma_comment "^\\/\\*\\s*sql\\s*\\*\\/")
+ (#set! injection.language "sql")
+)
+
+; '/* gql */' or '/*gql*/'
+; '/* graphql */' or '/*graphql*/'
+(
+ ((comment) @_ecma_comment [
+ (string (string_fragment) @injection.content)
+ (template_string (string_fragment) @injection.content)
+ ])
+ (#match? @_ecma_comment "^\\/\\*\\s*(gql|graphql)\\s*\\*\\/")
+ (#set! injection.language "graphql")
+)
+
+; '/* css */' or '/*css*/'
+(
+ ((comment) @_ecma_comment [
+ (string (string_fragment) @injection.content)
+ (template_string (string_fragment) @injection.content)
+ ])
+ (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/")
+ (#set! injection.language "css")
+)
@@ -1,2 +1,3 @@
(tag_name) @keyword.jsdoc
(type) @type.jsdoc
+(identifier) @variable.jsdoc
@@ -7,12 +7,13 @@ use futures::StreamExt;
use gpui::{App, AsyncApp, Task};
use http_client::github::{GitHubLspBinaryVersion, latest_github_release};
use language::{
- ContextProvider, LanguageName, LocalFile as _, LspAdapter, LspAdapterDelegate, LspInstaller,
- Toolchain,
+ ContextProvider, LanguageName, LanguageRegistry, LocalFile as _, LspAdapter,
+ LspAdapterDelegate, LspInstaller, Toolchain,
};
-use lsp::{LanguageServerBinary, LanguageServerName};
+use lsp::{LanguageServerBinary, LanguageServerName, Uri};
use node_runtime::{NodeRuntime, VersionStrategy};
use project::lsp_store::language_server_settings;
+use semver::Version;
use serde_json::{Value, json};
use smol::{
fs::{self},
@@ -129,26 +130,27 @@ fn server_binary_arguments(server_path: &Path) -> Vec<OsString> {
}
pub struct JsonLspAdapter {
+ languages: Arc<LanguageRegistry>,
node: NodeRuntime,
}
impl JsonLspAdapter {
const PACKAGE_NAME: &str = "vscode-langservers-extracted";
- pub fn new(node: NodeRuntime) -> Self {
- Self { node }
+ pub fn new(languages: Arc<LanguageRegistry>, node: NodeRuntime) -> Self {
+ Self { languages, node }
}
}
impl LspInstaller for JsonLspAdapter {
- type BinaryVersion = String;
+ type BinaryVersion = Version;
async fn fetch_latest_server_version(
&self,
_: &dyn LspAdapterDelegate,
_: bool,
_: &mut AsyncApp,
- ) -> Result<String> {
+ ) -> Result<Self::BinaryVersion> {
self.node
.npm_package_latest_version(Self::PACKAGE_NAME)
.await
@@ -174,7 +176,7 @@ impl LspInstaller for JsonLspAdapter {
async fn check_if_version_installed(
&self,
- version: &String,
+ version: &Self::BinaryVersion,
container_dir: &PathBuf,
_: &dyn LspAdapterDelegate,
) -> Option<LanguageServerBinary> {
@@ -203,11 +205,12 @@ impl LspInstaller for JsonLspAdapter {
async fn fetch_server_binary(
&self,
- latest_version: String,
+ latest_version: Self::BinaryVersion,
container_dir: PathBuf,
_: &dyn LspAdapterDelegate,
) -> Result<LanguageServerBinary> {
let server_path = container_dir.join(SERVER_PATH);
+ let latest_version = latest_version.to_string();
self.node
.npm_install_packages(
@@ -251,10 +254,11 @@ impl LspAdapter for JsonLspAdapter {
self: Arc<Self>,
delegate: &Arc<dyn LspAdapterDelegate>,
_: Option<Toolchain>,
+ _: Option<Uri>,
cx: &mut AsyncApp,
) -> Result<Value> {
let mut config = cx.update(|cx| {
- let schemas = json_schema_store::all_schema_file_associations(cx);
+ let schemas = json_schema_store::all_schema_file_associations(&self.languages, cx);
// This can be viewed via `dev: open language server logs` -> `json-language-server` ->
// `Server Info`
@@ -284,8 +288,8 @@ impl LspAdapter for JsonLspAdapter {
fn language_ids(&self) -> HashMap<LanguageName, String> {
[
- (LanguageName::new("JSON"), "json".into()),
- (LanguageName::new("JSONC"), "jsonc".into()),
+ (LanguageName::new_static("JSON"), "json".into()),
+ (LanguageName::new_static("JSONC"), "jsonc".into()),
]
.into_iter()
.collect()
@@ -301,20 +305,10 @@ async fn get_cached_server_binary(
node: &NodeRuntime,
) -> Option<LanguageServerBinary> {
maybe!(async {
- let mut last_version_dir = None;
- let mut entries = fs::read_dir(&container_dir).await?;
- while let Some(entry) = entries.next().await {
- let entry = entry?;
- if entry.file_type().await?.is_dir() {
- last_version_dir = Some(entry.path());
- }
- }
-
- let last_version_dir = last_version_dir.context("no cached binary")?;
- let server_path = last_version_dir.join(SERVER_PATH);
+ let server_path = container_dir.join(SERVER_PATH);
anyhow::ensure!(
server_path.exists(),
- "missing executable in directory {last_version_dir:?}"
+ "missing executable in directory {server_path:?}"
);
Ok(LanguageServerBinary {
path: node.binary_path().await?,
@@ -1,3 +1,3 @@
("[" @open "]" @close)
("{" @open "}" @close)
-("\"" @open "\"" @close)
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
@@ -4,12 +4,14 @@ path_suffixes = ["json", "flake.lock"]
line_comments = ["// "]
autoclose_before = ",]}"
brackets = [
- { start = "{", end = "}", close = true, newline = true },
- { start = "[", end = "]", close = true, newline = true },
- { start = "\"", end = "\"", close = true, newline = false, not_in = ["string"] },
+ { start = "{", end = "}", close = true, surround = true, newline = true },
+ { start = "[", end = "]", close = true, surround = true, newline = true },
+ { start = "(", end = ")", close = true, surround = true, newline = false },
+ { start = "\"", end = "\"", close = true, surround = true, newline = false, not_in = ["string"] },
]
tab_size = 2
prettier_parser_name = "json"
debuggers = ["JavaScript"]
+
[overrides.string]
-completion_query_characters = [":", " "]
+completion_query_characters = [":", " ", "."]
@@ -1,3 +1,3 @@
("[" @open "]" @close)
("{" @open "}" @close)
-("\"" @open "\"" @close)
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
@@ -4,9 +4,10 @@ path_suffixes = ["jsonc", "bun.lock", "tsconfig.json", "pyrightconfig.json"]
line_comments = ["// "]
autoclose_before = ",]}"
brackets = [
- { start = "{", end = "}", close = true, newline = true },
- { start = "[", end = "]", close = true, newline = true },
- { start = "\"", end = "\"", close = true, newline = false, not_in = ["string"] },
+ { start = "{", end = "}", close = true, surround = true, newline = true },
+ { start = "[", end = "]", close = true, surround = true, newline = true },
+ { start = "(", end = ")", close = true, surround = true, newline = false },
+ { start = "\"", end = "\"", close = true, surround = true, newline = false, not_in = ["string"] },
]
tab_size = 2
prettier_parser_name = "jsonc"
@@ -19,7 +19,9 @@ use crate::{
mod bash;
mod c;
+mod cpp;
mod css;
+mod eslint;
mod go;
mod json;
mod package_json;
@@ -83,11 +85,11 @@ pub fn init(languages: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, node: NodeRuntime
let c_lsp_adapter = Arc::new(c::CLspAdapter);
let css_lsp_adapter = Arc::new(css::CssLspAdapter::new(node.clone()));
- let eslint_adapter = Arc::new(typescript::EsLintLspAdapter::new(node.clone()));
+ let eslint_adapter = Arc::new(eslint::EsLintLspAdapter::new(node.clone()));
let go_context_provider = Arc::new(go::GoContextProvider);
let go_lsp_adapter = Arc::new(go::GoLspAdapter);
let json_context_provider = Arc::new(JsonTaskProvider);
- let json_lsp_adapter = Arc::new(json::JsonLspAdapter::new(node.clone()));
+ let json_lsp_adapter = Arc::new(json::JsonLspAdapter::new(languages.clone(), node.clone()));
let node_version_lsp_adapter = Arc::new(json::NodeVersionAdapter);
let py_lsp_adapter = Arc::new(python::PyLspAdapter::new());
let ty_lsp_adapter = Arc::new(python::TyLspAdapter::new(fs.clone()));
@@ -281,7 +283,6 @@ pub fn init(languages: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, node: NodeRuntime
"CSS",
"ERB",
"HTML+ERB",
- "HTML/ERB",
"HEEX",
"HTML",
"JavaScript",
@@ -0,0 +1,2 @@
+((latex_block) @injection.content
+ (#set! injection.language "latex"))
@@ -1,7 +1,7 @@
("(" @open ")" @close)
("[" @open "]" @close)
("{" @open "}" @close)
-("\"" @open "\"" @close)
-("`" @open "`" @close)
-("'" @open "'" @close)
-((fenced_code_block_delimiter) @open (fenced_code_block_delimiter) @close)
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
+(("`" @open "`" @close) (#set! rainbow.exclude))
+(("'" @open "'" @close) (#set! rainbow.exclude))
+(((fenced_code_block_delimiter) @open (fenced_code_block_delimiter) @close) (#set! rainbow.exclude))
@@ -24,4 +24,9 @@ rewrap_prefixes = [
auto_indent_on_paste = false
auto_indent_using_last_non_empty_line = false
tab_size = 2
+decrease_indent_patterns = [
+ { pattern = "^\\s*-", valid_after = ["list_item"] },
+ { pattern = "^\\s*\\d", valid_after = ["list_item"] },
+ { pattern = "^\\s*", valid_after = ["list_item"] },
+]
prettier_parser_name = "markdown"
@@ -0,0 +1,3 @@
+(list (list_item) @indent)
+
+(list_item) @start.list_item
@@ -10,8 +10,8 @@ use language::{ContextLocation, LanguageToolchainStore, LspInstaller};
use language::{ContextProvider, LspAdapter, LspAdapterDelegate};
use language::{LanguageName, ManifestName, ManifestProvider, ManifestQuery};
use language::{Toolchain, ToolchainList, ToolchainLister, ToolchainMetadata};
-use lsp::LanguageServerBinary;
use lsp::LanguageServerName;
+use lsp::{LanguageServerBinary, Uri};
use node_runtime::{NodeRuntime, VersionStrategy};
use pet_core::Configuration;
use pet_core::os_environment::Environment;
@@ -19,15 +19,17 @@ use pet_core::python_environment::{PythonEnvironment, PythonEnvironmentKind};
use pet_virtualenv::is_virtualenv_dir;
use project::Fs;
use project::lsp_store::language_server_settings;
+use semver::Version;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use settings::Settings;
use smol::lock::OnceCell;
-use std::cmp::Ordering;
+use std::cmp::{Ordering, Reverse};
use std::env::consts;
use terminal::terminal_settings::TerminalSettings;
use util::command::new_smol_command;
use util::fs::{make_file_executable, remove_matching};
+use util::paths::PathStyle;
use util::rel_path::RelPath;
use http_client::github_download::{GithubBinaryMetadata, download_server_binary};
@@ -100,9 +102,41 @@ impl FromStr for TestRunner {
/// The problem with it is that Pyright adjusts the sort text based on previous resolutions (items for which we've issued `completion/resolve` call have their sortText adjusted),
/// which - long story short - makes completion items list non-stable. Pyright probably relies on VSCode's implementation detail.
/// see https://github.com/microsoft/pyright/blob/95ef4e103b9b2f129c9320427e51b73ea7cf78bd/packages/pyright-internal/src/languageService/completionProvider.ts#LL2873
+///
+/// upd 02.12.25:
+/// Decided to ignore Pyright's sortText() completely and to manually sort all entries
fn process_pyright_completions(items: &mut [lsp::CompletionItem]) {
for item in items {
- item.sort_text.take();
+ let is_dunder = item.label.starts_with("__") && item.label.ends_with("__");
+
+ let visibility_priority = if is_dunder {
+ '3'
+ } else if item.label.starts_with("__") {
+ '2' // private non-dunder
+ } else if item.label.starts_with('_') {
+ '1' // protected
+ } else {
+ '0' // public
+ };
+
+ // Kind priority within same visibility level
+ let kind_priority = match item.kind {
+ Some(lsp::CompletionItemKind::ENUM_MEMBER) => '0',
+ Some(lsp::CompletionItemKind::FIELD) => '1',
+ Some(lsp::CompletionItemKind::PROPERTY) => '2',
+ Some(lsp::CompletionItemKind::VARIABLE) => '3',
+ Some(lsp::CompletionItemKind::CONSTANT) => '4',
+ Some(lsp::CompletionItemKind::METHOD) => '5',
+ Some(lsp::CompletionItemKind::FUNCTION) => '5',
+ Some(lsp::CompletionItemKind::CLASS) => '6',
+ Some(lsp::CompletionItemKind::MODULE) => '7',
+ _ => '8',
+ };
+
+ item.sort_text = Some(format!(
+ "{}{}{}",
+ visibility_priority, kind_priority, item.label
+ ));
}
}
@@ -206,6 +240,7 @@ impl LspAdapter for TyLspAdapter {
self: Arc<Self>,
delegate: &Arc<dyn LspAdapterDelegate>,
toolchain: Option<Toolchain>,
+ _: Option<Uri>,
cx: &mut AsyncApp,
) -> Result<Value> {
let mut ret = cx
@@ -246,7 +281,7 @@ impl LspInstaller for TyLspAdapter {
_: &mut AsyncApp,
) -> Result<Self::BinaryVersion> {
let release =
- latest_github_release("astral-sh/ty", true, true, delegate.http_client()).await?;
+ latest_github_release("astral-sh/ty", true, false, delegate.http_client()).await?;
let (_, asset_name) = Self::build_asset_name()?;
let asset = release
.assets
@@ -517,6 +552,7 @@ impl LspAdapter for PyrightLspAdapter {
self: Arc<Self>,
adapter: &Arc<dyn LspAdapterDelegate>,
toolchain: Option<Toolchain>,
+ _: Option<Uri>,
cx: &mut AsyncApp,
) -> Result<Value> {
cx.update(move |cx| {
@@ -586,14 +622,14 @@ impl LspAdapter for PyrightLspAdapter {
}
impl LspInstaller for PyrightLspAdapter {
- type BinaryVersion = String;
+ type BinaryVersion = Version;
async fn fetch_latest_server_version(
&self,
_: &dyn LspAdapterDelegate,
_: bool,
_: &mut AsyncApp,
- ) -> Result<String> {
+ ) -> Result<Self::BinaryVersion> {
self.node
.npm_package_latest_version(Self::SERVER_NAME.as_ref())
.await
@@ -637,6 +673,7 @@ impl LspInstaller for PyrightLspAdapter {
delegate: &dyn LspAdapterDelegate,
) -> Result<LanguageServerBinary> {
let server_path = container_dir.join(Self::SERVER_PATH);
+ let latest_version = latest_version.to_string();
self.node
.npm_install_packages(
@@ -868,7 +905,7 @@ impl ContextProvider for PythonContextProvider {
fn selected_test_runner(location: Option<&Arc<dyn language::File>>, cx: &App) -> TestRunner {
const TEST_RUNNER_VARIABLE: &str = "TEST_RUNNER";
- language_settings(Some(LanguageName::new("Python")), location, cx)
+ language_settings(Some(LanguageName::new_static("Python")), location, cx)
.tasks
.variables
.get(TEST_RUNNER_VARIABLE)
@@ -882,7 +919,7 @@ impl PythonContextProvider {
variables: &task::TaskVariables,
) -> Option<(VariableName, String)> {
let python_module_name =
- python_module_name_from_relative_path(variables.get(&VariableName::RelativeFile)?);
+ python_module_name_from_relative_path(variables.get(&VariableName::RelativeFile)?)?;
let unittest_class_name =
variables.get(&VariableName::Custom(Cow::Borrowed("_unittest_class_name")));
@@ -939,9 +976,10 @@ impl PythonContextProvider {
&self,
variables: &task::TaskVariables,
) -> Result<(VariableName, String)> {
- let python_module_name = python_module_name_from_relative_path(
- variables.get(&VariableName::RelativeFile).unwrap_or(""),
- );
+ let python_module_name = variables
+ .get(&VariableName::RelativeFile)
+ .and_then(|module| python_module_name_from_relative_path(module))
+ .unwrap_or_default();
let module_target = (PYTHON_MODULE_NAME_TASK_VARIABLE.clone(), python_module_name);
@@ -949,12 +987,15 @@ impl PythonContextProvider {
}
}
-fn python_module_name_from_relative_path(relative_path: &str) -> String {
- let path_with_dots = relative_path.replace('/', ".");
- path_with_dots
- .strip_suffix(".py")
- .unwrap_or(&path_with_dots)
- .to_string()
+fn python_module_name_from_relative_path(relative_path: &str) -> Option<String> {
+ let rel_path = RelPath::new(relative_path.as_ref(), PathStyle::local()).ok()?;
+ let path_with_dots = rel_path.display(PathStyle::Posix).replace('/', ".");
+ Some(
+ path_with_dots
+ .strip_suffix(".py")
+ .map(ToOwned::to_owned)
+ .unwrap_or(path_with_dots),
+ )
}
fn is_python_env_global(k: &PythonEnvironmentKind) -> bool {
@@ -991,6 +1032,8 @@ fn python_env_kind_display(k: &PythonEnvironmentKind) -> &'static str {
PythonEnvironmentKind::VirtualEnvWrapper => "virtualenvwrapper",
PythonEnvironmentKind::WindowsStore => "global (Windows Store)",
PythonEnvironmentKind::WindowsRegistry => "global (Windows Registry)",
+ PythonEnvironmentKind::Uv => "uv",
+ PythonEnvironmentKind::UvWorkspace => "uv (Workspace)",
}
}
@@ -998,6 +1041,8 @@ pub(crate) struct PythonToolchainProvider;
static ENV_PRIORITY_LIST: &[PythonEnvironmentKind] = &[
// Prioritize non-Conda environments.
+ PythonEnvironmentKind::UvWorkspace,
+ PythonEnvironmentKind::Uv,
PythonEnvironmentKind::Poetry,
PythonEnvironmentKind::Pipenv,
PythonEnvironmentKind::VirtualEnvWrapper,
@@ -1058,13 +1103,45 @@ fn get_venv_parent_dir(env: &PythonEnvironment) -> Option<PathBuf> {
venv.parent().map(|parent| parent.to_path_buf())
}
-fn wr_distance(wr: &PathBuf, venv: Option<&PathBuf>) -> usize {
+// How far is this venv from the root of our current project?
+#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
+enum SubprojectDistance {
+ WithinSubproject(Reverse<usize>),
+ WithinWorktree(Reverse<usize>),
+ NotInWorktree,
+}
+
+fn wr_distance(
+ wr: &PathBuf,
+ subroot_relative_path: &RelPath,
+ venv: Option<&PathBuf>,
+) -> SubprojectDistance {
if let Some(venv) = venv
&& let Ok(p) = venv.strip_prefix(wr)
{
- p.components().count()
+ if subroot_relative_path.components().next().is_some()
+ && let Ok(distance) = p
+ .strip_prefix(subroot_relative_path.as_std_path())
+ .map(|p| p.components().count())
+ {
+ SubprojectDistance::WithinSubproject(Reverse(distance))
+ } else {
+ SubprojectDistance::WithinWorktree(Reverse(p.components().count()))
+ }
} else {
- usize::MAX
+ SubprojectDistance::NotInWorktree
+ }
+}
+
+fn micromamba_shell_name(kind: ShellKind) -> &'static str {
+ match kind {
+ ShellKind::Csh => "csh",
+ ShellKind::Fish => "fish",
+ ShellKind::Nushell => "nu",
+ ShellKind::PowerShell => "powershell",
+ ShellKind::Cmd => "cmd.exe",
+ // default / catch-all:
+ _ => "posix",
}
}
@@ -1127,11 +1204,14 @@ impl ToolchainLister for PythonToolchainProvider {
});
// Compare project paths against worktree root
- let proj_ordering = || {
- let lhs_project = lhs.project.clone().or_else(|| get_venv_parent_dir(lhs));
- let rhs_project = rhs.project.clone().or_else(|| get_venv_parent_dir(rhs));
- wr_distance(&wr, lhs_project.as_ref()).cmp(&wr_distance(&wr, rhs_project.as_ref()))
- };
+ let proj_ordering =
+ || {
+ let lhs_project = lhs.project.clone().or_else(|| get_venv_parent_dir(lhs));
+ let rhs_project = rhs.project.clone().or_else(|| get_venv_parent_dir(rhs));
+ wr_distance(&wr, &subroot_relative_path, lhs_project.as_ref()).cmp(
+ &wr_distance(&wr, &subroot_relative_path, rhs_project.as_ref()),
+ )
+ };
// Compare environment priorities
let priority_ordering = || env_priority(lhs.kind).cmp(&env_priority(rhs.kind));
@@ -1231,24 +1311,28 @@ impl ToolchainLister for PythonToolchainProvider {
.as_option()
.map(|venv| venv.conda_manager)
.unwrap_or(settings::CondaManager::Auto);
-
let manager = match conda_manager {
settings::CondaManager::Conda => "conda",
settings::CondaManager::Mamba => "mamba",
settings::CondaManager::Micromamba => "micromamba",
- settings::CondaManager::Auto => {
- // When auto, prefer the detected manager or fall back to conda
- toolchain
- .environment
- .manager
- .as_ref()
- .and_then(|m| m.executable.file_name())
- .and_then(|name| name.to_str())
- .filter(|name| matches!(*name, "conda" | "mamba" | "micromamba"))
- .unwrap_or("conda")
- }
+ settings::CondaManager::Auto => toolchain
+ .environment
+ .manager
+ .as_ref()
+ .and_then(|m| m.executable.file_name())
+ .and_then(|name| name.to_str())
+ .filter(|name| matches!(*name, "conda" | "mamba" | "micromamba"))
+ .unwrap_or("conda"),
};
+ // Activate micromamba shell in the child shell
+ // [required for micromamba]
+ if manager == "micromamba" {
+ let shell = micromamba_shell_name(shell);
+ activation_script
+ .push(format!(r#"eval "$({manager} shell hook --shell {shell})""#));
+ }
+
if let Some(name) = &toolchain.environment.name {
activation_script.push(format!("{manager} activate {name}"));
} else {
@@ -1278,7 +1362,7 @@ impl ToolchainLister for PythonToolchainProvider {
ShellKind::Fish => Some(format!("\"{pyenv}\" shell - fish {version}")),
ShellKind::Posix => Some(format!("\"{pyenv}\" shell - sh {version}")),
ShellKind::Nushell => Some(format!("^\"{pyenv}\" shell - nu {version}")),
- ShellKind::PowerShell => None,
+ ShellKind::PowerShell | ShellKind::Pwsh => None,
ShellKind::Csh => None,
ShellKind::Tcsh => None,
ShellKind::Cmd => None,
@@ -1331,7 +1415,7 @@ async fn venv_to_toolchain(venv: PythonEnvironment, fs: &dyn Fs) -> Option<Toolc
.to_str()?
.to_owned()
.into(),
- language_name: LanguageName::new("Python"),
+ language_name: LanguageName::new_static("Python"),
as_json: serde_json::to_value(data).ok()?,
})
}
@@ -1589,6 +1673,7 @@ impl LspAdapter for PyLspAdapter {
self: Arc<Self>,
adapter: &Arc<dyn LspAdapterDelegate>,
toolchain: Option<Toolchain>,
+ _: Option<Uri>,
cx: &mut AsyncApp,
) -> Result<Value> {
cx.update(move |cx| {
@@ -1880,6 +1965,7 @@ impl LspAdapter for BasedPyrightLspAdapter {
self: Arc<Self>,
adapter: &Arc<dyn LspAdapterDelegate>,
toolchain: Option<Toolchain>,
+ _: Option<Uri>,
cx: &mut AsyncApp,
) -> Result<Value> {
cx.update(move |cx| {
@@ -1956,14 +2042,14 @@ impl LspAdapter for BasedPyrightLspAdapter {
}
impl LspInstaller for BasedPyrightLspAdapter {
- type BinaryVersion = String;
+ type BinaryVersion = Version;
async fn fetch_latest_server_version(
&self,
_: &dyn LspAdapterDelegate,
_: bool,
_: &mut AsyncApp,
- ) -> Result<String> {
+ ) -> Result<Self::BinaryVersion> {
self.node
.npm_package_latest_version(Self::SERVER_NAME.as_ref())
.await
@@ -2008,6 +2094,7 @@ impl LspInstaller for BasedPyrightLspAdapter {
delegate: &dyn LspAdapterDelegate,
) -> Result<LanguageServerBinary> {
let server_path = container_dir.join(Self::SERVER_PATH);
+ let latest_version = latest_version.to_string();
self.node
.npm_install_packages(
@@ -2303,6 +2390,8 @@ mod tests {
use settings::SettingsStore;
use std::num::NonZeroU32;
+ use crate::python::python_module_name_from_relative_path;
+
#[gpui::test]
async fn test_python_autoindent(cx: &mut TestAppContext) {
cx.executor().set_block_on_ticks(usize::MAX..=usize::MAX);
@@ -2431,4 +2520,35 @@ mod tests {
buffer
});
}
+
+ #[test]
+ fn test_python_module_name_from_relative_path() {
+ assert_eq!(
+ python_module_name_from_relative_path("foo/bar.py"),
+ Some("foo.bar".to_string())
+ );
+ assert_eq!(
+ python_module_name_from_relative_path("foo/bar"),
+ Some("foo.bar".to_string())
+ );
+ if cfg!(windows) {
+ assert_eq!(
+ python_module_name_from_relative_path("foo\\bar.py"),
+ Some("foo.bar".to_string())
+ );
+ assert_eq!(
+ python_module_name_from_relative_path("foo\\bar"),
+ Some("foo.bar".to_string())
+ );
+ } else {
+ assert_eq!(
+ python_module_name_from_relative_path("foo\\bar.py"),
+ Some("foo\\bar".to_string())
+ );
+ assert_eq!(
+ python_module_name_from_relative_path("foo\\bar"),
+ Some("foo\\bar".to_string())
+ );
+ }
+ }
}
@@ -1,4 +1,4 @@
("(" @open ")" @close)
("[" @open "]" @close)
("{" @open "}" @close)
-((string_start) @open (string_end) @close)
+(((string_start) @open (string_end) @close) (#set! rainbow.exclude))
@@ -1,3 +1,34 @@
((comment) @injection.content
(#set! injection.language "comment")
)
+
+; SQL -----------------------------------------------------------------------------
+(
+ [
+ ; function calls
+ (call
+ [
+ (attribute attribute: (identifier) @function_name)
+ (identifier) @function_name
+ ]
+ arguments: (argument_list
+ (comment) @comment
+ (string
+ (string_content) @injection.content
+ )
+ ))
+
+ ; string variables
+ ((comment) @comment
+ .
+ (expression_statement
+ (assignment
+ right: (string
+ (string_content) @injection.content
+ )
+ )
+ ))
+ ]
+ (#match? @comment "^(#|#\\s+)(?i:sql)\\s*$")
+ (#set! injection.language "sql")
+)
@@ -13,7 +13,9 @@ use project::project_settings::ProjectSettings;
use regex::Regex;
use serde_json::json;
use settings::Settings as _;
+use smallvec::SmallVec;
use smol::fs::{self};
+use std::cmp::Reverse;
use std::fmt::Display;
use std::ops::Range;
use std::{
@@ -67,10 +69,15 @@ impl RustLspAdapter {
#[cfg(target_os = "linux")]
async fn determine_libc_type() -> LibcType {
use futures::pin_mut;
- use smol::process::Command;
async fn from_ldd_version() -> Option<LibcType> {
- let ldd_output = Command::new("ldd").arg("--version").output().await.ok()?;
+ use util::command::new_smol_command;
+
+ let ldd_output = new_smol_command("ldd")
+ .arg("--version")
+ .output()
+ .await
+ .ok()?;
let ldd_version = String::from_utf8_lossy(&ldd_output.stdout);
if ldd_version.contains("GNU libc") || ldd_version.contains("GLIBC") {
@@ -230,7 +237,7 @@ impl LspAdapter for RustLspAdapter {
.or(completion.detail.as_ref())
.map(|detail| detail.trim());
// this tends to contain alias and import information
- let detail_left = completion
+ let mut detail_left = completion
.label_details
.as_ref()
.and_then(|detail| detail.detail.as_deref());
@@ -336,31 +343,92 @@ impl LspAdapter for RustLspAdapter {
}
}
(_, kind) => {
- let highlight_name = kind.and_then(|kind| match kind {
- lsp::CompletionItemKind::STRUCT
- | lsp::CompletionItemKind::INTERFACE
- | lsp::CompletionItemKind::ENUM => Some("type"),
- lsp::CompletionItemKind::ENUM_MEMBER => Some("variant"),
- lsp::CompletionItemKind::KEYWORD => Some("keyword"),
- lsp::CompletionItemKind::VALUE | lsp::CompletionItemKind::CONSTANT => {
- Some("constant")
+ let mut label;
+ let mut runs = vec![];
+
+ if completion.insert_text_format == Some(lsp::InsertTextFormat::SNIPPET)
+ && let Some(
+ lsp::CompletionTextEdit::InsertAndReplace(lsp::InsertReplaceEdit {
+ new_text,
+ ..
+ })
+ | lsp::CompletionTextEdit::Edit(lsp::TextEdit { new_text, .. }),
+ ) = completion.text_edit.as_ref()
+ && let Ok(mut snippet) = snippet::Snippet::parse(new_text)
+ && !snippet.tabstops.is_empty()
+ {
+ label = String::new();
+
+ // we never display the final tabstop
+ snippet.tabstops.remove(snippet.tabstops.len() - 1);
+
+ let mut text_pos = 0;
+
+ let mut all_stop_ranges = snippet
+ .tabstops
+ .into_iter()
+ .flat_map(|stop| stop.ranges)
+ .collect::<SmallVec<[_; 8]>>();
+ all_stop_ranges.sort_unstable_by_key(|a| (a.start, Reverse(a.end)));
+
+ for range in &all_stop_ranges {
+ let start_pos = range.start as usize;
+ let end_pos = range.end as usize;
+
+ label.push_str(&snippet.text[text_pos..start_pos]);
+
+ if start_pos == end_pos {
+ let caret_start = label.len();
+ label.push('…');
+ runs.push((caret_start..label.len(), HighlightId::TABSTOP_INSERT_ID));
+ } else {
+ let label_start = label.len();
+ label.push_str(&snippet.text[start_pos..end_pos]);
+ let label_end = label.len();
+ runs.push((label_start..label_end, HighlightId::TABSTOP_REPLACE_ID));
+ }
+
+ text_pos = end_pos;
}
- _ => None,
- });
- let label = completion.label.clone();
- let mut runs = vec![];
- if let Some(highlight_name) = highlight_name {
- let highlight_id = language.grammar()?.highlight_id_for_name(highlight_name)?;
- runs.push((
- 0..label.rfind('(').unwrap_or(completion.label.len()),
- highlight_id,
- ));
- } else if detail_left.is_none() {
- return None;
+ label.push_str(&snippet.text[text_pos..]);
+
+ if detail_left.is_some_and(|detail_left| detail_left == new_text) {
+ // We only include the left detail if it isn't the snippet again
+ detail_left.take();
+ }
+
+ runs.extend(language.highlight_text(&Rope::from(&label), 0..label.len()));
+ } else {
+ let highlight_name = kind.and_then(|kind| match kind {
+ lsp::CompletionItemKind::STRUCT
+ | lsp::CompletionItemKind::INTERFACE
+ | lsp::CompletionItemKind::ENUM => Some("type"),
+ lsp::CompletionItemKind::ENUM_MEMBER => Some("variant"),
+ lsp::CompletionItemKind::KEYWORD => Some("keyword"),
+ lsp::CompletionItemKind::VALUE | lsp::CompletionItemKind::CONSTANT => {
+ Some("constant")
+ }
+ _ => None,
+ });
+
+ label = completion.label.clone();
+
+ if let Some(highlight_name) = highlight_name {
+ let highlight_id =
+ language.grammar()?.highlight_id_for_name(highlight_name)?;
+ runs.push((
+ 0..label.rfind('(').unwrap_or(completion.label.len()),
+ highlight_id,
+ ));
+ } else if detail_left.is_none() {
+ return None;
+ }
}
- mk_label(label, &|| 0..completion.label.len(), runs)
+ let label_len = label.len();
+
+ mk_label(label, &|| 0..label_len, runs)
}
};
@@ -374,6 +442,7 @@ impl LspAdapter for RustLspAdapter {
label.text.push(')');
}
}
+
Some(label)
}
@@ -442,7 +511,7 @@ impl LspInstaller for RustLspAdapter {
// It is surprisingly common for ~/.cargo/bin/rust-analyzer to be a symlink to
// /usr/bin/rust-analyzer that fails when you run it; so we need to test it.
- log::info!("found rust-analyzer in PATH. trying to run `rust-analyzer --help`");
+ log::debug!("found rust-analyzer in PATH. trying to run `rust-analyzer --help`");
let result = delegate
.try_exec(LanguageServerBinary {
path: path.clone(),
@@ -817,7 +886,7 @@ impl ContextProvider for RustContextProvider {
RUST_BIN_REQUIRED_FEATURES_FLAG_TASK_VARIABLE.template_value(),
RUST_BIN_REQUIRED_FEATURES_TASK_VARIABLE.template_value(),
],
- cwd: Some("$ZED_DIRNAME".to_owned()),
+ cwd: Some(RUST_MANIFEST_DIRNAME_TASK_VARIABLE.template_value()),
tags: vec!["rust-main".to_owned()],
..TaskTemplate::default()
},
@@ -839,14 +908,14 @@ impl ContextProvider for RustContextProvider {
label: "Run".into(),
command: "cargo".into(),
args: run_task_args,
- cwd: Some("$ZED_DIRNAME".to_owned()),
+ cwd: Some(RUST_MANIFEST_DIRNAME_TASK_VARIABLE.template_value()),
..TaskTemplate::default()
},
TaskTemplate {
label: "Clean".into(),
command: "cargo".into(),
args: vec!["clean".into()],
- cwd: Some("$ZED_DIRNAME".to_owned()),
+ cwd: Some(RUST_MANIFEST_DIRNAME_TASK_VARIABLE.template_value()),
..TaskTemplate::default()
},
];
@@ -1061,9 +1130,11 @@ fn package_name_from_pkgid(pkgid: &str) -> Option<&str> {
}
async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServerBinary> {
- maybe!(async {
+ let binary_result = maybe!(async {
let mut last = None;
- let mut entries = fs::read_dir(&container_dir).await?;
+ let mut entries = fs::read_dir(&container_dir)
+ .await
+ .with_context(|| format!("listing {container_dir:?}"))?;
while let Some(entry) = entries.next().await {
let path = entry?.path();
if path.extension().is_some_and(|ext| ext == "metadata") {
@@ -1072,20 +1143,34 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
last = Some(path);
}
- let path = last.context("no cached binary")?;
+ let path = match last {
+ Some(last) => last,
+ None => return Ok(None),
+ };
let path = match RustLspAdapter::GITHUB_ASSET_KIND {
AssetKind::TarGz | AssetKind::Gz => path, // Tar and gzip extract in place.
AssetKind::Zip => path.join("rust-analyzer.exe"), // zip contains a .exe
};
- anyhow::Ok(LanguageServerBinary {
+ anyhow::Ok(Some(LanguageServerBinary {
path,
env: None,
- arguments: Default::default(),
- })
+ arguments: Vec::new(),
+ }))
})
- .await
- .log_err()
+ .await;
+
+ match binary_result {
+ Ok(Some(binary)) => Some(binary),
+ Ok(None) => {
+ log::info!("No cached rust-analyzer binary found");
+ None
+ }
+ Err(e) => {
+ log::error!("Failed to look up cached rust-analyzer binary: {e:#}");
+ None
+ }
+ }
}
fn test_fragment(variables: &TaskVariables, path: &Path, stem: &str) -> String {
@@ -1396,6 +1481,159 @@ mod tests {
vec![(0..11, HighlightId(3)), (13..19, HighlightId(0))],
))
);
+
+ // Snippet with insert tabstop (empty placeholder)
+ assert_eq!(
+ adapter
+ .label_for_completion(
+ &lsp::CompletionItem {
+ kind: Some(lsp::CompletionItemKind::SNIPPET),
+ label: "println!".to_string(),
+ insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
+ text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+ range: lsp::Range::default(),
+ new_text: "println!(\"$1\", $2)$0".to_string(),
+ })),
+ ..Default::default()
+ },
+ &language,
+ )
+ .await,
+ Some(CodeLabel::new(
+ "println!(\"…\", …)".to_string(),
+ 0..8,
+ vec![
+ (10..13, HighlightId::TABSTOP_INSERT_ID),
+ (16..19, HighlightId::TABSTOP_INSERT_ID),
+ (0..7, HighlightId(2)),
+ (7..8, HighlightId(2)),
+ ],
+ ))
+ );
+
+ // Snippet with replace tabstop (placeholder with default text)
+ assert_eq!(
+ adapter
+ .label_for_completion(
+ &lsp::CompletionItem {
+ kind: Some(lsp::CompletionItemKind::SNIPPET),
+ label: "vec!".to_string(),
+ insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
+ text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+ range: lsp::Range::default(),
+ new_text: "vec![${1:elem}]$0".to_string(),
+ })),
+ ..Default::default()
+ },
+ &language,
+ )
+ .await,
+ Some(CodeLabel::new(
+ "vec![elem]".to_string(),
+ 0..4,
+ vec![
+ (5..9, HighlightId::TABSTOP_REPLACE_ID),
+ (0..3, HighlightId(2)),
+ (3..4, HighlightId(2)),
+ ],
+ ))
+ );
+
+ // Snippet with tabstop appearing more than once
+ assert_eq!(
+ adapter
+ .label_for_completion(
+ &lsp::CompletionItem {
+ kind: Some(lsp::CompletionItemKind::SNIPPET),
+ label: "if let".to_string(),
+ insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
+ text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+ range: lsp::Range::default(),
+ new_text: "if let ${1:pat} = $1 {\n $0\n}".to_string(),
+ })),
+ ..Default::default()
+ },
+ &language,
+ )
+ .await,
+ Some(CodeLabel::new(
+ "if let pat = … {\n \n}".to_string(),
+ 0..6,
+ vec![
+ (7..10, HighlightId::TABSTOP_REPLACE_ID),
+ (13..16, HighlightId::TABSTOP_INSERT_ID),
+ (0..2, HighlightId(1)),
+ (3..6, HighlightId(1)),
+ ],
+ ))
+ );
+
+ // Snippet with tabstops not in left-to-right order
+ assert_eq!(
+ adapter
+ .label_for_completion(
+ &lsp::CompletionItem {
+ kind: Some(lsp::CompletionItemKind::SNIPPET),
+ label: "for".to_string(),
+ insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
+ text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+ range: lsp::Range::default(),
+ new_text: "for ${2:item} in ${1:iter} {\n $0\n}".to_string(),
+ })),
+ ..Default::default()
+ },
+ &language,
+ )
+ .await,
+ Some(CodeLabel::new(
+ "for item in iter {\n \n}".to_string(),
+ 0..3,
+ vec![
+ (4..8, HighlightId::TABSTOP_REPLACE_ID),
+ (12..16, HighlightId::TABSTOP_REPLACE_ID),
+ (0..3, HighlightId(1)),
+ (9..11, HighlightId(1)),
+ ],
+ ))
+ );
+
+ // Test for correct range calculation with mixed empty and non-empty tabstops.(See https://github.com/zed-industries/zed/issues/44825)
+ let res = adapter
+ .label_for_completion(
+ &lsp::CompletionItem {
+ kind: Some(lsp::CompletionItemKind::STRUCT),
+ label: "Particles".to_string(),
+ insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
+ text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+ range: lsp::Range::default(),
+ new_text: "Particles { pos_x: $1, pos_y: $2, vel_x: $3, vel_y: $4, acc_x: ${5:()}, acc_y: ${6:()}, mass: $7 }$0".to_string(),
+ })),
+ ..Default::default()
+ },
+ &language,
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(
+ res,
+ CodeLabel::new(
+ "Particles { pos_x: …, pos_y: …, vel_x: …, vel_y: …, acc_x: (), acc_y: (), mass: … }".to_string(),
+ 0..9,
+ vec![
+ (19..22, HighlightId::TABSTOP_INSERT_ID),
+ (31..34, HighlightId::TABSTOP_INSERT_ID),
+ (43..46, HighlightId::TABSTOP_INSERT_ID),
+ (55..58, HighlightId::TABSTOP_INSERT_ID),
+ (67..69, HighlightId::TABSTOP_REPLACE_ID),
+ (78..80, HighlightId::TABSTOP_REPLACE_ID),
+ (88..91, HighlightId::TABSTOP_INSERT_ID),
+ (0..9, highlight_type),
+ (60..65, highlight_field),
+ (71..76, highlight_field),
+ ],
+ )
+ );
}
#[gpui::test]
@@ -2,6 +2,6 @@
("[" @open "]" @close)
("{" @open "}" @close)
("<" @open ">" @close)
-("\"" @open "\"" @close)
(closure_parameters "|" @open "|" @close)
-("'" @open "'" @close)
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
+(("'" @open "'" @close) (#set! rainbow.exclude))
@@ -85,7 +85,6 @@
[
"as"
"async"
- "await"
"const"
"default"
"dyn"
@@ -102,6 +101,7 @@
"ref"
"static"
"struct"
+ "for"
"trait"
"type"
"union"
@@ -114,10 +114,10 @@
] @keyword
[
+ "await"
"break"
"continue"
"else"
- "for"
"if"
"in"
"loop"
@@ -127,6 +127,9 @@
"yield"
] @keyword.control
+(for_expression
+ ("for" @keyword.control))
+
[
(string_literal)
(raw_string_literal)
@@ -1,14 +1,13 @@
-use anyhow::{Context as _, Result};
+use anyhow::Result;
use async_trait::async_trait;
use collections::HashMap;
-use futures::StreamExt;
use gpui::AsyncApp;
use language::{LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain};
-use lsp::{LanguageServerBinary, LanguageServerName};
+use lsp::{LanguageServerBinary, LanguageServerName, Uri};
use node_runtime::{NodeRuntime, VersionStrategy};
use project::lsp_store::language_server_settings;
+use semver::Version;
use serde_json::{Value, json};
-use smol::fs;
use std::{
ffi::OsString,
path::{Path, PathBuf},
@@ -41,14 +40,14 @@ impl TailwindLspAdapter {
}
impl LspInstaller for TailwindLspAdapter {
- type BinaryVersion = String;
+ type BinaryVersion = Version;
async fn fetch_latest_server_version(
&self,
_: &dyn LspAdapterDelegate,
_: bool,
_: &mut AsyncApp,
- ) -> Result<String> {
+ ) -> Result<Self::BinaryVersion> {
self.node
.npm_package_latest_version(Self::PACKAGE_NAME)
.await
@@ -72,11 +71,12 @@ impl LspInstaller for TailwindLspAdapter {
async fn fetch_server_binary(
&self,
- latest_version: String,
+ latest_version: Self::BinaryVersion,
container_dir: PathBuf,
_: &dyn LspAdapterDelegate,
) -> Result<LanguageServerBinary> {
let server_path = container_dir.join(SERVER_PATH);
+ let latest_version = latest_version.to_string();
self.node
.npm_install_packages(
@@ -94,7 +94,7 @@ impl LspInstaller for TailwindLspAdapter {
async fn check_if_version_installed(
&self,
- version: &String,
+ version: &Self::BinaryVersion,
container_dir: &PathBuf,
_: &dyn LspAdapterDelegate,
) -> Option<LanguageServerBinary> {
@@ -142,13 +142,6 @@ impl LspAdapter for TailwindLspAdapter {
) -> Result<Option<serde_json::Value>> {
Ok(Some(json!({
"provideFormatter": true,
- "userLanguages": {
- "html": "html",
- "css": "css",
- "javascript": "javascript",
- "typescript": "typescript",
- "typescriptreact": "typescriptreact",
- },
})))
}
@@ -156,6 +149,7 @@ impl LspAdapter for TailwindLspAdapter {
self: Arc<Self>,
delegate: &Arc<dyn LspAdapterDelegate>,
_: Option<Toolchain>,
+ _: Option<Uri>,
cx: &mut AsyncApp,
) -> Result<Value> {
let mut tailwind_user_settings = cx.update(|cx| {
@@ -168,27 +162,49 @@ impl LspAdapter for TailwindLspAdapter {
tailwind_user_settings["emmetCompletions"] = Value::Bool(true);
}
+ if tailwind_user_settings.get("includeLanguages").is_none() {
+ tailwind_user_settings["includeLanguages"] = json!({
+ "html": "html",
+ "css": "css",
+ "javascript": "javascript",
+ "typescript": "typescript",
+ "typescriptreact": "typescriptreact",
+ });
+ }
+
Ok(json!({
- "tailwindCSS": tailwind_user_settings,
+ "tailwindCSS": tailwind_user_settings
}))
}
fn language_ids(&self) -> HashMap<LanguageName, String> {
HashMap::from_iter([
- (LanguageName::new("Astro"), "astro".to_string()),
- (LanguageName::new("HTML"), "html".to_string()),
- (LanguageName::new("CSS"), "css".to_string()),
- (LanguageName::new("JavaScript"), "javascript".to_string()),
- (LanguageName::new("TypeScript"), "typescript".to_string()),
- (LanguageName::new("TSX"), "typescriptreact".to_string()),
- (LanguageName::new("Svelte"), "svelte".to_string()),
- (LanguageName::new("Elixir"), "phoenix-heex".to_string()),
- (LanguageName::new("HEEX"), "phoenix-heex".to_string()),
- (LanguageName::new("ERB"), "erb".to_string()),
- (LanguageName::new("HTML+ERB"), "erb".to_string()),
- (LanguageName::new("HTML/ERB"), "erb".to_string()),
- (LanguageName::new("PHP"), "php".to_string()),
- (LanguageName::new("Vue.js"), "vue".to_string()),
+ (LanguageName::new_static("Astro"), "astro".to_string()),
+ (LanguageName::new_static("HTML"), "html".to_string()),
+ (LanguageName::new_static("Gleam"), "html".to_string()),
+ (LanguageName::new_static("CSS"), "css".to_string()),
+ (
+ LanguageName::new_static("JavaScript"),
+ "javascript".to_string(),
+ ),
+ (
+ LanguageName::new_static("TypeScript"),
+ "typescript".to_string(),
+ ),
+ (
+ LanguageName::new_static("TSX"),
+ "typescriptreact".to_string(),
+ ),
+ (LanguageName::new_static("Svelte"), "svelte".to_string()),
+ (
+ LanguageName::new_static("Elixir"),
+ "phoenix-heex".to_string(),
+ ),
+ (LanguageName::new_static("HEEX"), "phoenix-heex".to_string()),
+ (LanguageName::new_static("ERB"), "erb".to_string()),
+ (LanguageName::new_static("HTML+ERB"), "erb".to_string()),
+ (LanguageName::new_static("PHP"), "php".to_string()),
+ (LanguageName::new_static("Vue.js"), "vue".to_string()),
])
}
}
@@ -198,19 +214,10 @@ async fn get_cached_server_binary(
node: &NodeRuntime,
) -> Option<LanguageServerBinary> {
maybe!(async {
- let mut last_version_dir = None;
- let mut entries = fs::read_dir(&container_dir).await?;
- while let Some(entry) = entries.next().await {
- let entry = entry?;
- if entry.file_type().await?.is_dir() {
- last_version_dir = Some(entry.path());
- }
- }
- let last_version_dir = last_version_dir.context("no cached binary")?;
- let server_path = last_version_dir.join(SERVER_PATH);
+ let server_path = container_dir.join(SERVER_PATH);
anyhow::ensure!(
server_path.exists(),
- "missing executable in directory {last_version_dir:?}"
+ "missing executable in directory {server_path:?}"
);
Ok(LanguageServerBinary {
path: node.binary_path().await?,
@@ -4,8 +4,8 @@
("<" @open ">" @close)
("<" @open "/>" @close)
("</" @open ">" @close)
-("\"" @open "\"" @close)
-("'" @open "'" @close)
-("`" @open "`" @close)
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
+(("'" @open "'" @close) (#set! rainbow.exclude))
+(("`" @open "`" @close) (#set! rainbow.exclude))
-((jsx_element (jsx_opening_element) @open (jsx_closing_element) @close) (#set! newline.only))
+((jsx_element (jsx_opening_element) @open (jsx_closing_element) @close) (#set! newline.only) (#set! rainbow.exclude))
@@ -2,6 +2,40 @@
(identifier) @variable
+(call_expression
+ function: (member_expression
+ object: (identifier) @type.builtin
+ (#any-of?
+ @type.builtin
+ "Promise"
+ "Array"
+ "Object"
+ "Map"
+ "Set"
+ "WeakMap"
+ "WeakSet"
+ "Date"
+ "Error"
+ "TypeError"
+ "RangeError"
+ "SyntaxError"
+ "ReferenceError"
+ "EvalError"
+ "URIError"
+ "RegExp"
+ "Function"
+ "Number"
+ "String"
+ "Boolean"
+ "Symbol"
+ "BigInt"
+ "Proxy"
+ "ArrayBuffer"
+ "DataView"
+ )
+ )
+)
+
; Properties
(property_identifier) @property
@@ -18,6 +52,12 @@
function: (member_expression
property: [(property_identifier) (private_property_identifier)] @function.method))
+(new_expression
+ constructor: (identifier) @type)
+
+(nested_type_identifier
+ module: (identifier) @type)
+
; Function and method definitions
(function_expression
@@ -47,13 +87,68 @@
left: (identifier) @function
right: [(function_expression) (arrow_function)])
+; Parameters
+
+(required_parameter
+ (identifier) @variable.parameter)
+
+(required_parameter
+ (_
+ ([
+ (identifier)
+ (shorthand_property_identifier_pattern)
+ ]) @variable.parameter))
+
+(optional_parameter
+ (identifier) @variable.parameter)
+
+(optional_parameter
+ (_
+ ([
+ (identifier)
+ (shorthand_property_identifier_pattern)
+ ]) @variable.parameter))
+
+(catch_clause
+ parameter: (identifier) @variable.parameter)
+
+(index_signature
+ name: (identifier) @variable.parameter)
+
+(arrow_function
+ parameter: (identifier) @variable.parameter)
+
+(type_predicate
+ name: (identifier) @variable.parameter)
+
; Special identifiers
-((identifier) @type
- (#match? @type "^[A-Z]"))
+(type_annotation) @type
(type_identifier) @type
(predefined_type) @type.builtin
+(type_alias_declaration
+ (type_identifier) @type)
+
+(type_alias_declaration
+ value: (_
+ (type_identifier) @type))
+
+(interface_declaration
+ (type_identifier) @type)
+
+(class_declaration
+ (type_identifier) @type.class)
+
+(extends_clause
+ value: (identifier) @type.class)
+
+(extends_type_clause
+ type: (type_identifier) @type)
+
+(implements_clause
+ (type_identifier) @type)
+
([
(identifier)
(shorthand_property_identifier)
@@ -231,8 +326,42 @@
"<" @punctuation.bracket
">" @punctuation.bracket)
+(type_parameters
+ "<" @punctuation.bracket
+ ">" @punctuation.bracket)
+
(decorator "@" @punctuation.special)
+(union_type
+ ("|") @punctuation.special)
+
+(intersection_type
+ ("&") @punctuation.special)
+
+(type_annotation
+ (":") @punctuation.special)
+
+(index_signature
+ (":") @punctuation.special)
+
+(type_predicate_annotation
+ (":") @punctuation.special)
+
+(public_field_definition
+ ("?") @punctuation.special)
+
+(property_signature
+ ("?") @punctuation.special)
+
+(method_signature
+ ("?") @punctuation.special)
+
+(optional_parameter
+ ([
+ "?"
+ ":"
+ ]) @punctuation.special)
+
; Keywords
[ "abstract"
@@ -257,6 +386,34 @@
(jsx_closing_element (identifier) @tag.jsx (#match? @tag.jsx "^[a-z][^.]*$"))
(jsx_self_closing_element (identifier) @tag.jsx (#match? @tag.jsx "^[a-z][^.]*$"))
+(jsx_opening_element
+ [
+ (identifier) @type
+ (member_expression
+ object: (identifier) @type
+ property: (property_identifier) @type
+ )
+ ]
+)
+(jsx_closing_element
+ [
+ (identifier) @type
+ (member_expression
+ object: (identifier) @type
+ property: (property_identifier) @type
+ )
+ ]
+)
+(jsx_self_closing_element
+ [
+ (identifier) @type
+ (member_expression
+ object: (identifier) @type
+ property: (property_identifier) @type
+ )
+ ]
+)
+
(jsx_attribute (property_identifier) @attribute.jsx)
(jsx_opening_element (["<" ">"]) @punctuation.bracket.jsx)
(jsx_closing_element (["</" ">"]) @punctuation.bracket.jsx)
@@ -83,3 +83,46 @@
arguments: (arguments (template_string (string_fragment) @injection.content
(#set! injection.language "isograph")))
)
+
+; Parse the contents of strings and tagged template
+; literals with leading ECMAScript comments:
+; '/* html */' or '/*html*/'
+(
+ ((comment) @_ecma_comment [
+ (string (string_fragment) @injection.content)
+ (template_string (string_fragment) @injection.content)
+ ])
+ (#match? @_ecma_comment "^\\/\\*\\s*html\\s*\\*\\/")
+ (#set! injection.language "html")
+)
+
+; '/* sql */' or '/*sql*/'
+(
+ ((comment) @_ecma_comment [
+ (string (string_fragment) @injection.content)
+ (template_string (string_fragment) @injection.content)
+ ])
+ (#match? @_ecma_comment "^\\/\\*\\s*sql\\s*\\*\\/")
+ (#set! injection.language "sql")
+)
+
+; '/* gql */' or '/*gql*/'
+; '/* graphql */' or '/*graphql*/'
+(
+ ((comment) @_ecma_comment [
+ (string (string_fragment) @injection.content)
+ (template_string (string_fragment) @injection.content)
+ ])
+ (#match? @_ecma_comment "^\\/\\*\\s*(gql|graphql)\\s*\\*\\/")
+ (#set! injection.language "graphql")
+)
+
+; '/* css */' or '/*css*/'
+(
+ ((comment) @_ecma_comment [
+ (string (string_fragment) @injection.content)
+ (template_string (string_fragment) @injection.content)
+ ])
+ (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/")
+ (#set! injection.language "css")
+)
@@ -4,18 +4,17 @@ use chrono::{DateTime, Local};
use collections::HashMap;
use futures::future::join_all;
use gpui::{App, AppContext, AsyncApp, Task};
-use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url};
-use http_client::github_download::download_server_binary;
use itertools::Itertools as _;
use language::{
ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore, LspAdapter,
LspAdapterDelegate, LspInstaller, Toolchain,
};
-use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
+use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri};
use node_runtime::{NodeRuntime, VersionStrategy};
use project::{Fs, lsp_store::language_server_settings};
+use semver::Version;
use serde_json::{Value, json};
-use smol::{fs, lock::RwLock, stream::StreamExt};
+use smol::lock::RwLock;
use std::{
borrow::Cow,
ffi::OsString,
@@ -23,8 +22,8 @@ use std::{
sync::{Arc, LazyLock},
};
use task::{TaskTemplate, TaskTemplates, VariableName};
-use util::{ResultExt, fs::remove_matching, maybe};
-use util::{merge_json_value_into, rel_path::RelPath};
+use util::rel_path::RelPath;
+use util::{ResultExt, maybe};
use crate::{PackageJson, PackageJsonData};
@@ -113,8 +112,7 @@ impl PackageJsonData {
"--".to_owned(),
"vitest".to_owned(),
"run".to_owned(),
- "--poolOptions.forks.minForks=0".to_owned(),
- "--poolOptions.forks.maxForks=1".to_owned(),
+ "--no-file-parallelism".to_owned(),
VariableName::File.template_value(),
],
cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
@@ -132,8 +130,7 @@ impl PackageJsonData {
"--".to_owned(),
"vitest".to_owned(),
"run".to_owned(),
- "--poolOptions.forks.minForks=0".to_owned(),
- "--poolOptions.forks.maxForks=1".to_owned(),
+ "--no-file-parallelism".to_owned(),
"--testNamePattern".to_owned(),
format!(
"\"{}\"",
@@ -589,14 +586,6 @@ fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
vec![server_path.into(), "--stdio".into()]
}
-fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
- vec![
- "--max-old-space-size=8192".into(),
- server_path.into(),
- "--stdio".into(),
- ]
-}
-
fn replace_test_name_parameters(test_name: &str) -> String {
static PATTERN: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(\$([A-Za-z0-9_\.]+|[\#])|%[psdifjo#\$%])").unwrap());
@@ -609,14 +598,19 @@ pub struct TypeScriptLspAdapter {
}
impl TypeScriptLspAdapter {
- const OLD_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.js";
- const NEW_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.mjs";
- const SERVER_NAME: LanguageServerName =
- LanguageServerName::new_static("typescript-language-server");
+ const OLD_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.js";
+ const NEW_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.mjs";
+
const PACKAGE_NAME: &str = "typescript";
+ const SERVER_PACKAGE_NAME: &str = "typescript-language-server";
+
+ const SERVER_NAME: LanguageServerName =
+ LanguageServerName::new_static(Self::SERVER_PACKAGE_NAME);
+
pub fn new(node: NodeRuntime, fs: Arc<dyn Fs>) -> Self {
TypeScriptLspAdapter { fs, node }
}
+
async fn tsdk_path(&self, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
let is_yarn = adapter
.read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap())
@@ -642,8 +636,8 @@ impl TypeScriptLspAdapter {
}
pub struct TypeScriptVersions {
- typescript_version: String,
- server_version: String,
+ typescript_version: Version,
+ server_version: Version,
}
impl LspInstaller for TypeScriptLspAdapter {
@@ -654,48 +648,63 @@ impl LspInstaller for TypeScriptLspAdapter {
_: &dyn LspAdapterDelegate,
_: bool,
_: &mut AsyncApp,
- ) -> Result<TypeScriptVersions> {
+ ) -> Result<Self::BinaryVersion> {
Ok(TypeScriptVersions {
- typescript_version: self.node.npm_package_latest_version("typescript").await?,
+ typescript_version: self
+ .node
+ .npm_package_latest_version(Self::PACKAGE_NAME)
+ .await?,
server_version: self
.node
- .npm_package_latest_version("typescript-language-server")
+ .npm_package_latest_version(Self::SERVER_PACKAGE_NAME)
.await?,
})
}
async fn check_if_version_installed(
&self,
- version: &TypeScriptVersions,
+ version: &Self::BinaryVersion,
container_dir: &PathBuf,
_: &dyn LspAdapterDelegate,
) -> Option<LanguageServerBinary> {
let server_path = container_dir.join(Self::NEW_SERVER_PATH);
- let should_install_language_server = self
+ if self
.node
.should_install_npm_package(
Self::PACKAGE_NAME,
&server_path,
container_dir,
- VersionStrategy::Latest(version.typescript_version.as_str()),
+ VersionStrategy::Latest(&version.typescript_version),
)
- .await;
+ .await
+ {
+ return None;
+ }
- if should_install_language_server {
- None
- } else {
- Some(LanguageServerBinary {
- path: self.node.binary_path().await.ok()?,
- env: None,
- arguments: typescript_server_binary_arguments(&server_path),
- })
+ if self
+ .node
+ .should_install_npm_package(
+ Self::SERVER_PACKAGE_NAME,
+ &server_path,
+ container_dir,
+ VersionStrategy::Latest(&version.server_version),
+ )
+ .await
+ {
+ return None;
}
+
+ Some(LanguageServerBinary {
+ path: self.node.binary_path().await.ok()?,
+ env: None,
+ arguments: typescript_server_binary_arguments(&server_path),
+ })
}
async fn fetch_server_binary(
&self,
- latest_version: TypeScriptVersions,
+ latest_version: Self::BinaryVersion,
container_dir: PathBuf,
_: &dyn LspAdapterDelegate,
) -> Result<LanguageServerBinary> {
@@ -707,11 +716,11 @@ impl LspInstaller for TypeScriptLspAdapter {
&[
(
Self::PACKAGE_NAME,
- latest_version.typescript_version.as_str(),
+ &latest_version.typescript_version.to_string(),
),
(
- "typescript-language-server",
- latest_version.server_version.as_str(),
+ Self::SERVER_PACKAGE_NAME,
+ &latest_version.server_version.to_string(),
),
],
)
@@ -811,9 +820,9 @@ impl LspAdapter for TypeScriptLspAdapter {
async fn workspace_configuration(
self: Arc<Self>,
-
delegate: &Arc<dyn LspAdapterDelegate>,
_: Option<Toolchain>,
+ _: Option<Uri>,
cx: &mut AsyncApp,
) -> Result<Value> {
let override_options = cx.update(|cx| {
@@ -832,9 +841,9 @@ impl LspAdapter for TypeScriptLspAdapter {
fn language_ids(&self) -> HashMap<LanguageName, String> {
HashMap::from_iter([
- (LanguageName::new("TypeScript"), "typescript".into()),
- (LanguageName::new("JavaScript"), "javascript".into()),
- (LanguageName::new("TSX"), "typescriptreact".into()),
+ (LanguageName::new_static("TypeScript"), "typescript".into()),
+ (LanguageName::new_static("JavaScript"), "javascript".into()),
+ (LanguageName::new_static("TSX"), "typescriptreact".into()),
])
}
}
@@ -866,226 +875,6 @@ async fn get_cached_ts_server_binary(
.log_err()
}
-pub struct EsLintLspAdapter {
- node: NodeRuntime,
-}
-
-impl EsLintLspAdapter {
- const CURRENT_VERSION: &'static str = "2.4.4";
- const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
-
- #[cfg(not(windows))]
- const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
- #[cfg(windows)]
- const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
-
- const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
- const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
-
- const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
- "eslint.config.js",
- "eslint.config.mjs",
- "eslint.config.cjs",
- "eslint.config.ts",
- "eslint.config.cts",
- "eslint.config.mts",
- ];
-
- pub fn new(node: NodeRuntime) -> Self {
- EsLintLspAdapter { node }
- }
-
- fn build_destination_path(container_dir: &Path) -> PathBuf {
- container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
- }
-}
-
-impl LspInstaller for EsLintLspAdapter {
- type BinaryVersion = GitHubLspBinaryVersion;
-
- async fn fetch_latest_server_version(
- &self,
- _delegate: &dyn LspAdapterDelegate,
- _: bool,
- _: &mut AsyncApp,
- ) -> Result<GitHubLspBinaryVersion> {
- let url = build_asset_url(
- "zed-industries/vscode-eslint",
- Self::CURRENT_VERSION_TAG_NAME,
- Self::GITHUB_ASSET_KIND,
- )?;
-
- Ok(GitHubLspBinaryVersion {
- name: Self::CURRENT_VERSION.into(),
- digest: None,
- url,
- })
- }
-
- async fn fetch_server_binary(
- &self,
- version: GitHubLspBinaryVersion,
- container_dir: PathBuf,
- delegate: &dyn LspAdapterDelegate,
- ) -> Result<LanguageServerBinary> {
- let destination_path = Self::build_destination_path(&container_dir);
- let server_path = destination_path.join(Self::SERVER_PATH);
-
- if fs::metadata(&server_path).await.is_err() {
- remove_matching(&container_dir, |_| true).await;
-
- download_server_binary(
- &*delegate.http_client(),
- &version.url,
- None,
- &destination_path,
- Self::GITHUB_ASSET_KIND,
- )
- .await?;
-
- let mut dir = fs::read_dir(&destination_path).await?;
- let first = dir.next().await.context("missing first file")??;
- let repo_root = destination_path.join("vscode-eslint");
- fs::rename(first.path(), &repo_root).await?;
-
- #[cfg(target_os = "windows")]
- {
- handle_symlink(
- repo_root.join("$shared"),
- repo_root.join("client").join("src").join("shared"),
- )
- .await?;
- handle_symlink(
- repo_root.join("$shared"),
- repo_root.join("server").join("src").join("shared"),
- )
- .await?;
- }
-
- self.node
- .run_npm_subcommand(&repo_root, "install", &[])
- .await?;
-
- self.node
- .run_npm_subcommand(&repo_root, "run-script", &["compile"])
- .await?;
- }
-
- Ok(LanguageServerBinary {
- path: self.node.binary_path().await?,
- env: None,
- arguments: eslint_server_binary_arguments(&server_path),
- })
- }
-
- async fn cached_server_binary(
- &self,
- container_dir: PathBuf,
- _: &dyn LspAdapterDelegate,
- ) -> Option<LanguageServerBinary> {
- let server_path =
- Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
- Some(LanguageServerBinary {
- path: self.node.binary_path().await.ok()?,
- env: None,
- arguments: eslint_server_binary_arguments(&server_path),
- })
- }
-}
-
-#[async_trait(?Send)]
-impl LspAdapter for EsLintLspAdapter {
- fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
- Some(vec![
- CodeActionKind::QUICKFIX,
- CodeActionKind::new("source.fixAll.eslint"),
- ])
- }
-
- async fn workspace_configuration(
- self: Arc<Self>,
- delegate: &Arc<dyn LspAdapterDelegate>,
- _: Option<Toolchain>,
- cx: &mut AsyncApp,
- ) -> Result<Value> {
- let workspace_root = delegate.worktree_root_path();
- let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
- .iter()
- .any(|file| workspace_root.join(file).is_file());
-
- let mut default_workspace_configuration = json!({
- "validate": "on",
- "rulesCustomizations": [],
- "run": "onType",
- "nodePath": null,
- "workingDirectory": {
- "mode": "auto"
- },
- "workspaceFolder": {
- "uri": workspace_root,
- "name": workspace_root.file_name()
- .unwrap_or(workspace_root.as_os_str())
- .to_string_lossy(),
- },
- "problems": {},
- "codeActionOnSave": {
- // We enable this, but without also configuring code_actions_on_format
- // in the Zed configuration, it doesn't have an effect.
- "enable": true,
- },
- "codeAction": {
- "disableRuleComment": {
- "enable": true,
- "location": "separateLine",
- },
- "showDocumentation": {
- "enable": true
- }
- },
- "experimental": {
- "useFlatConfig": use_flat_config,
- }
- });
-
- let override_options = cx.update(|cx| {
- language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
- .and_then(|s| s.settings.clone())
- })?;
-
- if let Some(override_options) = override_options {
- merge_json_value_into(override_options, &mut default_workspace_configuration);
- }
-
- Ok(json!({
- "": default_workspace_configuration
- }))
- }
-
- fn name(&self) -> LanguageServerName {
- Self::SERVER_NAME
- }
-}
-
-#[cfg(target_os = "windows")]
-async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
- anyhow::ensure!(
- fs::metadata(&src_dir).await.is_ok(),
- "Directory {src_dir:?} is not present"
- );
- if fs::metadata(&dest_dir).await.is_ok() {
- fs::remove_file(&dest_dir).await?;
- }
- fs::create_dir_all(&dest_dir).await?;
- let mut entries = fs::read_dir(&src_dir).await?;
- while let Some(entry) = entries.try_next().await? {
- let entry_path = entry.path();
- let entry_name = entry.file_name();
- let dest_path = dest_dir.join(&entry_name);
- fs::copy(&entry_path, &dest_path).await?;
- }
- Ok(())
-}
-
#[cfg(test)]
mod tests {
use std::path::Path;
@@ -2,6 +2,6 @@
("[" @open "]" @close)
("{" @open "}" @close)
("<" @open ">" @close)
-("\"" @open "\"" @close)
-("'" @open "'" @close)
-("`" @open "`" @close)
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
+(("'" @open "'" @close) (#set! rainbow.exclude))
+(("`" @open "`" @close) (#set! rainbow.exclude))
@@ -2,13 +2,102 @@
(identifier) @variable
+(call_expression
+ function: (member_expression
+ object: (identifier) @type.builtin
+ (#any-of?
+ @type.builtin
+ "Promise"
+ "Array"
+ "Object"
+ "Map"
+ "Set"
+ "WeakMap"
+ "WeakSet"
+ "Date"
+ "Error"
+ "TypeError"
+ "RangeError"
+ "SyntaxError"
+ "ReferenceError"
+ "EvalError"
+ "URIError"
+ "RegExp"
+ "Function"
+ "Number"
+ "String"
+ "Boolean"
+ "Symbol"
+ "BigInt"
+ "Proxy"
+ "ArrayBuffer"
+ "DataView"
+ )
+ )
+)
+
; Special identifiers
-((identifier) @type
- (#match? @type "^[A-Z]"))
+(type_annotation) @type
+
(type_identifier) @type
(predefined_type) @type.builtin
+(type_alias_declaration
+ (type_identifier) @type)
+
+(type_alias_declaration
+ value: (_
+ (type_identifier) @type))
+
+(interface_declaration
+ (type_identifier) @type)
+
+(class_declaration
+ (type_identifier) @type.class)
+
+(extends_clause
+ value: (identifier) @type.class)
+
+(extends_type_clause
+ type: (type_identifier) @type)
+
+(implements_clause
+ (type_identifier) @type)
+
+;; Enables ts-pretty-errors
+;; The Lsp returns "snippets" of typescript, which are not valid typescript in totality,
+;; but should still be highlighted
+;; Highlights object literals by hijacking the statement_block pattern, but only if
+;; the statement block follows an object literal pattern
+((statement_block
+ (labeled_statement
+ ;; highlight the label like a property name
+ label: (statement_identifier) @property.name
+ body: [
+ ;; match a terminating expression statement
+ (expression_statement
+ ;; single identifier - treat as a type name
+ [(identifier) @type.name
+ ;; object - treat as a property - type pair
+ (object
+ (pair
+ key: (_) @property.name
+ value: (_) @type.name))
+ ;; subscript_expression - treat as an array declaration
+ (subscript_expression
+ object: (_) @type.name
+ index: (_)
+ )
+ ;; templated string - treat each identifier contained as a type name
+ (template_string
+ (template_substitution
+ (identifier) @type.name))
+ ])
+ ;; match a nested statement block
+ (statement_block) @nested
+ ])))
+
(import_specifier
"type"
name: (identifier) @type
@@ -50,6 +139,12 @@
function: (member_expression
property: [(property_identifier) (private_property_identifier)] @function.method))
+(new_expression
+ constructor: (identifier) @type)
+
+(nested_type_identifier
+ module: (identifier) @type)
+
; Function and method definitions
(function_expression
@@ -79,6 +174,42 @@
left: (identifier) @function
right: [(function_expression) (arrow_function)])
+(arrow_function) @function
+
+; Parameters
+
+(required_parameter
+ (identifier) @variable.parameter)
+
+(required_parameter
+ (_
+ ([
+ (identifier)
+ (shorthand_property_identifier_pattern)
+ ]) @variable.parameter))
+
+(optional_parameter
+ (identifier) @variable.parameter)
+
+(optional_parameter
+ (_
+ ([
+ (identifier)
+ (shorthand_property_identifier_pattern)
+ ]) @variable.parameter))
+
+(catch_clause
+ parameter: (identifier) @variable.parameter)
+
+(index_signature
+ name: (identifier) @variable.parameter)
+
+(arrow_function
+ parameter: (identifier) @variable.parameter)
+
+(type_predicate
+ name: (identifier) @variable.parameter)
+
; Literals
(this) @variable.special
@@ -209,8 +340,42 @@
"<" @punctuation.bracket
">" @punctuation.bracket)
+(type_parameters
+ "<" @punctuation.bracket
+ ">" @punctuation.bracket)
+
(decorator "@" @punctuation.special)
+(union_type
+ ("|") @punctuation.special)
+
+(intersection_type
+ ("&") @punctuation.special)
+
+(type_annotation
+ (":") @punctuation.special)
+
+(index_signature
+ (":") @punctuation.special)
+
+(type_predicate_annotation
+ (":") @punctuation.special)
+
+(public_field_definition
+ ("?") @punctuation.special)
+
+(property_signature
+ ("?") @punctuation.special)
+
+(method_signature
+ ("?") @punctuation.special)
+
+(optional_parameter
+ ([
+ "?"
+ ":"
+ ]) @punctuation.special)
+
; Keywords
[
@@ -124,3 +124,46 @@
]
)))
(#set! injection.language "css"))
+
+; Parse the contents of strings and tagged template
+; literals with leading ECMAScript comments:
+; '/* html */' or '/*html*/'
+(
+ ((comment) @_ecma_comment [
+ (string (string_fragment) @injection.content)
+ (template_string (string_fragment) @injection.content)
+ ])
+ (#match? @_ecma_comment "^\\/\\*\\s*html\\s*\\*\\/")
+ (#set! injection.language "html")
+)
+
+; '/* sql */' or '/*sql*/'
+(
+ ((comment) @_ecma_comment [
+ (string (string_fragment) @injection.content)
+ (template_string (string_fragment) @injection.content)
+ ])
+ (#match? @_ecma_comment "^\\/\\*\\s*sql\\s*\\*\\/")
+ (#set! injection.language "sql")
+)
+
+; '/* gql */' or '/*gql*/'
+; '/* graphql */' or '/*graphql*/'
+(
+ ((comment) @_ecma_comment [
+ (string (string_fragment) @injection.content)
+ (template_string (string_fragment) @injection.content)
+ ])
+ (#match? @_ecma_comment "^\\/\\*\\s*(gql|graphql)\\s*\\*\\/")
+ (#set! injection.language "graphql")
+)
+
+; '/* css */' or '/*css*/'
+(
+ ((comment) @_ecma_comment [
+ (string (string_fragment) @injection.content)
+ (template_string (string_fragment) @injection.content)
+ ])
+ (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/")
+ (#set! injection.language "css")
+)
@@ -2,18 +2,29 @@ use anyhow::Result;
use async_trait::async_trait;
use collections::HashMap;
use gpui::AsyncApp;
-use language::{LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain};
-use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
+use language::{
+ LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, PromptResponseContext, Toolchain,
+};
+use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri};
use node_runtime::{NodeRuntime, VersionStrategy};
use project::{Fs, lsp_store::language_server_settings};
+use regex::Regex;
+use semver::Version;
use serde_json::Value;
+use serde_json::json;
+use settings::update_settings_file;
use std::{
ffi::OsString,
path::{Path, PathBuf},
- sync::Arc,
+ sync::{Arc, LazyLock},
};
use util::{ResultExt, maybe, merge_json_value_into};
+const ACTION_ALWAYS: &str = "Always";
+const ACTION_NEVER: &str = "Never";
+const UPDATE_IMPORTS_MESSAGE_PATTERN: &str = "Update imports for";
+const VTSLS_SERVER_NAME: &str = "vtsls";
+
fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
vec![server_path.into(), "--stdio".into()]
}
@@ -56,11 +67,25 @@ impl VtslsLspAdapter {
None
}
}
+
+ pub fn enhance_diagnostic_message(message: &str) -> Option<String> {
+ static SINGLE_WORD_REGEX: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"'([^\s']*)'").expect("Failed to create REGEX"));
+
+ static MULTI_WORD_REGEX: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"'([^']+\s+[^']*)'").expect("Failed to create REGEX"));
+
+ let first = SINGLE_WORD_REGEX.replace_all(message, "`$1`").to_string();
+ let second = MULTI_WORD_REGEX
+ .replace_all(&first, "\n```typescript\n$1\n```\n")
+ .to_string();
+ Some(second)
+ }
}
pub struct TypeScriptVersions {
- typescript_version: String,
- server_version: String,
+ typescript_version: Version,
+ server_version: Version,
}
const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("vtsls");
@@ -73,7 +98,7 @@ impl LspInstaller for VtslsLspAdapter {
_: &dyn LspAdapterDelegate,
_: bool,
_: &mut AsyncApp,
- ) -> Result<TypeScriptVersions> {
+ ) -> Result<Self::BinaryVersion> {
Ok(TypeScriptVersions {
typescript_version: self.node.npm_package_latest_version("typescript").await?,
server_version: self
@@ -100,12 +125,15 @@ impl LspInstaller for VtslsLspAdapter {
async fn fetch_server_binary(
&self,
- latest_version: TypeScriptVersions,
+ latest_version: Self::BinaryVersion,
container_dir: PathBuf,
_: &dyn LspAdapterDelegate,
) -> Result<LanguageServerBinary> {
let server_path = container_dir.join(Self::SERVER_PATH);
+ let typescript_version = latest_version.typescript_version.to_string();
+ let server_version = latest_version.server_version.to_string();
+
let mut packages_to_install = Vec::new();
if self
@@ -118,7 +146,7 @@ impl LspInstaller for VtslsLspAdapter {
)
.await
{
- packages_to_install.push((Self::PACKAGE_NAME, latest_version.server_version.as_str()));
+ packages_to_install.push((Self::PACKAGE_NAME, server_version.as_str()));
}
if self
@@ -131,10 +159,7 @@ impl LspInstaller for VtslsLspAdapter {
)
.await
{
- packages_to_install.push((
- Self::TYPESCRIPT_PACKAGE_NAME,
- latest_version.typescript_version.as_str(),
- ));
+ packages_to_install.push((Self::TYPESCRIPT_PACKAGE_NAME, typescript_version.as_str()));
}
self.node
@@ -213,6 +238,7 @@ impl LspAdapter for VtslsLspAdapter {
self: Arc<Self>,
delegate: &Arc<dyn LspAdapterDelegate>,
_: Option<Toolchain>,
+ _: Option<Uri>,
cx: &mut AsyncApp,
) -> Result<Value> {
let tsdk_path = self.tsdk_path(delegate).await;
@@ -274,13 +300,63 @@ impl LspAdapter for VtslsLspAdapter {
Ok(default_workspace_configuration)
}
+ fn diagnostic_message_to_markdown(&self, message: &str) -> Option<String> {
+ VtslsLspAdapter::enhance_diagnostic_message(message)
+ }
+
fn language_ids(&self) -> HashMap<LanguageName, String> {
HashMap::from_iter([
- (LanguageName::new("TypeScript"), "typescript".into()),
- (LanguageName::new("JavaScript"), "javascript".into()),
- (LanguageName::new("TSX"), "typescriptreact".into()),
+ (LanguageName::new_static("TypeScript"), "typescript".into()),
+ (LanguageName::new_static("JavaScript"), "javascript".into()),
+ (LanguageName::new_static("TSX"), "typescriptreact".into()),
])
}
+
+ fn process_prompt_response(&self, context: &PromptResponseContext, cx: &mut AsyncApp) {
+ let selected_title = context.selected_action.title.as_str();
+ let is_preference_response =
+ selected_title == ACTION_ALWAYS || selected_title == ACTION_NEVER;
+ if !is_preference_response {
+ return;
+ }
+
+ if context.message.contains(UPDATE_IMPORTS_MESSAGE_PATTERN) {
+ let setting_value = match selected_title {
+ ACTION_ALWAYS => "always",
+ ACTION_NEVER => "never",
+ _ => return,
+ };
+
+ let settings = json!({
+ "typescript": {
+ "updateImportsOnFileMove": {
+ "enabled": setting_value
+ }
+ },
+ "javascript": {
+ "updateImportsOnFileMove": {
+ "enabled": setting_value
+ }
+ }
+ });
+
+ let _ = cx.update(|cx| {
+ update_settings_file(self.fs.clone(), cx, move |content, _| {
+ let lsp_settings = content
+ .project
+ .lsp
+ .entry(VTSLS_SERVER_NAME.into())
+ .or_default();
+
+ if let Some(existing) = &mut lsp_settings.settings {
+ merge_json_value_into(settings, existing);
+ } else {
+ lsp_settings.settings = Some(settings);
+ }
+ });
+ });
+ }
+ }
}
async fn get_cached_ts_server_binary(
@@ -302,3 +378,41 @@ async fn get_cached_ts_server_binary(
.await
.log_err()
}
+
+#[cfg(test)]
+mod tests {
+ use crate::vtsls::VtslsLspAdapter;
+
+ #[test]
+ fn test_diagnostic_message_to_markdown() {
+ // Leaves simple messages unchanged
+ let message = "The expected type comes from the return type of this signature.";
+
+ let expected = "The expected type comes from the return type of this signature.";
+
+ assert_eq!(
+ VtslsLspAdapter::enhance_diagnostic_message(message).expect("Should be some"),
+ expected
+ );
+
+ // Parses both multi-word and single-word correctly
+ let message = "Property 'baz' is missing in type '{ foo: string; bar: string; }' but required in type 'User'.";
+
+ let expected = "Property `baz` is missing in type \n```typescript\n{ foo: string; bar: string; }\n```\n but required in type `User`.";
+
+ assert_eq!(
+ VtslsLspAdapter::enhance_diagnostic_message(message).expect("Should be some"),
+ expected
+ );
+
+ // Parses multi-and-single word in any order, and ignores existing newlines
+ let message = "Type '() => { foo: string; bar: string; }' is not assignable to type 'GetUserFunction'.\n Property 'baz' is missing in type '{ foo: string; bar: string; }' but required in type 'User'.";
+
+ let expected = "Type \n```typescript\n() => { foo: string; bar: string; }\n```\n is not assignable to type `GetUserFunction`.\n Property `baz` is missing in type \n```typescript\n{ foo: string; bar: string; }\n```\n but required in type `User`.";
+
+ assert_eq!(
+ VtslsLspAdapter::enhance_diagnostic_message(message).expect("Should be some"),
+ expected
+ );
+ }
+}
@@ -1,16 +1,15 @@
-use anyhow::{Context as _, Result};
+use anyhow::Result;
use async_trait::async_trait;
-use futures::StreamExt;
use gpui::AsyncApp;
use language::{
LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain, language_settings::AllLanguageSettings,
};
-use lsp::{LanguageServerBinary, LanguageServerName};
+use lsp::{LanguageServerBinary, LanguageServerName, Uri};
use node_runtime::{NodeRuntime, VersionStrategy};
use project::lsp_store::language_server_settings;
+use semver::Version;
use serde_json::Value;
use settings::{Settings, SettingsLocation};
-use smol::fs;
use std::{
ffi::OsString,
path::{Path, PathBuf},
@@ -37,14 +36,14 @@ impl YamlLspAdapter {
}
impl LspInstaller for YamlLspAdapter {
- type BinaryVersion = String;
+ type BinaryVersion = Version;
async fn fetch_latest_server_version(
&self,
_: &dyn LspAdapterDelegate,
_: bool,
_: &mut AsyncApp,
- ) -> Result<String> {
+ ) -> Result<Self::BinaryVersion> {
self.node
.npm_package_latest_version("yaml-language-server")
.await
@@ -68,7 +67,7 @@ impl LspInstaller for YamlLspAdapter {
async fn fetch_server_binary(
&self,
- latest_version: String,
+ latest_version: Self::BinaryVersion,
container_dir: PathBuf,
_: &dyn LspAdapterDelegate,
) -> Result<LanguageServerBinary> {
@@ -77,7 +76,7 @@ impl LspInstaller for YamlLspAdapter {
self.node
.npm_install_packages(
&container_dir,
- &[(Self::PACKAGE_NAME, latest_version.as_str())],
+ &[(Self::PACKAGE_NAME, &latest_version.to_string())],
)
.await?;
@@ -90,7 +89,7 @@ impl LspInstaller for YamlLspAdapter {
async fn check_if_version_installed(
&self,
- version: &String,
+ version: &Self::BinaryVersion,
container_dir: &PathBuf,
_: &dyn LspAdapterDelegate,
) -> Option<LanguageServerBinary> {
@@ -134,9 +133,9 @@ impl LspAdapter for YamlLspAdapter {
async fn workspace_configuration(
self: Arc<Self>,
-
delegate: &Arc<dyn LspAdapterDelegate>,
_: Option<Toolchain>,
+ _: Option<Uri>,
cx: &mut AsyncApp,
) -> Result<Value> {
let location = SettingsLocation {
@@ -171,19 +170,10 @@ async fn get_cached_server_binary(
node: &NodeRuntime,
) -> Option<LanguageServerBinary> {
maybe!(async {
- let mut last_version_dir = None;
- let mut entries = fs::read_dir(&container_dir).await?;
- while let Some(entry) = entries.next().await {
- let entry = entry?;
- if entry.file_type().await?.is_dir() {
- last_version_dir = Some(entry.path());
- }
- }
- let last_version_dir = last_version_dir.context("no cached binary")?;
- let server_path = last_version_dir.join(SERVER_PATH);
+ let server_path = container_dir.join(SERVER_PATH);
anyhow::ensure!(
server_path.exists(),
- "missing executable in directory {last_version_dir:?}"
+ "missing executable in directory {server_path:?}"
);
Ok(LanguageServerBinary {
path: node.binary_path().await?,
@@ -1,4 +1,4 @@
("[" @open "]" @close)
("{" @open "}" @close)
-("\"" @open "\"" @close)
-("'" @open "'" @close)
+(("\"" @open "\"" @close) (#set! rainbow.exclude))
+(("'" @open "'" @close) (#set! rainbow.exclude))
@@ -1,6 +1,6 @@
name = "YAML"
grammar = "yaml"
-path_suffixes = ["yml", "yaml", "pixi.lock"]
+path_suffixes = ["yml", "yaml", "pixi.lock", "clang-format", "clangd"]
line_comments = ["# "]
autoclose_before = ",]}"
brackets = [
@@ -1,3 +1,25 @@
((comment) @injection.content
(#set! injection.language "comment")
)
+
+; GitHub actions: JavaScript for workflow scripting (inline and block)
+(block_mapping
+ (block_mapping_pair
+ key: (flow_node) @_uses (#eq? @_uses "uses")
+ value: (flow_node) @_actions_ghs (#match? @_actions_ghs "^actions/github-script"))
+ (block_mapping_pair
+ key: (flow_node) @_with (#eq? @_with "with")
+ value: (block_node
+ (block_mapping
+ (block_mapping_pair
+ key: (flow_node) @_run (#eq? @_run "script")
+ value: [
+ (flow_node (plain_scalar (string_scalar) @injection.content))
+ (block_node (block_scalar) @injection.content)
+ ]
+ (#set! injection.language "javascript")
+ )
+ )
+ )
+ )
+)
@@ -378,7 +378,7 @@ impl Render for LivekitWindow {
.when_some(state.audio_output_stream.as_ref(), |el, state| {
el.child(
button()
- .id(SharedString::from(identity.0.clone()))
+ .id(identity.0.clone())
.child(if state.0.is_enabled() {
"Deafen"
} else {
@@ -98,6 +98,14 @@ impl Room {
self.room.connection_state()
}
+ pub fn name(&self) -> String {
+ self.room.name()
+ }
+
+ pub async fn sid(&self) -> String {
+ self.room.sid().await.to_string()
+ }
+
pub async fn publish_local_microphone_track(
&self,
user_name: String,
@@ -47,14 +47,17 @@ impl LiveKitStream {
);
let (queue_input, queue_output) = rodio::queue::queue(true);
// spawn rtc stream
- let receiver_task = executor.spawn({
- async move {
- while let Some(frame) = stream.next().await {
- let samples = frame_to_samplesbuffer(frame);
- queue_input.append(samples);
+ let receiver_task = executor.spawn_with_priority(
+ gpui::Priority::Realtime(gpui::RealtimePriority::Audio),
+ {
+ async move {
+ while let Some(frame) = stream.next().await {
+ let samples = frame_to_samplesbuffer(frame);
+ queue_input.append(samples);
+ }
}
- }
- });
+ },
+ );
LiveKitStream {
_receiver_task: receiver_task,
@@ -714,6 +714,14 @@ impl Room {
self.0.lock().token.clone()
}
+ pub fn name(&self) -> String {
+ "test_room".to_string()
+ }
+
+ pub async fn sid(&self) -> String {
+ "RM_test_session".to_string()
+ }
+
pub fn play_remote_audio_track(
&self,
_track: &RemoteAudioTrack,
@@ -36,5 +36,6 @@ release_channel.workspace = true
async-pipe.workspace = true
ctor.workspace = true
gpui = { workspace = true, features = ["test-support"] }
+semver.workspace = true
util = { workspace = true, features = ["test-support"] }
zlog.workspace = true
@@ -62,7 +62,7 @@ pub enum IoKind {
/// Represents a launchable language server. This can either be a standalone binary or the path
/// to a runtime with arguments to instruct it to launch the actual language server file.
-#[derive(Clone)]
+#[derive(Clone, Serialize)]
pub struct LanguageServerBinary {
pub path: PathBuf,
pub arguments: Vec<OsString>,
@@ -331,14 +331,13 @@ impl LanguageServer {
};
let root_uri = Uri::from_file_path(&working_dir)
.map_err(|()| anyhow!("{working_dir:?} is not a valid URI"))?;
-
log::info!(
- "starting language server process. binary path: {:?}, working directory: {:?}, args: {:?}",
+ "starting language server process. binary path: \
+ {:?}, working directory: {:?}, args: {:?}",
binary.path,
working_dir,
&binary.arguments
);
-
let mut command = util::command::new_smol_command(&binary.path);
command
.current_dir(working_dir)
@@ -348,6 +347,7 @@ impl LanguageServer {
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
+
let mut server = command
.spawn()
.with_context(|| format!("failed to spawn command {command:?}",))?;
@@ -667,7 +667,7 @@ impl LanguageServer {
#[allow(deprecated)]
InitializeParams {
- process_id: None,
+ process_id: Some(std::process::id()),
root_path: None,
root_uri: Some(self.root_uri.clone()),
initialization_options: None,
@@ -764,6 +764,10 @@ impl LanguageServer {
// "textEdit".to_string(),
],
}),
+ deprecated_support: Some(true),
+ tag_support: Some(TagSupport {
+ value_set: vec![CompletionItemTag::DEPRECATED],
+ }),
insert_replace_support: Some(true),
label_details_support: Some(true),
insert_text_mode_support: Some(InsertTextModeSupport {
@@ -1848,7 +1852,7 @@ impl FakeLanguageServer {
#[cfg(test)]
mod tests {
use super::*;
- use gpui::{SemanticVersion, TestAppContext};
+ use gpui::TestAppContext;
use std::str::FromStr;
#[ctor::ctor]
@@ -1859,7 +1863,7 @@ mod tests {
#[gpui::test]
async fn test_fake(cx: &mut TestAppContext) {
cx.update(|cx| {
- release_channel::init(SemanticVersion::default(), cx);
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
});
let (server, mut fake) = FakeLanguageServer::new(
LanguageServerId(0),
@@ -54,11 +54,11 @@ impl Render for HelloWorld {
..Default::default()
},
code_block: StyleRefinement {
- text: Some(gpui::TextStyleRefinement {
+ text: gpui::TextStyleRefinement {
font_family: Some("Zed Mono".into()),
background_color: Some(cx.theme().colors().editor_background),
..Default::default()
- }),
+ },
margin: gpui::EdgesRefinement {
top: Some(Length::Definite(rems(4.).into())),
left: Some(Length::Definite(rems(4.).into())),
@@ -7,6 +7,7 @@ use gpui::HitboxBehavior;
use language::LanguageName;
use log::Level;
pub use path_range::{LineCol, PathWithRange};
+use ui::Checkbox;
use std::borrow::Cow;
use std::iter;
@@ -54,6 +55,7 @@ pub struct HeadingLevelStyles {
#[derive(Clone)]
pub struct MarkdownStyle {
pub base_text_style: TextStyle,
+ pub container_style: StyleRefinement,
pub code_block: StyleRefinement,
pub code_block_overflow_x_scroll: bool,
pub inline_code: TextStyleRefinement,
@@ -66,15 +68,16 @@ pub struct MarkdownStyle {
pub selection_background_color: Hsla,
pub heading: StyleRefinement,
pub heading_level_styles: Option<HeadingLevelStyles>,
- pub table_overflow_x_scroll: bool,
pub height_is_multiple_of_line_height: bool,
pub prevent_mouse_interaction: bool,
+ pub table_columns_min_size: bool,
}
impl Default for MarkdownStyle {
fn default() -> Self {
Self {
base_text_style: Default::default(),
+ container_style: Default::default(),
code_block: Default::default(),
code_block_overflow_x_scroll: false,
inline_code: Default::default(),
@@ -87,9 +90,9 @@ impl Default for MarkdownStyle {
selection_background_color: Default::default(),
heading: Default::default(),
heading_level_styles: None,
- table_overflow_x_scroll: false,
height_is_multiple_of_line_height: false,
prevent_mouse_interaction: false,
+ table_columns_min_size: false,
}
}
}
@@ -250,7 +253,7 @@ impl Markdown {
self.autoscroll_request = None;
self.pending_parse = None;
self.should_reparse = false;
- self.parsed_markdown = ParsedMarkdown::default();
+ // Don't clear parsed_markdown here - keep existing content visible until new parse completes
self.parse(cx);
}
@@ -421,28 +424,72 @@ impl Focusable for Markdown {
}
}
-#[derive(Copy, Clone, Default, Debug)]
+#[derive(Debug, Default, Clone)]
+enum SelectMode {
+ #[default]
+ Character,
+ Word(Range<usize>),
+ Line(Range<usize>),
+ All,
+}
+
+#[derive(Clone, Default)]
struct Selection {
start: usize,
end: usize,
reversed: bool,
pending: bool,
+ mode: SelectMode,
}
impl Selection {
- fn set_head(&mut self, head: usize) {
- if head < self.tail() {
- if !self.reversed {
- self.end = self.start;
- self.reversed = true;
+ fn set_head(&mut self, head: usize, rendered_text: &RenderedText) {
+ match &self.mode {
+ SelectMode::Character => {
+ if head < self.tail() {
+ if !self.reversed {
+ self.end = self.start;
+ self.reversed = true;
+ }
+ self.start = head;
+ } else {
+ if self.reversed {
+ self.start = self.end;
+ self.reversed = false;
+ }
+ self.end = head;
+ }
}
- self.start = head;
- } else {
- if self.reversed {
- self.start = self.end;
+ SelectMode::Word(original_range) | SelectMode::Line(original_range) => {
+ let head_range = if matches!(self.mode, SelectMode::Word(_)) {
+ rendered_text.surrounding_word_range(head)
+ } else {
+ rendered_text.surrounding_line_range(head)
+ };
+
+ if head < original_range.start {
+ self.start = head_range.start;
+ self.end = original_range.end;
+ self.reversed = true;
+ } else if head >= original_range.end {
+ self.start = original_range.start;
+ self.end = head_range.end;
+ self.reversed = false;
+ } else {
+ self.start = original_range.start;
+ self.end = original_range.end;
+ self.reversed = false;
+ }
+ }
+ SelectMode::All => {
+ self.start = 0;
+ self.end = rendered_text
+ .lines
+ .last()
+ .map(|line| line.source_end)
+ .unwrap_or(0);
self.reversed = false;
}
- self.end = head;
}
}
@@ -531,7 +578,7 @@ impl MarkdownElement {
window: &mut Window,
cx: &mut App,
) {
- let selection = self.markdown.read(cx).selection;
+ let selection = self.markdown.read(cx).selection.clone();
let selection_start = rendered_text.position_for_source_index(selection.start);
let selection_end = rendered_text.position_for_source_index(selection.end);
if let Some(((start_position, start_line_height), (end_position, end_line_height))) =
@@ -631,20 +678,36 @@ impl MarkdownElement {
match rendered_text.source_index_for_position(event.position) {
Ok(ix) | Err(ix) => ix,
};
- let range = if event.click_count == 2 {
- rendered_text.surrounding_word_range(source_index)
- } else if event.click_count == 3 {
- rendered_text.surrounding_line_range(source_index)
- } else {
- source_index..source_index
+ let (range, mode) = match event.click_count {
+ 1 => {
+ let range = source_index..source_index;
+ (range, SelectMode::Character)
+ }
+ 2 => {
+ let range = rendered_text.surrounding_word_range(source_index);
+ (range.clone(), SelectMode::Word(range))
+ }
+ 3 => {
+ let range = rendered_text.surrounding_line_range(source_index);
+ (range.clone(), SelectMode::Line(range))
+ }
+ _ => {
+ let range = 0..rendered_text
+ .lines
+ .last()
+ .map(|line| line.source_end)
+ .unwrap_or(0);
+ (range, SelectMode::All)
+ }
};
markdown.selection = Selection {
start: range.start,
end: range.end,
reversed: false,
pending: true,
+ mode,
};
- window.focus(&markdown.focus_handle);
+ window.focus(&markdown.focus_handle, cx);
}
window.prevent_default();
@@ -671,7 +734,7 @@ impl MarkdownElement {
{
Ok(ix) | Err(ix) => ix,
};
- markdown.selection.set_head(source_index);
+ markdown.selection.set_head(source_index, &rendered_text);
markdown.autoscroll_request = Some(source_index);
cx.notify();
} else {
@@ -750,6 +813,12 @@ impl MarkdownElement {
}
}
+impl Styled for MarkdownElement {
+ fn style(&mut self) -> &mut StyleRefinement {
+ &mut self.style.container_style
+ }
+}
+
impl Element for MarkdownElement {
type RequestLayoutState = RenderedMarkdown;
type PrepaintState = Hitbox;
@@ -770,6 +839,7 @@ impl Element for MarkdownElement {
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let mut builder = MarkdownElementBuilder::new(
+ &self.style.container_style,
self.style.base_text_style.clone(),
self.style.syntax.clone(),
);
@@ -788,7 +858,7 @@ impl Element for MarkdownElement {
let mut code_block_ids = HashSet::default();
let mut current_img_block_range: Option<Range<usize>> = None;
- for (range, event) in parsed_markdown.events.iter() {
+ for (index, (range, event)) in parsed_markdown.events.iter().enumerate() {
// Skip alt text for images that rendered
if let Some(current_img_block_range) = ¤t_img_block_range
&& current_img_block_range.end > range.end
@@ -830,8 +900,7 @@ impl Element for MarkdownElement {
heading.style().refine(&self.style.heading);
- let text_style =
- self.style.heading.text_style().clone().unwrap_or_default();
+ let text_style = self.style.heading.text_style().clone();
builder.push_text_style(text_style);
builder.push_div(heading, range, markdown_end);
@@ -882,7 +951,7 @@ impl Element for MarkdownElement {
{
let scrollbars = Scrollbars::new(ScrollAxes::Horizontal)
.id(("markdown-code-block-scrollbar", range.start))
- .tracked_scroll_handle(scroll_handle.clone())
+ .tracked_scroll_handle(scroll_handle)
.with_track_along(
ScrollAxes::Horizontal,
cx.theme().colors().editor_background,
@@ -925,10 +994,7 @@ impl Element for MarkdownElement {
}
});
- if let Some(code_block_text_style) = &self.style.code_block.text
- {
- builder.push_text_style(code_block_text_style.to_owned());
- }
+ builder.push_text_style(self.style.code_block.text.to_owned());
builder.push_code_block(language);
builder.push_div(code_block, range, markdown_end);
}
@@ -938,13 +1004,29 @@ impl Element for MarkdownElement {
MarkdownTag::HtmlBlock => builder.push_div(div(), range, markdown_end),
MarkdownTag::List(bullet_index) => {
builder.push_list(*bullet_index);
- builder.push_div(div().pl_4(), range, markdown_end);
+ builder.push_div(div().pl_2p5(), range, markdown_end);
}
MarkdownTag::Item => {
- let bullet = if let Some(bullet_index) = builder.next_bullet_index() {
- format!("{}.", bullet_index)
+ let bullet = if let Some((_, MarkdownEvent::TaskListMarker(checked))) =
+ parsed_markdown.events.get(index.saturating_add(1))
+ {
+ let source = &parsed_markdown.source()[range.clone()];
+
+ Checkbox::new(
+ ElementId::Name(source.to_string().into()),
+ if *checked {
+ ToggleState::Selected
+ } else {
+ ToggleState::Unselected
+ },
+ )
+ .fill()
+ .visualization_only(true)
+ .into_any_element()
+ } else if let Some(bullet_index) = builder.next_bullet_index() {
+ div().child(format!("{}.", bullet_index)).into_any_element()
} else {
- "•".to_string()
+ div().child("•").into_any_element()
};
builder.push_div(
div()
@@ -991,55 +1073,58 @@ impl Element for MarkdownElement {
}
MarkdownTag::MetadataBlock(_) => {}
MarkdownTag::Table(alignments) => {
- builder.table_alignments = alignments.clone();
+ builder.table.start(alignments.clone());
+
+ let column_count = alignments.len();
builder.push_div(
div()
.id(("table", range.start))
- .flex()
- .border_1()
+ .grid()
+ .grid_cols(column_count as u16)
+ .when(self.style.table_columns_min_size, |this| {
+ this.grid_cols_min_content(column_count as u16)
+ })
+ .when(!self.style.table_columns_min_size, |this| {
+ this.grid_cols(column_count as u16)
+ })
+ .size_full()
+ .mb_2()
+ .border(px(1.5))
.border_color(cx.theme().colors().border)
.rounded_sm()
- .when(self.style.table_overflow_x_scroll, |mut table| {
- table.style().restrict_scroll_to_axis = Some(true);
- table.overflow_x_scroll()
- }),
+ .overflow_hidden(),
range,
markdown_end,
);
- // This inner `v_flex` is so the table rows will stack vertically without disrupting the `overflow_x_scroll`.
- builder.push_div(div().v_flex().flex_grow(), range, markdown_end);
}
MarkdownTag::TableHead => {
- builder.push_div(
- div()
- .flex()
- .justify_between()
- .border_b_1()
- .border_color(cx.theme().colors().border),
- range,
- markdown_end,
- );
+ builder.table.start_head();
builder.push_text_style(TextStyleRefinement {
- font_weight: Some(FontWeight::BOLD),
+ font_weight: Some(FontWeight::SEMIBOLD),
..Default::default()
});
}
MarkdownTag::TableRow => {
- builder.push_div(
- div().h_flex().justify_between().px_1().py_0p5(),
- range,
- markdown_end,
- );
+ builder.table.start_row();
}
MarkdownTag::TableCell => {
- let column_count = builder.table_alignments.len();
+ let is_header = builder.table.in_head;
+ let row_index = builder.table.row_index;
+ let col_index = builder.table.col_index;
builder.push_div(
div()
- .flex()
+ .when(col_index > 0, |this| this.border_l_1())
+ .when(row_index > 0, |this| this.border_t_1())
+ .border_color(cx.theme().colors().border)
.px_1()
- .w(relative(1. / column_count as f32))
- .truncate(),
+ .py_0p5()
+ .when(is_header, |this| {
+ this.bg(cx.theme().colors().title_bar_background)
+ })
+ .when(!is_header && row_index % 2 == 1, |this| {
+ this.bg(cx.theme().colors().panel_background)
+ }),
range,
markdown_end,
);
@@ -1067,9 +1152,7 @@ impl Element for MarkdownElement {
builder.pop_div();
builder.pop_code_block();
- if self.style.code_block.text.is_some() {
- builder.pop_text_style();
- }
+ builder.pop_text_style();
if let CodeBlockRenderer::Default {
copy_button: true, ..
@@ -1155,18 +1238,18 @@ impl Element for MarkdownElement {
}
MarkdownTagEnd::Table => {
builder.pop_div();
- builder.pop_div();
- builder.table_alignments.clear();
+ builder.table.end();
}
MarkdownTagEnd::TableHead => {
- builder.pop_div();
builder.pop_text_style();
+ builder.table.end_head();
}
MarkdownTagEnd::TableRow => {
- builder.pop_div();
+ builder.table.end_row();
}
MarkdownTagEnd::TableCell => {
builder.pop_div();
+ builder.table.end_cell();
}
_ => log::debug!("unsupported markdown tag end: {:?}", tag),
},
@@ -1196,6 +1279,15 @@ impl Element for MarkdownElement {
builder.push_text(html, range.clone());
}
MarkdownEvent::InlineHtml => {
+ let html = &parsed_markdown.source[range.clone()];
+ if html.starts_with("<code>") {
+ builder.push_text_style(self.style.inline_code.clone());
+ continue;
+ }
+ if html.trim_end().starts_with("</code>") {
+ builder.pop_text_style();
+ continue;
+ }
builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
}
MarkdownEvent::Rule => {
@@ -1211,6 +1303,9 @@ impl Element for MarkdownElement {
}
MarkdownEvent::SoftBreak => builder.push_text(" ", range.clone()),
MarkdownEvent::HardBreak => builder.push_text("\n", range.clone()),
+ MarkdownEvent::TaskListMarker(_) => {
+ // handled inside the `MarkdownTag::Item` case
+ }
_ => log::debug!("unsupported markdown event {:?}", event),
}
}
@@ -1311,7 +1406,7 @@ fn apply_heading_style(
};
if let Some(style) = style_opt {
- heading.style().text = Some(style.clone());
+ heading.style().text = style.clone();
}
}
@@ -1417,6 +1512,50 @@ impl ParentElement for AnyDiv {
}
}
+#[derive(Default)]
+struct TableState {
+ alignments: Vec<Alignment>,
+ in_head: bool,
+ row_index: usize,
+ col_index: usize,
+}
+
+impl TableState {
+ fn start(&mut self, alignments: Vec<Alignment>) {
+ self.alignments = alignments;
+ self.in_head = false;
+ self.row_index = 0;
+ self.col_index = 0;
+ }
+
+ fn end(&mut self) {
+ self.alignments.clear();
+ self.in_head = false;
+ self.row_index = 0;
+ self.col_index = 0;
+ }
+
+ fn start_head(&mut self) {
+ self.in_head = true;
+ }
+
+ fn end_head(&mut self) {
+ self.in_head = false;
+ }
+
+ fn start_row(&mut self) {
+ self.col_index = 0;
+ }
+
+ fn end_row(&mut self) {
+ self.row_index += 1;
+ }
+
+ fn end_cell(&mut self) {
+ self.col_index += 1;
+ }
+}
+
struct MarkdownElementBuilder {
div_stack: Vec<AnyDiv>,
rendered_lines: Vec<RenderedLine>,
@@ -1428,7 +1567,7 @@ struct MarkdownElementBuilder {
text_style_stack: Vec<TextStyleRefinement>,
code_block_stack: Vec<Option<Arc<Language>>>,
list_stack: Vec<ListStackEntry>,
- table_alignments: Vec<Alignment>,
+ table: TableState,
syntax_theme: Arc<SyntaxTheme>,
}
@@ -1444,9 +1583,17 @@ struct ListStackEntry {
}
impl MarkdownElementBuilder {
- fn new(base_text_style: TextStyle, syntax_theme: Arc<SyntaxTheme>) -> Self {
+ fn new(
+ container_style: &StyleRefinement,
+ base_text_style: TextStyle,
+ syntax_theme: Arc<SyntaxTheme>,
+ ) -> Self {
Self {
- div_stack: vec![div().debug_selector(|| "inner".into()).into()],
+ div_stack: vec![{
+ let mut base_div = div();
+ base_div.style().refine(container_style);
+ base_div.debug_selector(|| "inner".into()).into()
+ }],
rendered_lines: Vec::new(),
pending_line: PendingLine::default(),
rendered_links: Vec::new(),
@@ -1456,7 +1603,7 @@ impl MarkdownElementBuilder {
text_style_stack: Vec::new(),
code_block_stack: Vec::new(),
list_stack: Vec::new(),
- table_alignments: Vec::new(),
+ table: TableState::default(),
syntax_theme,
}
}
@@ -1790,7 +1937,7 @@ impl RenderedText {
}
fn text_for_range(&self, range: Range<usize>) -> String {
- let mut ret = vec![];
+ let mut accumulator = String::new();
for line in self.lines.iter() {
if range.start > line.source_end {
@@ -1815,9 +1962,12 @@ impl RenderedText {
}
.min(text.len());
- ret.push(text[start..end].to_string());
+ accumulator.push_str(&text[start..end]);
+ accumulator.push('\n');
}
- ret.join("\n")
+ // Remove trailing newline
+ accumulator.pop();
+ accumulator
}
fn link_for_position(&self, position: Point<Pixels>) -> Option<&RenderedLink> {
@@ -1904,6 +2054,178 @@ mod tests {
rendered.text
}
+ #[gpui::test]
+ fn test_surrounding_word_range(cx: &mut TestAppContext) {
+ let rendered = render_markdown("Hello world tesεζ", cx);
+
+ // Test word selection for "Hello"
+ let word_range = rendered.surrounding_word_range(2); // Simulate click on 'l' in "Hello"
+ let selected_text = rendered.text_for_range(word_range);
+ assert_eq!(selected_text, "Hello");
+
+ // Test word selection for "world"
+ let word_range = rendered.surrounding_word_range(7); // Simulate click on 'o' in "world"
+ let selected_text = rendered.text_for_range(word_range);
+ assert_eq!(selected_text, "world");
+
+ // Test word selection for "tesεζ"
+ let word_range = rendered.surrounding_word_range(14); // Simulate click on 's' in "tesεζ"
+ let selected_text = rendered.text_for_range(word_range);
+ assert_eq!(selected_text, "tesεζ");
+
+ // Test word selection at word boundary (space)
+ let word_range = rendered.surrounding_word_range(5); // Simulate click on space between "Hello" and "world", expect highlighting word to the left
+ let selected_text = rendered.text_for_range(word_range);
+ assert_eq!(selected_text, "Hello");
+ }
+
+ #[gpui::test]
+ fn test_surrounding_line_range(cx: &mut TestAppContext) {
+ let rendered = render_markdown("First line\n\nSecond line\n\nThird lineεζ", cx);
+
+ // Test getting line range for first line
+ let line_range = rendered.surrounding_line_range(5); // Simulate click somewhere in first line
+ let selected_text = rendered.text_for_range(line_range);
+ assert_eq!(selected_text, "First line");
+
+ // Test getting line range for second line
+ let line_range = rendered.surrounding_line_range(13); // Simulate click at beginning in second line
+ let selected_text = rendered.text_for_range(line_range);
+ assert_eq!(selected_text, "Second line");
+
+ // Test getting line range for third line
+ let line_range = rendered.surrounding_line_range(37); // Simulate click at end of third line with multi-byte chars
+ let selected_text = rendered.text_for_range(line_range);
+ assert_eq!(selected_text, "Third lineεζ");
+ }
+
+ #[gpui::test]
+ fn test_selection_head_movement(cx: &mut TestAppContext) {
+ let rendered = render_markdown("Hello world test", cx);
+
+ let mut selection = Selection {
+ start: 5,
+ end: 5,
+ reversed: false,
+ pending: false,
+ mode: SelectMode::Character,
+ };
+
+ // Test forward selection
+ selection.set_head(10, &rendered);
+ assert_eq!(selection.start, 5);
+ assert_eq!(selection.end, 10);
+ assert!(!selection.reversed);
+ assert_eq!(selection.tail(), 5);
+
+ // Test backward selection
+ selection.set_head(2, &rendered);
+ assert_eq!(selection.start, 2);
+ assert_eq!(selection.end, 5);
+ assert!(selection.reversed);
+ assert_eq!(selection.tail(), 5);
+
+ // Test forward selection again from reversed state
+ selection.set_head(15, &rendered);
+ assert_eq!(selection.start, 5);
+ assert_eq!(selection.end, 15);
+ assert!(!selection.reversed);
+ assert_eq!(selection.tail(), 5);
+ }
+
+ #[gpui::test]
+ fn test_word_selection_drag(cx: &mut TestAppContext) {
+ let rendered = render_markdown("Hello world test", cx);
+
+ // Start with a simulated double-click on "world" (index 6-10)
+ let word_range = rendered.surrounding_word_range(7); // Click on 'o' in "world"
+ let mut selection = Selection {
+ start: word_range.start,
+ end: word_range.end,
+ reversed: false,
+ pending: true,
+ mode: SelectMode::Word(word_range),
+ };
+
+ // Drag forward to "test" - should expand selection to include "test"
+ selection.set_head(13, &rendered); // Index in "test"
+ assert_eq!(selection.start, 6); // Start of "world"
+ assert_eq!(selection.end, 16); // End of "test"
+ assert!(!selection.reversed);
+ let selected_text = rendered.text_for_range(selection.start..selection.end);
+ assert_eq!(selected_text, "world test");
+
+ // Drag backward to "Hello" - should expand selection to include "Hello"
+ selection.set_head(2, &rendered); // Index in "Hello"
+ assert_eq!(selection.start, 0); // Start of "Hello"
+ assert_eq!(selection.end, 11); // End of "world" (original selection)
+ assert!(selection.reversed);
+ let selected_text = rendered.text_for_range(selection.start..selection.end);
+ assert_eq!(selected_text, "Hello world");
+
+ // Drag back within original word - should revert to original selection
+ selection.set_head(8, &rendered); // Back within "world"
+ assert_eq!(selection.start, 6); // Start of "world"
+ assert_eq!(selection.end, 11); // End of "world"
+ assert!(!selection.reversed);
+ let selected_text = rendered.text_for_range(selection.start..selection.end);
+ assert_eq!(selected_text, "world");
+ }
+
+ #[gpui::test]
+ fn test_selection_with_markdown_formatting(cx: &mut TestAppContext) {
+ let rendered = render_markdown(
+ "This is **bold** text, this is *italic* text, use `code` here",
+ cx,
+ );
+ let word_range = rendered.surrounding_word_range(10); // Inside "bold"
+ let selected_text = rendered.text_for_range(word_range);
+ assert_eq!(selected_text, "bold");
+
+ let word_range = rendered.surrounding_word_range(32); // Inside "italic"
+ let selected_text = rendered.text_for_range(word_range);
+ assert_eq!(selected_text, "italic");
+
+ let word_range = rendered.surrounding_word_range(51); // Inside "code"
+ let selected_text = rendered.text_for_range(word_range);
+ assert_eq!(selected_text, "code");
+ }
+
+ #[gpui::test]
+ fn test_all_selection(cx: &mut TestAppContext) {
+ let rendered = render_markdown("Hello world\n\nThis is a test\n\nwith multiple lines", cx);
+
+ let total_length = rendered
+ .lines
+ .last()
+ .map(|line| line.source_end)
+ .unwrap_or(0);
+
+ let mut selection = Selection {
+ start: 0,
+ end: total_length,
+ reversed: false,
+ pending: true,
+ mode: SelectMode::All,
+ };
+
+ selection.set_head(5, &rendered); // Try to set head in middle
+ assert_eq!(selection.start, 0);
+ assert_eq!(selection.end, total_length);
+ assert!(!selection.reversed);
+
+ selection.set_head(25, &rendered); // Try to set head near end
+ assert_eq!(selection.start, 0);
+ assert_eq!(selection.end, total_length);
+ assert!(!selection.reversed);
+
+ let selected_text = rendered.text_for_range(selection.start..selection.end);
+ assert_eq!(
+ selected_text,
+ "Hello world\nThis is a test\nwith multiple lines"
+ );
+ }
+
#[test]
fn test_escape() {
assert_eq!(Markdown::escape("hello `world`"), "hello \\`world\\`");
@@ -37,3 +37,4 @@ workspace.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }
+language = { workspace = true, features = ["test-support"] }
@@ -171,10 +171,8 @@ pub struct ParsedMarkdownText {
pub contents: SharedString,
/// The list of highlights contained in the Markdown document.
pub highlights: Vec<(Range<usize>, MarkdownHighlight)>,
- /// The regions of the various ranges in the Markdown document.
- pub region_ranges: Vec<Range<usize>>,
/// The regions of the Markdown document.
- pub regions: Vec<ParsedRegion>,
+ pub regions: Vec<(Range<usize>, ParsedRegion)>,
}
/// A run of highlighted Markdown text.
@@ -245,8 +245,7 @@ impl<'a> MarkdownParser<'a> {
let mut strikethrough_depth = 0;
let mut link: Option<Link> = None;
let mut image: Option<Image> = None;
- let mut region_ranges: Vec<Range<usize>> = vec![];
- let mut regions: Vec<ParsedRegion> = vec![];
+ let mut regions: Vec<(Range<usize>, ParsedRegion)> = vec![];
let mut highlights: Vec<(Range<usize>, MarkdownHighlight)> = vec![];
let mut link_urls: Vec<String> = vec![];
let mut link_ranges: Vec<Range<usize>> = vec![];
@@ -291,11 +290,13 @@ impl<'a> MarkdownParser<'a> {
}
let last_run_len = if let Some(link) = link.clone() {
- region_ranges.push(prev_len..text.len());
- regions.push(ParsedRegion {
- code: false,
- link: Some(link),
- });
+ regions.push((
+ prev_len..text.len(),
+ ParsedRegion {
+ code: false,
+ link: Some(link),
+ },
+ ));
style.link = true;
prev_len
} else {
@@ -325,13 +326,16 @@ impl<'a> MarkdownParser<'a> {
..style
}),
));
- region_ranges.push(range.clone());
- regions.push(ParsedRegion {
- code: false,
- link: Some(Link::Web {
- url: link.as_str().to_string(),
- }),
- });
+
+ regions.push((
+ range.clone(),
+ ParsedRegion {
+ code: false,
+ link: Some(Link::Web {
+ url: link.as_str().to_string(),
+ }),
+ },
+ ));
last_link_len = end;
}
last_link_len
@@ -356,21 +360,24 @@ impl<'a> MarkdownParser<'a> {
}
Event::Code(t) => {
text.push_str(t.as_ref());
- region_ranges.push(prev_len..text.len());
+ let range = prev_len..text.len();
if link.is_some() {
highlights.push((
- prev_len..text.len(),
+ range.clone(),
MarkdownHighlight::Style(MarkdownHighlightStyle {
link: true,
..Default::default()
}),
));
}
- regions.push(ParsedRegion {
- code: true,
- link: link.clone(),
- });
+ regions.push((
+ range,
+ ParsedRegion {
+ code: true,
+ link: link.clone(),
+ },
+ ));
}
Event::Start(tag) => match tag {
Tag::Emphasis => italic_depth += 1,
@@ -388,7 +395,6 @@ impl<'a> MarkdownParser<'a> {
source_range: source_range.clone(),
contents: mem::take(&mut text).into(),
highlights: mem::take(&mut highlights),
- region_ranges: mem::take(&mut region_ranges),
regions: mem::take(&mut regions),
});
markdown_text_like.push(parsed_regions);
@@ -416,7 +422,6 @@ impl<'a> MarkdownParser<'a> {
if !text.is_empty() {
image.set_alt_text(std::mem::take(&mut text).into());
mem::take(&mut highlights);
- mem::take(&mut region_ranges);
mem::take(&mut regions);
}
markdown_text_like.push(MarkdownParagraphChunk::Image(image));
@@ -443,7 +448,6 @@ impl<'a> MarkdownParser<'a> {
contents: text.into(),
highlights,
regions,
- region_ranges,
}));
}
markdown_text_like
@@ -869,7 +873,6 @@ impl<'a> MarkdownParser<'a> {
MarkdownParagraphChunk::Text(ParsedMarkdownText {
source_range,
regions: Vec::default(),
- region_ranges: Vec::default(),
highlights: Vec::default(),
contents: contents.borrow().to_string().into(),
}),
@@ -891,7 +894,13 @@ impl<'a> MarkdownParser<'a> {
}
} else if local_name!("p") == name.local {
let mut paragraph = MarkdownParagraph::new();
- self.parse_paragraph(source_range, node, &mut paragraph, &mut styles);
+ self.parse_paragraph(
+ source_range,
+ node,
+ &mut paragraph,
+ &mut styles,
+ &mut Vec::new(),
+ );
if !paragraph.is_empty() {
elements.push(ParsedMarkdownElement::Paragraph(paragraph));
@@ -906,7 +915,13 @@ impl<'a> MarkdownParser<'a> {
| local_name!("h6")
) {
let mut paragraph = MarkdownParagraph::new();
- self.consume_paragraph(source_range.clone(), node, &mut paragraph, &mut styles);
+ self.consume_paragraph(
+ source_range.clone(),
+ node,
+ &mut paragraph,
+ &mut styles,
+ &mut Vec::new(),
+ );
if !paragraph.is_empty() {
elements.push(ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
@@ -954,15 +969,15 @@ impl<'a> MarkdownParser<'a> {
node: &Rc<markup5ever_rcdom::Node>,
paragraph: &mut MarkdownParagraph,
highlights: &mut Vec<MarkdownHighlight>,
+ regions: &mut Vec<(Range<usize>, ParsedRegion)>,
) {
- fn add_highlight_range(
- text: &String,
- start: usize,
- highlights: Vec<MarkdownHighlight>,
- ) -> Vec<(Range<usize>, MarkdownHighlight)> {
- highlights
+ fn items_with_range<T>(
+ range: Range<usize>,
+ items: impl IntoIterator<Item = T>,
+ ) -> Vec<(Range<usize>, T)> {
+ items
.into_iter()
- .map(|style| (start..text.len(), style))
+ .map(|item| (range.clone(), item))
.collect()
}
@@ -976,22 +991,30 @@ impl<'a> MarkdownParser<'a> {
}) {
let mut new_text = text.contents.to_string();
new_text.push_str(&contents.borrow());
- let highlights = add_highlight_range(
- &new_text,
- text.contents.len(),
- std::mem::take(highlights),
- );
+ text.highlights.extend(items_with_range(
+ text.contents.len()..new_text.len(),
+ std::mem::take(highlights),
+ ));
+ text.regions.extend(items_with_range(
+ text.contents.len()..new_text.len(),
+ std::mem::take(regions)
+ .into_iter()
+ .map(|(_, region)| region),
+ ));
text.contents = SharedString::from(new_text);
- text.highlights.extend(highlights);
} else {
let contents = contents.borrow().to_string();
paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
source_range,
- highlights: add_highlight_range(&contents, 0, std::mem::take(highlights)),
- regions: Vec::default(),
+ highlights: items_with_range(0..contents.len(), std::mem::take(highlights)),
+ regions: items_with_range(
+ 0..contents.len(),
+ std::mem::take(regions)
+ .into_iter()
+ .map(|(_, region)| region),
+ ),
contents: contents.into(),
- region_ranges: Vec::default(),
}));
}
}
@@ -1006,37 +1029,57 @@ impl<'a> MarkdownParser<'a> {
..Default::default()
}));
- self.consume_paragraph(source_range, node, paragraph, highlights);
+ self.consume_paragraph(source_range, node, paragraph, highlights, regions);
} else if local_name!("i") == name.local {
highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
italic: true,
..Default::default()
}));
- self.consume_paragraph(source_range, node, paragraph, highlights);
+ self.consume_paragraph(source_range, node, paragraph, highlights, regions);
} else if local_name!("em") == name.local {
highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
oblique: true,
..Default::default()
}));
- self.consume_paragraph(source_range, node, paragraph, highlights);
+ self.consume_paragraph(source_range, node, paragraph, highlights, regions);
} else if local_name!("del") == name.local {
highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
strikethrough: true,
..Default::default()
}));
- self.consume_paragraph(source_range, node, paragraph, highlights);
+ self.consume_paragraph(source_range, node, paragraph, highlights, regions);
} else if local_name!("ins") == name.local {
highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
underline: true,
..Default::default()
}));
- self.consume_paragraph(source_range, node, paragraph, highlights);
+ self.consume_paragraph(source_range, node, paragraph, highlights, regions);
+ } else if local_name!("a") == name.local {
+ if let Some(url) = Self::attr_value(attrs, local_name!("href"))
+ && let Some(link) =
+ Link::identify(self.file_location_directory.clone(), url)
+ {
+ highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
+ link: true,
+ ..Default::default()
+ }));
+
+ regions.push((
+ source_range.clone(),
+ ParsedRegion {
+ code: false,
+ link: Some(link),
+ },
+ ));
+ }
+
+ self.consume_paragraph(source_range, node, paragraph, highlights, regions);
} else {
- self.consume_paragraph(source_range, node, paragraph, highlights);
+ self.consume_paragraph(source_range, node, paragraph, highlights, regions);
}
}
_ => {}
@@ -1049,9 +1092,10 @@ impl<'a> MarkdownParser<'a> {
node: &Rc<markup5ever_rcdom::Node>,
paragraph: &mut MarkdownParagraph,
highlights: &mut Vec<MarkdownHighlight>,
+ regions: &mut Vec<(Range<usize>, ParsedRegion)>,
) {
for node in node.children.borrow().iter() {
- self.parse_paragraph(source_range.clone(), node, paragraph, highlights);
+ self.parse_paragraph(source_range.clone(), node, paragraph, highlights, regions);
}
}
@@ -1096,7 +1140,13 @@ impl<'a> MarkdownParser<'a> {
}
let mut children = MarkdownParagraph::new();
- self.consume_paragraph(source_range, node, &mut children, &mut Vec::new());
+ self.consume_paragraph(
+ source_range,
+ node,
+ &mut children,
+ &mut Vec::new(),
+ &mut Vec::new(),
+ );
let is_header = matches!(name.local, local_name!("th"));
@@ -1374,6 +1424,7 @@ impl<'a> MarkdownParser<'a> {
node,
&mut paragraph,
&mut Vec::new(),
+ &mut Vec::new(),
);
caption = Some(paragraph);
}
@@ -1416,9 +1467,7 @@ mod tests {
use ParsedMarkdownListItemType::*;
use core::panic;
use gpui::{AbsoluteLength, BackgroundExecutor, DefiniteLength};
- use language::{
- HighlightId, Language, LanguageConfig, LanguageMatcher, LanguageRegistry, tree_sitter_rust,
- };
+ use language::{HighlightId, LanguageRegistry};
use pretty_assertions::assert_eq;
async fn parse(input: &str) -> ParsedMarkdown {
@@ -1494,7 +1543,6 @@ mod tests {
source_range: 0..35,
contents: "Some bostrikethroughld text".into(),
highlights: Vec::new(),
- region_ranges: Vec::new(),
regions: Vec::new(),
}
)])
@@ -1618,6 +1666,51 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_html_href_element() {
+ let parsed =
+ parse("<p>Some text <a href=\"https://example.com\">link</a> more text</p>").await;
+
+ assert_eq!(1, parsed.children.len());
+ let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] {
+ chunks
+ } else {
+ panic!("Expected a paragraph");
+ };
+
+ assert_eq!(1, chunks.len());
+ let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] {
+ text
+ } else {
+ panic!("Expected a paragraph");
+ };
+
+ assert_eq!(0..65, text.source_range);
+ assert_eq!("Some text link more text", text.contents.as_str(),);
+ assert_eq!(
+ vec![(
+ 10..14,
+ MarkdownHighlight::Style(MarkdownHighlightStyle {
+ link: true,
+ ..Default::default()
+ },),
+ )],
+ text.highlights
+ );
+ assert_eq!(
+ vec![(
+ 10..14,
+ ParsedRegion {
+ code: false,
+ link: Some(Link::Web {
+ url: "https://example.com".into()
+ })
+ }
+ )],
+ text.regions
+ )
+ }
+
#[gpui::test]
async fn test_text_with_inline_html() {
let parsed = parse("This is a paragraph with an inline HTML <sometag>tag</sometag>.").await;
@@ -1768,7 +1861,6 @@ mod tests {
source_range: 0..81,
contents: " Lorem Ipsum ".into(),
highlights: Vec::new(),
- region_ranges: Vec::new(),
regions: Vec::new(),
}),
MarkdownParagraphChunk::Image(Image {
@@ -2029,7 +2121,6 @@ mod tests {
source_range: 0..71,
contents: "Some text".into(),
highlights: Default::default(),
- region_ranges: Default::default(),
regions: Default::default()
}),
MarkdownParagraphChunk::Image(Image {
@@ -2045,7 +2136,6 @@ mod tests {
source_range: 0..71,
contents: " some more text".into(),
highlights: Default::default(),
- region_ranges: Default::default(),
regions: Default::default()
}),
])]
@@ -2221,7 +2311,6 @@ mod tests {
source_range: 0..280,
contents: "My Table".into(),
highlights: Default::default(),
- region_ranges: Default::default(),
regions: Default::default()
})]),
vec![],
@@ -2385,7 +2474,6 @@ mod tests {
source_range: 0..96,
contents: "Heading".into(),
highlights: Vec::default(),
- region_ranges: Vec::default(),
regions: Vec::default()
})],
}),
@@ -2396,7 +2484,6 @@ mod tests {
source_range: 0..96,
contents: "Heading".into(),
highlights: Vec::default(),
- region_ranges: Vec::default(),
regions: Vec::default()
})],
}),
@@ -2407,7 +2494,6 @@ mod tests {
source_range: 0..96,
contents: "Heading".into(),
highlights: Vec::default(),
- region_ranges: Vec::default(),
regions: Vec::default()
})],
}),
@@ -2418,7 +2504,6 @@ mod tests {
source_range: 0..96,
contents: "Heading".into(),
highlights: Vec::default(),
- region_ranges: Vec::default(),
regions: Vec::default()
})],
}),
@@ -2429,7 +2514,6 @@ mod tests {
source_range: 0..96,
contents: "Heading".into(),
highlights: Vec::default(),
- region_ranges: Vec::default(),
regions: Vec::default()
})],
}),
@@ -2440,7 +2524,6 @@ mod tests {
source_range: 0..96,
contents: "Heading".into(),
highlights: Vec::default(),
- region_ranges: Vec::default(),
regions: Vec::default()
})],
}),
@@ -2968,7 +3051,7 @@ fn main() {
#[gpui::test]
async fn test_code_block_with_language(executor: BackgroundExecutor) {
let language_registry = Arc::new(LanguageRegistry::test(executor.clone()));
- language_registry.add(rust_lang());
+ language_registry.add(language::rust_lang());
let parsed = parse_markdown(
"\
@@ -2994,21 +3077,6 @@ fn main() {
);
}
- fn rust_lang() -> Arc<Language> {
- Arc::new(Language::new(
- LanguageConfig {
- name: "Rust".into(),
- matcher: LanguageMatcher {
- path_suffixes: vec!["rs".into()],
- ..Default::default()
- },
- collapsed_placeholder: " /* ... */ ".to_string(),
- ..Default::default()
- },
- Some(tree_sitter_rust::LANGUAGE.into()),
- ))
- }
-
fn h1(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
source_range,
@@ -3040,7 +3108,6 @@ fn main() {
fn text(contents: &str, source_range: Range<usize>) -> MarkdownParagraph {
vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
highlights: Vec::new(),
- region_ranges: Vec::new(),
regions: Vec::new(),
source_range,
contents: contents.to_string().into(),
@@ -11,9 +11,19 @@ actions!(
markdown,
[
/// Scrolls up by one page in the markdown preview.
- MovePageUp,
+ #[action(deprecated_aliases = ["markdown::MovePageUp"])]
+ ScrollPageUp,
/// Scrolls down by one page in the markdown preview.
- MovePageDown,
+ #[action(deprecated_aliases = ["markdown::MovePageDown"])]
+ ScrollPageDown,
+ /// Scrolls up by approximately one visual line.
+ ScrollUp,
+ /// Scrolls down by approximately one visual line.
+ ScrollDown,
+ /// Scrolls up by one markdown element in the markdown preview
+ ScrollUpByItem,
+ /// Scrolls down by one markdown element in the markdown preview
+ ScrollDownByItem,
/// Opens a markdown preview for the current file.
OpenPreview,
/// Opens a markdown preview in a split pane.
@@ -1,10 +1,11 @@
+use std::cmp::min;
use std::sync::Arc;
use std::time::Duration;
use std::{ops::Range, path::PathBuf};
use anyhow::Result;
use editor::scroll::Autoscroll;
-use editor::{Editor, EditorEvent, SelectionEffects};
+use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects};
use gpui::{
App, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, IsZero, ListState, ParentElement, Render, RetainAllImageCache, Styled,
@@ -20,11 +21,12 @@ use workspace::{Pane, Workspace};
use crate::markdown_elements::ParsedMarkdownElement;
use crate::markdown_renderer::CheckboxClickedEvent;
use crate::{
- MovePageDown, MovePageUp, OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide,
+ OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollPageDown, ScrollPageUp,
markdown_elements::ParsedMarkdown,
markdown_parser::parse_markdown,
markdown_renderer::{RenderContext, render_markdown_block},
};
+use crate::{ScrollDown, ScrollDownByItem, ScrollUp, ScrollUpByItem};
const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200);
@@ -94,7 +96,7 @@ impl MarkdownPreviewView {
pane.add_item(Box::new(view.clone()), false, false, None, window, cx)
}
});
- editor.focus_handle(cx).focus(window);
+ editor.focus_handle(cx).focus(window, cx);
cx.notify();
}
});
@@ -281,7 +283,7 @@ impl MarkdownPreviewView {
let selection_range = editor.update(cx, |editor, cx| {
editor
.selections
- .last::<usize>(&editor.display_snapshot(cx))
+ .last::<MultiBufferOffset>(&editor.display_snapshot(cx))
.range()
});
this.selected_block = this.get_block_index_under_cursor(selection_range);
@@ -358,7 +360,7 @@ impl MarkdownPreviewView {
&self,
window: &mut Window,
cx: &mut Context<Self>,
- selection: Range<usize>,
+ selection: Range<MultiBufferOffset>,
) {
if let Some(state) = &self.active_editor {
state.editor.update(cx, |editor, cx| {
@@ -368,14 +370,14 @@ impl MarkdownPreviewView {
cx,
|selections| selections.select_ranges(vec![selection]),
);
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
});
}
}
/// The absolute path of the file that is currently being previewed.
fn get_folder_for_active_editor(editor: &Editor, cx: &App) -> Option<PathBuf> {
- if let Some(file) = editor.file_at(0, cx) {
+ if let Some(file) = editor.file_at(MultiBufferOffset(0), cx) {
if let Some(file) = file.as_local() {
file.abs_path(cx).parent().map(|p| p.to_path_buf())
} else {
@@ -386,9 +388,9 @@ impl MarkdownPreviewView {
}
}
- fn get_block_index_under_cursor(&self, selection_range: Range<usize>) -> usize {
+ fn get_block_index_under_cursor(&self, selection_range: Range<MultiBufferOffset>) -> usize {
let mut block_index = None;
- let cursor = selection_range.start;
+ let cursor = selection_range.start.0;
let mut last_end = 0;
if let Some(content) = &self.contents {
@@ -425,7 +427,7 @@ impl MarkdownPreviewView {
!(current_block.is_list_item() && next_block.map(|b| b.is_list_item()).unwrap_or(false))
}
- fn scroll_page_up(&mut self, _: &MovePageUp, _window: &mut Window, cx: &mut Context<Self>) {
+ fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context<Self>) {
let viewport_height = self.list_state.viewport_bounds().size.height;
if viewport_height.is_zero() {
return;
@@ -435,7 +437,12 @@ impl MarkdownPreviewView {
cx.notify();
}
- fn scroll_page_down(&mut self, _: &MovePageDown, _window: &mut Window, cx: &mut Context<Self>) {
+ fn scroll_page_down(
+ &mut self,
+ _: &ScrollPageDown,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
let viewport_height = self.list_state.viewport_bounds().size.height;
if viewport_height.is_zero() {
return;
@@ -444,6 +451,56 @@ impl MarkdownPreviewView {
self.list_state.scroll_by(viewport_height);
cx.notify();
}
+
+ fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
+ let scroll_top = self.list_state.logical_scroll_top();
+ if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
+ let item_height = bounds.size.height;
+ // Scroll no more than the rough equivalent of a large headline
+ let max_height = window.rem_size() * 2;
+ let scroll_height = min(item_height, max_height);
+ self.list_state.scroll_by(-scroll_height);
+ }
+ cx.notify();
+ }
+
+ fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
+ let scroll_top = self.list_state.logical_scroll_top();
+ if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
+ let item_height = bounds.size.height;
+ // Scroll no more than the rough equivalent of a large headline
+ let max_height = window.rem_size() * 2;
+ let scroll_height = min(item_height, max_height);
+ self.list_state.scroll_by(scroll_height);
+ }
+ cx.notify();
+ }
+
+ fn scroll_up_by_item(
+ &mut self,
+ _: &ScrollUpByItem,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let scroll_top = self.list_state.logical_scroll_top();
+ if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
+ self.list_state.scroll_by(-bounds.size.height);
+ }
+ cx.notify();
+ }
+
+ fn scroll_down_by_item(
+ &mut self,
+ _: &ScrollDownByItem,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let scroll_top = self.list_state.logical_scroll_top();
+ if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
+ self.list_state.scroll_by(bounds.size.height);
+ }
+ cx.notify();
+ }
}
impl Focusable for MarkdownPreviewView {
@@ -496,6 +553,10 @@ impl Render for MarkdownPreviewView {
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(MarkdownPreviewView::scroll_page_up))
.on_action(cx.listener(MarkdownPreviewView::scroll_page_down))
+ .on_action(cx.listener(MarkdownPreviewView::scroll_up))
+ .on_action(cx.listener(MarkdownPreviewView::scroll_down))
+ .on_action(cx.listener(MarkdownPreviewView::scroll_up_by_item))
+ .on_action(cx.listener(MarkdownPreviewView::scroll_down_by_item))
.size_full()
.bg(cx.theme().colors().editor_background)
.p_4()
@@ -524,7 +585,15 @@ impl Render for MarkdownPreviewView {
if e.checked() { "[x]" } else { "[ ]" };
editor.edit(
- vec![(e.source_range(), task_marker)],
+ [(
+ MultiBufferOffset(
+ e.source_range().start,
+ )
+ ..MultiBufferOffset(
+ e.source_range().end,
+ ),
+ task_marker,
+ )],
cx,
);
});
@@ -564,7 +633,8 @@ impl Render for MarkdownPreviewView {
this.move_cursor_to_block(
window,
cx,
- source_range.start..source_range.start,
+ MultiBufferOffset(source_range.start)
+ ..MultiBufferOffset(source_range.start),
);
}
},
@@ -602,6 +672,6 @@ impl Render for MarkdownPreviewView {
.size_full(),
)
}))
- .vertical_scrollbar_for(self.list_state.clone(), window, cx)
+ .vertical_scrollbar_for(&self.list_state, window, cx)
}
}
@@ -9,7 +9,7 @@ use gpui::{
AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, Div, Element,
ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke,
Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle,
- WeakEntity, Window, div, img, rems,
+ WeakEntity, Window, div, img, px, rems,
};
use settings::Settings;
use std::{
@@ -75,8 +75,10 @@ impl RenderContext {
let settings = ThemeSettings::get_global(cx);
let buffer_font_family = settings.buffer_font.family.clone();
+ let buffer_font_features = settings.buffer_font.features.clone();
let mut buffer_text_style = window.text_style();
buffer_text_style.font_family = buffer_font_family.clone();
+ buffer_text_style.font_features = buffer_font_features;
buffer_text_style.font_size = AbsoluteLength::from(settings.buffer_font_size(cx));
RenderContext {
@@ -519,8 +521,8 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -
.children(render_markdown_text(&cell.children, cx))
.px_2()
.py_1()
- .border_1()
- .size_full()
+ .when(col_idx > 0, |this| this.border_l_1())
+ .when(row_idx > 0, |this| this.border_t_1())
.border_color(cx.border_color)
.when(cell.is_header, |this| {
this.bg(cx.title_bar_background_color)
@@ -550,8 +552,8 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -
}
let empty_cell = div()
- .border_1()
- .size_full()
+ .when(col_idx > 0, |this| this.border_l_1())
+ .when(row_idx > 0, |this| this.border_t_1())
.border_color(cx.border_color)
.when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color));
@@ -560,7 +562,7 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -
}
}
- cx.with_common_p(div())
+ cx.with_common_p(v_flex().items_start())
.when_some(parsed.caption.as_ref(), |this, caption| {
this.children(render_markdown_text(caption, cx))
})
@@ -568,8 +570,10 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -
div()
.grid()
.grid_cols(max_column_count as u16)
- .border_1()
+ .border(px(1.5))
.border_color(cx.border_color)
+ .rounded_sm()
+ .overflow_hidden()
.children(cells),
)
.into_any()
@@ -633,8 +637,14 @@ fn render_markdown_code_block(
.tooltip(Tooltip::text("Copy code block"))
.visible_on_hover("markdown-block");
+ let font = gpui::Font {
+ family: cx.buffer_font_family.clone(),
+ features: cx.buffer_text_style.font_features.clone(),
+ ..Default::default()
+ };
+
cx.with_common_p(div())
- .font_family(cx.buffer_font_family.clone())
+ .font(font)
.px_3()
.py_3()
.bg(cx.code_block_background_color)
@@ -679,33 +689,31 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext)
.to_highlight_style(&syntax_theme)
.map(|style| (range.clone(), style))
}),
- parsed.regions.iter().zip(&parsed.region_ranges).filter_map(
- |(region, range)| {
- if region.code {
- Some((
- range.clone(),
- HighlightStyle {
- background_color: Some(code_span_bg_color),
- ..Default::default()
- },
- ))
- } else if region.link.is_some() {
- Some((
- range.clone(),
- HighlightStyle {
- color: Some(link_color),
- ..Default::default()
- },
- ))
- } else {
- None
- }
- },
- ),
+ parsed.regions.iter().filter_map(|(range, region)| {
+ if region.code {
+ Some((
+ range.clone(),
+ HighlightStyle {
+ background_color: Some(code_span_bg_color),
+ ..Default::default()
+ },
+ ))
+ } else if region.link.is_some() {
+ Some((
+ range.clone(),
+ HighlightStyle {
+ color: Some(link_color),
+ ..Default::default()
+ },
+ ))
+ } else {
+ None
+ }
+ }),
);
let mut links = Vec::new();
let mut link_ranges = Vec::new();
- for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) {
+ for (range, region) in parsed.regions.iter() {
if let Some(link) = region.link.clone() {
links.push(link);
link_ranges.push(range.clone());
@@ -927,7 +935,6 @@ mod tests {
source_range: 0..text.len(),
contents: SharedString::new(text),
highlights: Default::default(),
- region_ranges: Default::default(),
regions: Default::default(),
})
}
@@ -135,3 +135,39 @@ pub(crate) mod m_2025_10_21 {
pub(crate) use settings::make_relative_line_numbers_an_enum;
}
+
+pub(crate) mod m_2025_11_12 {
+ mod settings;
+
+ pub(crate) use settings::SETTINGS_PATTERNS;
+}
+
+pub(crate) mod m_2025_11_20 {
+ mod settings;
+
+ pub(crate) use settings::SETTINGS_PATTERNS;
+}
+
+pub(crate) mod m_2025_11_25 {
+ mod settings;
+
+ pub(crate) use settings::remove_context_server_source;
+}
+
+pub(crate) mod m_2025_12_01 {
+ mod settings;
+
+ pub(crate) use settings::SETTINGS_PATTERNS;
+}
+
+pub(crate) mod m_2025_12_08 {
+ mod keymap;
+
+ pub(crate) use keymap::KEYMAP_PATTERNS;
+}
+
+pub(crate) mod m_2025_12_15 {
+ mod settings;
+
+ pub(crate) use settings::SETTINGS_PATTERNS;
+}
@@ -0,0 +1,84 @@
+use std::ops::Range;
+use tree_sitter::{Query, QueryMatch};
+
+use crate::MigrationPatterns;
+use crate::patterns::SETTINGS_NESTED_KEY_VALUE_PATTERN;
+
+pub const SETTINGS_PATTERNS: MigrationPatterns = &[
+ (
+ SETTINGS_NESTED_KEY_VALUE_PATTERN,
+ rename_open_file_on_paste_setting,
+ ),
+ (
+ SETTINGS_NESTED_KEY_VALUE_PATTERN,
+ replace_open_file_on_paste_setting_value,
+ ),
+];
+
+fn rename_open_file_on_paste_setting(
+ contents: &str,
+ mat: &QueryMatch,
+ query: &Query,
+) -> Option<(Range<usize>, String)> {
+ if !is_project_panel_open_file_on_paste(contents, mat, query) {
+ return None;
+ }
+
+ let setting_name_ix = query.capture_index_for_name("setting_name")?;
+ let setting_name_range = mat
+ .nodes_for_capture_index(setting_name_ix)
+ .next()?
+ .byte_range();
+
+ Some((setting_name_range, "auto_open".to_string()))
+}
+
+fn replace_open_file_on_paste_setting_value(
+ contents: &str,
+ mat: &QueryMatch,
+ query: &Query,
+) -> Option<(Range<usize>, String)> {
+ if !is_project_panel_open_file_on_paste(contents, mat, query) {
+ return None;
+ }
+
+ let value_ix = query.capture_index_for_name("setting_value")?;
+ let value_node = mat.nodes_for_capture_index(value_ix).next()?;
+ let value_range = value_node.byte_range();
+ let value_text = contents.get(value_range.clone())?.trim();
+
+ let normalized_value = match value_text {
+ "true" => "true",
+ "false" => "false",
+ _ => return None,
+ };
+
+ Some((
+ value_range,
+ format!("{{ \"on_paste\": {normalized_value} }}"),
+ ))
+}
+
+fn is_project_panel_open_file_on_paste(contents: &str, mat: &QueryMatch, query: &Query) -> bool {
+ let parent_key_ix = match query.capture_index_for_name("parent_key") {
+ Some(ix) => ix,
+ None => return false,
+ };
+ let parent_range = match mat.nodes_for_capture_index(parent_key_ix).next() {
+ Some(node) => node.byte_range(),
+ None => return false,
+ };
+ if contents.get(parent_range) != Some("project_panel") {
+ return false;
+ }
+
+ let setting_name_ix = match query.capture_index_for_name("setting_name") {
+ Some(ix) => ix,
+ None => return false,
+ };
+ let setting_name_range = match mat.nodes_for_capture_index(setting_name_ix).next() {
+ Some(node) => node.byte_range(),
+ None => return false,
+ };
+ contents.get(setting_name_range) == Some("open_file_on_paste")
+}
@@ -0,0 +1,76 @@
+use std::ops::Range;
+
+use tree_sitter::{Query, QueryMatch};
+
+use crate::MigrationPatterns;
+
+pub const SETTINGS_PATTERNS: MigrationPatterns = &[(
+ SETTINGS_AGENT_SERVERS_CUSTOM_PATTERN,
+ migrate_custom_agent_settings,
+)];
+
+const SETTINGS_AGENT_SERVERS_CUSTOM_PATTERN: &str = r#"(document
+ (object
+ (pair
+ key: (string (string_content) @agent-servers)
+ value: (object
+ (pair
+ key: (string (string_content) @server-name)
+ value: (object) @server-settings
+ )
+ )
+ )
+ )
+ (#eq? @agent-servers "agent_servers")
+)"#;
+
+fn migrate_custom_agent_settings(
+ contents: &str,
+ mat: &QueryMatch,
+ query: &Query,
+) -> Option<(Range<usize>, String)> {
+ let server_name_index = query.capture_index_for_name("server-name")?;
+ let server_name = mat.nodes_for_capture_index(server_name_index).next()?;
+ let server_name_text = &contents[server_name.byte_range()];
+
+ if matches!(server_name_text, "gemini" | "claude" | "codex") {
+ return None;
+ }
+
+ let server_settings_index = query.capture_index_for_name("server-settings")?;
+ let server_settings = mat.nodes_for_capture_index(server_settings_index).next()?;
+
+ let mut column = None;
+
+ // Parse the server settings to check what keys it contains
+ let mut cursor = server_settings.walk();
+ for child in server_settings.children(&mut cursor) {
+ if child.kind() == "pair" {
+ if let Some(key_node) = child.child_by_field_name("key") {
+ if let (None, Some(quote_content)) = (column, key_node.child(0)) {
+ column = Some(quote_content.start_position().column);
+ }
+ if let Some(string_content) = key_node.child(1) {
+ let key = &contents[string_content.byte_range()];
+ match key {
+ // If it already has a type key, don't modify it
+ "type" => return None,
+ _ => {}
+ }
+ }
+ }
+ }
+ }
+
+ // Insert the type key at the beginning of the object
+ let start = server_settings.start_byte() + 1;
+ let indent = " ".repeat(column.unwrap_or(12));
+
+ Some((
+ start..start,
+ format!(
+ r#"
+{indent}"type": "custom","#
+ ),
+ ))
+}
@@ -0,0 +1,17 @@
+use anyhow::Result;
+use serde_json::Value;
+
+pub fn remove_context_server_source(settings: &mut Value) -> Result<()> {
+ if let Some(obj) = settings.as_object_mut() {
+ if let Some(context_servers) = obj.get_mut("context_servers") {
+ if let Some(servers) = context_servers.as_object_mut() {
+ for (_, server) in servers.iter_mut() {
+ if let Some(server_obj) = server.as_object_mut() {
+ server_obj.remove("source");
+ }
+ }
+ }
+ }
+ }
+ Ok(())
+}
@@ -0,0 +1,55 @@
+use std::ops::Range;
+use tree_sitter::{Query, QueryMatch};
+
+use crate::MigrationPatterns;
+use crate::patterns::SETTINGS_NESTED_KEY_VALUE_PATTERN;
+
+pub const SETTINGS_PATTERNS: MigrationPatterns = &[(
+ SETTINGS_NESTED_KEY_VALUE_PATTERN,
+ rename_enable_preview_from_code_navigation_setting,
+)];
+
+fn rename_enable_preview_from_code_navigation_setting(
+ contents: &str,
+ mat: &QueryMatch,
+ query: &Query,
+) -> Option<(Range<usize>, String)> {
+ if !is_enable_preview_from_code_navigation(contents, mat, query) {
+ return None;
+ }
+
+ let setting_name_ix = query.capture_index_for_name("setting_name")?;
+ let setting_name_range = mat
+ .nodes_for_capture_index(setting_name_ix)
+ .next()?
+ .byte_range();
+
+ Some((
+ setting_name_range,
+ "enable_keep_preview_on_code_navigation".to_string(),
+ ))
+}
+
+fn is_enable_preview_from_code_navigation(contents: &str, mat: &QueryMatch, query: &Query) -> bool {
+ let parent_key_ix = match query.capture_index_for_name("parent_key") {
+ Some(ix) => ix,
+ None => return false,
+ };
+ let parent_range = match mat.nodes_for_capture_index(parent_key_ix).next() {
+ Some(node) => node.byte_range(),
+ None => return false,
+ };
+ if contents.get(parent_range) != Some("preview_tabs") {
+ return false;
+ }
+
+ let setting_name_ix = match query.capture_index_for_name("setting_name") {
+ Some(ix) => ix,
+ None => return false,
+ };
+ let setting_name_range = match mat.nodes_for_capture_index(setting_name_ix).next() {
+ Some(node) => node.byte_range(),
+ None => return false,
+ };
+ contents.get(setting_name_range) == Some("enable_preview_from_code_navigation")
+}
@@ -0,0 +1,33 @@
+use collections::HashMap;
+use std::{ops::Range, sync::LazyLock};
+use tree_sitter::{Query, QueryMatch};
+
+use crate::MigrationPatterns;
+use crate::patterns::KEYMAP_ACTION_STRING_PATTERN;
+
+pub const KEYMAP_PATTERNS: MigrationPatterns =
+ &[(KEYMAP_ACTION_STRING_PATTERN, replace_string_action)];
+
+fn replace_string_action(
+ contents: &str,
+ mat: &QueryMatch,
+ query: &Query,
+) -> Option<(Range<usize>, String)> {
+ let action_name_ix = query.capture_index_for_name("action_name")?;
+ let action_name_node = mat.nodes_for_capture_index(action_name_ix).next()?;
+ let action_name_range = action_name_node.byte_range();
+ let action_name = contents.get(action_name_range.clone())?;
+
+ if let Some(new_action_name) = STRING_REPLACE.get(&action_name) {
+ return Some((action_name_range, new_action_name.to_string()));
+ }
+
+ None
+}
+
+static STRING_REPLACE: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| {
+ HashMap::from_iter([(
+ "editor::AcceptPartialEditPrediction",
+ "editor::AcceptNextWordEditPrediction",
+ )])
+});
@@ -0,0 +1,52 @@
+use std::ops::Range;
+use tree_sitter::{Query, QueryMatch};
+
+use crate::MigrationPatterns;
+use crate::patterns::SETTINGS_NESTED_KEY_VALUE_PATTERN;
+
+pub const SETTINGS_PATTERNS: MigrationPatterns = &[(
+ SETTINGS_NESTED_KEY_VALUE_PATTERN,
+ rename_restore_on_startup_values,
+)];
+
+fn rename_restore_on_startup_values(
+ contents: &str,
+ mat: &QueryMatch,
+ query: &Query,
+) -> Option<(Range<usize>, String)> {
+ if !is_restore_on_startup_setting(contents, mat, query) {
+ return None;
+ }
+
+ let setting_value_ix = query.capture_index_for_name("setting_value")?;
+ let setting_value_range = mat
+ .nodes_for_capture_index(setting_value_ix)
+ .next()?
+ .byte_range();
+ let setting_value = contents.get(setting_value_range.clone())?;
+
+ // The value includes quotes, so we check for the quoted string
+ let new_value = match setting_value.trim() {
+ "\"none\"" => "\"empty_tab\"",
+ "\"welcome\"" => "\"launchpad\"",
+ _ => return None,
+ };
+
+ Some((setting_value_range, new_value.to_string()))
+}
+
+fn is_restore_on_startup_setting(contents: &str, mat: &QueryMatch, query: &Query) -> bool {
+ // Check that the parent key is "workspace" (since restore_on_startup is under workspace settings)
+ // Actually, restore_on_startup can be at the root level too, so we need to handle both cases
+ // The SETTINGS_NESTED_KEY_VALUE_PATTERN captures parent_key and setting_name
+
+ let setting_name_ix = match query.capture_index_for_name("setting_name") {
+ Some(ix) => ix,
+ None => return false,
+ };
+ let setting_name_range = match mat.nodes_for_capture_index(setting_name_ix).next() {
+ Some(node) => node.byte_range(),
+ None => return false,
+ };
+ contents.get(setting_name_range) == Some("restore_on_startup")
+}
@@ -139,6 +139,10 @@ pub fn migrate_keymap(text: &str) -> Result<Option<String>> {
migrations::m_2025_04_15::KEYMAP_PATTERNS,
&KEYMAP_QUERY_2025_04_15,
),
+ MigrationType::TreeSitter(
+ migrations::m_2025_12_08::KEYMAP_PATTERNS,
+ &KEYMAP_QUERY_2025_12_08,
+ ),
];
run_migrations(text, migrations)
}
@@ -215,6 +219,23 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
MigrationType::Json(migrations::m_2025_10_16::restore_code_actions_on_format),
MigrationType::Json(migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum),
MigrationType::Json(migrations::m_2025_10_21::make_relative_line_numbers_an_enum),
+ MigrationType::TreeSitter(
+ migrations::m_2025_11_12::SETTINGS_PATTERNS,
+ &SETTINGS_QUERY_2025_11_12,
+ ),
+ MigrationType::TreeSitter(
+ migrations::m_2025_12_01::SETTINGS_PATTERNS,
+ &SETTINGS_QUERY_2025_12_01,
+ ),
+ MigrationType::TreeSitter(
+ migrations::m_2025_11_20::SETTINGS_PATTERNS,
+ &SETTINGS_QUERY_2025_11_20,
+ ),
+ MigrationType::Json(migrations::m_2025_11_25::remove_context_server_source),
+ MigrationType::TreeSitter(
+ migrations::m_2025_12_15::SETTINGS_PATTERNS,
+ &SETTINGS_QUERY_2025_12_15,
+ ),
];
run_migrations(text, migrations)
}
@@ -333,6 +354,26 @@ define_query!(
SETTINGS_QUERY_2025_10_03,
migrations::m_2025_10_03::SETTINGS_PATTERNS
);
+define_query!(
+ SETTINGS_QUERY_2025_11_12,
+ migrations::m_2025_11_12::SETTINGS_PATTERNS
+);
+define_query!(
+ SETTINGS_QUERY_2025_12_01,
+ migrations::m_2025_12_01::SETTINGS_PATTERNS
+);
+define_query!(
+ SETTINGS_QUERY_2025_11_20,
+ migrations::m_2025_11_20::SETTINGS_PATTERNS
+);
+define_query!(
+ KEYMAP_QUERY_2025_12_08,
+ migrations::m_2025_12_08::KEYMAP_PATTERNS
+);
+define_query!(
+ SETTINGS_QUERY_2025_12_15,
+ migrations::m_2025_12_15::SETTINGS_PATTERNS
+);
// custom query
static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock<Query> = LazyLock::new(|| {
@@ -1184,6 +1225,63 @@ mod tests {
);
}
+ #[test]
+ fn test_custom_agent_server_settings_migration() {
+ assert_migrate_settings_with_migrations(
+ &[MigrationType::TreeSitter(
+ migrations::m_2025_11_20::SETTINGS_PATTERNS,
+ &SETTINGS_QUERY_2025_11_20,
+ )],
+ r#"{
+ "agent_servers": {
+ "gemini": {
+ "default_model": "gemini-1.5-pro"
+ },
+ "claude": {},
+ "codex": {},
+ "my-custom-agent": {
+ "command": "/path/to/agent",
+ "args": ["--foo"],
+ "default_model": "my-model"
+ },
+ "already-migrated-agent": {
+ "type": "custom",
+ "command": "/path/to/agent"
+ },
+ "future-extension-agent": {
+ "type": "extension",
+ "default_model": "ext-model"
+ }
+ }
+}"#,
+ Some(
+ r#"{
+ "agent_servers": {
+ "gemini": {
+ "default_model": "gemini-1.5-pro"
+ },
+ "claude": {},
+ "codex": {},
+ "my-custom-agent": {
+ "type": "custom",
+ "command": "/path/to/agent",
+ "args": ["--foo"],
+ "default_model": "my-model"
+ },
+ "already-migrated-agent": {
+ "type": "custom",
+ "command": "/path/to/agent"
+ },
+ "future-extension-agent": {
+ "type": "extension",
+ "default_model": "ext-model"
+ }
+ }
+}"#,
+ ),
+ );
+ }
+
#[test]
fn test_remove_version_fields() {
assert_migrate_settings(
@@ -1261,7 +1359,6 @@ mod tests {
r#"{
"context_servers": {
"some-mcp-server": {
- "source": "custom",
"command": {
"path": "npx",
"args": [
@@ -1281,7 +1378,6 @@ mod tests {
r#"{
"context_servers": {
"some-mcp-server": {
- "source": "custom",
"command": "npx",
"args": [
"-y",
@@ -1303,7 +1399,6 @@ mod tests {
r#"{
"context_servers": {
"server-with-extras": {
- "source": "custom",
"command": {
"path": "/usr/bin/node",
"args": ["server.js"]
@@ -1316,7 +1411,6 @@ mod tests {
r#"{
"context_servers": {
"server-with-extras": {
- "source": "custom",
"command": "/usr/bin/node",
"args": ["server.js"],
"settings": {}
@@ -1331,7 +1425,6 @@ mod tests {
r#"{
"context_servers": {
"simple-server": {
- "source": "custom",
"command": {
"path": "simple-mcp-server"
}
@@ -1342,7 +1435,6 @@ mod tests {
r#"{
"context_servers": {
"simple-server": {
- "source": "custom",
"command": "simple-mcp-server"
}
}
@@ -2193,4 +2285,150 @@ mod tests {
),
);
}
+
+ #[test]
+ fn test_remove_context_server_source() {
+ assert_migrate_settings(
+ &r#"
+ {
+ "context_servers": {
+ "extension_server": {
+ "source": "extension",
+ "settings": {
+ "foo": "bar"
+ }
+ },
+ "custom_server": {
+ "source": "custom",
+ "command": "foo",
+ "args": ["bar"],
+ "env": {
+ "FOO": "BAR"
+ }
+ },
+ }
+ }
+ "#
+ .unindent(),
+ Some(
+ &r#"
+ {
+ "context_servers": {
+ "extension_server": {
+ "settings": {
+ "foo": "bar"
+ }
+ },
+ "custom_server": {
+ "command": "foo",
+ "args": ["bar"],
+ "env": {
+ "FOO": "BAR"
+ }
+ },
+ }
+ }
+ "#
+ .unindent(),
+ ),
+ );
+ }
+
+ #[test]
+ fn test_project_panel_open_file_on_paste_migration() {
+ assert_migrate_settings(
+ &r#"
+ {
+ "project_panel": {
+ "open_file_on_paste": true
+ }
+ }
+ "#
+ .unindent(),
+ Some(
+ &r#"
+ {
+ "project_panel": {
+ "auto_open": { "on_paste": true }
+ }
+ }
+ "#
+ .unindent(),
+ ),
+ );
+
+ assert_migrate_settings(
+ &r#"
+ {
+ "project_panel": {
+ "open_file_on_paste": false
+ }
+ }
+ "#
+ .unindent(),
+ Some(
+ &r#"
+ {
+ "project_panel": {
+ "auto_open": { "on_paste": false }
+ }
+ }
+ "#
+ .unindent(),
+ ),
+ );
+ }
+
+ #[test]
+ fn test_enable_preview_from_code_navigation_migration() {
+ assert_migrate_settings(
+ &r#"
+ {
+ "other_setting_1": 1,
+ "preview_tabs": {
+ "other_setting_2": 2,
+ "enable_preview_from_code_navigation": false
+ }
+ }
+ "#
+ .unindent(),
+ Some(
+ &r#"
+ {
+ "other_setting_1": 1,
+ "preview_tabs": {
+ "other_setting_2": 2,
+ "enable_keep_preview_on_code_navigation": false
+ }
+ }
+ "#
+ .unindent(),
+ ),
+ );
+
+ assert_migrate_settings(
+ &r#"
+ {
+ "other_setting_1": 1,
+ "preview_tabs": {
+ "other_setting_2": 2,
+ "enable_preview_from_code_navigation": true
+ }
+ }
+ "#
+ .unindent(),
+ Some(
+ &r#"
+ {
+ "other_setting_1": 1,
+ "preview_tabs": {
+ "other_setting_2": 2,
+ "enable_keep_preview_on_code_navigation": true
+ }
+ }
+ "#
+ .unindent(),
+ ),
+ );
+ }
}
@@ -0,0 +1,23 @@
+[package]
+name = "miniprofiler_ui"
+version = "0.1.0"
+publish.workspace = true
+edition.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/miniprofiler_ui.rs"
+
+[dependencies]
+gpui.workspace = true
+zed_actions.workspace = true
+workspace.workspace = true
+util.workspace = true
+serde_json.workspace = true
+smol.workspace = true
+
+[dev-dependencies]
+gpui = { workspace = true, features = ["test-support"] }
@@ -0,0 +1,410 @@
+use std::{
+ ops::Range,
+ path::PathBuf,
+ rc::Rc,
+ time::{Duration, Instant},
+};
+
+use gpui::{
+ App, AppContext, ClipboardItem, Context, Div, Entity, Hsla, InteractiveElement,
+ ParentElement as _, Render, SerializedTaskTiming, SharedString, StatefulInteractiveElement,
+ Styled, Task, TaskTiming, TitlebarOptions, UniformListScrollHandle, WindowBounds, WindowHandle,
+ WindowOptions, div, prelude::FluentBuilder, px, relative, size, uniform_list,
+};
+use util::ResultExt;
+use workspace::{
+ Workspace,
+ ui::{
+ ActiveTheme, Button, ButtonCommon, ButtonStyle, Checkbox, Clickable, Divider,
+ ScrollableHandle as _, ToggleState, Tooltip, WithScrollbar, h_flex, v_flex,
+ },
+};
+use zed_actions::OpenPerformanceProfiler;
+
+pub fn init(startup_time: Instant, cx: &mut App) {
+ cx.observe_new(move |workspace: &mut workspace::Workspace, _, _| {
+ workspace.register_action(move |workspace, _: &OpenPerformanceProfiler, window, cx| {
+ let window_handle = window
+ .window_handle()
+ .downcast::<Workspace>()
+ .expect("Workspaces are root Windows");
+ open_performance_profiler(startup_time, workspace, window_handle, cx);
+ });
+ })
+ .detach();
+}
+
+fn open_performance_profiler(
+ startup_time: Instant,
+ _workspace: &mut workspace::Workspace,
+ workspace_handle: WindowHandle<Workspace>,
+ cx: &mut App,
+) {
+ let existing_window = cx
+ .windows()
+ .into_iter()
+ .find_map(|window| window.downcast::<ProfilerWindow>());
+
+ if let Some(existing_window) = existing_window {
+ existing_window
+ .update(cx, |profiler_window, window, _cx| {
+ profiler_window.workspace = Some(workspace_handle);
+ window.activate_window();
+ })
+ .log_err();
+ return;
+ }
+
+ let default_bounds = size(px(1280.), px(720.)); // 16:9
+
+ cx.open_window(
+ WindowOptions {
+ titlebar: Some(TitlebarOptions {
+ title: Some("Profiler Window".into()),
+ appears_transparent: false,
+ traffic_light_position: None,
+ }),
+ focus: true,
+ show: true,
+ is_movable: true,
+ kind: gpui::WindowKind::Normal,
+ window_background: cx.theme().window_background_appearance(),
+ window_decorations: None,
+ window_min_size: Some(default_bounds),
+ window_bounds: Some(WindowBounds::centered(default_bounds, cx)),
+ ..Default::default()
+ },
+ |_window, cx| ProfilerWindow::new(startup_time, Some(workspace_handle), cx),
+ )
+ .log_err();
+}
+
+enum DataMode {
+ Realtime(Option<Vec<TaskTiming>>),
+ Snapshot(Vec<TaskTiming>),
+}
+
+struct TimingBar {
+ location: &'static core::panic::Location<'static>,
+ start: Instant,
+ end: Instant,
+ color: Hsla,
+}
+
+pub struct ProfilerWindow {
+ startup_time: Instant,
+ data: DataMode,
+ include_self_timings: ToggleState,
+ autoscroll: bool,
+ scroll_handle: UniformListScrollHandle,
+ workspace: Option<WindowHandle<Workspace>>,
+ _refresh: Option<Task<()>>,
+}
+
+impl ProfilerWindow {
+ pub fn new(
+ startup_time: Instant,
+ workspace_handle: Option<WindowHandle<Workspace>>,
+ cx: &mut App,
+ ) -> Entity<Self> {
+ let entity = cx.new(|cx| ProfilerWindow {
+ startup_time,
+ data: DataMode::Realtime(None),
+ include_self_timings: ToggleState::Unselected,
+ autoscroll: true,
+ scroll_handle: UniformListScrollHandle::default(),
+ workspace: workspace_handle,
+ _refresh: Some(Self::begin_listen(cx)),
+ });
+
+ entity
+ }
+
+ fn begin_listen(cx: &mut Context<Self>) -> Task<()> {
+ cx.spawn(async move |this, cx| {
+ loop {
+ let data = cx
+ .foreground_executor()
+ .dispatcher
+ .get_current_thread_timings();
+
+ this.update(cx, |this: &mut ProfilerWindow, cx| {
+ this.data = DataMode::Realtime(Some(data));
+ cx.notify();
+ })
+ .ok();
+
+ // yield to the executor
+ cx.background_executor()
+ .timer(Duration::from_micros(1))
+ .await;
+ }
+ })
+ }
+
+ fn get_timings(&self) -> Option<&Vec<TaskTiming>> {
+ match &self.data {
+ DataMode::Realtime(data) => data.as_ref(),
+ DataMode::Snapshot(data) => Some(data),
+ }
+ }
+
+ fn render_timing(value_range: Range<Instant>, item: TimingBar, cx: &App) -> Div {
+ let time_ms = item.end.duration_since(item.start).as_secs_f32() * 1000f32;
+
+ let remap = value_range
+ .end
+ .duration_since(value_range.start)
+ .as_secs_f32()
+ * 1000f32;
+
+ let start = (item.start.duration_since(value_range.start).as_secs_f32() * 1000f32) / remap;
+ let end = (item.end.duration_since(value_range.start).as_secs_f32() * 1000f32) / remap;
+
+ let bar_width = end - start.abs();
+
+ let location = item
+ .location
+ .file()
+ .rsplit_once("/")
+ .unwrap_or(("", item.location.file()))
+ .1;
+ let location = location.rsplit_once("\\").unwrap_or(("", location)).1;
+
+ let label = SharedString::from(format!(
+ "{}:{}:{}",
+ location,
+ item.location.line(),
+ item.location.column()
+ ));
+
+ h_flex()
+ .gap_2()
+ .w_full()
+ .h(px(32.0))
+ .child(
+ div()
+ .id(label.clone())
+ .w(px(200.0))
+ .flex_shrink_0()
+ .overflow_hidden()
+ .child(div().text_ellipsis().child(label.clone()))
+ .tooltip(Tooltip::text(label.clone()))
+ .on_click(move |_, _, cx| {
+ cx.write_to_clipboard(ClipboardItem::new_string(label.to_string()))
+ }),
+ )
+ .child(
+ div()
+ .flex_1()
+ .h(px(24.0))
+ .bg(cx.theme().colors().background)
+ .rounded_md()
+ .p(px(2.0))
+ .relative()
+ .child(
+ div()
+ .absolute()
+ .h_full()
+ .rounded_sm()
+ .bg(item.color)
+ .left(relative(start.max(0f32)))
+ .w(relative(bar_width)),
+ ),
+ )
+ .child(
+ div()
+ .min_w(px(70.))
+ .flex_shrink_0()
+ .text_right()
+ .child(format!("{:.1} ms", time_ms)),
+ )
+ }
+}
+
+impl Render for ProfilerWindow {
+ fn render(
+ &mut self,
+ window: &mut gpui::Window,
+ cx: &mut gpui::Context<Self>,
+ ) -> impl gpui::IntoElement {
+ let scroll_offset = self.scroll_handle.offset();
+ let max_offset = self.scroll_handle.max_offset();
+ self.autoscroll = -scroll_offset.y >= (max_offset.height - px(24.));
+ if self.autoscroll {
+ self.scroll_handle.scroll_to_bottom();
+ }
+
+ v_flex()
+ .id("profiler")
+ .w_full()
+ .h_full()
+ .bg(cx.theme().colors().surface_background)
+ .text_color(cx.theme().colors().text)
+ .child(
+ h_flex()
+ .py_2()
+ .px_4()
+ .w_full()
+ .justify_between()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Button::new(
+ "switch-mode",
+ match self.data {
+ DataMode::Snapshot { .. } => "Resume",
+ DataMode::Realtime(_) => "Pause",
+ },
+ )
+ .style(ButtonStyle::Filled)
+ .on_click(cx.listener(
+ |this, _, _window, cx| {
+ match &this.data {
+ DataMode::Realtime(Some(data)) => {
+ this._refresh = None;
+ this.data = DataMode::Snapshot(data.clone());
+ }
+ DataMode::Snapshot { .. } => {
+ this._refresh = Some(Self::begin_listen(cx));
+ this.data = DataMode::Realtime(None);
+ }
+ _ => {}
+ };
+ cx.notify();
+ },
+ )),
+ )
+ .child(
+ Button::new("export-data", "Save")
+ .style(ButtonStyle::Filled)
+ .on_click(cx.listener(|this, _, _window, cx| {
+ let Some(workspace) = this.workspace else {
+ return;
+ };
+
+ let Some(data) = this.get_timings() else {
+ return;
+ };
+ let timings =
+ SerializedTaskTiming::convert(this.startup_time, &data);
+
+ let active_path = workspace
+ .read_with(cx, |workspace, cx| {
+ workspace.most_recent_active_path(cx)
+ })
+ .log_err()
+ .flatten()
+ .and_then(|p| p.parent().map(|p| p.to_owned()))
+ .unwrap_or_else(|| PathBuf::default());
+
+ let path = cx.prompt_for_new_path(
+ &active_path,
+ Some("performance_profile.miniprof"),
+ );
+
+ cx.background_spawn(async move {
+ let path = path.await;
+ let path =
+ path.log_err().and_then(|p| p.log_err()).flatten();
+
+ let Some(path) = path else {
+ return;
+ };
+
+ let Some(timings) =
+ serde_json::to_string(&timings).log_err()
+ else {
+ return;
+ };
+
+ smol::fs::write(path, &timings).await.log_err();
+ })
+ .detach();
+ })),
+ ),
+ )
+ .child(
+ Checkbox::new("include-self", self.include_self_timings)
+ .label("Include profiler timings")
+ .on_click(cx.listener(|this, checked, _window, cx| {
+ this.include_self_timings = *checked;
+ cx.notify();
+ })),
+ ),
+ )
+ .when_some(self.get_timings(), |div, e| {
+ if e.len() == 0 {
+ return div;
+ }
+
+ let min = e[0].start;
+ let max = e[e.len() - 1].end.unwrap_or_else(|| Instant::now());
+ let timings = Rc::new(
+ e.into_iter()
+ .filter(|timing| {
+ timing
+ .end
+ .unwrap_or_else(|| Instant::now())
+ .duration_since(timing.start)
+ .as_millis()
+ >= 1
+ })
+ .filter(|timing| {
+ if self.include_self_timings.selected() {
+ true
+ } else {
+ !timing.location.file().ends_with("miniprofiler_ui.rs")
+ }
+ })
+ .cloned()
+ .collect::<Vec<_>>(),
+ );
+
+ div.child(Divider::horizontal()).child(
+ v_flex()
+ .id("timings.bars")
+ .w_full()
+ .h_full()
+ .gap_2()
+ .child(
+ uniform_list("list", timings.len(), {
+ let timings = timings.clone();
+ move |visible_range, _, cx| {
+ let mut items = vec![];
+ for i in visible_range {
+ let timing = &timings[i];
+ let value_range =
+ max.checked_sub(Duration::from_secs(10)).unwrap_or(min)
+ ..max;
+ items.push(Self::render_timing(
+ value_range,
+ TimingBar {
+ location: timing.location,
+ start: timing.start,
+ end: timing.end.unwrap_or_else(|| Instant::now()),
+ color: cx
+ .theme()
+ .accents()
+ .color_for_index(i as u32),
+ },
+ cx,
+ ));
+ }
+ items
+ }
+ })
+ .p_4()
+ .on_scroll_wheel(cx.listener(|this, _, _, cx| {
+ this.autoscroll = false;
+ cx.notify();
+ }))
+ .track_scroll(&self.scroll_handle)
+ .size_full(),
+ )
+ .vertical_scrollbar_for(&self.scroll_handle, window, cx),
+ )
+ })
+ }
+}
@@ -42,6 +42,8 @@ sum_tree.workspace = true
text.workspace = true
theme.workspace = true
tree-sitter.workspace = true
+ztracing.workspace = true
+tracing.workspace = true
util.workspace = true
[dev-dependencies]
@@ -56,3 +58,6 @@ settings = { workspace = true, features = ["test-support"] }
text = { workspace = true, features = ["test-support"] }
util = { workspace = true, features = ["test-support"] }
zlog.workspace = true
+
+[package.metadata.cargo-machete]
+ignored = ["tracing"]
@@ -1,20 +1,37 @@
+use crate::{MultiBufferDimension, MultiBufferOffset, MultiBufferOffsetUtf16};
+
use super::{ExcerptId, MultiBufferSnapshot, ToOffset, ToPoint};
-use language::{OffsetUtf16, Point, TextDimension};
+use language::Point;
use std::{
cmp::Ordering,
- ops::{Range, Sub},
+ ops::{AddAssign, Range, Sub},
};
use sum_tree::Bias;
-use text::BufferId;
-#[derive(Clone, Copy, Eq, PartialEq, Debug, Hash)]
+#[derive(Clone, Copy, Eq, PartialEq, Hash)]
pub struct Anchor {
- pub buffer_id: Option<BufferId>,
pub excerpt_id: ExcerptId,
pub text_anchor: text::Anchor,
pub diff_base_anchor: Option<text::Anchor>,
}
+impl std::fmt::Debug for Anchor {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ if self.is_min() {
+ return write!(f, "Anchor::min({:?})", self.text_anchor.buffer_id);
+ }
+ if self.is_max() {
+ return write!(f, "Anchor::max({:?})", self.text_anchor.buffer_id);
+ }
+
+ f.debug_struct("Anchor")
+ .field("excerpt_id", &self.excerpt_id)
+ .field("text_anchor", &self.text_anchor)
+ .field("diff_base_anchor", &self.diff_base_anchor)
+ .finish()
+ }
+}
+
impl Anchor {
pub fn with_diff_base_anchor(self, diff_base_anchor: text::Anchor) -> Self {
Self {
@@ -23,31 +40,20 @@ impl Anchor {
}
}
- pub fn in_buffer(
- excerpt_id: ExcerptId,
- buffer_id: BufferId,
- text_anchor: text::Anchor,
- ) -> Self {
+ pub fn in_buffer(excerpt_id: ExcerptId, text_anchor: text::Anchor) -> Self {
Self {
- buffer_id: Some(buffer_id),
excerpt_id,
text_anchor,
diff_base_anchor: None,
}
}
- pub fn range_in_buffer(
- excerpt_id: ExcerptId,
- buffer_id: BufferId,
- range: Range<text::Anchor>,
- ) -> Range<Self> {
- Self::in_buffer(excerpt_id, buffer_id, range.start)
- ..Self::in_buffer(excerpt_id, buffer_id, range.end)
+ pub fn range_in_buffer(excerpt_id: ExcerptId, range: Range<text::Anchor>) -> Range<Self> {
+ Self::in_buffer(excerpt_id, range.start)..Self::in_buffer(excerpt_id, range.end)
}
pub fn min() -> Self {
Self {
- buffer_id: None,
excerpt_id: ExcerptId::min(),
text_anchor: text::Anchor::MIN,
diff_base_anchor: None,
@@ -56,13 +62,24 @@ impl Anchor {
pub fn max() -> Self {
Self {
- buffer_id: None,
excerpt_id: ExcerptId::max(),
text_anchor: text::Anchor::MAX,
diff_base_anchor: None,
}
}
+ pub fn is_min(&self) -> bool {
+ self.excerpt_id == ExcerptId::min()
+ && self.text_anchor.is_min()
+ && self.diff_base_anchor.is_none()
+ }
+
+ pub fn is_max(&self) -> bool {
+ self.excerpt_id == ExcerptId::max()
+ && self.text_anchor.is_max()
+ && self.diff_base_anchor.is_none()
+ }
+
pub fn cmp(&self, other: &Anchor, snapshot: &MultiBufferSnapshot) -> Ordering {
if self == other {
return Ordering::Equal;
@@ -75,7 +92,12 @@ impl Anchor {
if excerpt_id_cmp.is_ne() {
return excerpt_id_cmp;
}
- if self_excerpt_id == ExcerptId::min() || self_excerpt_id == ExcerptId::max() {
+ if self_excerpt_id == ExcerptId::max()
+ && self.text_anchor.is_max()
+ && self.text_anchor.is_max()
+ && self.diff_base_anchor.is_none()
+ && other.diff_base_anchor.is_none()
+ {
return Ordering::Equal;
}
if let Some(excerpt) = snapshot.excerpt(self_excerpt_id) {
@@ -117,8 +139,7 @@ impl Anchor {
&& let Some(excerpt) = snapshot.excerpt(self.excerpt_id)
{
return Self {
- buffer_id: self.buffer_id,
- excerpt_id: self.excerpt_id,
+ excerpt_id: excerpt.id,
text_anchor: self.text_anchor.bias_left(&excerpt.buffer),
diff_base_anchor: self.diff_base_anchor.map(|a| {
if let Some(base_text) = snapshot
@@ -141,8 +162,7 @@ impl Anchor {
&& let Some(excerpt) = snapshot.excerpt(self.excerpt_id)
{
return Self {
- buffer_id: self.buffer_id,
- excerpt_id: self.excerpt_id,
+ excerpt_id: excerpt.id,
text_anchor: self.text_anchor.bias_right(&excerpt.buffer),
diff_base_anchor: self.diff_base_anchor.map(|a| {
if let Some(base_text) = snapshot
@@ -162,13 +182,17 @@ impl Anchor {
pub fn summary<D>(&self, snapshot: &MultiBufferSnapshot) -> D
where
- D: TextDimension + Ord + Sub<D, Output = D>,
+ D: MultiBufferDimension
+ + Ord
+ + Sub<Output = D::TextDimension>
+ + AddAssign<D::TextDimension>,
+ D::TextDimension: Sub<Output = D::TextDimension> + Ord,
{
snapshot.summary_for_anchor(self)
}
pub fn is_valid(&self, snapshot: &MultiBufferSnapshot) -> bool {
- if *self == Anchor::min() || *self == Anchor::max() {
+ if self.is_min() || self.is_max() {
true
} else if let Some(excerpt) = snapshot.excerpt(self.excerpt_id) {
(self.text_anchor == excerpt.range.context.start
@@ -182,10 +206,10 @@ impl Anchor {
}
impl ToOffset for Anchor {
- fn to_offset(&self, snapshot: &MultiBufferSnapshot) -> usize {
+ fn to_offset(&self, snapshot: &MultiBufferSnapshot) -> MultiBufferOffset {
self.summary(snapshot)
}
- fn to_offset_utf16(&self, snapshot: &MultiBufferSnapshot) -> OffsetUtf16 {
+ fn to_offset_utf16(&self, snapshot: &MultiBufferSnapshot) -> MultiBufferOffsetUtf16 {
self.summary(snapshot)
}
}
@@ -203,7 +227,7 @@ pub trait AnchorRangeExt {
fn cmp(&self, other: &Range<Anchor>, buffer: &MultiBufferSnapshot) -> Ordering;
fn includes(&self, other: &Range<Anchor>, buffer: &MultiBufferSnapshot) -> bool;
fn overlaps(&self, other: &Range<Anchor>, buffer: &MultiBufferSnapshot) -> bool;
- fn to_offset(&self, content: &MultiBufferSnapshot) -> Range<usize>;
+ fn to_offset(&self, content: &MultiBufferSnapshot) -> Range<MultiBufferOffset>;
fn to_point(&self, content: &MultiBufferSnapshot) -> Range<Point>;
}
@@ -223,7 +247,7 @@ impl AnchorRangeExt for Range<Anchor> {
self.end.cmp(&other.start, buffer).is_ge() && self.start.cmp(&other.end, buffer).is_le()
}
- fn to_offset(&self, content: &MultiBufferSnapshot) -> Range<usize> {
+ fn to_offset(&self, content: &MultiBufferSnapshot) -> Range<MultiBufferOffset> {
self.start.to_offset(content)..self.end.to_offset(content)
}
@@ -231,6 +255,3 @@ impl AnchorRangeExt for Range<Anchor> {
self.start.to_point(content)..self.end.to_point(content)
}
}
-
-#[derive(Clone, Copy, Eq, PartialEq, Debug, Hash, Ord, PartialOrd)]
-pub struct Offset(pub usize);
@@ -2,13 +2,11 @@ mod anchor;
#[cfg(test)]
mod multi_buffer_tests;
mod path_key;
-mod position;
mod transaction;
use self::transaction::History;
-pub use anchor::{Anchor, AnchorRangeExt, Offset};
-pub use position::{TypedOffset, TypedPoint, TypedRow};
+pub use anchor::{Anchor, AnchorRangeExt};
use anyhow::{Result, anyhow};
use buffer_diff::{
@@ -20,10 +18,10 @@ use collections::{BTreeMap, Bound, HashMap, HashSet};
use gpui::{App, Context, Entity, EntityId, EventEmitter};
use itertools::Itertools;
use language::{
- AutoindentMode, Buffer, BufferChunks, BufferRow, BufferSnapshot, Capability, CharClassifier,
- CharKind, CharScopeContext, Chunk, CursorShape, DiagnosticEntryRef, DiskState, File,
- IndentGuideSettings, IndentSize, Language, LanguageScope, OffsetRangeExt, OffsetUtf16, Outline,
- OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject, ToOffset as _,
+ AutoindentMode, BracketMatch, Buffer, BufferChunks, BufferRow, BufferSnapshot, Capability,
+ CharClassifier, CharKind, CharScopeContext, Chunk, CursorShape, DiagnosticEntryRef, DiskState,
+ File, IndentGuideSettings, IndentSize, Language, LanguageScope, OffsetRangeExt, OffsetUtf16,
+ Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject, ToOffset as _,
ToPoint as _, TransactionId, TreeSitterOptions, Unclipped,
language_settings::{LanguageSettings, language_settings},
};
@@ -38,18 +36,20 @@ use std::{
any::type_name,
borrow::Cow,
cell::{Cell, Ref, RefCell},
- cmp, fmt,
+ cmp,
+ collections::VecDeque,
+ fmt::{self, Debug},
future::Future,
io,
iter::{self, FromIterator},
mem,
- ops::{Range, RangeBounds, Sub},
+ ops::{self, AddAssign, ControlFlow, Range, RangeBounds, Sub, SubAssign},
rc::Rc,
str,
sync::Arc,
time::Duration,
};
-use sum_tree::{Bias, Cursor, Dimension, Dimensions, SumTree, Summary, TreeMap};
+use sum_tree::{Bias, Cursor, Dimension, Dimensions, SumTree, TreeMap};
use text::{
BufferId, Edit, LineIndent, TextSummary,
locator::Locator,
@@ -57,12 +57,16 @@ use text::{
};
use theme::SyntaxTheme;
use util::post_inc;
+use ztracing::instrument;
pub use self::path_key::PathKey;
#[derive(Debug, Default, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct ExcerptId(u32);
+#[derive(Debug, Default, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
+pub struct BaseTextRow(pub u32);
+
/// One or more [`Buffers`](Buffer) being edited in a single view.
///
/// See <https://zed.dev/features#multi-buffers>
@@ -78,7 +82,7 @@ pub struct MultiBuffer {
paths_by_excerpt: HashMap<ExcerptId, PathKey>,
/// Mapping from buffer IDs to their diff states
diffs: HashMap<BufferId, DiffState>,
- subscriptions: Topic,
+ subscriptions: Topic<MultiBufferOffset>,
/// If true, the multi-buffer only contains a single [`Buffer`] and a single [`Excerpt`]
singleton: bool,
/// The history of the multi-buffer.
@@ -89,6 +93,14 @@ pub struct MultiBuffer {
/// The writing capability of the multi-buffer.
capability: Capability,
buffer_changed_since_sync: Rc<Cell<bool>>,
+ follower: Option<Entity<MultiBuffer>>,
+ filter_mode: Option<MultiBufferFilterMode>,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MultiBufferFilterMode {
+ KeepInsertions,
+ KeepDeletions,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -117,7 +129,7 @@ pub enum Event {
transaction_id: TransactionId,
},
Reloaded,
- LanguageChanged(BufferId),
+ LanguageChanged(BufferId, bool),
Reparsed(BufferId),
Saved,
FileHandleChanged,
@@ -138,9 +150,11 @@ pub struct MultiBufferDiffHunk {
/// The excerpt that contains the diff hunk.
pub excerpt_id: ExcerptId,
/// The range within the buffer's diff base that this hunk corresponds to.
- pub diff_base_byte_range: Range<usize>,
+ pub diff_base_byte_range: Range<BufferOffset>,
/// Whether or not this hunk also appears in the 'secondary diff'.
pub secondary_status: DiffHunkSecondaryStatus,
+ /// The word diffs for this hunk.
+ pub word_diffs: Vec<Range<MultiBufferOffset>>,
}
impl MultiBufferDiffHunk {
@@ -159,20 +173,21 @@ impl MultiBufferDiffHunk {
}
pub fn is_created_file(&self) -> bool {
- self.diff_base_byte_range == (0..0)
- && self.buffer_range == (text::Anchor::MIN..text::Anchor::MAX)
+ self.diff_base_byte_range == (BufferOffset(0)..BufferOffset(0))
+ && self.buffer_range.start.is_min()
+ && self.buffer_range.end.is_max()
}
pub fn multi_buffer_range(&self) -> Range<Anchor> {
- let start = Anchor::in_buffer(self.excerpt_id, self.buffer_id, self.buffer_range.start);
- let end = Anchor::in_buffer(self.excerpt_id, self.buffer_id, self.buffer_range.end);
+ let start = Anchor::in_buffer(self.excerpt_id, self.buffer_range.start);
+ let end = Anchor::in_buffer(self.excerpt_id, self.buffer_range.end);
start..end
}
}
pub type MultiBufferPoint = Point;
-type ExcerptOffset = TypedOffset<Excerpt>;
-type ExcerptPoint = TypedPoint<Excerpt>;
+type ExcerptOffset = ExcerptDimension<MultiBufferOffset>;
+type ExcerptPoint = ExcerptDimension<Point>;
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq, Hash, serde::Deserialize)]
#[serde(transparent)]
@@ -183,7 +198,7 @@ impl MultiBufferRow {
pub const MAX: Self = Self(u32::MAX);
}
-impl std::ops::Add<usize> for MultiBufferRow {
+impl ops::Add<usize> for MultiBufferRow {
type Output = Self;
fn add(self, rhs: usize) -> Self::Output {
@@ -191,9 +206,305 @@ impl std::ops::Add<usize> for MultiBufferRow {
}
}
+pub trait MultiBufferDimension: 'static + Copy + Default + std::fmt::Debug {
+ type TextDimension: TextDimension;
+ fn from_summary(summary: &MBTextSummary) -> Self;
+
+ fn add_text_dim(&mut self, summary: &Self::TextDimension);
+
+ fn add_mb_text_summary(&mut self, summary: &MBTextSummary);
+}
+
+// todo(lw): MultiBufferPoint
+impl MultiBufferDimension for Point {
+ type TextDimension = Point;
+ fn from_summary(summary: &MBTextSummary) -> Self {
+ summary.lines
+ }
+
+ fn add_text_dim(&mut self, other: &Self::TextDimension) {
+ *self += *other;
+ }
+
+ fn add_mb_text_summary(&mut self, summary: &MBTextSummary) {
+ *self += summary.lines;
+ }
+}
+
+// todo(lw): MultiBufferPointUtf16
+impl MultiBufferDimension for PointUtf16 {
+ type TextDimension = PointUtf16;
+ fn from_summary(summary: &MBTextSummary) -> Self {
+ summary.lines_utf16()
+ }
+
+ fn add_text_dim(&mut self, other: &Self::TextDimension) {
+ *self += *other;
+ }
+
+ fn add_mb_text_summary(&mut self, summary: &MBTextSummary) {
+ *self += summary.lines_utf16();
+ }
+}
+
+#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq, Hash, serde::Deserialize)]
+pub struct MultiBufferOffset(pub usize);
+
+impl fmt::Display for MultiBufferOffset {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+impl rand::distr::uniform::SampleUniform for MultiBufferOffset {
+ type Sampler = MultiBufferOffsetUniformSampler;
+}
+
+pub struct MultiBufferOffsetUniformSampler {
+ sampler: rand::distr::uniform::UniformUsize,
+}
+
+impl rand::distr::uniform::UniformSampler for MultiBufferOffsetUniformSampler {
+ type X = MultiBufferOffset;
+
+ fn new<B1, B2>(low_b: B1, high_b: B2) -> Result<Self, rand::distr::uniform::Error>
+ where
+ B1: rand::distr::uniform::SampleBorrow<Self::X> + Sized,
+ B2: rand::distr::uniform::SampleBorrow<Self::X> + Sized,
+ {
+ let low = *low_b.borrow();
+ let high = *high_b.borrow();
+ let sampler = rand::distr::uniform::UniformUsize::new(low.0, high.0);
+ sampler.map(|sampler| MultiBufferOffsetUniformSampler { sampler })
+ }
+
+ #[inline] // if the range is constant, this helps LLVM to do the
+ // calculations at compile-time.
+ fn new_inclusive<B1, B2>(low_b: B1, high_b: B2) -> Result<Self, rand::distr::uniform::Error>
+ where
+ B1: rand::distr::uniform::SampleBorrow<Self::X> + Sized,
+ B2: rand::distr::uniform::SampleBorrow<Self::X> + Sized,
+ {
+ let low = *low_b.borrow();
+ let high = *high_b.borrow();
+ let sampler = rand::distr::uniform::UniformUsize::new_inclusive(low.0, high.0);
+ sampler.map(|sampler| MultiBufferOffsetUniformSampler { sampler })
+ }
+
+ fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> Self::X {
+ MultiBufferOffset(self.sampler.sample(rng))
+ }
+}
+impl MultiBufferDimension for MultiBufferOffset {
+ type TextDimension = usize;
+ fn from_summary(summary: &MBTextSummary) -> Self {
+ summary.len
+ }
+
+ fn add_text_dim(&mut self, other: &Self::TextDimension) {
+ self.0 += *other;
+ }
+
+ fn add_mb_text_summary(&mut self, summary: &MBTextSummary) {
+ *self += summary.len;
+ }
+}
+impl MultiBufferDimension for MultiBufferOffsetUtf16 {
+ type TextDimension = OffsetUtf16;
+ fn from_summary(summary: &MBTextSummary) -> Self {
+ MultiBufferOffsetUtf16(summary.len_utf16)
+ }
+
+ fn add_text_dim(&mut self, other: &Self::TextDimension) {
+ self.0 += *other;
+ }
+
+ fn add_mb_text_summary(&mut self, summary: &MBTextSummary) {
+ self.0 += summary.len_utf16;
+ }
+}
+
+#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq, Hash, serde::Deserialize)]
+pub struct BufferOffset(pub usize);
+
+impl TextDimension for BufferOffset {
+ fn from_text_summary(summary: &TextSummary) -> Self {
+ BufferOffset(usize::from_text_summary(summary))
+ }
+ fn from_chunk(chunk: rope::ChunkSlice) -> Self {
+ BufferOffset(usize::from_chunk(chunk))
+ }
+ fn add_assign(&mut self, other: &Self) {
+ TextDimension::add_assign(&mut self.0, &other.0);
+ }
+}
+impl<'a> sum_tree::Dimension<'a, rope::ChunkSummary> for BufferOffset {
+ fn zero(cx: ()) -> Self {
+ BufferOffset(<usize as sum_tree::Dimension<'a, rope::ChunkSummary>>::zero(cx))
+ }
+
+ fn add_summary(&mut self, summary: &'a rope::ChunkSummary, cx: ()) {
+ usize::add_summary(&mut self.0, summary, cx);
+ }
+}
+
+impl Sub for BufferOffset {
+ type Output = usize;
+
+ fn sub(self, other: BufferOffset) -> Self::Output {
+ self.0 - other.0
+ }
+}
+
+impl AddAssign<DimensionPair<usize, Point>> for BufferOffset {
+ fn add_assign(&mut self, other: DimensionPair<usize, Point>) {
+ self.0 += other.key;
+ }
+}
+
+impl language::ToPoint for BufferOffset {
+ fn to_point(&self, snapshot: &text::BufferSnapshot) -> Point {
+ self.0.to_point(snapshot)
+ }
+}
+
+impl language::ToPointUtf16 for BufferOffset {
+ fn to_point_utf16(&self, snapshot: &text::BufferSnapshot) -> PointUtf16 {
+ self.0.to_point_utf16(snapshot)
+ }
+}
+
+impl language::ToOffset for BufferOffset {
+ fn to_offset(&self, snapshot: &text::BufferSnapshot) -> usize {
+ self.0.to_offset(snapshot)
+ }
+}
+
+impl language::ToOffsetUtf16 for BufferOffset {
+ fn to_offset_utf16(&self, snapshot: &text::BufferSnapshot) -> OffsetUtf16 {
+ self.0.to_offset_utf16(snapshot)
+ }
+}
+
+#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
+pub struct MultiBufferOffsetUtf16(pub OffsetUtf16);
+
+impl ops::Add<usize> for MultiBufferOffsetUtf16 {
+ type Output = MultiBufferOffsetUtf16;
+
+ fn add(self, rhs: usize) -> Self::Output {
+ MultiBufferOffsetUtf16(OffsetUtf16(self.0.0 + rhs))
+ }
+}
+
+impl AddAssign<OffsetUtf16> for MultiBufferOffsetUtf16 {
+ fn add_assign(&mut self, rhs: OffsetUtf16) {
+ self.0 += rhs;
+ }
+}
+
+impl AddAssign<usize> for MultiBufferOffsetUtf16 {
+ fn add_assign(&mut self, rhs: usize) {
+ self.0.0 += rhs;
+ }
+}
+
+impl Sub for MultiBufferOffsetUtf16 {
+ type Output = OffsetUtf16;
+
+ fn sub(self, other: MultiBufferOffsetUtf16) -> Self::Output {
+ self.0 - other.0
+ }
+}
+
+#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
+pub struct BufferOffsetUtf16(pub OffsetUtf16);
+
+impl MultiBufferOffset {
+ const ZERO: Self = Self(0);
+ pub fn saturating_sub(self, other: MultiBufferOffset) -> usize {
+ self.0.saturating_sub(other.0)
+ }
+ pub fn saturating_sub_usize(self, other: usize) -> MultiBufferOffset {
+ MultiBufferOffset(self.0.saturating_sub(other))
+ }
+}
+
+impl ops::Sub for MultiBufferOffset {
+ type Output = usize;
+
+ fn sub(self, other: MultiBufferOffset) -> Self::Output {
+ self.0 - other.0
+ }
+}
+
+impl ops::Sub<usize> for MultiBufferOffset {
+ type Output = Self;
+
+ fn sub(self, other: usize) -> Self::Output {
+ MultiBufferOffset(self.0 - other)
+ }
+}
+
+impl ops::SubAssign<usize> for MultiBufferOffset {
+ fn sub_assign(&mut self, other: usize) {
+ self.0 -= other;
+ }
+}
+
+impl ops::Add<usize> for BufferOffset {
+ type Output = Self;
+
+ fn add(self, rhs: usize) -> Self::Output {
+ BufferOffset(self.0 + rhs)
+ }
+}
+
+impl ops::AddAssign<usize> for BufferOffset {
+ fn add_assign(&mut self, other: usize) {
+ self.0 += other;
+ }
+}
+
+impl ops::Add<usize> for MultiBufferOffset {
+ type Output = Self;
+
+ fn add(self, rhs: usize) -> Self::Output {
+ MultiBufferOffset(self.0 + rhs)
+ }
+}
+
+impl ops::AddAssign<usize> for MultiBufferOffset {
+ fn add_assign(&mut self, other: usize) {
+ self.0 += other;
+ }
+}
+
+impl ops::Add<isize> for MultiBufferOffset {
+ type Output = Self;
+
+ fn add(self, rhs: isize) -> Self::Output {
+ MultiBufferOffset((self.0 as isize + rhs) as usize)
+ }
+}
+
+impl ops::Add for MultiBufferOffset {
+ type Output = Self;
+
+ fn add(self, rhs: MultiBufferOffset) -> Self::Output {
+ MultiBufferOffset(self.0 + rhs.0)
+ }
+}
+
+impl ops::AddAssign<MultiBufferOffset> for MultiBufferOffset {
+ fn add_assign(&mut self, other: MultiBufferOffset) {
+ self.0 += other.0;
+ }
+}
+
pub trait ToOffset: 'static + fmt::Debug {
- fn to_offset(&self, snapshot: &MultiBufferSnapshot) -> usize;
- fn to_offset_utf16(&self, snapshot: &MultiBufferSnapshot) -> OffsetUtf16;
+ fn to_offset(&self, snapshot: &MultiBufferSnapshot) -> MultiBufferOffset;
+ fn to_offset_utf16(&self, snapshot: &MultiBufferSnapshot) -> MultiBufferOffsetUtf16;
}
pub trait ToPoint: 'static + fmt::Debug {
@@ -253,25 +564,33 @@ pub struct MultiBufferSnapshot {
}
#[derive(Debug, Clone)]
+/// A piece of text in the multi-buffer
enum DiffTransform {
- BufferContent {
- summary: TextSummary,
- inserted_hunk_info: Option<DiffTransformHunkInfo>,
+ Unmodified {
+ summary: MBTextSummary,
+ },
+ InsertedHunk {
+ summary: MBTextSummary,
+ hunk_info: DiffTransformHunkInfo,
+ },
+ FilteredInsertedHunk {
+ summary: MBTextSummary,
+ hunk_info: DiffTransformHunkInfo,
},
DeletedHunk {
summary: TextSummary,
buffer_id: BufferId,
hunk_info: DiffTransformHunkInfo,
- base_text_byte_range: Range<usize>,
has_trailing_newline: bool,
},
}
-#[derive(Clone, Copy, Debug)]
+#[derive(Clone, Debug)]
struct DiffTransformHunkInfo {
excerpt_id: ExcerptId,
hunk_start_anchor: text::Anchor,
hunk_secondary_status: DiffHunkSecondaryStatus,
+ base_text_byte_range: Range<usize>,
}
impl Eq for DiffTransformHunkInfo {}
@@ -298,6 +617,15 @@ pub struct ExcerptInfo {
pub end_row: MultiBufferRow,
}
+/// Used with [`MultiBuffer::push_buffer_content_transform`]
+#[derive(Clone, Debug)]
+struct CurrentInsertedHunk {
+ hunk_excerpt_start: ExcerptOffset,
+ insertion_end_offset: ExcerptOffset,
+ hunk_info: DiffTransformHunkInfo,
+ is_filtered: bool,
+}
+
impl std::fmt::Debug for ExcerptInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct(type_name::<Self>())
@@ -337,6 +665,7 @@ pub struct ExpandInfo {
pub struct RowInfo {
pub buffer_id: Option<BufferId>,
pub buffer_row: Option<u32>,
+ pub base_text_row: Option<BaseTextRow>,
pub multibuffer_row: Option<MultiBufferRow>,
pub diff_status: Option<buffer_diff::DiffHunkStatus>,
pub expand_info: Option<ExpandInfo>,
@@ -370,10 +699,13 @@ struct Excerpt {
#[derive(Clone)]
pub struct MultiBufferExcerpt<'a> {
excerpt: &'a Excerpt,
- diff_transforms: sum_tree::Cursor<'a, 'static, DiffTransform, DiffTransforms<usize>>,
- offset: usize,
- excerpt_offset: ExcerptDimension<usize>,
- buffer_offset: usize,
+ diff_transforms:
+ sum_tree::Cursor<'a, 'static, DiffTransform, DiffTransforms<MultiBufferOffset>>,
+ /// The offset in the multibuffer considering diff transforms.
+ offset: MultiBufferOffset,
+ /// The offset in the multibuffer without diff transforms.
+ excerpt_offset: ExcerptOffset,
+ buffer_offset: BufferOffset,
}
#[derive(Clone, Debug)]
@@ -408,13 +740,153 @@ pub struct ExcerptSummary {
/// The location of the last [`Excerpt`] being summarized
excerpt_locator: Locator,
widest_line_number: u32,
- text: TextSummary,
+ text: MBTextSummary,
}
#[derive(Debug, Clone)]
pub struct DiffTransformSummary {
- input: TextSummary,
- output: TextSummary,
+ input: MBTextSummary,
+ output: MBTextSummary,
+}
+
+/// Summary of a string of text.
+#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
+pub struct MBTextSummary {
+ /// Length in bytes.
+ pub len: MultiBufferOffset,
+ /// Length in UTF-8.
+ pub chars: usize,
+ /// Length in UTF-16 code units
+ pub len_utf16: OffsetUtf16,
+ /// A point representing the number of lines and the length of the last line.
+ ///
+ /// In other words, it marks the point after the last byte in the text, (if
+ /// EOF was a character, this would be its position).
+ pub lines: Point,
+ /// How many `char`s are in the first line
+ pub first_line_chars: u32,
+ /// How many `char`s are in the last line
+ pub last_line_chars: u32,
+ /// How many UTF-16 code units are in the last line
+ pub last_line_len_utf16: u32,
+ /// The row idx of the longest row
+ pub longest_row: u32,
+ /// How many `char`s are in the longest row
+ pub longest_row_chars: u32,
+}
+
+impl From<TextSummary> for MBTextSummary {
+ fn from(summary: TextSummary) -> Self {
+ MBTextSummary {
+ len: MultiBufferOffset(summary.len),
+ chars: summary.chars,
+ len_utf16: summary.len_utf16,
+ lines: summary.lines,
+ first_line_chars: summary.first_line_chars,
+ last_line_chars: summary.last_line_chars,
+ last_line_len_utf16: summary.last_line_len_utf16,
+ longest_row: summary.longest_row,
+ longest_row_chars: summary.longest_row_chars,
+ }
+ }
+}
+impl From<&str> for MBTextSummary {
+ fn from(text: &str) -> Self {
+ MBTextSummary::from(TextSummary::from(text))
+ }
+}
+
+impl MultiBufferDimension for MBTextSummary {
+ type TextDimension = TextSummary;
+
+ fn from_summary(summary: &MBTextSummary) -> Self {
+ *summary
+ }
+
+ fn add_text_dim(&mut self, summary: &Self::TextDimension) {
+ *self += *summary;
+ }
+
+ fn add_mb_text_summary(&mut self, summary: &MBTextSummary) {
+ *self += *summary;
+ }
+}
+
+impl AddAssign for MBTextSummary {
+ fn add_assign(&mut self, other: MBTextSummary) {
+ let joined_chars = self.last_line_chars + other.first_line_chars;
+ if joined_chars > self.longest_row_chars {
+ self.longest_row = self.lines.row;
+ self.longest_row_chars = joined_chars;
+ }
+ if other.longest_row_chars > self.longest_row_chars {
+ self.longest_row = self.lines.row + other.longest_row;
+ self.longest_row_chars = other.longest_row_chars;
+ }
+
+ if self.lines.row == 0 {
+ self.first_line_chars += other.first_line_chars;
+ }
+
+ if other.lines.row == 0 {
+ self.last_line_chars += other.first_line_chars;
+ self.last_line_len_utf16 += other.last_line_len_utf16;
+ } else {
+ self.last_line_chars = other.last_line_chars;
+ self.last_line_len_utf16 = other.last_line_len_utf16;
+ }
+
+ self.chars += other.chars;
+ self.len += other.len;
+ self.len_utf16 += other.len_utf16;
+ self.lines += other.lines;
+ }
+}
+
+impl AddAssign<TextSummary> for MBTextSummary {
+ fn add_assign(&mut self, other: TextSummary) {
+ *self += MBTextSummary::from(other);
+ }
+}
+
+impl MBTextSummary {
+ pub fn lines_utf16(&self) -> PointUtf16 {
+ PointUtf16 {
+ row: self.lines.row,
+ column: self.last_line_len_utf16,
+ }
+ }
+}
+
+impl<K, V> MultiBufferDimension for DimensionPair<K, V>
+where
+ K: MultiBufferDimension,
+ V: MultiBufferDimension,
+{
+ type TextDimension = DimensionPair<K::TextDimension, V::TextDimension>;
+
+ fn from_summary(summary: &MBTextSummary) -> Self {
+ Self {
+ key: K::from_summary(summary),
+ value: Some(V::from_summary(summary)),
+ }
+ }
+
+ fn add_text_dim(&mut self, summary: &Self::TextDimension) {
+ self.key.add_text_dim(&summary.key);
+ if let Some(value) = &mut self.value {
+ if let Some(other_value) = summary.value.as_ref() {
+ value.add_text_dim(other_value);
+ }
+ }
+ }
+
+ fn add_mb_text_summary(&mut self, summary: &MBTextSummary) {
+ self.key.add_mb_text_summary(summary);
+ if let Some(value) = &mut self.value {
+ value.add_mb_text_summary(summary);
+ }
+ }
}
#[derive(Clone)]
@@ -422,53 +894,54 @@ pub struct MultiBufferRows<'a> {
point: Point,
is_empty: bool,
is_singleton: bool,
- cursor: MultiBufferCursor<'a, Point>,
+ cursor: MultiBufferCursor<'a, Point, Point>,
}
pub struct MultiBufferChunks<'a> {
excerpts: Cursor<'a, 'static, Excerpt, ExcerptOffset>,
- diff_transforms: Cursor<'a, 'static, DiffTransform, Dimensions<usize, ExcerptOffset>>,
+ diff_transforms:
+ Cursor<'a, 'static, DiffTransform, Dimensions<MultiBufferOffset, ExcerptOffset>>,
diffs: &'a TreeMap<BufferId, BufferDiffSnapshot>,
diff_base_chunks: Option<(BufferId, BufferChunks<'a>)>,
buffer_chunk: Option<Chunk<'a>>,
- range: Range<usize>,
+ range: Range<MultiBufferOffset>,
excerpt_offset_range: Range<ExcerptOffset>,
excerpt_chunks: Option<ExcerptChunks<'a>>,
language_aware: bool,
}
pub struct ReversedMultiBufferChunks<'a> {
- cursor: MultiBufferCursor<'a, usize>,
+ cursor: MultiBufferCursor<'a, MultiBufferOffset, BufferOffset>,
current_chunks: Option<rope::Chunks<'a>>,
- start: usize,
- offset: usize,
+ start: MultiBufferOffset,
+ offset: MultiBufferOffset,
}
pub struct MultiBufferBytes<'a> {
- range: Range<usize>,
- cursor: MultiBufferCursor<'a, usize>,
+ range: Range<MultiBufferOffset>,
+ cursor: MultiBufferCursor<'a, MultiBufferOffset, BufferOffset>,
excerpt_bytes: Option<text::Bytes<'a>>,
has_trailing_newline: bool,
chunk: &'a [u8],
}
pub struct ReversedMultiBufferBytes<'a> {
- range: Range<usize>,
+ range: Range<MultiBufferOffset>,
chunks: ReversedMultiBufferChunks<'a>,
chunk: &'a [u8],
}
#[derive(Clone)]
-struct DiffTransforms<D> {
- output_dimension: OutputDimension<D>,
- excerpt_dimension: ExcerptDimension<D>,
+struct DiffTransforms<MBD> {
+ output_dimension: OutputDimension<MBD>,
+ excerpt_dimension: ExcerptDimension<MBD>,
}
-impl<'a, D: TextDimension> Dimension<'a, DiffTransformSummary> for DiffTransforms<D> {
+impl<'a, MBD: MultiBufferDimension> Dimension<'a, DiffTransformSummary> for DiffTransforms<MBD> {
fn zero(cx: <DiffTransformSummary as sum_tree::Summary>::Context<'_>) -> Self {
Self {
output_dimension: OutputDimension::zero(cx),
- excerpt_dimension: <ExcerptDimension<D> as Dimension<'a, DiffTransformSummary>>::zero(
+ excerpt_dimension: <ExcerptDimension<MBD> as Dimension<'a, DiffTransformSummary>>::zero(
cx,
),
}
@@ -485,24 +958,37 @@ impl<'a, D: TextDimension> Dimension<'a, DiffTransformSummary> for DiffTransform
}
#[derive(Clone)]
-struct MultiBufferCursor<'a, D: TextDimension> {
- excerpts: Cursor<'a, 'static, Excerpt, ExcerptDimension<D>>,
- diff_transforms: Cursor<'a, 'static, DiffTransform, DiffTransforms<D>>,
- diffs: &'a TreeMap<BufferId, BufferDiffSnapshot>,
- cached_region: Option<MultiBufferRegion<'a, D>>,
+struct MultiBufferCursor<'a, MBD, BD> {
+ excerpts: Cursor<'a, 'static, Excerpt, ExcerptDimension<MBD>>,
+ diff_transforms: Cursor<'a, 'static, DiffTransform, DiffTransforms<MBD>>,
+ snapshot: &'a MultiBufferSnapshot,
+ cached_region: Option<MultiBufferRegion<'a, MBD, BD>>,
}
+/// Matches transformations to an item
+/// This is essentially a more detailed version of DiffTransform
#[derive(Clone)]
-struct MultiBufferRegion<'a, D: TextDimension> {
+struct MultiBufferRegion<'a, MBD, BD> {
buffer: &'a BufferSnapshot,
is_main_buffer: bool,
diff_hunk_status: Option<DiffHunkStatus>,
excerpt: &'a Excerpt,
- buffer_range: Range<D>,
- range: Range<D>,
+ buffer_range: Range<BD>,
+ diff_base_byte_range: Option<Range<usize>>,
+ range: Range<MBD>,
has_trailing_newline: bool,
}
+impl<'a, MBD, BD> MultiBufferRegion<'a, MBD, BD>
+where
+ MBD: Ord,
+ BD: Ord,
+{
+ fn is_filtered(&self) -> bool {
+ self.range.is_empty() && self.buffer_range.is_empty() && self.diff_hunk_status == None
+ }
+}
+
struct ExcerptChunks<'a> {
excerpt_id: ExcerptId,
content_chunks: BufferChunks<'a>,
@@ -511,7 +997,7 @@ struct ExcerptChunks<'a> {
#[derive(Debug)]
struct BufferEdit {
- range: Range<usize>,
+ range: Range<BufferOffset>,
new_text: Arc<str>,
is_insertion: bool,
original_indent_column: Option<u32>,
@@ -590,9 +1076,12 @@ impl MultiBuffer {
},
);
this.singleton = true;
+ let buffer_id = buffer.read(cx).remote_id();
this.push_excerpts(
buffer,
- [ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)],
+ [ExcerptRange::new(text::Anchor::min_max_range_for_buffer(
+ buffer_id,
+ ))],
cx,
);
this
@@ -612,6 +1101,8 @@ impl MultiBuffer {
paths_by_excerpt: Default::default(),
buffer_changed_since_sync: Default::default(),
history: History::default(),
+ follower: None,
+ filter_mode: None,
}
}
@@ -645,8 +1136,8 @@ impl MultiBuffer {
Self {
snapshot: RefCell::new(self.snapshot.borrow().clone()),
buffers: buffers,
- excerpts_by_path: Default::default(),
- paths_by_excerpt: Default::default(),
+ excerpts_by_path: self.excerpts_by_path.clone(),
+ paths_by_excerpt: self.paths_by_excerpt.clone(),
diffs: diff_bases,
subscriptions: Default::default(),
singleton: self.singleton,
@@ -654,6 +1145,46 @@ impl MultiBuffer {
history: self.history.clone(),
title: self.title.clone(),
buffer_changed_since_sync,
+ follower: None,
+ filter_mode: None,
+ }
+ }
+
+ pub fn get_or_create_follower(&mut self, cx: &mut Context<Self>) -> Entity<MultiBuffer> {
+ use gpui::AppContext as _;
+
+ if let Some(follower) = &self.follower {
+ return follower.clone();
+ }
+
+ let follower = cx.new(|cx| self.clone(cx));
+ follower.update(cx, |follower, _cx| {
+ follower.capability = Capability::ReadOnly;
+ });
+ self.follower = Some(follower.clone());
+ follower
+ }
+
+ pub fn set_filter_mode(&mut self, new_mode: Option<MultiBufferFilterMode>) {
+ self.filter_mode = new_mode;
+ let excerpt_len = self
+ .snapshot
+ .get_mut()
+ .diff_transforms
+ .summary()
+ .excerpt_len();
+ let edits = Self::sync_diff_transforms(
+ self.snapshot.get_mut(),
+ vec![Edit {
+ old: ExcerptDimension(MultiBufferOffset(0))..excerpt_len,
+ new: ExcerptDimension(MultiBufferOffset(0))..excerpt_len,
+ }],
+ // TODO(split-diff) is this right?
+ DiffChangeKind::BufferEdited,
+ new_mode,
+ );
+ if !edits.is_empty() {
+ self.subscriptions.publish(edits);
}
}
@@ -671,6 +1202,7 @@ impl MultiBuffer {
}
/// Returns an up-to-date snapshot of the MultiBuffer.
+ #[ztracing::instrument(skip_all)]
pub fn snapshot(&self, cx: &App) -> MultiBufferSnapshot {
self.sync(cx);
self.snapshot.borrow().clone()
@@ -693,7 +1225,7 @@ impl MultiBuffer {
self.singleton
}
- pub fn subscribe(&mut self) -> Subscription {
+ pub fn subscribe(&mut self) -> Subscription<MultiBufferOffset> {
self.subscriptions.subscribe()
}
@@ -711,7 +1243,7 @@ impl MultiBuffer {
// The `is_empty` signature doesn't match what clippy expects
#[allow(clippy::len_without_is_empty)]
- pub fn len(&self, cx: &App) -> usize {
+ pub fn len(&self, cx: &App) -> MultiBufferOffset {
self.read(cx).len()
}
@@ -750,7 +1282,7 @@ impl MultiBuffer {
// Non-generic part of edit, hoisted out to avoid blowing up LLVM IR.
fn edit_internal(
this: &mut MultiBuffer,
- edits: Vec<(Range<usize>, Arc<str>)>,
+ edits: Vec<(Range<MultiBufferOffset>, Arc<str>)>,
mut autoindent_mode: Option<AutoindentMode>,
cx: &mut Context<MultiBuffer>,
) {
@@ -849,13 +1381,13 @@ impl MultiBuffer {
}
fn convert_edits_to_buffer_edits(
- edits: Vec<(Range<usize>, Arc<str>)>,
+ edits: Vec<(Range<MultiBufferOffset>, Arc<str>)>,
snapshot: &MultiBufferSnapshot,
original_indent_columns: &[Option<u32>],
) -> (HashMap<BufferId, Vec<BufferEdit>>, Vec<ExcerptId>) {
let mut buffer_edits: HashMap<BufferId, Vec<BufferEdit>> = Default::default();
let mut edited_excerpt_ids = Vec::new();
- let mut cursor = snapshot.cursor::<usize>();
+ let mut cursor = snapshot.cursor::<MultiBufferOffset, BufferOffset>();
for (ix, (range, new_text)) in edits.into_iter().enumerate() {
let original_indent_column = original_indent_columns.get(ix).copied().flatten();
@@ -994,7 +1526,7 @@ impl MultiBuffer {
fn autoindent_ranges_internal(
this: &mut MultiBuffer,
- edits: Vec<(Range<usize>, Arc<str>)>,
+ edits: Vec<(Range<MultiBufferOffset>, Arc<str>)>,
cx: &mut Context<MultiBuffer>,
) {
let (buffer_edits, edited_excerpt_ids) =
@@ -1005,7 +1537,7 @@ impl MultiBuffer {
buffer_ids.push(buffer_id);
edits.sort_unstable_by_key(|edit| edit.range.start);
- let mut ranges: Vec<Range<usize>> = Vec::new();
+ let mut ranges: Vec<Range<BufferOffset>> = Vec::new();
for edit in edits {
if let Some(last_range) = ranges.last_mut()
&& edit.range.start <= last_range.end
@@ -1136,11 +1668,12 @@ impl MultiBuffer {
cx: &mut Context<Self>,
) -> Vec<ExcerptId>
where
- O: text::ToOffset,
+ O: text::ToOffset + Clone,
{
self.insert_excerpts_after(ExcerptId::max(), buffer, ranges, cx)
}
+ #[instrument(skip_all)]
fn merge_excerpt_ranges<'a>(
expanded_ranges: impl IntoIterator<Item = &'a ExcerptRange<Point>> + 'a,
) -> (Vec<ExcerptRange<Point>>, Vec<usize>) {
@@ -1174,7 +1707,7 @@ impl MultiBuffer {
cx: &mut Context<Self>,
) -> Vec<ExcerptId>
where
- O: text::ToOffset,
+ O: text::ToOffset + Clone,
{
let mut ids = Vec::new();
let mut next_excerpt_id =
@@ -1203,10 +1736,13 @@ impl MultiBuffer {
ranges: impl IntoIterator<Item = (ExcerptId, ExcerptRange<O>)>,
cx: &mut Context<Self>,
) where
- O: text::ToOffset,
+ O: text::ToOffset + Clone,
{
+ // TODO(split-diff) see if it's worth time avoiding collecting here later
+ let collected_ranges: Vec<_> = ranges.into_iter().collect();
+
assert_eq!(self.history.transaction_depth(), 0);
- let mut ranges = ranges.into_iter().peekable();
+ let mut ranges = collected_ranges.iter().cloned().peekable();
if ranges.peek().is_none() {
return Default::default();
}
@@ -29,6 +29,7 @@ fn test_empty_singleton(cx: &mut App) {
[RowInfo {
buffer_id: Some(buffer_id),
buffer_row: Some(0),
+ base_text_row: None,
multibuffer_row: Some(MultiBufferRow(0)),
diff_status: None,
expand_info: None,
@@ -130,8 +131,8 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) {
assert_eq!(
subscription.consume().into_inner(),
[Edit {
- old: 0..0,
- new: 0..10
+ old: MultiBufferOffset(0)..MultiBufferOffset(0),
+ new: MultiBufferOffset(0)..MultiBufferOffset(10)
}]
);
@@ -148,8 +149,8 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) {
assert_eq!(
subscription.consume().into_inner(),
[Edit {
- old: 10..10,
- new: 10..22
+ old: MultiBufferOffset(10)..MultiBufferOffset(10),
+ new: MultiBufferOffset(10)..MultiBufferOffset(22)
}]
);
@@ -282,8 +283,8 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) {
assert_eq!(
subscription.consume().into_inner(),
[Edit {
- old: 6..8,
- new: 6..7
+ old: MultiBufferOffset(6)..MultiBufferOffset(8),
+ new: MultiBufferOffset(6)..MultiBufferOffset(7)
}]
);
@@ -350,7 +351,7 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) {
}
#[gpui::test]
-fn test_diff_boundary_anchors(cx: &mut TestAppContext) {
+async fn test_diff_boundary_anchors(cx: &mut TestAppContext) {
let base_text = "one\ntwo\nthree\n";
let text = "one\nthree\n";
let buffer = cx.new(|cx| Buffer::local(text, cx));
@@ -392,7 +393,7 @@ fn test_diff_boundary_anchors(cx: &mut TestAppContext) {
}
#[gpui::test]
-fn test_diff_hunks_in_range(cx: &mut TestAppContext) {
+async fn test_diff_hunks_in_range(cx: &mut TestAppContext) {
let base_text = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\n";
let text = "one\nfour\nseven\n";
let buffer = cx.new(|cx| Buffer::local(text, cx));
@@ -472,7 +473,7 @@ fn test_diff_hunks_in_range(cx: &mut TestAppContext) {
}
#[gpui::test]
-fn test_editing_text_in_diff_hunks(cx: &mut TestAppContext) {
+async fn test_editing_text_in_diff_hunks(cx: &mut TestAppContext) {
let base_text = "one\ntwo\nfour\nfive\nsix\nseven\n";
let text = "one\ntwo\nTHREE\nfour\nfive\nseven\n";
let buffer = cx.new(|cx| Buffer::local(text, cx));
@@ -904,7 +905,7 @@ fn test_empty_multibuffer(cx: &mut App) {
}
#[gpui::test]
-fn test_empty_diff_excerpt(cx: &mut TestAppContext) {
+async fn test_empty_diff_excerpt(cx: &mut TestAppContext) {
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let buffer = cx.new(|cx| Buffer::local("", cx));
let base_text = "a\nb\nc";
@@ -925,7 +926,7 @@ fn test_empty_diff_excerpt(cx: &mut TestAppContext) {
.next()
.unwrap();
- assert_eq!(hunk.diff_base_byte_range.start, 0);
+ assert_eq!(hunk.diff_base_byte_range.start, BufferOffset(0));
let buf2 = cx.new(|cx| Buffer::local("X", cx));
multibuffer.update(cx, |multibuffer, cx| {
@@ -971,10 +972,30 @@ fn test_singleton_multibuffer_anchors(cx: &mut App) {
assert_eq!(old_snapshot.text(), "abcd");
assert_eq!(new_snapshot.text(), "XabcdY");
- assert_eq!(old_snapshot.anchor_before(0).to_offset(&new_snapshot), 0);
- assert_eq!(old_snapshot.anchor_after(0).to_offset(&new_snapshot), 1);
- assert_eq!(old_snapshot.anchor_before(4).to_offset(&new_snapshot), 5);
- assert_eq!(old_snapshot.anchor_after(4).to_offset(&new_snapshot), 6);
+ assert_eq!(
+ old_snapshot
+ .anchor_before(MultiBufferOffset(0))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(0)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_after(MultiBufferOffset(0))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(1)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_before(MultiBufferOffset(4))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(5)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_after(MultiBufferOffset(4))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(6)
+ );
}
#[gpui::test]
@@ -989,12 +1010,28 @@ fn test_multibuffer_anchors(cx: &mut App) {
});
let old_snapshot = multibuffer.read(cx).snapshot(cx);
- assert_eq!(old_snapshot.anchor_before(0).to_offset(&old_snapshot), 0);
- assert_eq!(old_snapshot.anchor_after(0).to_offset(&old_snapshot), 0);
- assert_eq!(Anchor::min().to_offset(&old_snapshot), 0);
- assert_eq!(Anchor::min().to_offset(&old_snapshot), 0);
- assert_eq!(Anchor::max().to_offset(&old_snapshot), 10);
- assert_eq!(Anchor::max().to_offset(&old_snapshot), 10);
+ assert_eq!(
+ old_snapshot
+ .anchor_before(MultiBufferOffset(0))
+ .to_offset(&old_snapshot),
+ MultiBufferOffset(0)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_after(MultiBufferOffset(0))
+ .to_offset(&old_snapshot),
+ MultiBufferOffset(0)
+ );
+ assert_eq!(Anchor::min().to_offset(&old_snapshot), MultiBufferOffset(0));
+ assert_eq!(Anchor::min().to_offset(&old_snapshot), MultiBufferOffset(0));
+ assert_eq!(
+ Anchor::max().to_offset(&old_snapshot),
+ MultiBufferOffset(10)
+ );
+ assert_eq!(
+ Anchor::max().to_offset(&old_snapshot),
+ MultiBufferOffset(10)
+ );
buffer_1.update(cx, |buffer, cx| {
buffer.edit([(0..0, "W")], None, cx);
@@ -1009,16 +1046,66 @@ fn test_multibuffer_anchors(cx: &mut App) {
assert_eq!(old_snapshot.text(), "abcd\nefghi");
assert_eq!(new_snapshot.text(), "WabcdX\nYefghiZ");
- assert_eq!(old_snapshot.anchor_before(0).to_offset(&new_snapshot), 0);
- assert_eq!(old_snapshot.anchor_after(0).to_offset(&new_snapshot), 1);
- assert_eq!(old_snapshot.anchor_before(1).to_offset(&new_snapshot), 2);
- assert_eq!(old_snapshot.anchor_after(1).to_offset(&new_snapshot), 2);
- assert_eq!(old_snapshot.anchor_before(2).to_offset(&new_snapshot), 3);
- assert_eq!(old_snapshot.anchor_after(2).to_offset(&new_snapshot), 3);
- assert_eq!(old_snapshot.anchor_before(5).to_offset(&new_snapshot), 7);
- assert_eq!(old_snapshot.anchor_after(5).to_offset(&new_snapshot), 8);
- assert_eq!(old_snapshot.anchor_before(10).to_offset(&new_snapshot), 13);
- assert_eq!(old_snapshot.anchor_after(10).to_offset(&new_snapshot), 14);
+ assert_eq!(
+ old_snapshot
+ .anchor_before(MultiBufferOffset(0))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(0)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_after(MultiBufferOffset(0))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(1)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_before(MultiBufferOffset(1))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(2)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_after(MultiBufferOffset(1))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(2)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_before(MultiBufferOffset(2))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(3)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_after(MultiBufferOffset(2))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(3)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_before(MultiBufferOffset(5))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(7)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_after(MultiBufferOffset(5))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(8)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_before(MultiBufferOffset(10))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(13)
+ );
+ assert_eq!(
+ old_snapshot
+ .anchor_after(MultiBufferOffset(10))
+ .to_offset(&new_snapshot),
+ MultiBufferOffset(14)
+ );
}
#[gpui::test]
@@ -1066,26 +1153,30 @@ fn test_resolving_anchors_after_replacing_their_excerpts(cx: &mut App) {
// The current excerpts are from a different buffer, so we don't attempt to
// resolve the old text anchor in the new buffer.
assert_eq!(
- snapshot_2.summary_for_anchor::<usize>(&snapshot_1.anchor_before(2)),
- 0
+ snapshot_2.summary_for_anchor::<MultiBufferOffset>(
+ &snapshot_1.anchor_before(MultiBufferOffset(2))
+ ),
+ MultiBufferOffset(0)
);
assert_eq!(
- snapshot_2.summaries_for_anchors::<usize, _>(&[
- snapshot_1.anchor_before(2),
- snapshot_1.anchor_after(3)
+ snapshot_2.summaries_for_anchors::<MultiBufferOffset, _>(&[
+ snapshot_1.anchor_before(MultiBufferOffset(2)),
+ snapshot_1.anchor_after(MultiBufferOffset(3))
]),
- vec![0, 0]
+ vec![MultiBufferOffset(0), MultiBufferOffset(0)]
);
// Refresh anchors from the old snapshot. The return value indicates that both
// anchors lost their original excerpt.
- let refresh =
- snapshot_2.refresh_anchors(&[snapshot_1.anchor_before(2), snapshot_1.anchor_after(3)]);
+ let refresh = snapshot_2.refresh_anchors(&[
+ snapshot_1.anchor_before(MultiBufferOffset(2)),
+ snapshot_1.anchor_after(MultiBufferOffset(3)),
+ ]);
assert_eq!(
refresh,
&[
- (0, snapshot_2.anchor_before(0), false),
- (1, snapshot_2.anchor_after(0), false),
+ (0, snapshot_2.anchor_before(MultiBufferOffset(0)), false),
+ (1, snapshot_2.anchor_after(MultiBufferOffset(0)), false),
]
);
@@ -1112,14 +1203,19 @@ fn test_resolving_anchors_after_replacing_their_excerpts(cx: &mut App) {
// The third anchor can't be resolved, since its excerpt has been removed,
// so it resolves to the same position as its predecessor.
let anchors = [
- snapshot_2.anchor_before(0),
- snapshot_2.anchor_after(2),
- snapshot_2.anchor_after(6),
- snapshot_2.anchor_after(14),
+ snapshot_2.anchor_before(MultiBufferOffset(0)),
+ snapshot_2.anchor_after(MultiBufferOffset(2)),
+ snapshot_2.anchor_after(MultiBufferOffset(6)),
+ snapshot_2.anchor_after(MultiBufferOffset(14)),
];
assert_eq!(
- snapshot_3.summaries_for_anchors::<usize, _>(&anchors),
- &[0, 2, 9, 13]
+ snapshot_3.summaries_for_anchors::<MultiBufferOffset, _>(&anchors),
+ &[
+ MultiBufferOffset(0),
+ MultiBufferOffset(2),
+ MultiBufferOffset(9),
+ MultiBufferOffset(13)
+ ]
);
let new_anchors = snapshot_3.refresh_anchors(&anchors);
@@ -1128,13 +1224,18 @@ fn test_resolving_anchors_after_replacing_their_excerpts(cx: &mut App) {
&[(0, true), (1, true), (2, true), (3, true)]
);
assert_eq!(
- snapshot_3.summaries_for_anchors::<usize, _>(new_anchors.iter().map(|a| &a.1)),
- &[0, 2, 7, 13]
+ snapshot_3.summaries_for_anchors::<MultiBufferOffset, _>(new_anchors.iter().map(|a| &a.1)),
+ &[
+ MultiBufferOffset(0),
+ MultiBufferOffset(2),
+ MultiBufferOffset(7),
+ MultiBufferOffset(13)
+ ]
);
}
#[gpui::test]
-fn test_basic_diff_hunks(cx: &mut TestAppContext) {
+async fn test_basic_diff_hunks(cx: &mut TestAppContext) {
let text = indoc!(
"
ZERO
@@ -1371,7 +1472,7 @@ fn test_basic_diff_hunks(cx: &mut TestAppContext) {
assert_eq!(
snapshot
- .diff_hunks_in_range(0..snapshot.len())
+ .diff_hunks_in_range(MultiBufferOffset(0)..snapshot.len())
.map(|hunk| hunk.row_range.start.0..hunk.row_range.end.0)
.collect::<Vec<_>>(),
&[0..4, 5..7]
@@ -1379,7 +1480,7 @@ fn test_basic_diff_hunks(cx: &mut TestAppContext) {
}
#[gpui::test]
-fn test_repeatedly_expand_a_diff_hunk(cx: &mut TestAppContext) {
+async fn test_repeatedly_expand_a_diff_hunk(cx: &mut TestAppContext) {
let text = indoc!(
"
one
@@ -1893,7 +1994,7 @@ fn test_set_excerpts_for_buffer_rename(cx: &mut TestAppContext) {
}
#[gpui::test]
-fn test_diff_hunks_with_multiple_excerpts(cx: &mut TestAppContext) {
+async fn test_diff_hunks_with_multiple_excerpts(cx: &mut TestAppContext) {
let base_text_1 = indoc!(
"
one
@@ -2072,7 +2173,7 @@ fn test_diff_hunks_with_multiple_excerpts(cx: &mut TestAppContext) {
assert_eq!(
snapshot
- .diff_hunks_in_range(0..snapshot.len())
+ .diff_hunks_in_range(MultiBufferOffset(0)..snapshot.len())
.map(|hunk| hunk.row_range.start.0..hunk.row_range.end.0)
.collect::<Vec<_>>(),
&[0..1, 2..4, 5..7, 9..10, 12..13, 14..17]
@@ -2142,7 +2243,7 @@ struct ReferenceExcerpt {
struct ReferenceRegion {
buffer_id: Option<BufferId>,
range: Range<usize>,
- buffer_start: Option<Point>,
+ buffer_range: Option<Range<Point>>,
status: Option<DiffHunkStatus>,
excerpt_id: Option<ExcerptId>,
}
@@ -2253,9 +2354,15 @@ impl ReferenceMultibuffer {
}
}
- fn expected_content(&self, cx: &App) -> (String, Vec<RowInfo>, HashSet<MultiBufferRow>) {
+ fn expected_content(
+ &self,
+ filter_mode: Option<MultiBufferFilterMode>,
+ all_diff_hunks_expanded: bool,
+ cx: &App,
+ ) -> (String, Vec<RowInfo>, HashSet<MultiBufferRow>) {
let mut text = String::new();
let mut regions = Vec::<ReferenceRegion>::new();
+ let mut filtered_regions = Vec::<ReferenceRegion>::new();
let mut excerpt_boundary_rows = HashSet::default();
for excerpt in &self.excerpts {
excerpt_boundary_rows.insert(MultiBufferRow(text.matches('\n').count() as u32));
@@ -2279,10 +2386,12 @@ impl ReferenceMultibuffer {
continue;
}
- if !excerpt.expanded_diff_hunks.iter().any(|expanded_anchor| {
- expanded_anchor.to_offset(buffer).max(buffer_range.start)
- == hunk_range.start.max(buffer_range.start)
- }) {
+ if !all_diff_hunks_expanded
+ && !excerpt.expanded_diff_hunks.iter().any(|expanded_anchor| {
+ expanded_anchor.to_offset(buffer).max(buffer_range.start)
+ == hunk_range.start.max(buffer_range.start)
+ })
+ {
log::trace!("skipping a hunk that's not marked as expanded");
continue;
}
@@ -2296,16 +2405,20 @@ impl ReferenceMultibuffer {
// Add the buffer text before the hunk
let len = text.len();
text.extend(buffer.text_for_range(offset..hunk_range.start));
- regions.push(ReferenceRegion {
- buffer_id: Some(buffer.remote_id()),
- range: len..text.len(),
- buffer_start: Some(buffer.offset_to_point(offset)),
- status: None,
- excerpt_id: Some(excerpt.id),
- });
+ if text.len() > len {
+ regions.push(ReferenceRegion {
+ buffer_id: Some(buffer.remote_id()),
+ range: len..text.len(),
+ buffer_range: Some((offset..hunk_range.start).to_point(&buffer)),
+ status: None,
+ excerpt_id: Some(excerpt.id),
+ });
+ }
// Add the deleted text for the hunk.
- if !hunk.diff_base_byte_range.is_empty() {
+ if !hunk.diff_base_byte_range.is_empty()
+ && filter_mode != Some(MultiBufferFilterMode::KeepInsertions)
+ {
let mut base_text = base_buffer
.text_for_range(hunk.diff_base_byte_range.clone())
.collect::<String>();
@@ -2317,9 +2430,7 @@ impl ReferenceMultibuffer {
regions.push(ReferenceRegion {
buffer_id: Some(base_buffer.remote_id()),
range: len..text.len(),
- buffer_start: Some(
- base_buffer.offset_to_point(hunk.diff_base_byte_range.start),
- ),
+ buffer_range: Some(hunk.diff_base_byte_range.to_point(&base_buffer)),
status: Some(DiffHunkStatus::deleted(hunk.secondary_status)),
excerpt_id: Some(excerpt.id),
});
@@ -2330,16 +2441,27 @@ impl ReferenceMultibuffer {
// Add the inserted text for the hunk.
if hunk_range.end > offset {
- let len = text.len();
- text.extend(buffer.text_for_range(offset..hunk_range.end));
- regions.push(ReferenceRegion {
+ let is_filtered = filter_mode == Some(MultiBufferFilterMode::KeepDeletions);
+ let range = if is_filtered {
+ text.len()..text.len()
+ } else {
+ let len = text.len();
+ text.extend(buffer.text_for_range(offset..hunk_range.end));
+ len..text.len()
+ };
+ let region = ReferenceRegion {
buffer_id: Some(buffer.remote_id()),
- range: len..text.len(),
- buffer_start: Some(buffer.offset_to_point(offset)),
+ range,
+ buffer_range: Some((offset..hunk_range.end).to_point(&buffer)),
status: Some(DiffHunkStatus::added(hunk.secondary_status)),
excerpt_id: Some(excerpt.id),
- });
+ };
offset = hunk_range.end;
+ if is_filtered {
+ filtered_regions.push(region);
+ } else {
+ regions.push(region);
+ }
}
}
@@ -2350,7 +2472,7 @@ impl ReferenceMultibuffer {
regions.push(ReferenceRegion {
buffer_id: Some(buffer.remote_id()),
range: len..text.len(),
- buffer_start: Some(buffer.offset_to_point(offset)),
+ buffer_range: Some((offset..buffer_range.end).to_point(&buffer)),
status: None,
excerpt_id: Some(excerpt.id),
});
@@ -2361,7 +2483,7 @@ impl ReferenceMultibuffer {
regions.push(ReferenceRegion {
buffer_id: None,
range: 0..1,
- buffer_start: Some(Point::new(0, 0)),
+ buffer_range: Some(Point::new(0, 0)..Point::new(0, 1)),
status: None,
excerpt_id: None,
});
@@ -2380,10 +2502,47 @@ impl ReferenceMultibuffer {
.position(|region| region.range.contains(&ix))
.map_or(RowInfo::default(), |region_ix| {
let region = ®ions[region_ix];
- let buffer_row = region.buffer_start.map(|start_point| {
- start_point.row
+ let buffer_row = region.buffer_range.as_ref().map(|buffer_range| {
+ buffer_range.start.row
+ text[region.range.start..ix].matches('\n').count() as u32
});
+ let main_buffer = self
+ .excerpts
+ .iter()
+ .find(|e| e.id == region.excerpt_id.unwrap())
+ .map(|e| e.buffer.clone());
+ let base_text_row = match region.status {
+ None => Some(
+ main_buffer
+ .as_ref()
+ .map(|main_buffer| {
+ let diff = self
+ .diffs
+ .get(&main_buffer.read(cx).remote_id())
+ .unwrap();
+ let buffer_row = buffer_row.unwrap();
+ BaseTextRow(
+ diff.read(cx).snapshot(cx).row_to_base_text_row(
+ buffer_row,
+ &main_buffer.read(cx).snapshot(),
+ ),
+ )
+ })
+ .unwrap_or_default(),
+ ),
+ Some(DiffHunkStatus {
+ kind: DiffHunkStatusKind::Added,
+ ..
+ }) => None,
+ Some(DiffHunkStatus {
+ kind: DiffHunkStatusKind::Deleted,
+ ..
+ }) => Some(BaseTextRow(buffer_row.unwrap())),
+ Some(DiffHunkStatus {
+ kind: DiffHunkStatusKind::Modified,
+ ..
+ }) => unreachable!(),
+ };
let is_excerpt_start = region_ix == 0
|| ®ions[region_ix - 1].excerpt_id != ®ion.excerpt_id
|| regions[region_ix - 1].range.is_empty();
@@ -2407,18 +2566,15 @@ impl ReferenceMultibuffer {
is_end = true;
is_excerpt_end = true;
}
+ let multibuffer_row =
+ MultiBufferRow(text[..ix].matches('\n').count() as u32);
let mut expand_direction = None;
- if let Some(buffer) = &self
- .excerpts
- .iter()
- .find(|e| e.id == region.excerpt_id.unwrap())
- .map(|e| e.buffer.clone())
- {
- let needs_expand_up =
- is_excerpt_start && is_start && buffer_row.unwrap() > 0;
+ if let Some(buffer) = &main_buffer {
+ let buffer_row = buffer_row.unwrap();
+ let needs_expand_up = is_excerpt_start && is_start && buffer_row > 0;
let needs_expand_down = is_excerpt_end
&& is_end
- && buffer.read(cx).max_point().row > buffer_row.unwrap();
+ && buffer.read(cx).max_point().row > buffer_row;
expand_direction = if needs_expand_up && needs_expand_down {
Some(ExpandExcerptDirection::UpAndDown)
} else if needs_expand_up {
@@ -2433,11 +2589,10 @@ impl ReferenceMultibuffer {
buffer_id: region.buffer_id,
diff_status: region.status,
buffer_row,
+ base_text_row,
wrapped_buffer_row: None,
- multibuffer_row: Some(MultiBufferRow(
- text[..ix].matches('\n').count() as u32
- )),
+ multibuffer_row: Some(multibuffer_row),
expand_info: expand_direction.zip(region.excerpt_id).map(
|(direction, excerpt_id)| ExpandInfo {
direction,
@@ -2564,18 +2719,48 @@ async fn test_random_set_ranges(cx: &mut TestAppContext, mut rng: StdRng) {
}
}
+// TODO(split-diff) bump up iterations
+// #[gpui::test(iterations = 100)]
+#[gpui::test]
+async fn test_random_filtered_multibuffer(cx: &mut TestAppContext, rng: StdRng) {
+ let multibuffer = cx.new(|cx| {
+ let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
+ multibuffer.set_all_diff_hunks_expanded(cx);
+ multibuffer.set_filter_mode(Some(MultiBufferFilterMode::KeepInsertions));
+ multibuffer
+ });
+ let follower = multibuffer.update(cx, |multibuffer, cx| multibuffer.get_or_create_follower(cx));
+ follower.update(cx, |follower, _| {
+ assert!(follower.all_diff_hunks_expanded());
+ follower.set_filter_mode(Some(MultiBufferFilterMode::KeepDeletions));
+ });
+ test_random_multibuffer_impl(multibuffer, cx, rng).await;
+}
+
#[gpui::test(iterations = 100)]
-async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
+async fn test_random_multibuffer(cx: &mut TestAppContext, rng: StdRng) {
+ let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
+ test_random_multibuffer_impl(multibuffer, cx, rng).await;
+}
+
+async fn test_random_multibuffer_impl(
+ multibuffer: Entity<MultiBuffer>,
+ cx: &mut TestAppContext,
+ mut rng: StdRng,
+) {
let operations = env::var("OPERATIONS")
.map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
.unwrap_or(10);
+ multibuffer.read_with(cx, |multibuffer, _| assert!(multibuffer.is_empty()));
+ let all_diff_hunks_expanded =
+ multibuffer.read_with(cx, |multibuffer, _| multibuffer.all_diff_hunks_expanded());
let mut buffers: Vec<Entity<Buffer>> = Vec::new();
let mut base_texts: HashMap<BufferId, String> = HashMap::default();
- let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let mut reference = ReferenceMultibuffer::default();
let mut anchors = Vec::new();
let mut old_versions = Vec::new();
+ let mut old_follower_versions = Vec::new();
let mut needs_diff_calculation = false;
for _ in 0..operations {
@@ -2636,14 +2821,16 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
30..=39 if !reference.excerpts.is_empty() => {
let multibuffer =
multibuffer.read_with(cx, |multibuffer, cx| multibuffer.snapshot(cx));
- let offset =
- multibuffer.clip_offset(rng.random_range(0..=multibuffer.len()), Bias::Left);
+ let offset = multibuffer.clip_offset(
+ MultiBufferOffset(rng.random_range(0..=multibuffer.len().0)),
+ Bias::Left,
+ );
let bias = if rng.random() {
Bias::Left
} else {
Bias::Right
};
- log::info!("Creating anchor at {} with bias {:?}", offset, bias);
+ log::info!("Creating anchor at {} with bias {:?}", offset.0, bias);
anchors.push(multibuffer.anchor_at(offset, bias));
anchors.sort_by(|a, b| a.cmp(b, &multibuffer));
}
@@ -2672,7 +2859,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
assert!(excerpt.contains(anchor));
}
}
- 45..=55 if !reference.excerpts.is_empty() => {
+ 45..=55 if !reference.excerpts.is_empty() && !all_diff_hunks_expanded => {
multibuffer.update(cx, |multibuffer, cx| {
let snapshot = multibuffer.snapshot(cx);
let excerpt_ix = rng.random_range(0..reference.excerpts.len());
@@ -2756,17 +2943,6 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
(start_ix..end_ix, anchor_range)
});
- multibuffer.update(cx, |multibuffer, cx| {
- let id = buffer_handle.read(cx).remote_id();
- if multibuffer.diff_for(id).is_none() {
- let base_text = base_texts.get(&id).unwrap();
- let diff = cx
- .new(|cx| BufferDiff::new_with_base_text(base_text, buffer_handle, cx));
- reference.add_diff(diff.clone(), cx);
- multibuffer.add_diff(diff, cx)
- }
- });
-
let excerpt_id = multibuffer.update(cx, |multibuffer, cx| {
multibuffer
.insert_excerpts_after(
@@ -2784,197 +2960,283 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
excerpt_id,
(buffer_handle.clone(), anchor_range),
);
+
+ multibuffer.update(cx, |multibuffer, cx| {
+ let id = buffer_handle.read(cx).remote_id();
+ if multibuffer.diff_for(id).is_none() {
+ let base_text = base_texts.get(&id).unwrap();
+ let diff = cx
+ .new(|cx| BufferDiff::new_with_base_text(base_text, buffer_handle, cx));
+ reference.add_diff(diff.clone(), cx);
+ multibuffer.add_diff(diff, cx)
+ }
+ });
}
}
if rng.random_bool(0.3) {
multibuffer.update(cx, |multibuffer, cx| {
old_versions.push((multibuffer.snapshot(cx), multibuffer.subscribe()));
+
+ if let Some(follower) = &multibuffer.follower {
+ follower.update(cx, |follower, cx| {
+ old_follower_versions.push((follower.snapshot(cx), follower.subscribe()));
+ })
+ }
})
}
- let snapshot = multibuffer.read_with(cx, |multibuffer, cx| multibuffer.snapshot(cx));
- let actual_text = snapshot.text();
- let actual_boundary_rows = snapshot
- .excerpt_boundaries_in_range(0..)
- .map(|b| b.row)
- .collect::<HashSet<_>>();
- let actual_row_infos = snapshot.row_infos(MultiBufferRow(0)).collect::<Vec<_>>();
+ multibuffer.read_with(cx, |multibuffer, cx| {
+ check_multibuffer(multibuffer, &reference, &anchors, cx, &mut rng);
- let (expected_text, expected_row_infos, expected_boundary_rows) =
- cx.update(|cx| reference.expected_content(cx));
+ if let Some(follower) = &multibuffer.follower {
+ check_multibuffer(follower.read(cx), &reference, &anchors, cx, &mut rng);
+ }
+ });
+ }
- let has_diff = actual_row_infos
+ let snapshot = multibuffer.read_with(cx, |multibuffer, cx| multibuffer.snapshot(cx));
+ for (old_snapshot, subscription) in old_versions {
+ check_multibuffer_edits(&snapshot, &old_snapshot, subscription);
+ }
+ if let Some(follower) = multibuffer.read_with(cx, |multibuffer, _| multibuffer.follower.clone())
+ {
+ let snapshot = follower.read_with(cx, |follower, cx| follower.snapshot(cx));
+ for (old_snapshot, subscription) in old_follower_versions {
+ check_multibuffer_edits(&snapshot, &old_snapshot, subscription);
+ }
+ }
+}
+
+fn check_multibuffer(
+ multibuffer: &MultiBuffer,
+ reference: &ReferenceMultibuffer,
+ anchors: &[Anchor],
+ cx: &App,
+ rng: &mut StdRng,
+) {
+ let snapshot = multibuffer.snapshot(cx);
+ let filter_mode = multibuffer.filter_mode;
+ assert!(filter_mode.is_some() == snapshot.all_diff_hunks_expanded);
+ let actual_text = snapshot.text();
+ let actual_boundary_rows = snapshot
+ .excerpt_boundaries_in_range(MultiBufferOffset(0)..)
+ .map(|b| b.row)
+ .collect::<HashSet<_>>();
+ let actual_row_infos = snapshot.row_infos(MultiBufferRow(0)).collect::<Vec<_>>();
+
+ let (expected_text, expected_row_infos, expected_boundary_rows) =
+ reference.expected_content(filter_mode, snapshot.all_diff_hunks_expanded, cx);
+
+ let (unfiltered_text, unfiltered_row_infos, unfiltered_boundary_rows) =
+ reference.expected_content(None, snapshot.all_diff_hunks_expanded, cx);
+
+ let has_diff = actual_row_infos
+ .iter()
+ .any(|info| info.diff_status.is_some())
+ || unfiltered_row_infos
.iter()
- .any(|info| info.diff_status.is_some())
- || expected_row_infos
- .iter()
- .any(|info| info.diff_status.is_some());
- let actual_diff = format_diff(
- &actual_text,
- &actual_row_infos,
- &actual_boundary_rows,
- Some(has_diff),
- );
- let expected_diff = format_diff(
- &expected_text,
- &expected_row_infos,
- &expected_boundary_rows,
- Some(has_diff),
+ .any(|info| info.diff_status.is_some());
+ let actual_diff = format_diff(
+ &actual_text,
+ &actual_row_infos,
+ &actual_boundary_rows,
+ Some(has_diff),
+ );
+ let expected_diff = format_diff(
+ &expected_text,
+ &expected_row_infos,
+ &expected_boundary_rows,
+ Some(has_diff),
+ );
+
+ log::info!("Multibuffer content:\n{}", actual_diff);
+ if filter_mode.is_some() {
+ log::info!(
+ "Unfiltered multibuffer content:\n{}",
+ format_diff(
+ &unfiltered_text,
+ &unfiltered_row_infos,
+ &unfiltered_boundary_rows,
+ None,
+ ),
);
+ }
- log::info!("Multibuffer content:\n{}", actual_diff);
+ assert_eq!(
+ actual_row_infos.len(),
+ actual_text.split('\n').count(),
+ "line count: {}",
+ actual_text.split('\n').count()
+ );
+ pretty_assertions::assert_eq!(actual_diff, expected_diff);
+ pretty_assertions::assert_eq!(actual_text, expected_text);
+ pretty_assertions::assert_eq!(actual_row_infos, expected_row_infos);
+ for _ in 0..5 {
+ let start_row = rng.random_range(0..=expected_row_infos.len());
assert_eq!(
- actual_row_infos.len(),
- actual_text.split('\n').count(),
- "line count: {}",
- actual_text.split('\n').count()
+ snapshot
+ .row_infos(MultiBufferRow(start_row as u32))
+ .collect::<Vec<_>>(),
+ &expected_row_infos[start_row..],
+ "buffer_rows({})",
+ start_row
);
- pretty_assertions::assert_eq!(actual_diff, expected_diff);
- pretty_assertions::assert_eq!(actual_text, expected_text);
- pretty_assertions::assert_eq!(actual_row_infos, expected_row_infos);
-
- for _ in 0..5 {
- let start_row = rng.random_range(0..=expected_row_infos.len());
- assert_eq!(
- snapshot
- .row_infos(MultiBufferRow(start_row as u32))
- .collect::<Vec<_>>(),
- &expected_row_infos[start_row..],
- "buffer_rows({})",
- start_row
- );
- }
+ }
+ assert_eq!(
+ snapshot.widest_line_number(),
+ expected_row_infos
+ .into_iter()
+ .filter_map(|info| {
+ if info.diff_status.is_some_and(|status| status.is_deleted()) {
+ None
+ } else {
+ info.buffer_row
+ }
+ })
+ .max()
+ .unwrap()
+ + 1
+ );
+ let reference_ranges = reference
+ .excerpts
+ .iter()
+ .map(|excerpt| {
+ (
+ excerpt.id,
+ excerpt.range.to_offset(&excerpt.buffer.read(cx).snapshot()),
+ )
+ })
+ .collect::<HashMap<_, _>>();
+ for i in 0..snapshot.len().0 {
+ let excerpt = snapshot
+ .excerpt_containing(MultiBufferOffset(i)..MultiBufferOffset(i))
+ .unwrap();
assert_eq!(
- snapshot.widest_line_number(),
- expected_row_infos
- .into_iter()
- .filter_map(|info| {
- if info.diff_status.is_some_and(|status| status.is_deleted()) {
- None
- } else {
- info.buffer_row
- }
- })
- .max()
- .unwrap()
- + 1
+ excerpt.buffer_range().start.0..excerpt.buffer_range().end.0,
+ reference_ranges[&excerpt.id()]
);
- let reference_ranges = cx.update(|cx| {
- reference
- .excerpts
- .iter()
- .map(|excerpt| {
- (
- excerpt.id,
- excerpt.range.to_offset(&excerpt.buffer.read(cx).snapshot()),
- )
- })
- .collect::<HashMap<_, _>>()
- });
- for i in 0..snapshot.len() {
- let excerpt = snapshot.excerpt_containing(i..i).unwrap();
- assert_eq!(excerpt.buffer_range(), reference_ranges[&excerpt.id()]);
- }
+ }
- assert_consistent_line_numbers(&snapshot);
- assert_position_translation(&snapshot);
+ assert_consistent_line_numbers(&snapshot);
+ assert_position_translation(&snapshot);
- for (row, line) in expected_text.split('\n').enumerate() {
- assert_eq!(
- snapshot.line_len(MultiBufferRow(row as u32)),
- line.len() as u32,
- "line_len({}).",
- row
- );
- }
+ for (row, line) in expected_text.split('\n').enumerate() {
+ assert_eq!(
+ snapshot.line_len(MultiBufferRow(row as u32)),
+ line.len() as u32,
+ "line_len({}).",
+ row
+ );
+ }
- let text_rope = Rope::from(expected_text.as_str());
- for _ in 0..10 {
- let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right);
- let start_ix = text_rope.clip_offset(rng.random_range(0..=end_ix), Bias::Left);
+ let text_rope = Rope::from(expected_text.as_str());
+ for _ in 0..10 {
+ let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right);
+ let start_ix = text_rope.clip_offset(rng.random_range(0..=end_ix), Bias::Left);
- let text_for_range = snapshot
- .text_for_range(start_ix..end_ix)
- .collect::<String>();
- assert_eq!(
- text_for_range,
- &expected_text[start_ix..end_ix],
- "incorrect text for range {:?}",
- start_ix..end_ix
- );
+ let text_for_range = snapshot
+ .text_for_range(MultiBufferOffset(start_ix)..MultiBufferOffset(end_ix))
+ .collect::<String>();
+ assert_eq!(
+ text_for_range,
+ &expected_text[start_ix..end_ix],
+ "incorrect text for range {:?}",
+ start_ix..end_ix
+ );
- let expected_summary = TextSummary::from(&expected_text[start_ix..end_ix]);
- assert_eq!(
- snapshot.text_summary_for_range::<TextSummary, _>(start_ix..end_ix),
- expected_summary,
- "incorrect summary for range {:?}",
- start_ix..end_ix
- );
- }
+ let expected_summary =
+ MBTextSummary::from(TextSummary::from(&expected_text[start_ix..end_ix]));
+ assert_eq!(
+ snapshot.text_summary_for_range::<MBTextSummary, _>(
+ MultiBufferOffset(start_ix)..MultiBufferOffset(end_ix)
+ ),
+ expected_summary,
+ "incorrect summary for range {:?}",
+ start_ix..end_ix
+ );
+ }
- // Anchor resolution
- let summaries = snapshot.summaries_for_anchors::<usize, _>(&anchors);
- assert_eq!(anchors.len(), summaries.len());
- for (anchor, resolved_offset) in anchors.iter().zip(summaries) {
- assert!(resolved_offset <= snapshot.len());
- assert_eq!(
- snapshot.summary_for_anchor::<usize>(anchor),
- resolved_offset,
- "anchor: {:?}",
- anchor
- );
- }
+ // Anchor resolution
+ let summaries = snapshot.summaries_for_anchors::<MultiBufferOffset, _>(anchors);
+ assert_eq!(anchors.len(), summaries.len());
+ for (anchor, resolved_offset) in anchors.iter().zip(summaries) {
+ assert!(resolved_offset <= snapshot.len());
+ assert_eq!(
+ snapshot.summary_for_anchor::<MultiBufferOffset>(anchor),
+ resolved_offset,
+ "anchor: {:?}",
+ anchor
+ );
+ }
- for _ in 0..10 {
- let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right);
- assert_eq!(
- snapshot.reversed_chars_at(end_ix).collect::<String>(),
- expected_text[..end_ix].chars().rev().collect::<String>(),
- );
- }
+ for _ in 0..10 {
+ let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right);
+ assert_eq!(
+ snapshot
+ .reversed_chars_at(MultiBufferOffset(end_ix))
+ .collect::<String>(),
+ expected_text[..end_ix].chars().rev().collect::<String>(),
+ );
+ }
- for _ in 0..10 {
- let end_ix = rng.random_range(0..=text_rope.len());
- let start_ix = rng.random_range(0..=end_ix);
- assert_eq!(
- snapshot
- .bytes_in_range(start_ix..end_ix)
- .flatten()
- .copied()
- .collect::<Vec<_>>(),
- expected_text.as_bytes()[start_ix..end_ix].to_vec(),
- "bytes_in_range({:?})",
- start_ix..end_ix,
- );
- }
+ for _ in 0..10 {
+ let end_ix = rng.random_range(0..=text_rope.len());
+ let end_ix = text_rope.floor_char_boundary(end_ix);
+ let start_ix = rng.random_range(0..=end_ix);
+ let start_ix = text_rope.floor_char_boundary(start_ix);
+ assert_eq!(
+ snapshot
+ .bytes_in_range(MultiBufferOffset(start_ix)..MultiBufferOffset(end_ix))
+ .flatten()
+ .copied()
+ .collect::<Vec<_>>(),
+ expected_text.as_bytes()[start_ix..end_ix].to_vec(),
+ "bytes_in_range({:?})",
+ start_ix..end_ix,
+ );
}
+}
- let snapshot = multibuffer.read_with(cx, |multibuffer, cx| multibuffer.snapshot(cx));
- for (old_snapshot, subscription) in old_versions {
- let edits = subscription.consume().into_inner();
+fn check_multibuffer_edits(
+ snapshot: &MultiBufferSnapshot,
+ old_snapshot: &MultiBufferSnapshot,
+ subscription: Subscription<MultiBufferOffset>,
+) {
+ let edits = subscription.consume().into_inner();
- log::info!(
- "applying subscription edits to old text: {:?}: {:?}",
- old_snapshot.text(),
- edits,
- );
+ log::info!(
+ "applying subscription edits to old text: {:?}: {:#?}",
+ old_snapshot.text(),
+ edits,
+ );
- let mut text = old_snapshot.text();
- for edit in edits {
- let new_text: String = snapshot.text_for_range(edit.new.clone()).collect();
- text.replace_range(edit.new.start..edit.new.start + edit.old.len(), &new_text);
- }
- assert_eq!(text.to_string(), snapshot.text());
+ let mut text = old_snapshot.text();
+ for edit in edits {
+ let new_text: String = snapshot
+ .text_for_range(edit.new.start..edit.new.end)
+ .collect();
+ text.replace_range(
+ (edit.new.start.0..edit.new.start.0 + (edit.old.end.0 - edit.old.start.0)).clone(),
+ &new_text,
+ );
+ pretty_assertions::assert_eq!(
+ &text[0..edit.new.end.0],
+ snapshot
+ .text_for_range(MultiBufferOffset(0)..edit.new.end)
+ .collect::<String>()
+ );
}
+ pretty_assertions::assert_eq!(text, snapshot.text());
}
#[gpui::test]
fn test_history(cx: &mut App) {
let test_settings = SettingsStore::test(cx);
cx.set_global(test_settings);
+
let group_interval: Duration = Duration::from_millis(1);
let buffer_1 = cx.new(|cx| {
let mut buf = Buffer::local("1234", cx);
@@ -1,426 +1,440 @@
-use std::{mem, ops::Range, sync::Arc};
-
-use collections::HashSet;
-use gpui::{App, AppContext, Context, Entity};
-use itertools::Itertools;
-use language::{Buffer, BufferSnapshot};
-use rope::Point;
-use text::{Bias, BufferId, OffsetRangeExt, locator::Locator};
-use util::{post_inc, rel_path::RelPath};
-
-use crate::{
- Anchor, ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer, build_excerpt_ranges,
-};
-
-#[derive(PartialEq, Eq, Ord, PartialOrd, Clone, Hash, Debug)]
-pub struct PathKey {
- // Used by the derived PartialOrd & Ord
- pub sort_prefix: Option<u64>,
- pub path: Arc<RelPath>,
-}
-
-impl PathKey {
- pub fn with_sort_prefix(sort_prefix: u64, path: Arc<RelPath>) -> Self {
- Self {
- sort_prefix: Some(sort_prefix),
- path,
- }
- }
-
- pub fn for_buffer(buffer: &Entity<Buffer>, cx: &App) -> Self {
- if let Some(file) = buffer.read(cx).file() {
- Self::with_sort_prefix(file.worktree_id(cx).to_proto(), file.path().clone())
- } else {
- Self {
- sort_prefix: None,
- path: RelPath::unix(&buffer.entity_id().to_string())
- .unwrap()
- .into_arc(),
- }
- }
- }
-}
-
-impl MultiBuffer {
- pub fn paths(&self) -> impl Iterator<Item = PathKey> + '_ {
- self.excerpts_by_path.keys().cloned()
- }
-
- pub fn remove_excerpts_for_path(&mut self, path: PathKey, cx: &mut Context<Self>) {
- if let Some(to_remove) = self.excerpts_by_path.remove(&path) {
- self.remove_excerpts(to_remove, cx)
- }
- }
-
- pub fn location_for_path(&self, path: &PathKey, cx: &App) -> Option<Anchor> {
- let excerpt_id = self.excerpts_by_path.get(path)?.first()?;
- let snapshot = self.read(cx);
- let excerpt = snapshot.excerpt(*excerpt_id)?;
- Some(Anchor::in_buffer(
- *excerpt_id,
- excerpt.buffer_id,
- excerpt.range.context.start,
- ))
- }
-
- pub fn excerpt_paths(&self) -> impl Iterator<Item = &PathKey> {
- self.excerpts_by_path.keys()
- }
-
- /// Sets excerpts, returns `true` if at least one new excerpt was added.
- pub fn set_excerpts_for_path(
- &mut self,
- path: PathKey,
- buffer: Entity<Buffer>,
- ranges: impl IntoIterator<Item = Range<Point>>,
- context_line_count: u32,
- cx: &mut Context<Self>,
- ) -> (Vec<Range<Anchor>>, bool) {
- let buffer_snapshot = buffer.read(cx).snapshot();
- let excerpt_ranges = build_excerpt_ranges(ranges, context_line_count, &buffer_snapshot);
-
- let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges);
- self.set_merged_excerpt_ranges_for_path(
- path,
- buffer,
- excerpt_ranges,
- &buffer_snapshot,
- new,
- counts,
- cx,
- )
- }
-
- pub fn set_excerpt_ranges_for_path(
- &mut self,
- path: PathKey,
- buffer: Entity<Buffer>,
- buffer_snapshot: &BufferSnapshot,
- excerpt_ranges: Vec<ExcerptRange<Point>>,
- cx: &mut Context<Self>,
- ) -> (Vec<Range<Anchor>>, bool) {
- let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges);
- self.set_merged_excerpt_ranges_for_path(
- path,
- buffer,
- excerpt_ranges,
- buffer_snapshot,
- new,
- counts,
- cx,
- )
- }
-
- pub fn set_anchored_excerpts_for_path(
- &self,
- path_key: PathKey,
- buffer: Entity<Buffer>,
- ranges: Vec<Range<text::Anchor>>,
- context_line_count: u32,
- cx: &Context<Self>,
- ) -> impl Future<Output = Vec<Range<Anchor>>> + use<> {
- let buffer_snapshot = buffer.read(cx).snapshot();
- let multi_buffer = cx.weak_entity();
- let mut app = cx.to_async();
- async move {
- let snapshot = buffer_snapshot.clone();
- let (excerpt_ranges, new, counts) = app
- .background_spawn(async move {
- let ranges = ranges.into_iter().map(|range| range.to_point(&snapshot));
- let excerpt_ranges =
- build_excerpt_ranges(ranges, context_line_count, &snapshot);
- let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges);
- (excerpt_ranges, new, counts)
- })
- .await;
-
- multi_buffer
- .update(&mut app, move |multi_buffer, cx| {
- let (ranges, _) = multi_buffer.set_merged_excerpt_ranges_for_path(
- path_key,
- buffer,
- excerpt_ranges,
- &buffer_snapshot,
- new,
- counts,
- cx,
- );
- ranges
- })
- .ok()
- .unwrap_or_default()
- }
- }
-
- pub fn remove_excerpts_for_buffer(&mut self, buffer: BufferId, cx: &mut Context<Self>) {
- self.remove_excerpts(
- self.excerpts_for_buffer(buffer, cx)
- .into_iter()
- .map(|(excerpt, _)| excerpt),
- cx,
- );
- }
-
- pub(super) fn expand_excerpts_with_paths(
- &mut self,
- ids: impl IntoIterator<Item = ExcerptId>,
- line_count: u32,
- direction: ExpandExcerptDirection,
- cx: &mut Context<Self>,
- ) {
- let grouped = ids
- .into_iter()
- .chunk_by(|id| self.paths_by_excerpt.get(id).cloned())
- .into_iter()
- .filter_map(|(k, v)| Some((k?, v.into_iter().collect::<Vec<_>>())))
- .collect::<Vec<_>>();
- let snapshot = self.snapshot(cx);
-
- for (path, ids) in grouped.into_iter() {
- let Some(excerpt_ids) = self.excerpts_by_path.get(&path) else {
- continue;
- };
-
- let ids_to_expand = HashSet::from_iter(ids);
- let expanded_ranges = excerpt_ids.iter().filter_map(|excerpt_id| {
- let excerpt = snapshot.excerpt(*excerpt_id)?;
-
- let mut context = excerpt.range.context.to_point(&excerpt.buffer);
- if ids_to_expand.contains(excerpt_id) {
- match direction {
- ExpandExcerptDirection::Up => {
- context.start.row = context.start.row.saturating_sub(line_count);
- context.start.column = 0;
- }
- ExpandExcerptDirection::Down => {
- context.end.row =
- (context.end.row + line_count).min(excerpt.buffer.max_point().row);
- context.end.column = excerpt.buffer.line_len(context.end.row);
- }
- ExpandExcerptDirection::UpAndDown => {
- context.start.row = context.start.row.saturating_sub(line_count);
- context.start.column = 0;
- context.end.row =
- (context.end.row + line_count).min(excerpt.buffer.max_point().row);
- context.end.column = excerpt.buffer.line_len(context.end.row);
- }
- }
- }
-
- Some(ExcerptRange {
- context,
- primary: excerpt.range.primary.to_point(&excerpt.buffer),
- })
- });
- let mut merged_ranges: Vec<ExcerptRange<Point>> = Vec::new();
- for range in expanded_ranges {
- if let Some(last_range) = merged_ranges.last_mut()
- && last_range.context.end >= range.context.start
- {
- last_range.context.end = range.context.end;
- continue;
- }
- merged_ranges.push(range)
- }
- let Some(excerpt_id) = excerpt_ids.first() else {
- continue;
- };
- let Some(buffer_id) = &snapshot.buffer_id_for_excerpt(*excerpt_id) else {
- continue;
- };
-
- let Some(buffer) = self.buffers.get(buffer_id).map(|b| b.buffer.clone()) else {
- continue;
- };
-
- let buffer_snapshot = buffer.read(cx).snapshot();
- self.update_path_excerpts(path.clone(), buffer, &buffer_snapshot, merged_ranges, cx);
- }
- }
-
- /// Sets excerpts, returns `true` if at least one new excerpt was added.
- fn set_merged_excerpt_ranges_for_path(
- &mut self,
- path: PathKey,
- buffer: Entity<Buffer>,
- ranges: Vec<ExcerptRange<Point>>,
- buffer_snapshot: &BufferSnapshot,
- new: Vec<ExcerptRange<Point>>,
- counts: Vec<usize>,
- cx: &mut Context<Self>,
- ) -> (Vec<Range<Anchor>>, bool) {
- let (excerpt_ids, added_a_new_excerpt) =
- self.update_path_excerpts(path, buffer, buffer_snapshot, new, cx);
-
- let mut result = Vec::new();
- let mut ranges = ranges.into_iter();
- for (excerpt_id, range_count) in excerpt_ids.into_iter().zip(counts.into_iter()) {
- for range in ranges.by_ref().take(range_count) {
- let range = Anchor::range_in_buffer(
- excerpt_id,
- buffer_snapshot.remote_id(),
- buffer_snapshot.anchor_before(&range.primary.start)
- ..buffer_snapshot.anchor_after(&range.primary.end),
- );
- result.push(range)
- }
- }
- (result, added_a_new_excerpt)
- }
-
- fn update_path_excerpts(
- &mut self,
- path: PathKey,
- buffer: Entity<Buffer>,
- buffer_snapshot: &BufferSnapshot,
- new: Vec<ExcerptRange<Point>>,
- cx: &mut Context<Self>,
- ) -> (Vec<ExcerptId>, bool) {
- let mut insert_after = self
- .excerpts_by_path
- .range(..path.clone())
- .next_back()
- .and_then(|(_, value)| value.last().copied())
- .unwrap_or(ExcerptId::min());
-
- let existing = self
- .excerpts_by_path
- .get(&path)
- .cloned()
- .unwrap_or_default();
-
- let mut new_iter = new.into_iter().peekable();
- let mut existing_iter = existing.into_iter().peekable();
-
- let mut excerpt_ids = Vec::new();
- let mut to_remove = Vec::new();
- let mut to_insert: Vec<(ExcerptId, ExcerptRange<Point>)> = Vec::new();
- let mut added_a_new_excerpt = false;
- let snapshot = self.snapshot(cx);
-
- let mut next_excerpt_id =
- // is this right? What if we remove the last excerpt, then we might reallocate with a wrong mapping?
- if let Some(last_entry) = self.snapshot.borrow().excerpt_ids.last() {
- last_entry.id.0 + 1
- } else {
- 1
- };
-
- let mut next_excerpt_id = move || ExcerptId(post_inc(&mut next_excerpt_id));
-
- let mut excerpts_cursor = snapshot.excerpts.cursor::<Option<&Locator>>(());
- excerpts_cursor.next();
-
- loop {
- let existing = if let Some(&existing_id) = existing_iter.peek() {
- let locator = snapshot.excerpt_locator_for_id(existing_id);
- excerpts_cursor.seek_forward(&Some(locator), Bias::Left);
- if let Some(excerpt) = excerpts_cursor.item() {
- if excerpt.buffer_id != buffer_snapshot.remote_id() {
- to_remove.push(existing_id);
- existing_iter.next();
- continue;
- }
- Some((existing_id, excerpt.range.context.to_point(buffer_snapshot)))
- } else {
- None
- }
- } else {
- None
- };
-
- let new = new_iter.peek();
- if let Some((last_id, last)) = to_insert.last_mut() {
- if let Some(new) = new
- && last.context.end >= new.context.start
- {
- last.context.end = last.context.end.max(new.context.end);
- excerpt_ids.push(*last_id);
- new_iter.next();
- continue;
- }
- if let Some((existing_id, existing_range)) = &existing
- && last.context.end >= existing_range.start
- {
- last.context.end = last.context.end.max(existing_range.end);
- to_remove.push(*existing_id);
- self.snapshot
- .get_mut()
- .replaced_excerpts
- .insert(*existing_id, *last_id);
- existing_iter.next();
- continue;
- }
- }
-
- match (new, existing) {
- (None, None) => break,
- (None, Some((existing_id, _))) => {
- existing_iter.next();
- to_remove.push(existing_id);
- continue;
- }
- (Some(_), None) => {
- added_a_new_excerpt = true;
- let new_id = next_excerpt_id();
- excerpt_ids.push(new_id);
- to_insert.push((new_id, new_iter.next().unwrap()));
- continue;
- }
- (Some(new), Some((_, existing_range))) => {
- if existing_range.end < new.context.start {
- let existing_id = existing_iter.next().unwrap();
- to_remove.push(existing_id);
- continue;
- } else if existing_range.start > new.context.end {
- let new_id = next_excerpt_id();
- excerpt_ids.push(new_id);
- to_insert.push((new_id, new_iter.next().unwrap()));
- continue;
- }
-
- if existing_range.start == new.context.start
- && existing_range.end == new.context.end
- {
- self.insert_excerpts_with_ids_after(
- insert_after,
- buffer.clone(),
- mem::take(&mut to_insert),
- cx,
- );
- insert_after = existing_iter.next().unwrap();
- excerpt_ids.push(insert_after);
- new_iter.next();
- } else {
- let existing_id = existing_iter.next().unwrap();
- let new_id = next_excerpt_id();
- self.snapshot
- .get_mut()
- .replaced_excerpts
- .insert(existing_id, new_id);
- to_remove.push(existing_id);
- let mut range = new_iter.next().unwrap();
- range.context.start = range.context.start.min(existing_range.start);
- range.context.end = range.context.end.max(existing_range.end);
- excerpt_ids.push(new_id);
- to_insert.push((new_id, range));
- }
- }
- };
- }
-
- self.insert_excerpts_with_ids_after(insert_after, buffer, to_insert, cx);
- self.remove_excerpts(to_remove, cx);
- if excerpt_ids.is_empty() {
- self.excerpts_by_path.remove(&path);
- } else {
- for excerpt_id in &excerpt_ids {
- self.paths_by_excerpt.insert(*excerpt_id, path.clone());
- }
- self.excerpts_by_path
- .insert(path, excerpt_ids.iter().dedup().cloned().collect());
- }
-
- (excerpt_ids, added_a_new_excerpt)
- }
-}
+use std::{mem, ops::Range, sync::Arc};
+
+use collections::HashSet;
+use gpui::{App, AppContext, Context, Entity};
+use itertools::Itertools;
+use language::{Buffer, BufferSnapshot};
+use rope::Point;
+use text::{Bias, BufferId, OffsetRangeExt, locator::Locator};
+use util::{post_inc, rel_path::RelPath};
+use ztracing::instrument;
+
+use crate::{
+ Anchor, ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer, build_excerpt_ranges,
+};
+
+#[derive(PartialEq, Eq, Ord, PartialOrd, Clone, Hash, Debug)]
+pub struct PathKey {
+ // Used by the derived PartialOrd & Ord
+ pub sort_prefix: Option<u64>,
+ pub path: Arc<RelPath>,
+}
+
+impl PathKey {
+ pub fn with_sort_prefix(sort_prefix: u64, path: Arc<RelPath>) -> Self {
+ Self {
+ sort_prefix: Some(sort_prefix),
+ path,
+ }
+ }
+
+ pub fn for_buffer(buffer: &Entity<Buffer>, cx: &App) -> Self {
+ if let Some(file) = buffer.read(cx).file() {
+ Self::with_sort_prefix(file.worktree_id(cx).to_proto(), file.path().clone())
+ } else {
+ Self {
+ sort_prefix: None,
+ path: RelPath::unix(&buffer.entity_id().to_string())
+ .unwrap()
+ .into_arc(),
+ }
+ }
+ }
+}
+
+impl MultiBuffer {
+ pub fn paths(&self) -> impl Iterator<Item = &PathKey> + '_ {
+ self.excerpts_by_path.keys()
+ }
+
+ pub fn remove_excerpts_for_path(&mut self, path: PathKey, cx: &mut Context<Self>) {
+ if let Some(to_remove) = self.excerpts_by_path.remove(&path) {
+ self.remove_excerpts(to_remove, cx)
+ }
+ if let Some(follower) = &self.follower {
+ follower.update(cx, |follower, cx| {
+ follower.remove_excerpts_for_path(path, cx);
+ });
+ }
+ }
+
+ pub fn buffer_for_path(&self, path: &PathKey, cx: &App) -> Option<Entity<Buffer>> {
+ let excerpt_id = self.excerpts_by_path.get(path)?.first()?;
+ let snapshot = self.read(cx);
+ let excerpt = snapshot.excerpt(*excerpt_id)?;
+ self.buffer(excerpt.buffer_id)
+ }
+
+ pub fn location_for_path(&self, path: &PathKey, cx: &App) -> Option<Anchor> {
+ let excerpt_id = self.excerpts_by_path.get(path)?.first()?;
+ let snapshot = self.read(cx);
+ let excerpt = snapshot.excerpt(*excerpt_id)?;
+ Some(Anchor::in_buffer(excerpt.id, excerpt.range.context.start))
+ }
+
+ /// Sets excerpts, returns `true` if at least one new excerpt was added.
+ #[instrument(skip_all)]
+ pub fn set_excerpts_for_path(
+ &mut self,
+ path: PathKey,
+ buffer: Entity<Buffer>,
+ ranges: impl IntoIterator<Item = Range<Point>>,
+ context_line_count: u32,
+ cx: &mut Context<Self>,
+ ) -> (Vec<Range<Anchor>>, bool) {
+ let buffer_snapshot = buffer.read(cx).snapshot();
+ let excerpt_ranges = build_excerpt_ranges(ranges, context_line_count, &buffer_snapshot);
+
+ let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges);
+ self.set_merged_excerpt_ranges_for_path(
+ path,
+ buffer,
+ excerpt_ranges,
+ &buffer_snapshot,
+ new,
+ counts,
+ cx,
+ )
+ }
+
+ pub fn set_excerpt_ranges_for_path(
+ &mut self,
+ path: PathKey,
+ buffer: Entity<Buffer>,
+ buffer_snapshot: &BufferSnapshot,
+ excerpt_ranges: Vec<ExcerptRange<Point>>,
+ cx: &mut Context<Self>,
+ ) -> (Vec<Range<Anchor>>, bool) {
+ let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges);
+ self.set_merged_excerpt_ranges_for_path(
+ path,
+ buffer,
+ excerpt_ranges,
+ buffer_snapshot,
+ new,
+ counts,
+ cx,
+ )
+ }
+
+ pub fn set_anchored_excerpts_for_path(
+ &self,
+ path_key: PathKey,
+ buffer: Entity<Buffer>,
+ ranges: Vec<Range<text::Anchor>>,
+ context_line_count: u32,
+ cx: &Context<Self>,
+ ) -> impl Future<Output = Vec<Range<Anchor>>> + use<> {
+ let buffer_snapshot = buffer.read(cx).snapshot();
+ let multi_buffer = cx.weak_entity();
+ let mut app = cx.to_async();
+ async move {
+ let snapshot = buffer_snapshot.clone();
+ let (excerpt_ranges, new, counts) = app
+ .background_spawn(async move {
+ let ranges = ranges.into_iter().map(|range| range.to_point(&snapshot));
+ let excerpt_ranges =
+ build_excerpt_ranges(ranges, context_line_count, &snapshot);
+ let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges);
+ (excerpt_ranges, new, counts)
+ })
+ .await;
+
+ multi_buffer
+ .update(&mut app, move |multi_buffer, cx| {
+ let (ranges, _) = multi_buffer.set_merged_excerpt_ranges_for_path(
+ path_key,
+ buffer,
+ excerpt_ranges,
+ &buffer_snapshot,
+ new,
+ counts,
+ cx,
+ );
+ ranges
+ })
+ .ok()
+ .unwrap_or_default()
+ }
+ }
+
+ pub fn remove_excerpts_for_buffer(&mut self, buffer: BufferId, cx: &mut Context<Self>) {
+ self.remove_excerpts(
+ self.excerpts_for_buffer(buffer, cx)
+ .into_iter()
+ .map(|(excerpt, _)| excerpt),
+ cx,
+ );
+ }
+
+ pub(super) fn expand_excerpts_with_paths(
+ &mut self,
+ ids: impl IntoIterator<Item = ExcerptId>,
+ line_count: u32,
+ direction: ExpandExcerptDirection,
+ cx: &mut Context<Self>,
+ ) {
+ let grouped = ids
+ .into_iter()
+ .chunk_by(|id| self.paths_by_excerpt.get(id).cloned())
+ .into_iter()
+ .filter_map(|(k, v)| Some((k?, v.into_iter().collect::<Vec<_>>())))
+ .collect::<Vec<_>>();
+ let snapshot = self.snapshot(cx);
+
+ for (path, ids) in grouped.into_iter() {
+ let Some(excerpt_ids) = self.excerpts_by_path.get(&path) else {
+ continue;
+ };
+
+ let ids_to_expand = HashSet::from_iter(ids);
+ let mut excerpt_id_ = None;
+ let expanded_ranges = excerpt_ids.iter().filter_map(|excerpt_id| {
+ let excerpt = snapshot.excerpt(*excerpt_id)?;
+ let excerpt_id = excerpt.id;
+ if excerpt_id_.is_none() {
+ excerpt_id_ = Some(excerpt_id);
+ }
+
+ let mut context = excerpt.range.context.to_point(&excerpt.buffer);
+ if ids_to_expand.contains(&excerpt_id) {
+ match direction {
+ ExpandExcerptDirection::Up => {
+ context.start.row = context.start.row.saturating_sub(line_count);
+ context.start.column = 0;
+ }
+ ExpandExcerptDirection::Down => {
+ context.end.row =
+ (context.end.row + line_count).min(excerpt.buffer.max_point().row);
+ context.end.column = excerpt.buffer.line_len(context.end.row);
+ }
+ ExpandExcerptDirection::UpAndDown => {
+ context.start.row = context.start.row.saturating_sub(line_count);
+ context.start.column = 0;
+ context.end.row =
+ (context.end.row + line_count).min(excerpt.buffer.max_point().row);
+ context.end.column = excerpt.buffer.line_len(context.end.row);
+ }
+ }
+ }
+
+ Some(ExcerptRange {
+ context,
+ primary: excerpt.range.primary.to_point(&excerpt.buffer),
+ })
+ });
+ let mut merged_ranges: Vec<ExcerptRange<Point>> = Vec::new();
+ for range in expanded_ranges {
+ if let Some(last_range) = merged_ranges.last_mut()
+ && last_range.context.end >= range.context.start
+ {
+ last_range.context.end = range.context.end;
+ continue;
+ }
+ merged_ranges.push(range)
+ }
+ let Some(excerpt_id) = excerpt_id_ else {
+ continue;
+ };
+ let Some(buffer_id) = &snapshot.buffer_id_for_excerpt(excerpt_id) else {
+ continue;
+ };
+
+ let Some(buffer) = self.buffers.get(buffer_id).map(|b| b.buffer.clone()) else {
+ continue;
+ };
+
+ let buffer_snapshot = buffer.read(cx).snapshot();
+ self.update_path_excerpts(path.clone(), buffer, &buffer_snapshot, merged_ranges, cx);
+ }
+ }
+
+ /// Sets excerpts, returns `true` if at least one new excerpt was added.
+ fn set_merged_excerpt_ranges_for_path(
+ &mut self,
+ path: PathKey,
+ buffer: Entity<Buffer>,
+ ranges: Vec<ExcerptRange<Point>>,
+ buffer_snapshot: &BufferSnapshot,
+ new: Vec<ExcerptRange<Point>>,
+ counts: Vec<usize>,
+ cx: &mut Context<Self>,
+ ) -> (Vec<Range<Anchor>>, bool) {
+ let (excerpt_ids, added_a_new_excerpt) =
+ self.update_path_excerpts(path, buffer, buffer_snapshot, new, cx);
+
+ let mut result = Vec::new();
+ let mut ranges = ranges.into_iter();
+ for (excerpt_id, range_count) in excerpt_ids.into_iter().zip(counts.into_iter()) {
+ for range in ranges.by_ref().take(range_count) {
+ let range = Anchor::range_in_buffer(
+ excerpt_id,
+ buffer_snapshot.anchor_before(&range.primary.start)
+ ..buffer_snapshot.anchor_after(&range.primary.end),
+ );
+ result.push(range)
+ }
+ }
+ (result, added_a_new_excerpt)
+ }
+
+ fn update_path_excerpts(
+ &mut self,
+ path: PathKey,
+ buffer: Entity<Buffer>,
+ buffer_snapshot: &BufferSnapshot,
+ new: Vec<ExcerptRange<Point>>,
+ cx: &mut Context<Self>,
+ ) -> (Vec<ExcerptId>, bool) {
+ let mut insert_after = self
+ .excerpts_by_path
+ .range(..path.clone())
+ .next_back()
+ .and_then(|(_, value)| value.last().copied())
+ .unwrap_or(ExcerptId::min());
+
+ let existing = self
+ .excerpts_by_path
+ .get(&path)
+ .cloned()
+ .unwrap_or_default();
+ let mut new_iter = new.into_iter().peekable();
+ let mut existing_iter = existing.into_iter().peekable();
+
+ let mut excerpt_ids = Vec::new();
+ let mut to_remove = Vec::new();
+ let mut to_insert: Vec<(ExcerptId, ExcerptRange<Point>)> = Vec::new();
+ let mut added_a_new_excerpt = false;
+ let snapshot = self.snapshot(cx);
+
+ let mut next_excerpt_id =
+ // todo(lw): is this right? What if we remove the last excerpt, then we might reallocate with a wrong mapping?
+ if let Some(last_entry) = self.snapshot.borrow().excerpt_ids.last() {
+ last_entry.id.0 + 1
+ } else {
+ 1
+ };
+
+ let mut next_excerpt_id = move || ExcerptId(post_inc(&mut next_excerpt_id));
+
+ let mut excerpts_cursor = snapshot.excerpts.cursor::<Option<&Locator>>(());
+ excerpts_cursor.next();
+
+ loop {
+ let existing = if let Some(&existing_id) = existing_iter.peek() {
+ let locator = snapshot.excerpt_locator_for_id(existing_id);
+ excerpts_cursor.seek_forward(&Some(locator), Bias::Left);
+ if let Some(excerpt) = excerpts_cursor.item() {
+ if excerpt.buffer_id != buffer_snapshot.remote_id() {
+ to_remove.push(existing_id);
+ existing_iter.next();
+ continue;
+ }
+ Some((existing_id, excerpt.range.context.to_point(buffer_snapshot)))
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
+ let new = new_iter.peek();
+ if let Some((last_id, last)) = to_insert.last_mut() {
+ if let Some(new) = new
+ && last.context.end >= new.context.start
+ {
+ last.context.end = last.context.end.max(new.context.end);
+ excerpt_ids.push(*last_id);
+ new_iter.next();
+ continue;
+ }
+ if let Some((existing_id, existing_range)) = &existing
+ && last.context.end >= existing_range.start
+ {
+ last.context.end = last.context.end.max(existing_range.end);
+ to_remove.push(*existing_id);
+ self.snapshot
+ .get_mut()
+ .replaced_excerpts
+ .insert(*existing_id, *last_id);
+ existing_iter.next();
+ continue;
+ }
+ }
+
+ match (new, existing) {
+ (None, None) => break,
+ (None, Some((existing_id, _))) => {
+ existing_iter.next();
+ to_remove.push(existing_id);
+ continue;
+ }
+ (Some(_), None) => {
+ added_a_new_excerpt = true;
+ let new_id = next_excerpt_id();
+ excerpt_ids.push(new_id);
+ to_insert.push((new_id, new_iter.next().unwrap()));
+ continue;
+ }
+ (Some(new), Some((_, existing_range))) => {
+ if existing_range.end < new.context.start {
+ let existing_id = existing_iter.next().unwrap();
+ to_remove.push(existing_id);
+ continue;
+ } else if existing_range.start > new.context.end {
+ let new_id = next_excerpt_id();
+ excerpt_ids.push(new_id);
+ to_insert.push((new_id, new_iter.next().unwrap()));
+ continue;
+ }
+
+ if existing_range.start == new.context.start
+ && existing_range.end == new.context.end
+ {
+ self.insert_excerpts_with_ids_after(
+ insert_after,
+ buffer.clone(),
+ mem::take(&mut to_insert),
+ cx,
+ );
+ insert_after = existing_iter.next().unwrap();
+ excerpt_ids.push(insert_after);
+ new_iter.next();
+ } else {
+ let existing_id = existing_iter.next().unwrap();
+ let new_id = next_excerpt_id();
+ self.snapshot
+ .get_mut()
+ .replaced_excerpts
+ .insert(existing_id, new_id);
+ to_remove.push(existing_id);
+ let mut range = new_iter.next().unwrap();
+ range.context.start = range.context.start.min(existing_range.start);
+ range.context.end = range.context.end.max(existing_range.end);
+ excerpt_ids.push(new_id);
+ to_insert.push((new_id, range));
+ }
+ }
+ };
+ }
+
+ self.insert_excerpts_with_ids_after(insert_after, buffer, to_insert, cx);
+ // todo(lw): There is a logic bug somewhere that causes the to_remove vector to be not ordered correctly
+ to_remove.sort_by_cached_key(|&id| snapshot.excerpt_locator_for_id(id));
+ self.remove_excerpts(to_remove, cx);
+
+ if excerpt_ids.is_empty() {
+ self.excerpts_by_path.remove(&path);
+ } else {
+ for excerpt_id in &excerpt_ids {
+ self.paths_by_excerpt.insert(*excerpt_id, path.clone());
+ }
+ let snapshot = &*self.snapshot.get_mut();
+ let mut excerpt_ids: Vec<_> = excerpt_ids.iter().dedup().cloned().collect();
+ excerpt_ids.sort_by_cached_key(|&id| snapshot.excerpt_locator_for_id(id));
+ self.excerpts_by_path.insert(path, excerpt_ids);
+ }
+
+ (excerpt_ids, added_a_new_excerpt)
+ }
+}
@@ -1,264 +0,0 @@
-use std::{
- fmt::{Debug, Display},
- marker::PhantomData,
- ops::{Add, AddAssign, Sub, SubAssign},
-};
-use text::Point;
-
-#[repr(transparent)]
-pub struct TypedOffset<T> {
- pub value: usize,
- _marker: PhantomData<T>,
-}
-
-#[repr(transparent)]
-pub struct TypedPoint<T> {
- pub value: Point,
- _marker: PhantomData<T>,
-}
-
-#[repr(transparent)]
-pub struct TypedRow<T> {
- pub value: u32,
- _marker: PhantomData<T>,
-}
-
-impl<T> TypedOffset<T> {
- pub fn new(offset: usize) -> Self {
- Self {
- value: offset,
- _marker: PhantomData,
- }
- }
-
- pub fn saturating_sub(self, n: TypedOffset<T>) -> Self {
- Self {
- value: self.value.saturating_sub(n.value),
- _marker: PhantomData,
- }
- }
-
- pub fn zero() -> Self {
- Self::new(0)
- }
-
- pub fn is_zero(&self) -> bool {
- self.value == 0
- }
-}
-
-impl<T> TypedPoint<T> {
- pub fn new(row: u32, column: u32) -> Self {
- Self {
- value: Point::new(row, column),
- _marker: PhantomData,
- }
- }
-
- pub fn wrap(point: Point) -> Self {
- Self {
- value: point,
- _marker: PhantomData,
- }
- }
-
- pub fn row(&self) -> u32 {
- self.value.row
- }
-
- pub fn column(&self) -> u32 {
- self.value.column
- }
-
- pub fn zero() -> Self {
- Self::wrap(Point::zero())
- }
-
- pub fn is_zero(&self) -> bool {
- self.value.is_zero()
- }
-}
-
-impl<T> TypedRow<T> {
- pub fn new(row: u32) -> Self {
- Self {
- value: row,
- _marker: PhantomData,
- }
- }
-}
-
-impl<T> Copy for TypedOffset<T> {}
-impl<T> Copy for TypedPoint<T> {}
-impl<T> Copy for TypedRow<T> {}
-
-impl<T> Clone for TypedOffset<T> {
- fn clone(&self) -> Self {
- *self
- }
-}
-impl<T> Clone for TypedPoint<T> {
- fn clone(&self) -> Self {
- *self
- }
-}
-impl<T> Clone for TypedRow<T> {
- fn clone(&self) -> Self {
- *self
- }
-}
-
-impl<T> Default for TypedOffset<T> {
- fn default() -> Self {
- Self::new(0)
- }
-}
-impl<T> Default for TypedPoint<T> {
- fn default() -> Self {
- Self::wrap(Point::default())
- }
-}
-impl<T> Default for TypedRow<T> {
- fn default() -> Self {
- Self::new(0)
- }
-}
-
-impl<T> PartialOrd for TypedOffset<T> {
- fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
- Some(self.cmp(other))
- }
-}
-impl<T> PartialOrd for TypedPoint<T> {
- fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
- Some(self.cmp(other))
- }
-}
-impl<T> PartialOrd for TypedRow<T> {
- fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
- Some(self.cmp(other))
- }
-}
-
-impl<T> Ord for TypedOffset<T> {
- fn cmp(&self, other: &Self) -> std::cmp::Ordering {
- self.value.cmp(&other.value)
- }
-}
-impl<T> Ord for TypedPoint<T> {
- fn cmp(&self, other: &Self) -> std::cmp::Ordering {
- self.value.cmp(&other.value)
- }
-}
-impl<T> Ord for TypedRow<T> {
- fn cmp(&self, other: &Self) -> std::cmp::Ordering {
- self.value.cmp(&other.value)
- }
-}
-
-impl<T> PartialEq for TypedOffset<T> {
- fn eq(&self, other: &Self) -> bool {
- self.value == other.value
- }
-}
-impl<T> PartialEq for TypedPoint<T> {
- fn eq(&self, other: &Self) -> bool {
- self.value == other.value
- }
-}
-impl<T> PartialEq for TypedRow<T> {
- fn eq(&self, other: &Self) -> bool {
- self.value == other.value
- }
-}
-
-impl<T> Eq for TypedOffset<T> {}
-impl<T> Eq for TypedPoint<T> {}
-impl<T> Eq for TypedRow<T> {}
-
-impl<T> Debug for TypedOffset<T> {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "{}Offset({})", type_name::<T>(), self.value)
- }
-}
-impl<T> Debug for TypedPoint<T> {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(
- f,
- "{}Point({}, {})",
- type_name::<T>(),
- self.value.row,
- self.value.column
- )
- }
-}
-impl<T> Debug for TypedRow<T> {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "{}Row({})", type_name::<T>(), self.value)
- }
-}
-
-impl<T> Display for TypedOffset<T> {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- Display::fmt(&self.value, f)
- }
-}
-impl<T> Display for TypedRow<T> {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- Display::fmt(&self.value, f)
- }
-}
-
-fn type_name<T>() -> &'static str {
- std::any::type_name::<T>().split("::").last().unwrap()
-}
-
-impl<T> Add<TypedOffset<T>> for TypedOffset<T> {
- type Output = Self;
-
- fn add(self, other: Self) -> Self {
- TypedOffset::new(self.value + other.value)
- }
-}
-impl<T> Add<TypedPoint<T>> for TypedPoint<T> {
- type Output = Self;
-
- fn add(self, other: Self) -> Self {
- TypedPoint::wrap(self.value + other.value)
- }
-}
-
-impl<T> Sub<TypedOffset<T>> for TypedOffset<T> {
- type Output = Self;
- fn sub(self, other: Self) -> Self {
- TypedOffset::new(self.value - other.value)
- }
-}
-impl<T> Sub<TypedPoint<T>> for TypedPoint<T> {
- type Output = Self;
- fn sub(self, other: Self) -> Self {
- TypedPoint::wrap(self.value - other.value)
- }
-}
-
-impl<T> AddAssign<TypedOffset<T>> for TypedOffset<T> {
- fn add_assign(&mut self, other: Self) {
- self.value += other.value;
- }
-}
-impl<T> AddAssign<TypedPoint<T>> for TypedPoint<T> {
- fn add_assign(&mut self, other: Self) {
- self.value += other.value;
- }
-}
-
-impl<T> SubAssign<Self> for TypedOffset<T> {
- fn sub_assign(&mut self, other: Self) {
- self.value -= other.value;
- }
-}
-impl<T> SubAssign<Self> for TypedRow<T> {
- fn sub_assign(&mut self, other: Self) {
- self.value -= other.value;
- }
-}
@@ -1,14 +1,14 @@
use gpui::{App, Context, Entity};
-use language::{self, Buffer, TextDimension, TransactionId};
+use language::{self, Buffer, TransactionId};
use std::{
collections::HashMap,
- ops::{Range, Sub},
+ ops::{AddAssign, Range, Sub},
time::{Duration, Instant},
};
use sum_tree::Bias;
use text::BufferId;
-use crate::BufferState;
+use crate::{BufferState, MultiBufferDimension};
use super::{Event, ExcerptSummary, MultiBuffer};
@@ -320,7 +320,11 @@ impl MultiBuffer {
cx: &App,
) -> Vec<Range<D>>
where
- D: TextDimension + Ord + Sub<D, Output = D>,
+ D: MultiBufferDimension
+ + Ord
+ + Sub<D, Output = D::TextDimension>
+ + AddAssign<D::TextDimension>,
+ D::TextDimension: PartialOrd + Sub<D::TextDimension, Output = D::TextDimension>,
{
let Some(transaction) = self.history.transaction(transaction_id) else {
return Vec::new();
@@ -336,24 +340,34 @@ impl MultiBuffer {
};
let buffer = buffer_state.buffer.read(cx);
- for range in buffer.edited_ranges_for_transaction_id::<D>(*buffer_transaction) {
+ for range in
+ buffer.edited_ranges_for_transaction_id::<D::TextDimension>(*buffer_transaction)
+ {
for excerpt_id in &buffer_state.excerpts {
cursor.seek(excerpt_id, Bias::Left);
if let Some(excerpt) = cursor.item()
&& excerpt.locator == *excerpt_id
{
- let excerpt_buffer_start = excerpt.range.context.start.summary::<D>(buffer);
- let excerpt_buffer_end = excerpt.range.context.end.summary::<D>(buffer);
+ let excerpt_buffer_start = excerpt
+ .range
+ .context
+ .start
+ .summary::<D::TextDimension>(buffer);
+ let excerpt_buffer_end = excerpt
+ .range
+ .context
+ .end
+ .summary::<D::TextDimension>(buffer);
let excerpt_range = excerpt_buffer_start..excerpt_buffer_end;
if excerpt_range.contains(&range.start)
&& excerpt_range.contains(&range.end)
{
- let excerpt_start = D::from_text_summary(&cursor.start().text);
+ let excerpt_start = D::from_summary(&cursor.start().text);
let mut start = excerpt_start;
- start.add_assign(&(range.start - excerpt_buffer_start));
+ start += range.start - excerpt_buffer_start;
let mut end = excerpt_start;
- end.add_assign(&(range.end - excerpt_buffer_start));
+ end += range.end - excerpt_buffer_start;
ranges.push(start..end);
break;
@@ -32,9 +32,9 @@ pub struct NodeBinaryOptions {
pub enum VersionStrategy<'a> {
/// Install if current version doesn't match pinned version
- Pin(&'a str),
+ Pin(&'a Version),
/// Install if current version is older than latest version
- Latest(&'a str),
+ Latest(&'a Version),
}
#[derive(Clone)]
@@ -206,14 +206,14 @@ impl NodeRuntime {
pub async fn run_npm_subcommand(
&self,
- directory: &Path,
+ directory: Option<&Path>,
subcommand: &str,
args: &[&str],
) -> Result<Output> {
let http = self.0.lock().await.http.clone();
self.instance()
.await
- .run_npm_subcommand(Some(directory), http.proxy(), subcommand, args)
+ .run_npm_subcommand(directory, http.proxy(), subcommand, args)
.await
}
@@ -221,14 +221,14 @@ impl NodeRuntime {
&self,
local_package_directory: &Path,
name: &str,
- ) -> Result<Option<String>> {
+ ) -> Result<Option<Version>> {
self.instance()
.await
.npm_package_installed_version(local_package_directory, name)
.await
}
- pub async fn npm_package_latest_version(&self, name: &str) -> Result<String> {
+ pub async fn npm_package_latest_version(&self, name: &str) -> Result<Version> {
let http = self.0.lock().await.http.clone();
let output = self
.instance()
@@ -271,19 +271,22 @@ impl NodeRuntime {
.map(|(name, version)| format!("{name}@{version}"))
.collect();
- let mut arguments: Vec<_> = packages.iter().map(|p| p.as_str()).collect();
- arguments.extend_from_slice(&[
- "--save-exact",
- "--fetch-retry-mintimeout",
- "2000",
- "--fetch-retry-maxtimeout",
- "5000",
- "--fetch-timeout",
- "5000",
- ]);
+ let arguments: Vec<_> = packages
+ .iter()
+ .map(|p| p.as_str())
+ .chain([
+ "--save-exact",
+ "--fetch-retry-mintimeout",
+ "2000",
+ "--fetch-retry-maxtimeout",
+ "5000",
+ "--fetch-timeout",
+ "5000",
+ ])
+ .collect();
// This is also wrong because the directory is wrong.
- self.run_npm_subcommand(directory, "install", &arguments)
+ self.run_npm_subcommand(Some(directory), "install", &arguments)
.await?;
Ok(())
}
@@ -311,23 +314,9 @@ impl NodeRuntime {
return true;
};
- let Some(installed_version) = Version::parse(&installed_version).log_err() else {
- return true;
- };
-
match version_strategy {
- VersionStrategy::Pin(pinned_version) => {
- let Some(pinned_version) = Version::parse(pinned_version).log_err() else {
- return true;
- };
- installed_version != pinned_version
- }
- VersionStrategy::Latest(latest_version) => {
- let Some(latest_version) = Version::parse(latest_version).log_err() else {
- return true;
- };
- installed_version < latest_version
- }
+ VersionStrategy::Pin(pinned_version) => &installed_version != pinned_version,
+ VersionStrategy::Latest(latest_version) => &installed_version < latest_version,
}
}
}
@@ -342,12 +331,12 @@ enum ArchiveType {
pub struct NpmInfo {
#[serde(default)]
dist_tags: NpmInfoDistTags,
- versions: Vec<String>,
+ versions: Vec<Version>,
}
#[derive(Debug, Deserialize, Default)]
pub struct NpmInfoDistTags {
- latest: Option<String>,
+ latest: Option<Version>,
}
#[async_trait::async_trait]
@@ -367,7 +356,7 @@ trait NodeRuntimeTrait: Send + Sync {
&self,
local_package_directory: &Path,
name: &str,
- ) -> Result<Option<String>>;
+ ) -> Result<Option<Version>>;
}
#[derive(Clone)]
@@ -414,7 +403,6 @@ impl ManagedNodeRuntime {
let valid = if fs::metadata(&node_binary).await.is_ok() {
let result = util::command::new_smol_command(&node_binary)
- .env_clear()
.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
.arg(npm_file)
.arg("--version")
@@ -557,11 +545,13 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());
let mut command = util::command::new_smol_command(node_binary);
- command.env_clear();
command.env("PATH", env_path);
command.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs);
command.arg(npm_file).arg(subcommand);
- command.args(["--cache".into(), self.installation_path.join("cache")]);
+ command.arg(format!(
+ "--cache={}",
+ self.installation_path.join("cache").display()
+ ));
command.args([
"--userconfig".into(),
self.installation_path.join("blank_user_npmrc"),
@@ -600,7 +590,7 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
&self,
local_package_directory: &Path,
name: &str,
- ) -> Result<Option<String>> {
+ ) -> Result<Option<Version>> {
read_package_installed_version(local_package_directory.join("node_modules"), name).await
}
}
@@ -702,11 +692,13 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
let mut command = util::command::new_smol_command(self.npm.clone());
let path = path_with_node_binary_prepended(&self.node).unwrap_or_default();
command
- .env_clear()
.env("PATH", path)
.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
.arg(subcommand)
- .args(["--cache".into(), self.scratch_dir.join("cache")])
+ .arg(format!(
+ "--cache={}",
+ self.scratch_dir.join("cache").display()
+ ))
.args(args);
configure_npm_command(&mut command, directory, proxy);
let output = command.output().await?;
@@ -723,7 +715,7 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
&self,
local_package_directory: &Path,
name: &str,
- ) -> Result<Option<String>> {
+ ) -> Result<Option<Version>> {
read_package_installed_version(local_package_directory.join("node_modules"), name).await
// todo: allow returning a globally installed version (requires callers not to hard-code the path)
}
@@ -732,7 +724,7 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
pub async fn read_package_installed_version(
node_module_directory: PathBuf,
name: &str,
-) -> Result<Option<String>> {
+) -> Result<Option<Version>> {
let package_json_path = node_module_directory.join(name).join("package.json");
let mut file = match fs::File::open(package_json_path).await {
@@ -748,7 +740,7 @@ pub async fn read_package_installed_version(
#[derive(Deserialize)]
struct PackageJson {
- version: String,
+ version: Version,
}
let mut contents = String::new();
@@ -785,7 +777,7 @@ impl NodeRuntimeTrait for UnavailableNodeRuntime {
&self,
_local_package_directory: &Path,
_: &str,
- ) -> Result<Option<String>> {
+ ) -> Result<Option<Version>> {
bail!("{}", self.error_message)
}
}
@@ -137,7 +137,8 @@ impl Render for StatusToast {
let handle = self.this_handle.clone();
this.child(
IconButton::new("dismiss", IconName::Close)
- .icon_size(IconSize::XSmall)
+ .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Dismiss"))
.on_click(move |_click_event, _window, cx| {
@@ -102,9 +102,11 @@ pub enum ChatMessage {
}
#[derive(Serialize, Deserialize, Debug)]
-#[serde(rename_all = "lowercase")]
-pub enum OllamaToolCall {
- Function(OllamaFunctionCall),
+pub struct OllamaToolCall {
+ // TODO: Remove `Option` after most users have updated to Ollama v0.12.10,
+ // which was released on the 4th of November 2025
+ pub id: Option<String>,
+ pub function: OllamaFunctionCall,
}
#[derive(Serialize, Deserialize, Debug)]
@@ -444,6 +446,7 @@ mod tests {
"content": "",
"tool_calls": [
{
+ "id": "call_llama3.2:3b_145155",
"function": {
"name": "weather",
"arguments": {
@@ -479,6 +482,56 @@ mod tests {
}
}
+ // Backwards compatibility with Ollama versions prior to v0.12.10 November 2025
+ // This test is a copy of `parse_tool_call()` with the `id` field omitted.
+ #[test]
+ fn parse_tool_call_pre_0_12_10() {
+ let response = serde_json::json!({
+ "model": "llama3.2:3b",
+ "created_at": "2025-04-28T20:02:02.140489Z",
+ "message": {
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [
+ {
+ "function": {
+ "name": "weather",
+ "arguments": {
+ "city": "london",
+ }
+ }
+ }
+ ]
+ },
+ "done_reason": "stop",
+ "done": true,
+ "total_duration": 2758629166u64,
+ "load_duration": 1770059875,
+ "prompt_eval_count": 147,
+ "prompt_eval_duration": 684637583,
+ "eval_count": 16,
+ "eval_duration": 302561917,
+ });
+
+ let result: ChatResponseDelta = serde_json::from_value(response).unwrap();
+ match result.message {
+ ChatMessage::Assistant {
+ content,
+ tool_calls: Some(tool_calls),
+ images: _,
+ thinking,
+ } => {
+ assert!(content.is_empty());
+ assert!(thinking.is_none());
+
+ // When the `Option` around `id` is removed, this test should complain
+ // and be subsequently deleted in favor of `parse_tool_call()`
+ assert!(tool_calls.first().is_some_and(|call| call.id.is_none()))
+ }
+ _ => panic!("Deserialized wrong role"),
+ }
+ }
+
#[test]
fn parse_show_model() {
let response = serde_json::json!({
@@ -22,7 +22,6 @@ db.workspace = true
documented.workspace = true
fs.workspace = true
fuzzy.workspace = true
-git.workspace = true
gpui.workspace = true
menu.workspace = true
notifications.workspace = true
@@ -3,6 +3,7 @@ use std::sync::Arc;
use client::TelemetrySettings;
use fs::Fs;
use gpui::{Action, App, IntoElement};
+use project::project_settings::ProjectSettings;
use settings::{BaseKeymap, Settings, update_settings_file};
use theme::{
Appearance, SystemAppearance, ThemeAppearanceMode, ThemeName, ThemeRegistry, ThemeSelection,
@@ -10,8 +11,8 @@ use theme::{
};
use ui::{
Divider, ParentElement as _, StatefulInteractiveElement, SwitchField, TintColor,
- ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, prelude::*,
- rems_from_px,
+ ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip,
+ prelude::*, rems_from_px,
};
use vim_mode_setting::VimModeSetting;
@@ -220,7 +221,7 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement
});
} else {
let appearance = *SystemAppearance::global(cx);
- theme::set_theme(settings, theme, appearance);
+ theme::set_theme(settings, theme, appearance, appearance);
}
});
}
@@ -409,6 +410,48 @@ fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoEleme
})
}
+fn render_worktree_auto_trust_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
+ let toggle_state = if ProjectSettings::get_global(cx).session.trust_all_worktrees {
+ ui::ToggleState::Selected
+ } else {
+ ui::ToggleState::Unselected
+ };
+
+ let tooltip_description = "Zed can only allow services like language servers, project settings, and MCP servers to run after you mark a new project as trusted.";
+
+ SwitchField::new(
+ "onboarding-auto-trust-worktrees",
+ Some("Trust All Projects By Default"),
+ Some("Automatically mark all new projects as trusted to unlock all Zed's features".into()),
+ toggle_state,
+ {
+ let fs = <dyn Fs>::global(cx);
+ move |&selection, _, cx| {
+ let trust = match selection {
+ ToggleState::Selected => true,
+ ToggleState::Unselected => false,
+ ToggleState::Indeterminate => {
+ return;
+ }
+ };
+ update_settings_file(fs.clone(), cx, move |setting, _| {
+ setting.session.get_or_insert_default().trust_all_worktrees = Some(trust);
+ });
+
+ telemetry::event!(
+ "Welcome Page Worktree Auto Trust Toggled",
+ options = if trust { "on" } else { "off" }
+ );
+ }
+ },
+ )
+ .tab_index({
+ *tab_index += 1;
+ *tab_index - 1
+ })
+ .tooltip(Tooltip::text(tooltip_description))
+}
+
fn render_setting_import_button(
tab_index: isize,
label: SharedString,
@@ -481,6 +524,7 @@ pub(crate) fn render_basics_page(cx: &mut App) -> impl IntoElement {
.child(render_base_keymap_section(&mut tab_index, cx))
.child(render_import_settings_section(&mut tab_index, cx))
.child(render_vim_mode_switch(&mut tab_index, cx))
+ .child(render_worktree_auto_trust_switch(&mut tab_index, cx))
.child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
.child(render_telemetry_section(&mut tab_index, cx))
}
@@ -1,5 +1,4 @@
-pub use crate::welcome::ShowWelcome;
-use crate::{multibuffer_hint::MultibufferHint, welcome::WelcomePage};
+use crate::multibuffer_hint::MultibufferHint;
use client::{Client, UserStore, zed_urls};
use db::kvp::KEY_VALUE_STORE;
use fs::Fs;
@@ -17,6 +16,8 @@ use ui::{
Divider, KeyBinding, ParentElement as _, StatefulInteractiveElement, Vector, VectorName,
WithScrollbar as _, prelude::*, rems_from_px,
};
+pub use workspace::welcome::ShowWelcome;
+use workspace::welcome::WelcomePage;
use workspace::{
AppState, Workspace, WorkspaceId,
dock::DockPosition,
@@ -24,12 +25,12 @@ use workspace::{
notifications::NotifyResultExt as _,
open_new, register_serializable_item, with_active_or_new_workspace,
};
+use zed_actions::OpenOnboarding;
mod base_keymap_picker;
mod basics_page;
pub mod multibuffer_hint;
mod theme_preview;
-mod welcome;
/// Imports settings from Visual Studio Code.
#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
@@ -52,14 +53,6 @@ pub struct ImportCursorSettings {
pub const FIRST_OPEN: &str = "first_open";
pub const DOCS_URL: &str = "https://zed.dev/docs/";
-actions!(
- zed,
- [
- /// Opens the onboarding view.
- OpenOnboarding
- ]
-);
-
actions!(
onboarding,
[
@@ -121,7 +114,8 @@ pub fn init(cx: &mut App) {
if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
} else {
- let settings_page = WelcomePage::new(window, cx);
+ let settings_page = cx
+ .new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx));
workspace.add_item_to_active_pane(
Box::new(settings_page),
None,
@@ -196,7 +190,7 @@ pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyh
let onboarding_page = Onboarding::new(workspace, cx);
workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
- window.focus(&onboarding_page.focus_handle(cx));
+ window.focus(&onboarding_page.focus_handle(cx), cx);
cx.notify();
};
@@ -283,11 +277,11 @@ impl Render for Onboarding {
.on_action(Self::handle_sign_in)
.on_action(Self::handle_open_account)
.on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
- window.focus_next();
+ window.focus_next(cx);
cx.notify();
}))
.on_action(cx.listener(|_, _: &menu::SelectPrevious, window, cx| {
- window.focus_prev();
+ window.focus_prev(cx);
cx.notify();
}))
.child(
@@ -350,7 +344,7 @@ impl Render for Onboarding {
.child(self.render_page(cx))
.track_scroll(&self.scroll_handle),
)
- .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx),
+ .vertical_scrollbar_for(&self.scroll_handle, window, cx),
)
}
}
@@ -427,7 +421,9 @@ fn go_to_welcome_page(cx: &mut App) {
if let Some(idx) = idx {
pane.activate_item(idx, true, true, window, cx);
} else {
- let item = Box::new(WelcomePage::new(window, cx));
+ let item = Box::new(
+ cx.new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx)),
+ );
pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
}
@@ -449,7 +445,7 @@ pub async fn handle_import_vscode_settings(
match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
Ok(vscode_settings) => vscode_settings,
Err(err) => {
- zlog::error!("{err}");
+ zlog::error!("{err:?}");
let _ = cx.prompt(
gpui::PromptLevel::Info,
&format!("Could not find or load a {source} settings file"),
@@ -1,443 +0,0 @@
-use gpui::{
- Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
- ParentElement, Render, Styled, Task, Window, actions,
-};
-use menu::{SelectNext, SelectPrevious};
-use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*};
-use workspace::{
- NewFile, Open,
- item::{Item, ItemEvent},
- with_active_or_new_workspace,
-};
-use zed_actions::{Extensions, OpenSettings, agent, command_palette};
-
-use crate::{Onboarding, OpenOnboarding};
-
-actions!(
- zed,
- [
- /// Show the Zed welcome screen
- ShowWelcome
- ]
-);
-
-const CONTENT: (Section<4>, Section<3>) = (
- Section {
- title: "Get Started",
- entries: [
- SectionEntry {
- icon: IconName::Plus,
- title: "New File",
- action: &NewFile,
- },
- SectionEntry {
- icon: IconName::FolderOpen,
- title: "Open Project",
- action: &Open,
- },
- SectionEntry {
- icon: IconName::CloudDownload,
- title: "Clone Repository",
- action: &git::Clone,
- },
- SectionEntry {
- icon: IconName::ListCollapse,
- title: "Open Command Palette",
- action: &command_palette::Toggle,
- },
- ],
- },
- Section {
- title: "Configure",
- entries: [
- SectionEntry {
- icon: IconName::Settings,
- title: "Open Settings",
- action: &OpenSettings,
- },
- SectionEntry {
- icon: IconName::ZedAssistant,
- title: "View AI Settings",
- action: &agent::OpenSettings,
- },
- SectionEntry {
- icon: IconName::Blocks,
- title: "Explore Extensions",
- action: &Extensions {
- category_filter: None,
- id: None,
- },
- },
- ],
- },
-);
-
-struct Section<const COLS: usize> {
- title: &'static str,
- entries: [SectionEntry; COLS],
-}
-
-impl<const COLS: usize> Section<COLS> {
- fn render(self, index_offset: usize, focus: &FocusHandle, cx: &mut App) -> impl IntoElement {
- v_flex()
- .min_w_full()
- .child(
- h_flex()
- .px_1()
- .mb_2()
- .gap_2()
- .child(
- Label::new(self.title.to_ascii_uppercase())
- .buffer_font(cx)
- .color(Color::Muted)
- .size(LabelSize::XSmall),
- )
- .child(Divider::horizontal().color(DividerColor::BorderVariant)),
- )
- .children(
- self.entries
- .iter()
- .enumerate()
- .map(|(index, entry)| entry.render(index_offset + index, focus, cx)),
- )
- }
-}
-
-struct SectionEntry {
- icon: IconName,
- title: &'static str,
- action: &'static dyn Action,
-}
-
-impl SectionEntry {
- fn render(&self, button_index: usize, focus: &FocusHandle, cx: &App) -> impl IntoElement {
- ButtonLike::new(("onboarding-button-id", button_index))
- .tab_index(button_index as isize)
- .full_width()
- .size(ButtonSize::Medium)
- .child(
- h_flex()
- .w_full()
- .justify_between()
- .child(
- h_flex()
- .gap_2()
- .child(
- Icon::new(self.icon)
- .color(Color::Muted)
- .size(IconSize::XSmall),
- )
- .child(Label::new(self.title)),
- )
- .child(
- KeyBinding::for_action_in(self.action, focus, cx).size(rems_from_px(12.)),
- ),
- )
- .on_click(|_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx))
- }
-}
-
-pub struct WelcomePage {
- focus_handle: FocusHandle,
-}
-
-impl WelcomePage {
- fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
- window.focus_next();
- cx.notify();
- }
-
- fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
- window.focus_prev();
- cx.notify();
- }
-}
-
-impl Render for WelcomePage {
- fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let (first_section, second_section) = CONTENT;
- let first_section_entries = first_section.entries.len();
- let last_index = first_section_entries + second_section.entries.len();
-
- h_flex()
- .size_full()
- .justify_center()
- .overflow_hidden()
- .bg(cx.theme().colors().editor_background)
- .key_context("Welcome")
- .track_focus(&self.focus_handle(cx))
- .on_action(cx.listener(Self::select_previous))
- .on_action(cx.listener(Self::select_next))
- .child(
- h_flex()
- .px_12()
- .py_40()
- .size_full()
- .relative()
- .max_w(px(1100.))
- .child(
- div()
- .size_full()
- .max_w_128()
- .mx_auto()
- .child(
- h_flex()
- .w_full()
- .justify_center()
- .gap_4()
- .child(Vector::square(VectorName::ZedLogo, rems(2.)))
- .child(
- div().child(Headline::new("Welcome to Zed")).child(
- Label::new("The editor for what's next")
- .size(LabelSize::Small)
- .color(Color::Muted)
- .italic(),
- ),
- ),
- )
- .child(
- v_flex()
- .mt_10()
- .gap_6()
- .child(first_section.render(
- Default::default(),
- &self.focus_handle,
- cx,
- ))
- .child(second_section.render(
- first_section_entries,
- &self.focus_handle,
- cx,
- ))
- .child(
- h_flex()
- .w_full()
- .pt_4()
- .justify_center()
- // We call this a hack
- .rounded_b_xs()
- .border_t_1()
- .border_color(cx.theme().colors().border.opacity(0.6))
- .border_dashed()
- .child(
- Button::new("welcome-exit", "Return to Setup")
- .tab_index(last_index as isize)
- .full_width()
- .label_size(LabelSize::XSmall)
- .on_click(|_, window, cx| {
- window.dispatch_action(
- OpenOnboarding.boxed_clone(),
- cx,
- );
-
- with_active_or_new_workspace(cx, |workspace, window, cx| {
- let Some((welcome_id, welcome_idx)) = workspace
- .active_pane()
- .read(cx)
- .items()
- .enumerate()
- .find_map(|(idx, item)| {
- let _ = item.downcast::<WelcomePage>()?;
- Some((item.item_id(), idx))
- })
- else {
- return;
- };
-
- workspace.active_pane().update(cx, |pane, cx| {
- // Get the index here to get around the borrow checker
- let idx = pane.items().enumerate().find_map(
- |(idx, item)| {
- let _ =
- item.downcast::<Onboarding>()?;
- Some(idx)
- },
- );
-
- if let Some(idx) = idx {
- pane.activate_item(
- idx, true, true, window, cx,
- );
- } else {
- let item =
- Box::new(Onboarding::new(workspace, cx));
- pane.add_item(
- item,
- true,
- true,
- Some(welcome_idx),
- window,
- cx,
- );
- }
-
- pane.remove_item(
- welcome_id,
- false,
- false,
- window,
- cx,
- );
- });
- });
- }),
- ),
- ),
- ),
- ),
- )
- }
-}
-
-impl WelcomePage {
- pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
- cx.new(|cx| {
- let focus_handle = cx.focus_handle();
- cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
- .detach();
-
- WelcomePage { focus_handle }
- })
- }
-}
-
-impl EventEmitter<ItemEvent> for WelcomePage {}
-
-impl Focusable for WelcomePage {
- fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
- self.focus_handle.clone()
- }
-}
-
-impl Item for WelcomePage {
- type Event = ItemEvent;
-
- fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
- "Welcome".into()
- }
-
- fn telemetry_event_text(&self) -> Option<&'static str> {
- Some("New Welcome Page Opened")
- }
-
- fn show_toolbar(&self) -> bool {
- false
- }
-
- fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
- f(*event)
- }
-}
-
-impl workspace::SerializableItem for WelcomePage {
- fn serialized_item_kind() -> &'static str {
- "WelcomePage"
- }
-
- fn cleanup(
- workspace_id: workspace::WorkspaceId,
- alive_items: Vec<workspace::ItemId>,
- _window: &mut Window,
- cx: &mut App,
- ) -> Task<gpui::Result<()>> {
- workspace::delete_unloaded_items(
- alive_items,
- workspace_id,
- "welcome_pages",
- &persistence::WELCOME_PAGES,
- cx,
- )
- }
-
- fn deserialize(
- _project: Entity<project::Project>,
- _workspace: gpui::WeakEntity<workspace::Workspace>,
- workspace_id: workspace::WorkspaceId,
- item_id: workspace::ItemId,
- window: &mut Window,
- cx: &mut App,
- ) -> Task<gpui::Result<Entity<Self>>> {
- if persistence::WELCOME_PAGES
- .get_welcome_page(item_id, workspace_id)
- .ok()
- .is_some_and(|is_open| is_open)
- {
- window.spawn(cx, async move |cx| cx.update(WelcomePage::new))
- } else {
- Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
- }
- }
-
- fn serialize(
- &mut self,
- workspace: &mut workspace::Workspace,
- item_id: workspace::ItemId,
- _closing: bool,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Option<Task<gpui::Result<()>>> {
- let workspace_id = workspace.database_id()?;
- Some(cx.background_spawn(async move {
- persistence::WELCOME_PAGES
- .save_welcome_page(item_id, workspace_id, true)
- .await
- }))
- }
-
- fn should_serialize(&self, event: &Self::Event) -> bool {
- event == &ItemEvent::UpdateTab
- }
-}
-
-mod persistence {
- use db::{
- query,
- sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
- sqlez_macros::sql,
- };
- use workspace::WorkspaceDb;
-
- pub struct WelcomePagesDb(ThreadSafeConnection);
-
- impl Domain for WelcomePagesDb {
- const NAME: &str = stringify!(WelcomePagesDb);
-
- const MIGRATIONS: &[&str] = (&[sql!(
- CREATE TABLE welcome_pages (
- workspace_id INTEGER,
- item_id INTEGER UNIQUE,
- is_open INTEGER DEFAULT FALSE,
-
- PRIMARY KEY(workspace_id, item_id),
- FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
- ON DELETE CASCADE
- ) STRICT;
- )]);
- }
-
- db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]);
-
- impl WelcomePagesDb {
- query! {
- pub async fn save_welcome_page(
- item_id: workspace::ItemId,
- workspace_id: workspace::WorkspaceId,
- is_open: bool
- ) -> Result<()> {
- INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
- VALUES (?, ?, ?)
- }
- }
-
- query! {
- pub fn get_welcome_page(
- item_id: workspace::ItemId,
- workspace_id: workspace::WorkspaceId
- ) -> Result<bool> {
- SELECT is_open
- FROM welcome_pages
- WHERE item_id = ? AND workspace_id = ?
- }
- }
- }
-}
@@ -25,3 +25,4 @@ serde.workspace = true
serde_json.workspace = true
settings.workspace = true
strum.workspace = true
+thiserror.workspace = true
@@ -1,11 +1,15 @@
use anyhow::{Context as _, Result, anyhow};
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
-use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
+use http_client::{
+ AsyncBody, HttpClient, Method, Request as HttpRequest, StatusCode,
+ http::{HeaderMap, HeaderValue},
+};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use settings::OpenAiReasoningEffort as ReasoningEffort;
use std::{convert::TryFrom, future::Future};
use strum::EnumIter;
+use thiserror::Error;
pub const OPEN_AI_API_URL: &str = "https://api.openai.com/v1";
@@ -81,7 +85,10 @@ pub enum Model {
FiveMini,
#[serde(rename = "gpt-5-nano")]
FiveNano,
-
+ #[serde(rename = "gpt-5.1")]
+ FivePointOne,
+ #[serde(rename = "gpt-5.2")]
+ FivePointTwo,
#[serde(rename = "custom")]
Custom {
name: String,
@@ -117,6 +124,8 @@ impl Model {
"gpt-5" => Ok(Self::Five),
"gpt-5-mini" => Ok(Self::FiveMini),
"gpt-5-nano" => Ok(Self::FiveNano),
+ "gpt-5.1" => Ok(Self::FivePointOne),
+ "gpt-5.2" => Ok(Self::FivePointTwo),
invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"),
}
}
@@ -138,6 +147,8 @@ impl Model {
Self::Five => "gpt-5",
Self::FiveMini => "gpt-5-mini",
Self::FiveNano => "gpt-5-nano",
+ Self::FivePointOne => "gpt-5.1",
+ Self::FivePointTwo => "gpt-5.2",
Self::Custom { name, .. } => name,
}
}
@@ -159,6 +170,8 @@ impl Model {
Self::Five => "gpt-5",
Self::FiveMini => "gpt-5-mini",
Self::FiveNano => "gpt-5-nano",
+ Self::FivePointOne => "gpt-5.1",
+ Self::FivePointTwo => "gpt-5.2",
Self::Custom {
name, display_name, ..
} => display_name.as_ref().unwrap_or(name),
@@ -182,6 +195,8 @@ impl Model {
Self::Five => 272_000,
Self::FiveMini => 272_000,
Self::FiveNano => 272_000,
+ Self::FivePointOne => 400_000,
+ Self::FivePointTwo => 400_000,
Self::Custom { max_tokens, .. } => *max_tokens,
}
}
@@ -206,6 +221,8 @@ impl Model {
Self::Five => Some(128_000),
Self::FiveMini => Some(128_000),
Self::FiveNano => Some(128_000),
+ Self::FivePointOne => Some(128_000),
+ Self::FivePointTwo => Some(128_000),
}
}
@@ -233,6 +250,8 @@ impl Model {
| Self::FourPointOneNano
| Self::Five
| Self::FiveMini
+ | Self::FivePointOne
+ | Self::FivePointTwo
| Self::FiveNano => true,
Self::O1 | Self::O3 | Self::O3Mini | Self::O4Mini | Model::Custom { .. } => false,
}
@@ -255,7 +274,8 @@ pub struct Request {
pub max_completion_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stop: Vec<String>,
- pub temperature: f32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub temperature: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
/// Whether to enable parallel function calling during tool use.
@@ -441,8 +461,21 @@ pub struct ChoiceDelta {
pub finish_reason: Option<String>,
}
+#[derive(Error, Debug)]
+pub enum RequestError {
+ #[error("HTTP response error from {provider}'s API: status {status_code} - {body:?}")]
+ HttpResponseError {
+ provider: String,
+ status_code: StatusCode,
+ body: String,
+ headers: HeaderMap<HeaderValue>,
+ },
+ #[error(transparent)]
+ Other(#[from] anyhow::Error),
+}
+
#[derive(Serialize, Deserialize, Debug)]
-pub struct OpenAiError {
+pub struct ResponseStreamError {
message: String,
}
@@ -450,7 +483,7 @@ pub struct OpenAiError {
#[serde(untagged)]
pub enum ResponseStreamResult {
Ok(ResponseStreamEvent),
- Err { error: OpenAiError },
+ Err { error: ResponseStreamError },
}
#[derive(Serialize, Deserialize, Debug)]
@@ -461,10 +494,11 @@ pub struct ResponseStreamEvent {
pub async fn stream_completion(
client: &dyn HttpClient,
+ provider_name: &str,
api_url: &str,
api_key: &str,
request: Request,
-) -> Result<BoxStream<'static, Result<ResponseStreamEvent>>> {
+) -> Result<BoxStream<'static, Result<ResponseStreamEvent>>, RequestError> {
let uri = format!("{api_url}/chat/completions");
let request_builder = HttpRequest::builder()
.method(Method::POST)
@@ -472,7 +506,12 @@ pub async fn stream_completion(
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", api_key.trim()));
- let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?;
+ let request = request_builder
+ .body(AsyncBody::from(
+ serde_json::to_string(&request).map_err(|e| RequestError::Other(e.into()))?,
+ ))
+ .map_err(|e| RequestError::Other(e.into()))?;
+
let mut response = client.send(request).await?;
if response.status().is_success() {
let reader = BufReader::new(response.into_body());
@@ -508,27 +547,18 @@ pub async fn stream_completion(
.boxed())
} else {
let mut body = String::new();
- response.body_mut().read_to_string(&mut body).await?;
-
- #[derive(Deserialize)]
- struct OpenAiResponse {
- error: OpenAiError,
- }
-
- match serde_json::from_str::<OpenAiResponse>(&body) {
- Ok(response) if !response.error.message.is_empty() => Err(anyhow!(
- "API request to {} failed: {}",
- api_url,
- response.error.message,
- )),
-
- _ => anyhow::bail!(
- "API request to {} failed with status {}: {}",
- api_url,
- response.status(),
- body,
- ),
- }
+ response
+ .body_mut()
+ .read_to_string(&mut body)
+ .await
+ .map_err(|e| RequestError::Other(e.into()))?;
+
+ Err(RequestError::HttpResponseError {
+ provider: provider_name.to_owned(),
+ status_code: response.status(),
+ body,
+ headers: response.headers().clone(),
+ })
}
}
@@ -215,6 +215,8 @@ pub enum RequestMessage {
content: Option<MessageContent>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tool_calls: Vec<ToolCall>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ reasoning_details: Option<serde_json::Value>,
},
User {
content: MessageContent,
@@ -341,6 +343,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<String>,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
@@ -350,6 +354,8 @@ pub struct ResponseMessageDelta {
pub reasoning: Option<String>,
#[serde(default, skip_serializing_if = "is_none_or_empty")]
pub tool_calls: Option<Vec<ToolCallChunk>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reasoning_details: Option<serde_json::Value>,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
@@ -363,6 +369,8 @@ pub struct ToolCallChunk {
pub struct FunctionChunk {
pub name: Option<String>,
pub arguments: Option<String>,
+ #[serde(default)]
+ pub thought_signature: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
@@ -6,7 +6,7 @@ use std::{
use editor::scroll::ScrollOffset;
use editor::{Anchor, AnchorRangeExt, Editor, scroll::Autoscroll};
-use editor::{RowHighlightOptions, SelectionEffects};
+use editor::{MultiBufferOffset, RowHighlightOptions, SelectionEffects};
use fuzzy::StringMatch;
use gpui::{
App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle,
@@ -247,7 +247,7 @@ impl PickerDelegate for OutlineViewDelegate {
let buffer = editor.buffer().read(cx).snapshot(cx);
let cursor_offset = editor
.selections
- .newest::<usize>(&editor.display_snapshot(cx))
+ .newest::<MultiBufferOffset>(&editor.display_snapshot(cx))
.head();
(buffer, cursor_offset)
});
@@ -259,8 +259,8 @@ impl PickerDelegate for OutlineViewDelegate {
.map(|(ix, item)| {
let range = item.range.to_offset(&buffer);
let distance_to_closest_endpoint = cmp::min(
- (range.start as isize - cursor_offset as isize).abs(),
- (range.end as isize - cursor_offset as isize).abs(),
+ (range.start.0 as isize - cursor_offset.0 as isize).abs(),
+ (range.end.0 as isize - cursor_offset.0 as isize).abs(),
);
let depth = if range.contains(&cursor_offset) {
Some(item.depth)
@@ -311,7 +311,7 @@ impl PickerDelegate for OutlineViewDelegate {
|s| s.select_ranges([rows.start..rows.start]),
);
active_editor.clear_row_highlights::<OutlineRowHighlights>();
- window.focus(&active_editor.focus_handle(cx));
+ window.focus(&active_editor.focus_handle(cx), cx);
}
});
@@ -391,7 +391,6 @@ mod tests {
use super::*;
use gpui::{TestAppContext, VisualTestContext};
use indoc::indoc;
- use language::{Language, LanguageConfig, LanguageMatcher};
use project::{FakeFs, Project};
use serde_json::json;
use util::{path, rel_path::rel_path};
@@ -418,7 +417,9 @@ mod tests {
.await;
let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
- project.read_with(cx, |project, _| project.languages().add(rust_lang()));
+ project.read_with(cx, |project, _| {
+ project.languages().add(language::rust_lang())
+ });
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
@@ -581,89 +582,6 @@ mod tests {
})
}
- fn rust_lang() -> Arc<Language> {
- 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()),
- )
- .with_outline_query(
- r#"(struct_item
- (visibility_modifier)? @context
- "struct" @context
- name: (_) @name) @item
-
- (enum_item
- (visibility_modifier)? @context
- "enum" @context
- name: (_) @name) @item
-
- (enum_variant
- (visibility_modifier)? @context
- name: (_) @name) @item
-
- (impl_item
- "impl" @context
- trait: (_)? @name
- "for"? @context
- type: (_) @name) @item
-
- (trait_item
- (visibility_modifier)? @context
- "trait" @context
- name: (_) @name) @item
-
- (function_item
- (visibility_modifier)? @context
- (function_modifiers)? @context
- "fn" @context
- name: (_) @name) @item
-
- (function_signature_item
- (visibility_modifier)? @context
- (function_modifiers)? @context
- "fn" @context
- name: (_) @name) @item
-
- (macro_definition
- . "macro_rules!" @context
- name: (_) @name) @item
-
- (mod_item
- (visibility_modifier)? @context
- "mod" @context
- name: (_) @name) @item
-
- (type_item
- (visibility_modifier)? @context
- "type" @context
- name: (_) @name) @item
-
- (associated_type
- "type" @context
- name: (_) @name) @item
-
- (const_item
- (visibility_modifier)? @context
- "const" @context
- name: (_) @name) @item
-
- (field_declaration
- (visibility_modifier)? @context
- name: (_) @name) @item
-"#,
- )
- .unwrap(),
- )
- }
-
#[track_caller]
fn assert_single_caret_at_row(
editor: &Entity<Editor>,
@@ -46,10 +46,8 @@ use settings::{Settings, SettingsStore};
use smol::channel;
use theme::{SyntaxTheme, ThemeSettings};
use ui::{
- ActiveTheme, ButtonCommon, Clickable, Color, ContextMenu, DynamicSpacing, FluentBuilder,
- HighlightedLabel, Icon, IconButton, IconButtonShape, IconName, IconSize, IndentGuideColors,
- IndentGuideLayout, Label, LabelCommon, ListItem, ScrollAxes, Scrollbars, StyledExt,
- StyledTypography, Toggleable, Tooltip, WithScrollbar, h_flex, v_flex,
+ ContextMenu, FluentBuilder, HighlightedLabel, IconButton, IconButtonShape, IndentGuideColors,
+ IndentGuideLayout, ListItem, ScrollAxes, Scrollbars, Tab, Tooltip, WithScrollbar, prelude::*,
};
use util::{RangeExt, ResultExt, TryFutureExt, debug_panic, rel_path::RelPath};
use workspace::{
@@ -77,6 +75,16 @@ actions!(
OpenSelectedEntry,
/// Reveals the selected item in the system file manager.
RevealInFileManager,
+ /// Scroll half a page upwards
+ ScrollUp,
+ /// Scroll half a page downwards
+ ScrollDown,
+ /// Scroll until the cursor displays at the center
+ ScrollCursorCenter,
+ /// Scroll until the cursor displays at the top
+ ScrollCursorTop,
+ /// Scroll until the cursor displays at the bottom
+ ScrollCursorBottom,
/// Selects the parent of the current entry.
SelectParent,
/// Toggles the pin status of the active editor.
@@ -102,6 +110,7 @@ pub struct OutlinePanel {
active: bool,
pinned: bool,
scroll_handle: UniformListScrollHandle,
+ rendered_entries_len: usize,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
focus_handle: FocusHandle,
pending_serialization: Task<Option<()>>,
@@ -113,8 +122,6 @@ pub struct OutlinePanel {
selected_entry: SelectedEntry,
active_item: Option<ActiveItem>,
_subscriptions: Vec<Subscription>,
- updating_fs_entries: bool,
- updating_cached_entries: bool,
new_entries_for_fs_update: HashSet<ExcerptId>,
fs_entries_update_task: Task<()>,
cached_entries_update_task: Task<()>,
@@ -714,7 +721,7 @@ impl OutlinePanel {
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 buffer symbols…", window, cx);
editor
});
let filter_update_subscription = cx.subscribe_in(
@@ -843,6 +850,7 @@ impl OutlinePanel {
fs: workspace.app_state().fs.clone(),
max_width_item_index: None,
scroll_handle,
+ rendered_entries_len: 0,
focus_handle,
filter_editor,
fs_entries: Vec::new(),
@@ -855,8 +863,6 @@ impl OutlinePanel {
width: None,
active_item: None,
pending_serialization: Task::ready(None),
- updating_fs_entries: false,
- updating_cached_entries: false,
new_entries_for_fs_update: HashSet::default(),
preserve_selection_on_buffer_fold_toggles: HashSet::default(),
pending_default_expansion_depth: None,
@@ -986,16 +992,15 @@ impl OutlinePanel {
if self.filter_editor.focus_handle(cx).is_focused(window) {
cx.propagate()
} else if let Some(selected_entry) = self.selected_entry().cloned() {
- self.toggle_expanded(&selected_entry, window, cx);
self.scroll_editor_to_entry(&selected_entry, true, true, window, cx);
}
}
fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
if self.filter_editor.focus_handle(cx).is_focused(window) {
- self.focus_handle.focus(window);
+ self.focus_handle.focus(window, cx);
} else {
- self.filter_editor.focus_handle(cx).focus(window);
+ self.filter_editor.focus_handle(cx).focus(window, cx);
}
if self.context_menu.is_some() {
@@ -1148,14 +1153,78 @@ impl OutlinePanel {
}
if change_focus {
- active_editor.focus_handle(cx).focus(window);
+ active_editor.focus_handle(cx).focus(window, cx);
} else {
- self.focus_handle.focus(window);
+ self.focus_handle.focus(window, cx);
}
}
}
}
+ fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
+ for _ in 0..self.rendered_entries_len / 2 {
+ window.dispatch_action(SelectPrevious.boxed_clone(), cx);
+ }
+ }
+
+ fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
+ for _ in 0..self.rendered_entries_len / 2 {
+ window.dispatch_action(SelectNext.boxed_clone(), cx);
+ }
+ }
+
+ fn scroll_cursor_center(
+ &mut self,
+ _: &ScrollCursorCenter,
+ _: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some(selected_entry) = self.selected_entry() {
+ let index = self
+ .cached_entries
+ .iter()
+ .position(|cached_entry| &cached_entry.entry == selected_entry);
+ if let Some(index) = index {
+ self.scroll_handle
+ .scroll_to_item_strict(index, ScrollStrategy::Center);
+ cx.notify();
+ }
+ }
+ }
+
+ fn scroll_cursor_top(&mut self, _: &ScrollCursorTop, _: &mut Window, cx: &mut Context<Self>) {
+ if let Some(selected_entry) = self.selected_entry() {
+ let index = self
+ .cached_entries
+ .iter()
+ .position(|cached_entry| &cached_entry.entry == selected_entry);
+ if let Some(index) = index {
+ self.scroll_handle
+ .scroll_to_item_strict(index, ScrollStrategy::Top);
+ cx.notify();
+ }
+ }
+ }
+
+ fn scroll_cursor_bottom(
+ &mut self,
+ _: &ScrollCursorBottom,
+ _: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some(selected_entry) = self.selected_entry() {
+ let index = self
+ .cached_entries
+ .iter()
+ .position(|cached_entry| &cached_entry.entry == selected_entry);
+ if let Some(index) = index {
+ self.scroll_handle
+ .scroll_to_item_strict(index, ScrollStrategy::Bottom);
+ cx.notify();
+ }
+ }
+ }
+
fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| {
self.cached_entries
@@ -1389,7 +1458,7 @@ impl OutlinePanel {
Box::new(zed_actions::workspace::CopyRelativePath),
)
});
- window.focus(&context_menu.focus_handle(cx));
+ window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe(&context_menu, |outline_panel, _, _: &DismissEvent, cx| {
outline_panel.context_menu.take();
cx.notify();
@@ -2047,8 +2116,9 @@ impl OutlinePanel {
PanelEntry::Fs(FsEntry::ExternalFile(..)) => None,
PanelEntry::Search(SearchEntry { match_range, .. }) => match_range
.start
+ .text_anchor
.buffer_id
- .or(match_range.end.buffer_id)
+ .or(match_range.end.text_anchor.buffer_id)
.map(|buffer_id| {
outline_panel.update(cx, |outline_panel, cx| {
outline_panel
@@ -2616,7 +2686,7 @@ impl OutlinePanel {
})
.when(
is_active && self.focus_handle.contains_focused(window, cx),
- |div| div.border_color(Color::Selected.color(cx)),
+ |div| div.border_color(cx.theme().colors().panel_focused_border),
)
}
@@ -2660,7 +2730,6 @@ impl OutlinePanel {
let repo_snapshots = self.project.update(cx, |project, cx| {
project.git_store().read(cx).repo_snapshots(cx)
});
- self.updating_fs_entries = true;
self.fs_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| {
if let Some(debounce) = debounce {
cx.background_executor().timer(debounce).await;
@@ -3018,7 +3087,6 @@ impl OutlinePanel {
outline_panel
.update_in(cx, |outline_panel, window, cx| {
- outline_panel.updating_fs_entries = false;
outline_panel.new_entries_for_fs_update.clear();
outline_panel.excerpts = new_excerpts;
outline_panel.collapsed_entries = new_collapsed_entries;
@@ -3581,7 +3649,6 @@ impl OutlinePanel {
let is_singleton = self.is_singleton_active(cx);
let query = self.query(cx);
- self.updating_cached_entries = true;
self.cached_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| {
if let Some(debounce) = debounce {
cx.background_executor().timer(debounce).await;
@@ -3614,7 +3681,6 @@ impl OutlinePanel {
}
outline_panel.autoscroll(cx);
- outline_panel.updating_cached_entries = false;
cx.notify();
})
.ok();
@@ -4473,7 +4539,7 @@ impl OutlinePanel {
cx: &mut Context<Self>,
) {
if focus {
- self.focus_handle.focus(window);
+ self.focus_handle.focus(window, cx);
}
let ix = self
.cached_entries
@@ -4544,43 +4610,40 @@ impl OutlinePanel {
cx: &mut Context<Self>,
) -> impl IntoElement {
let contents = if self.cached_entries.is_empty() {
- let header = if self.updating_fs_entries || self.updating_cached_entries {
- None
- } else if query.is_some() {
- Some("No matches for query")
+ let header = if query.is_some() {
+ "No matches for query"
} else {
- Some("No outlines available")
+ "No outlines available"
};
v_flex()
.id("empty-outline-state")
+ .gap_0p5()
.flex_1()
.justify_center()
.size_full()
- .when_some(header, |panel, header| {
- panel
- .child(h_flex().justify_center().child(Label::new(header)))
- .when_some(query.clone(), |panel, query| {
- panel.child(h_flex().justify_center().child(Label::new(query)))
- })
- .child(
- h_flex()
- .pt(DynamicSpacing::Base04.rems(cx))
- .justify_center()
- .child({
- let keystroke =
- match self.position(window, cx) {
- DockPosition::Left => window
- .keystroke_text_for(&workspace::ToggleLeftDock),
- DockPosition::Bottom => window
- .keystroke_text_for(&workspace::ToggleBottomDock),
- DockPosition::Right => window
- .keystroke_text_for(&workspace::ToggleRightDock),
- };
- Label::new(format!("Toggle this panel with {keystroke}"))
- }),
- )
+ .child(h_flex().justify_center().child(Label::new(header)))
+ .when_some(query, |panel, query| {
+ panel.child(
+ h_flex()
+ .px_0p5()
+ .justify_center()
+ .bg(cx.theme().colors().element_selected.opacity(0.2))
+ .child(Label::new(query)),
+ )
})
+ .child(h_flex().justify_center().child({
+ let keystroke = match self.position(window, cx) {
+ DockPosition::Left => window.keystroke_text_for(&workspace::ToggleLeftDock),
+ DockPosition::Bottom => {
+ window.keystroke_text_for(&workspace::ToggleBottomDock)
+ }
+ DockPosition::Right => {
+ window.keystroke_text_for(&workspace::ToggleRightDock)
+ }
+ };
+ Label::new(format!("Toggle Panel With {keystroke}")).color(Color::Muted)
+ }))
} else {
let list_contents = {
let items_len = self.cached_entries.len();
@@ -4591,6 +4654,7 @@ impl OutlinePanel {
"entries",
items_len,
cx.processor(move |outline_panel, range: Range<usize>, window, cx| {
+ outline_panel.rendered_entries_len = range.end - range.start;
let entries = outline_panel.cached_entries.get(range);
entries
.map(|entries| entries.to_vec())
@@ -4652,7 +4716,7 @@ impl OutlinePanel {
.with_sizing_behavior(ListSizingBehavior::Infer)
.with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
.with_width_from_item(self.max_width_item_index)
- .track_scroll(self.scroll_handle.clone())
+ .track_scroll(&self.scroll_handle)
.when(show_indent_guides, |list| {
list.with_decoration(
ui::indent_guides(px(indent_size), IndentGuideColors::panel(cx))
@@ -4705,7 +4769,7 @@ impl OutlinePanel {
.child(list_contents.size_full().flex_shrink())
.custom_scrollbars(
Scrollbars::for_settings::<OutlinePanelSettings>()
- .tracked_scroll_handle(self.scroll_handle.clone())
+ .tracked_scroll_handle(&self.scroll_handle.clone())
.with_track_along(
ScrollAxes::Horizontal,
cx.theme().colors().panel_background,
@@ -4729,39 +4793,37 @@ impl OutlinePanel {
}
fn render_filter_footer(&mut self, pinned: bool, cx: &mut Context<Self>) -> Div {
- v_flex().flex_none().child(horizontal_separator(cx)).child(
- h_flex()
- .p_2()
- .w_full()
- .child(self.filter_editor.clone())
- .child(
- div().child(
- IconButton::new(
- "outline-panel-menu",
- if pinned {
- IconName::Unpin
- } else {
- IconName::Pin
- },
- )
- .tooltip(Tooltip::text(if pinned {
- "Unpin Outline"
- } else {
- "Pin Active Outline"
- }))
- .shape(IconButtonShape::Square)
- .on_click(cx.listener(
- |outline_panel, _, window, cx| {
- outline_panel.toggle_active_editor_pin(
- &ToggleActiveEditorPin,
- window,
- cx,
- );
- },
- )),
- ),
- ),
- )
+ let (icon, icon_tooltip) = if pinned {
+ (IconName::Unpin, "Unpin Outline")
+ } else {
+ (IconName::Pin, "Pin Active Outline")
+ };
+
+ h_flex()
+ .p_2()
+ .h(Tab::container_height(cx))
+ .justify_between()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ h_flex()
+ .w_full()
+ .gap_1p5()
+ .child(
+ Icon::new(IconName::MagnifyingGlass)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .child(self.filter_editor.clone()),
+ )
+ .child(
+ IconButton::new("pin_button", icon)
+ .tooltip(Tooltip::text(icon_tooltip))
+ .shape(IconButtonShape::Square)
+ .on_click(cx.listener(|outline_panel, _, window, cx| {
+ outline_panel.toggle_active_editor_pin(&ToggleActiveEditorPin, window, cx);
+ })),
+ )
}
fn buffers_inside_directory(
@@ -4975,6 +5037,8 @@ impl Render for OutlinePanel {
_ => None,
};
+ let search_query_text = search_query.map(|sq| sq.query.to_string());
+
v_flex()
.id("outline-panel")
.size_full()
@@ -4983,7 +5047,12 @@ impl Render for OutlinePanel {
.key_context(self.dispatch_context(window, cx))
.on_action(cx.listener(Self::open_selected_entry))
.on_action(cx.listener(Self::cancel))
+ .on_action(cx.listener(Self::scroll_up))
+ .on_action(cx.listener(Self::scroll_down))
.on_action(cx.listener(Self::select_next))
+ .on_action(cx.listener(Self::scroll_cursor_center))
+ .on_action(cx.listener(Self::scroll_cursor_top))
+ .on_action(cx.listener(Self::scroll_cursor_bottom))
.on_action(cx.listener(Self::select_previous))
.on_action(cx.listener(Self::select_first))
.on_action(cx.listener(Self::select_last))
@@ -5021,22 +5090,21 @@ impl Render for OutlinePanel {
}),
)
.track_focus(&self.focus_handle)
- .when_some(search_query, |outline_panel, search_state| {
+ .child(self.render_filter_footer(pinned, cx))
+ .when_some(search_query_text, |outline_panel, query_text| {
outline_panel.child(
h_flex()
.py_1p5()
.px_2()
- .h(DynamicSpacing::Base32.px(cx))
- .flex_shrink_0()
- .border_b_1()
- .border_color(cx.theme().colors().border)
+ .h(Tab::container_height(cx))
.gap_0p5()
+ .border_b_1()
+ .border_color(cx.theme().colors().border_variant)
.child(Label::new("Searching:").color(Color::Muted))
- .child(Label::new(search_state.query.to_string())),
+ .child(Label::new(query_text)),
)
})
.child(self.render_main_contents(query, show_indent_guides, indent_size, window, cx))
- .child(self.render_filter_footer(pinned, cx))
}
}
@@ -5215,10 +5283,6 @@ fn empty_icon() -> AnyElement {
.into_any_element()
}
-fn horizontal_separator(cx: &mut App) -> Div {
- div().mx_2().border_primary(cx).border_t_1()
-}
-
#[derive(Debug, Default)]
struct GenerationState {
entries: Vec<CachedEntry>,
@@ -5238,7 +5302,7 @@ impl GenerationState {
mod tests {
use db::indoc;
use gpui::{TestAppContext, VisualTestContext, WindowHandle};
- use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
+ use language::rust_lang;
use pretty_assertions::assert_eq;
use project::FakeFs;
use search::{
@@ -5261,9 +5325,7 @@ mod tests {
let root = path!("/rust-analyzer");
populate_with_test_ra_project(&fs, root).await;
let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
- project.read_with(cx, |project, _| {
- project.languages().add(Arc::new(rust_lang()))
- });
+ project.read_with(cx, |project, _| project.languages().add(rust_lang()));
let workspace = add_outline_panel(&project, cx).await;
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let outline_panel = outline_panel(&workspace, cx);
@@ -5496,9 +5558,7 @@ mod tests {
let root = path!("/rust-analyzer");
populate_with_test_ra_project(&fs, root).await;
let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
- project.read_with(cx, |project, _| {
- project.languages().add(Arc::new(rust_lang()))
- });
+ project.read_with(cx, |project, _| project.languages().add(rust_lang()));
let workspace = add_outline_panel(&project, cx).await;
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let outline_panel = outline_panel(&workspace, cx);
@@ -5635,9 +5695,7 @@ mod tests {
let root = path!("/rust-analyzer");
populate_with_test_ra_project(&fs, root).await;
let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
- project.read_with(cx, |project, _| {
- project.languages().add(Arc::new(rust_lang()))
- });
+ project.read_with(cx, |project, _| project.languages().add(rust_lang()));
let workspace = add_outline_panel(&project, cx).await;
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let outline_panel = outline_panel(&workspace, cx);
@@ -5834,7 +5892,8 @@ mod tests {
outline_panel.selected_entry(),
cx,
),
- "fn_lifetime_fn.rs <==== selected"
+ "outline: pub(super) fn hints
+outline: fn hints_lifetimes_named <==== selected"
);
assert_eq!(
selected_row_text(&new_active_editor, cx),
@@ -5845,7 +5904,7 @@ mod tests {
}
#[gpui::test]
- async fn test_multiple_workrees(cx: &mut TestAppContext) {
+ async fn test_multiple_worktrees(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
@@ -5951,7 +6010,7 @@ two/
outline_panel.update_in(cx, |outline_panel, window, cx| {
outline_panel.select_previous(&SelectPrevious, window, cx);
- outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
+ outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
});
cx.executor()
.advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
@@ -5977,7 +6036,7 @@ two/
outline_panel.update_in(cx, |outline_panel, window, cx| {
outline_panel.select_next(&SelectNext, window, cx);
- outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
+ outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
});
cx.executor()
.advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
@@ -6000,7 +6059,7 @@ two/ <==== selected"#,
});
outline_panel.update_in(cx, |outline_panel, window, cx| {
- outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
+ outline_panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
});
cx.executor()
.advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
@@ -6047,24 +6106,7 @@ struct OutlineEntryExcerpt {
)
.await;
let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
- project.read_with(cx, |project, _| {
- project.languages().add(Arc::new(
- rust_lang()
- .with_outline_query(
- r#"
- (struct_item
- (visibility_modifier)? @context
- "struct" @context
- name: (_) @name) @item
-
- (field_declaration
- (visibility_modifier)? @context
- name: (_) @name) @item
-"#,
- )
- .unwrap(),
- ))
- });
+ project.read_with(cx, |project, _| project.languages().add(rust_lang()));
let workspace = add_outline_panel(&project, cx).await;
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let outline_panel = outline_panel(&workspace, cx);
@@ -6619,11 +6661,13 @@ outline: struct OutlineEntryExcerpt
format!(
r#"frontend-project/
public/lottie/
- syntax-tree.json <==== selected
+ syntax-tree.json
+ search: {{ "something": "«static»" }}
src/
app/(site)/
components/
- ErrorBoundary.tsx"#
+ ErrorBoundary.tsx <==== selected
+ search: «static»"#
)
);
});
@@ -6665,7 +6709,7 @@ outline: struct OutlineEntryExcerpt
format!(
r#"frontend-project/
public/lottie/
- syntax-tree.json <==== selected
+ syntax-tree.json
search: {{ "something": "«static»" }}
src/
app/(site)/
@@ -6676,7 +6720,7 @@ outline: struct OutlineEntryExcerpt
page.tsx
search: «static»
components/
- ErrorBoundary.tsx
+ ErrorBoundary.tsx <==== selected
search: «static»"#
)
);
@@ -7010,35 +7054,6 @@ outline: struct OutlineEntryExcerpt
.await;
}
- 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(
- r#"
- (field_identifier) @field
- (struct_expression) @struct
- "#,
- )
- .unwrap()
- .with_injection_query(
- r#"
- (macro_invocation
- (token_tree) @injection.content
- (#set! injection.language "rust"))
- "#,
- )
- .unwrap()
- }
-
fn snapshot(outline_panel: &OutlinePanel, cx: &App) -> MultiBufferSnapshot {
outline_panel
.active_editor()
@@ -7104,44 +7119,7 @@ outline: struct OutlineEntryExcerpt
.await;
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
- project.read_with(cx, |project, _| {
- project.languages().add(Arc::new(
- rust_lang()
- .with_outline_query(
- r#"
- (struct_item
- (visibility_modifier)? @context
- "struct" @context
- name: (_) @name) @item
- (impl_item
- "impl" @context
- trait: (_)? @context
- "for"? @context
- type: (_) @context
- body: (_)) @item
- (function_item
- (visibility_modifier)? @context
- "fn" @context
- name: (_) @name
- parameters: (_) @context) @item
- (mod_item
- (visibility_modifier)? @context
- "mod" @context
- name: (_) @name) @item
- (enum_item
- (visibility_modifier)? @context
- "enum" @context
- name: (_) @name) @item
- (field_declaration
- (visibility_modifier)? @context
- name: (_) @name
- ":" @context
- type: (_) @context) @item
- "#,
- )
- .unwrap(),
- ))
- });
+ project.read_with(cx, |project, _| project.languages().add(rust_lang()));
let workspace = add_outline_panel(&project, cx).await;
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let outline_panel = outline_panel(&workspace, cx);
@@ -7192,15 +7170,15 @@ outline: struct OutlineEntryExcerpt
"
outline: mod outer <==== selected
outline: pub struct OuterStruct
- outline: field: String
+ outline: field
outline: impl OuterStruct
- outline: pub fn new()
- outline: pub fn method(&self)
+ outline: pub fn new
+ outline: pub fn method
outline: mod inner
- outline: pub fn inner_function()
+ outline: pub fn inner_function
outline: pub struct InnerStruct
- outline: value: i32
-outline: fn main()"
+ outline: value
+outline: fn main"
)
);
});
@@ -7250,7 +7228,7 @@ outline: fn main()"
indoc!(
"
outline: mod outer <==== selected
-outline: fn main()"
+outline: fn main"
)
);
});
@@ -7275,15 +7253,15 @@ outline: fn main()"
"
outline: mod outer <==== selected
outline: pub struct OuterStruct
- outline: field: String
+ outline: field
outline: impl OuterStruct
- outline: pub fn new()
- outline: pub fn method(&self)
+ outline: pub fn new
+ outline: pub fn method
outline: mod inner
- outline: pub fn inner_function()
+ outline: pub fn inner_function
outline: pub struct InnerStruct
- outline: value: i32
-outline: fn main()"
+ outline: value
+outline: fn main"
)
);
});
@@ -7339,7 +7317,7 @@ outline: fn main()"
indoc!(
"
outline: mod outer
-outline: fn main()"
+outline: fn main"
)
);
});
@@ -7396,44 +7374,7 @@ outline: fn main()"
.await;
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
- project.read_with(cx, |project, _| {
- project.languages().add(Arc::new(
- rust_lang()
- .with_outline_query(
- r#"
- (struct_item
- (visibility_modifier)? @context
- "struct" @context
- name: (_) @name) @item
- (impl_item
- "impl" @context
- trait: (_)? @context
- "for"? @context
- type: (_) @context
- body: (_)) @item
- (function_item
- (visibility_modifier)? @context
- "fn" @context
- name: (_) @name
- parameters: (_) @context) @item
- (mod_item
- (visibility_modifier)? @context
- "mod" @context
- name: (_) @name) @item
- (enum_item
- (visibility_modifier)? @context
- "enum" @context
- name: (_) @name) @item
- (field_declaration
- (visibility_modifier)? @context
- name: (_) @name
- ":" @context
- type: (_) @context) @item
- "#,
- )
- .unwrap(),
- ))
- });
+ project.read_with(cx, |project, _| project.languages().add(rust_lang()));
let workspace = add_outline_panel(&project, cx).await;
let cx = &mut VisualTestContext::from_window(*workspace, cx);
@@ -7480,14 +7421,16 @@ outline: fn main()"
indoc!(
"
outline: struct Config
- outline: name: String
- outline: value: i32
+ outline: name
+ outline: value
outline: impl Config
- outline: fn new(name: String)
- outline: fn get_value(&self)
+ outline: fn new
+ outline: fn get_value
outline: enum Status
-outline: fn process_config(config: Config)
-outline: fn main()"
+ outline: Active
+ outline: Inactive
+outline: fn process_config
+outline: fn main"
)
);
});
@@ -7518,21 +7461,23 @@ outline: fn main()"
indoc!(
"
outline: struct Config <==== selected
- outline: name: String
- outline: value: i32
+ outline: name
+ outline: value
outline: impl Config
- outline: fn new(name: String)
- outline: fn get_value(&self)
+ outline: fn new
+ outline: fn get_value
outline: enum Status
-outline: fn process_config(config: Config)
-outline: fn main()"
+ outline: Active
+ outline: Inactive
+outline: fn process_config
+outline: fn main"
)
);
});
cx.update(|window, cx| {
outline_panel.update(cx, |outline_panel, cx| {
- outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
+ outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
});
});
@@ -7553,18 +7498,20 @@ outline: fn main()"
"
outline: struct Config <==== selected
outline: impl Config
- outline: fn new(name: String)
- outline: fn get_value(&self)
+ outline: fn new
+ outline: fn get_value
outline: enum Status
-outline: fn process_config(config: Config)
-outline: fn main()"
+ outline: Active
+ outline: Inactive
+outline: fn process_config
+outline: fn main"
)
);
});
cx.update(|window, cx| {
outline_panel.update(cx, |outline_panel, cx| {
- outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
+ outline_panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
});
});
@@ -7584,14 +7531,16 @@ outline: fn main()"
indoc!(
"
outline: struct Config <==== selected
- outline: name: String
- outline: value: i32
+ outline: name
+ outline: value
outline: impl Config
- outline: fn new(name: String)
- outline: fn get_value(&self)
+ outline: fn new
+ outline: fn get_value
outline: enum Status
-outline: fn process_config(config: Config)
-outline: fn main()"
+ outline: Active
+ outline: Inactive
+outline: fn process_config
+outline: fn main"
)
);
});
@@ -7640,44 +7589,7 @@ outline: fn main()"
.await;
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
- project.read_with(cx, |project, _| {
- project.languages().add(Arc::new(
- rust_lang()
- .with_outline_query(
- r#"
- (struct_item
- (visibility_modifier)? @context
- "struct" @context
- name: (_) @name) @item
- (impl_item
- "impl" @context
- trait: (_)? @context
- "for"? @context
- type: (_) @context
- body: (_)) @item
- (function_item
- (visibility_modifier)? @context
- "fn" @context
- name: (_) @name
- parameters: (_) @context) @item
- (mod_item
- (visibility_modifier)? @context
- "mod" @context
- name: (_) @name) @item
- (enum_item
- (visibility_modifier)? @context
- "enum" @context
- name: (_) @name) @item
- (field_declaration
- (visibility_modifier)? @context
- name: (_) @name
- ":" @context
- type: (_) @context) @item
- "#,
- )
- .unwrap(),
- ))
- });
+ project.read_with(cx, |project, _| project.languages().add(rust_lang()));
let workspace = add_outline_panel(&project, cx).await;
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let outline_panel = outline_panel(&workspace, cx);
@@ -7728,15 +7640,15 @@ outline: fn main()"
"
outline: mod outer <==== selected
outline: pub struct OuterStruct
- outline: field: String
+ outline: field
outline: impl OuterStruct
- outline: pub fn new()
- outline: pub fn method(&self)
+ outline: pub fn new
+ outline: pub fn method
outline: mod inner
- outline: pub fn inner_function()
+ outline: pub fn inner_function
outline: pub struct InnerStruct
- outline: value: i32
-outline: fn main()"
+ outline: value
+outline: fn main"
)
);
});
@@ -7777,7 +7689,7 @@ outline: fn main()"
let expected_collapsed_output = indoc!(
"
outline: mod outer <==== selected
- outline: fn main()"
+ outline: fn main"
);
outline_panel.update(cx, |panel, cx| {
@@ -7805,15 +7717,15 @@ outline: fn main()"
"
outline: mod outer <==== selected
outline: pub struct OuterStruct
- outline: field: String
+ outline: field
outline: impl OuterStruct
- outline: pub fn new()
- outline: pub fn method(&self)
+ outline: pub fn new
+ outline: pub fn method
outline: mod inner
- outline: pub fn inner_function()
+ outline: pub fn inner_function
outline: pub struct InnerStruct
- outline: value: i32
- outline: fn main()"
+ outline: value
+ outline: fn main"
);
outline_panel.update(cx, |panel, cx| {
@@ -155,6 +155,12 @@ pub fn temp_dir() -> &'static PathBuf {
})
}
+/// Returns the path to the hang traces directory.
+pub fn hang_traces_dir() -> &'static PathBuf {
+ static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
+ LOGS_DIR.get_or_init(|| data_dir().join("hang_traces"))
+}
+
/// Returns the path to the logs directory.
pub fn logs_dir() -> &'static PathBuf {
static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
@@ -402,6 +408,12 @@ pub fn remote_servers_dir() -> &'static PathBuf {
REMOTE_SERVERS_DIR.get_or_init(|| data_dir().join("remote_servers"))
}
+/// Returns the path to the directory where the devcontainer CLI is installed.
+pub fn devcontainer_dir() -> &'static PathBuf {
+ static DEVCONTAINER_DIR: OnceLock<PathBuf> = OnceLock::new();
+ DEVCONTAINER_DIR.get_or_init(|| data_dir().join("devcontainer"))
+}
+
/// Returns the relative path to a `.zed` folder within a project.
pub fn local_settings_folder_name() -> &'static str {
".zed"
@@ -97,6 +97,18 @@ pub trait PickerDelegate: Sized + 'static {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
);
+
+ /// Called before the picker handles `SelectPrevious` or `SelectNext`. Return `Some(query)` to
+ /// set a new query and prevent the default selection behavior.
+ fn select_history(
+ &mut self,
+ _direction: Direction,
+ _query: &str,
+ _window: &mut Window,
+ _cx: &mut App,
+ ) -> Option<String> {
+ None
+ }
fn can_select(
&mut self,
_ix: usize,
@@ -372,7 +384,7 @@ impl<D: PickerDelegate> Picker<D> {
}
pub fn focus(&self, window: &mut Window, cx: &mut App) {
- self.focus_handle(cx).focus(window);
+ self.focus_handle(cx).focus(window, cx);
}
/// Handles the selecting an index, and passing the change to the delegate.
@@ -448,6 +460,14 @@ impl<D: PickerDelegate> Picker<D> {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ let query = self.query(cx);
+ if let Some(query) = self
+ .delegate
+ .select_history(Direction::Down, &query, window, cx)
+ {
+ self.set_query(query, window, cx);
+ return;
+ }
let count = self.delegate.match_count();
if count > 0 {
let index = self.delegate.selected_index();
@@ -467,6 +487,14 @@ impl<D: PickerDelegate> Picker<D> {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ let query = self.query(cx);
+ if let Some(query) = self
+ .delegate
+ .select_history(Direction::Up, &query, window, cx)
+ {
+ self.set_query(query, window, cx);
+ return;
+ }
let count = self.delegate.match_count();
if count > 0 {
let index = self.delegate.selected_index();
@@ -607,7 +635,7 @@ impl<D: PickerDelegate> Picker<D> {
self.update_matches(query, window, cx);
}
editor::EditorEvent::Blurred => {
- if self.is_modal {
+ if self.is_modal && window.is_window_active() {
self.cancel(&menu::Cancel, window, cx);
}
}
@@ -619,7 +647,9 @@ impl<D: PickerDelegate> Picker<D> {
let Head::Empty(_) = &self.head else {
panic!("unexpected call");
};
- self.cancel(&menu::Cancel, window, cx);
+ if window.is_window_active() {
+ self.cancel(&menu::Cancel, window, cx);
+ }
}
pub fn refresh_placeholder(&mut self, window: &mut Window, cx: &mut App) {
@@ -709,7 +739,7 @@ impl<D: PickerDelegate> Picker<D> {
match &mut self.element_container {
ElementContainer::List(state) => state.scroll_to_reveal_item(ix),
ElementContainer::UniformList(scroll_handle) => {
- scroll_handle.scroll_to_item(ix, ScrollStrategy::Top)
+ scroll_handle.scroll_to_item(ix, ScrollStrategy::Nearest)
}
}
}
@@ -778,7 +808,7 @@ impl<D: PickerDelegate> Picker<D> {
})
.flex_grow()
.py_1()
- .track_scroll(scroll_handle.clone())
+ .track_scroll(&scroll_handle)
.into_any_element(),
ElementContainer::List(state) => list(
state.clone(),
@@ -864,12 +894,12 @@ impl<D: PickerDelegate> Render for Picker<D> {
this.map(|this| match &self.element_container {
ElementContainer::List(state) => this.custom_scrollbars(
- base_scrollbar_config.tracked_scroll_handle(state.clone()),
+ base_scrollbar_config.tracked_scroll_handle(state),
window,
cx,
),
ElementContainer::UniformList(state) => this.custom_scrollbars(
- base_scrollbar_config.tracked_scroll_handle(state.clone()),
+ base_scrollbar_config.tracked_scroll_handle(state),
window,
cx,
),
@@ -2,7 +2,8 @@ use anyhow::Context as _;
use collections::{HashMap, HashSet};
use fs::Fs;
use gpui::{AsyncApp, Entity};
-use language::{Buffer, Diff, language_settings::language_settings};
+use language::language_settings::PrettierSettings;
+use language::{Buffer, Diff, Language, language_settings::language_settings};
use lsp::{LanguageServer, LanguageServerId};
use node_runtime::NodeRuntime;
use paths::default_prettier_dir;
@@ -12,7 +13,10 @@ use std::{
path::{Path, PathBuf},
sync::Arc,
};
-use util::paths::{PathMatcher, PathStyle};
+use util::{
+ paths::{PathMatcher, PathStyle},
+ rel_path::RelPath,
+};
#[derive(Debug, Clone)]
pub enum Prettier {
@@ -119,20 +123,32 @@ impl Prettier {
None
}
}).any(|workspace_definition| {
- workspace_definition == subproject_path.to_string_lossy() || PathMatcher::new(&[workspace_definition], PathStyle::local()).ok().is_some_and(|path_matcher| path_matcher.is_match(subproject_path))
+ workspace_definition == subproject_path.to_string_lossy() || PathMatcher::new(&[workspace_definition], PathStyle::local()).ok().is_some_and(
+ |path_matcher| RelPath::new(subproject_path, PathStyle::local()).is_ok_and(|path| path_matcher.is_match(path)))
}) {
- anyhow::ensure!(has_prettier_in_node_modules(fs, &path_to_check).await?, "Path {path_to_check:?} is the workspace root for project in {closest_package_json_path:?}, but it has no prettier installed");
- log::info!("Found prettier path {path_to_check:?} in the workspace root for project in {closest_package_json_path:?}");
+ anyhow::ensure!(has_prettier_in_node_modules(fs, &path_to_check).await?,
+ "Path {path_to_check:?} is the workspace root for project in \
+ {closest_package_json_path:?}, but it has no prettier installed"
+ );
+ log::info!(
+ "Found prettier path {path_to_check:?} in the workspace \
+ root for project in {closest_package_json_path:?}"
+ );
return Ok(ControlFlow::Continue(Some(path_to_check)));
} else {
- log::warn!("Skipping path {path_to_check:?} workspace root with workspaces {workspaces:?} that have no prettier installed");
+ log::warn!(
+ "Skipping path {path_to_check:?} workspace root with \
+ workspaces {workspaces:?} that have no prettier installed"
+ );
}
}
Some(unknown) => log::error!(
- "Failed to parse workspaces for {path_to_check:?} from package.json, got {unknown:?}. Skipping."
+ "Failed to parse workspaces for {path_to_check:?} from package.json, \
+ got {unknown:?}. Skipping."
),
None => log::warn!(
- "Skipping path {path_to_check:?} that has no prettier dependency and no workspaces section in its package.json"
+ "Skipping path {path_to_check:?} that has no prettier \
+ dependency and no workspaces section in its package.json"
),
}
}
@@ -221,7 +237,12 @@ impl Prettier {
)
.ok()
.is_some_and(
- |path_matcher| path_matcher.is_match(subproject_path),
+ |path_matcher| {
+ RelPath::new(subproject_path, PathStyle::local())
+ .is_ok_and(|rel_path| {
+ path_matcher.is_match(rel_path)
+ })
+ },
)
})
{
@@ -329,7 +350,7 @@ impl Prettier {
Self::Real(local) => {
let params = buffer
.update(cx, |buffer, cx| {
- let buffer_language = buffer.language();
+ let buffer_language = buffer.language().map(|language| language.as_ref());
let language_settings = language_settings(buffer_language.map(|l| l.name()), buffer.file(), cx);
let prettier_settings = &language_settings.prettier;
anyhow::ensure!(
@@ -429,15 +450,7 @@ impl Prettier {
})
.collect();
- let mut prettier_parser = prettier_settings.parser.as_deref();
- if buffer_path.is_none() {
- prettier_parser = prettier_parser.or_else(|| buffer_language.and_then(|language| language.prettier_parser_name()));
- if prettier_parser.is_none() {
- log::error!("Formatting unsaved file with prettier failed. No prettier parser configured for language {buffer_language:?}");
- anyhow::bail!("Cannot determine prettier parser for unsaved file");
- }
-
- }
+ let parser = prettier_parser_name(buffer_path.as_deref(), buffer_language, prettier_settings).context("getting prettier parser")?;
let ignore_path = ignore_dir.and_then(|dir| {
let ignore_file = dir.join(".prettierignore");
@@ -455,15 +468,15 @@ impl Prettier {
anyhow::Ok(FormatParams {
text: buffer.text(),
options: FormatOptions {
- parser: prettier_parser.map(ToOwned::to_owned),
- plugins,
path: buffer_path,
+ parser,
+ plugins,
prettier_options,
ignore_path,
},
})
- })?
- .context("building prettier request")?;
+ })?
+ .context("building prettier request")?;
let response = local
.server
@@ -483,7 +496,26 @@ impl Prettier {
{
Some("rust") => anyhow::bail!("prettier does not support Rust"),
Some(_other) => {
- let formatted_text = buffer.text() + FORMAT_SUFFIX;
+ let mut formatted_text = buffer.text() + FORMAT_SUFFIX;
+
+ let buffer_language =
+ buffer.language().map(|language| language.as_ref());
+ let language_settings = language_settings(
+ buffer_language.map(|l| l.name()),
+ buffer.file(),
+ cx,
+ );
+ let prettier_settings = &language_settings.prettier;
+ let parser = prettier_parser_name(
+ buffer_path.as_deref(),
+ buffer_language,
+ prettier_settings,
+ )?;
+
+ if let Some(parser) = parser {
+ formatted_text = format!("{formatted_text}\n{parser}");
+ }
+
Ok(buffer.diff(formatted_text, cx))
}
None => panic!("Should not format buffer without a language with prettier"),
@@ -531,6 +563,40 @@ impl Prettier {
}
}
+fn prettier_parser_name(
+ buffer_path: Option<&Path>,
+ buffer_language: Option<&Language>,
+ prettier_settings: &PrettierSettings,
+) -> anyhow::Result<Option<String>> {
+ let parser = if buffer_path.is_none() {
+ let parser = prettier_settings
+ .parser
+ .as_deref()
+ .or_else(|| buffer_language.and_then(|language| language.prettier_parser_name()));
+ if parser.is_none() {
+ log::error!(
+ "Formatting unsaved file with prettier failed. No prettier parser configured for language {buffer_language:?}"
+ );
+ anyhow::bail!("Cannot determine prettier parser for unsaved file");
+ }
+ parser
+ } else if let (Some(buffer_language), Some(buffer_path)) = (buffer_language, buffer_path)
+ && buffer_path.extension().is_some_and(|extension| {
+ !buffer_language
+ .config()
+ .matcher
+ .path_suffixes
+ .contains(&extension.to_string_lossy().into_owned())
+ })
+ {
+ buffer_language.prettier_parser_name()
+ } else {
+ prettier_settings.parser.as_deref()
+ };
+
+ Ok(parser.map(ToOwned::to_owned))
+}
+
async fn has_prettier_in_node_modules(fs: &dyn Fs, path: &Path) -> anyhow::Result<bool> {
let possible_node_modules_location = path.join("node_modules").join(PRETTIER_PACKAGE_NAME);
if let Some(node_modules_location_metadata) = fs
@@ -18,6 +18,7 @@ test-support = [
"client/test-support",
"language/test-support",
"settings/test-support",
+ "snippet_provider/test-support",
"text/test-support",
"prettier/test-support",
"worktree/test-support",
@@ -39,6 +40,7 @@ clock.workspace = true
collections.workspace = true
context_server.workspace = true
dap.workspace = true
+encoding_rs.workspace = true
extension.workspace = true
fancy-regex.workspace = true
fs.workspace = true
@@ -85,13 +87,17 @@ toml.workspace = true
url.workspace = true
util.workspace = true
watch.workspace = true
+wax.workspace = true
which.workspace = true
worktree.workspace = true
zeroize.workspace = true
zlog.workspace = true
+ztracing.workspace = true
+tracing.workspace = true
[dev-dependencies]
client = { workspace = true, features = ["test-support"] }
+db = { workspace = true, features = ["test-support"] }
collections = { workspace = true, features = ["test-support"] }
context_server = { workspace = true, features = ["test-support"] }
buffer_diff = { workspace = true, features = ["test-support"] }
@@ -107,6 +113,10 @@ pretty_assertions.workspace = true
release_channel.workspace = true
rpc = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
+snippet_provider = { workspace = true, features = ["test-support"] }
unindent.workspace = true
util = { workspace = true, features = ["test-support"] }
worktree = { workspace = true, features = ["test-support"] }
+
+[package.metadata.cargo-machete]
+ignored = ["tracing"]
@@ -17,11 +17,15 @@ use gpui::{
use http_client::{HttpClient, github::AssetKind};
use node_runtime::NodeRuntime;
use remote::RemoteClient;
-use rpc::{AnyProtoClient, TypedEnvelope, proto};
+use rpc::{
+ AnyProtoClient, TypedEnvelope,
+ proto::{self, ExternalExtensionAgent},
+};
use schemars::JsonSchema;
+use semver::Version;
use serde::{Deserialize, Serialize};
use settings::{RegisterSetting, SettingsStore};
-use task::Shell;
+use task::{Shell, SpawnInTerminal};
use util::{ResultExt as _, debug_panic};
use crate::ProjectEnvironment;
@@ -114,6 +118,13 @@ enum AgentServerStoreState {
downstream_client: Option<(u64, AnyProtoClient)>,
settings: Option<AllAgentServersSettings>,
http_client: Arc<dyn HttpClient>,
+ extension_agents: Vec<(
+ Arc<str>,
+ String,
+ HashMap<String, extension::TargetConfig>,
+ HashMap<String, String>,
+ Option<String>,
+ )>,
_subscriptions: [Subscription; 1],
},
Remote {
@@ -127,6 +138,7 @@ pub struct AgentServerStore {
state: AgentServerStoreState,
external_agents: HashMap<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
agent_icons: HashMap<ExternalAgentServerName, SharedString>,
+ agent_display_names: HashMap<ExternalAgentServerName, SharedString>,
}
pub struct AgentServersUpdated;
@@ -145,6 +157,7 @@ mod ext_agent_tests {
state: AgentServerStoreState::Collab,
external_agents: HashMap::default(),
agent_icons: HashMap::default(),
+ agent_display_names: HashMap::default(),
}
}
@@ -248,6 +261,7 @@ impl AgentServerStore {
self.external_agents.retain(|name, agent| {
if agent.downcast_mut::<LocalExtensionArchiveAgent>().is_some() {
self.agent_icons.remove(name);
+ self.agent_display_names.remove(name);
false
} else {
// Keep the hardcoded external agents that don't come from extensions
@@ -257,20 +271,63 @@ impl AgentServerStore {
});
// Insert agent servers from extension manifests
- match &self.state {
+ match &mut self.state {
AgentServerStoreState::Local {
- node_runtime,
- project_environment,
- fs,
- http_client,
- ..
+ extension_agents, ..
} => {
+ extension_agents.clear();
for (ext_id, manifest) in manifests {
for (agent_name, agent_entry) in &manifest.agent_servers {
- let display = SharedString::from(agent_entry.name.clone());
+ // Store absolute icon path if provided, resolving symlinks for dev extensions
+ // Store display name from manifest
+ self.agent_display_names.insert(
+ ExternalAgentServerName(agent_name.clone().into()),
+ SharedString::from(agent_entry.name.clone()),
+ );
+
+ let icon_path = if let Some(icon) = &agent_entry.icon {
+ let icon_path = extensions_dir.join(ext_id).join(icon);
+ // Canonicalize to resolve symlinks (dev extensions are symlinked)
+ let absolute_icon_path = icon_path
+ .canonicalize()
+ .unwrap_or(icon_path)
+ .to_string_lossy()
+ .to_string();
+ self.agent_icons.insert(
+ ExternalAgentServerName(agent_name.clone().into()),
+ SharedString::from(absolute_icon_path.clone()),
+ );
+ Some(absolute_icon_path)
+ } else {
+ None
+ };
+
+ extension_agents.push((
+ agent_name.clone(),
+ ext_id.to_owned(),
+ agent_entry.targets.clone(),
+ agent_entry.env.clone(),
+ icon_path,
+ ));
+ }
+ }
+ self.reregister_agents(cx);
+ }
+ AgentServerStoreState::Remote {
+ project_id,
+ upstream_client,
+ } => {
+ let mut agents = vec![];
+ for (ext_id, manifest) in manifests {
+ for (agent_name, agent_entry) in &manifest.agent_servers {
+ // Store display name from manifest
+ self.agent_display_names.insert(
+ ExternalAgentServerName(agent_name.clone().into()),
+ SharedString::from(agent_entry.name.clone()),
+ );
// Store absolute icon path if provided, resolving symlinks for dev extensions
- if let Some(icon) = &agent_entry.icon {
+ let icon = if let Some(icon) = &agent_entry.icon {
let icon_path = extensions_dir.join(ext_id).join(icon);
// Canonicalize to resolve symlinks (dev extensions are symlinked)
let absolute_icon_path = icon_path
@@ -278,31 +335,46 @@ impl AgentServerStore {
.unwrap_or(icon_path)
.to_string_lossy()
.to_string();
+
+ // Store icon locally for remote client
self.agent_icons.insert(
- ExternalAgentServerName(display.clone()),
- SharedString::from(absolute_icon_path),
+ ExternalAgentServerName(agent_name.clone().into()),
+ SharedString::from(absolute_icon_path.clone()),
);
- }
- // Archive-based launcher (download from URL)
- self.external_agents.insert(
- ExternalAgentServerName(display),
- Box::new(LocalExtensionArchiveAgent {
- fs: fs.clone(),
- http_client: http_client.clone(),
- node_runtime: node_runtime.clone(),
- project_environment: project_environment.clone(),
- extension_id: Arc::from(ext_id),
- agent_id: agent_name.clone(),
- targets: agent_entry.targets.clone(),
- env: agent_entry.env.clone(),
- }) as Box<dyn ExternalAgentServer>,
- );
+ Some(absolute_icon_path)
+ } else {
+ None
+ };
+
+ agents.push(ExternalExtensionAgent {
+ name: agent_name.to_string(),
+ icon_path: icon,
+ extension_id: ext_id.to_string(),
+ targets: agent_entry
+ .targets
+ .iter()
+ .map(|(k, v)| (k.clone(), v.to_proto()))
+ .collect(),
+ env: agent_entry
+ .env
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ });
}
}
+ upstream_client
+ .read(cx)
+ .proto_client()
+ .send(proto::ExternalExtensionAgentsUpdated {
+ project_id: *project_id,
+ agents,
+ })
+ .log_err();
}
- _ => {
- // Only local projects support local extension agents
+ AgentServerStoreState::Collab => {
+ // Do nothing
}
}
@@ -313,6 +385,10 @@ impl AgentServerStore {
self.agent_icons.get(name).cloned()
}
+ pub fn agent_display_name(&self, name: &ExternalAgentServerName) -> Option<SharedString> {
+ self.agent_display_names.get(name).cloned()
+ }
+
pub fn init_remote(session: &AnyProtoClient) {
session.add_entity_message_handler(Self::handle_external_agents_updated);
session.add_entity_message_handler(Self::handle_loading_status_updated);
@@ -320,6 +396,7 @@ impl AgentServerStore {
}
pub fn init_headless(session: &AnyProtoClient) {
+ session.add_entity_message_handler(Self::handle_external_extension_agents_updated);
session.add_entity_request_handler(Self::handle_get_agent_server_command);
}
@@ -354,6 +431,7 @@ impl AgentServerStore {
downstream_client,
settings: old_settings,
http_client,
+ extension_agents,
..
} = &mut self.state
else {
@@ -395,7 +473,9 @@ impl AgentServerStore {
.clone()
.and_then(|settings| settings.custom_command()),
http_client: http_client.clone(),
- is_remote: downstream_client.is_some(),
+ no_browser: downstream_client
+ .as_ref()
+ .is_some_and(|(_, client)| !client.has_wsl_interop()),
}),
);
self.external_agents.insert(
@@ -411,15 +491,46 @@ impl AgentServerStore {
}),
);
self.external_agents
- .extend(new_settings.custom.iter().map(|(name, settings)| {
+ .extend(
+ new_settings
+ .custom
+ .iter()
+ .filter_map(|(name, settings)| match settings {
+ CustomAgentServerSettings::Custom { command, .. } => Some((
+ ExternalAgentServerName(name.clone()),
+ Box::new(LocalCustomAgent {
+ command: command.clone(),
+ project_environment: project_environment.clone(),
+ }) as Box<dyn ExternalAgentServer>,
+ )),
+ CustomAgentServerSettings::Extension { .. } => None,
+ }),
+ );
+ self.external_agents.extend(extension_agents.iter().map(
+ |(agent_name, ext_id, targets, env, icon_path)| {
+ let name = ExternalAgentServerName(agent_name.clone().into());
+
+ // Restore icon if present
+ if let Some(icon) = icon_path {
+ self.agent_icons
+ .insert(name.clone(), SharedString::from(icon.clone()));
+ }
+
(
- ExternalAgentServerName(name.clone()),
- Box::new(LocalCustomAgent {
- command: settings.command.clone(),
+ name,
+ Box::new(LocalExtensionArchiveAgent {
+ fs: fs.clone(),
+ http_client: http_client.clone(),
+ node_runtime: node_runtime.clone(),
project_environment: project_environment.clone(),
+ extension_id: Arc::from(&**ext_id),
+ targets: targets.clone(),
+ env: env.clone(),
+ agent_id: agent_name.clone(),
}) as Box<dyn ExternalAgentServer>,
)
- }));
+ },
+ ));
*old_settings = Some(new_settings.clone());
@@ -463,10 +574,12 @@ impl AgentServerStore {
http_client,
downstream_client: None,
settings: None,
+ extension_agents: vec![],
_subscriptions: [subscription],
},
external_agents: Default::default(),
agent_icons: Default::default(),
+ agent_display_names: Default::default(),
};
if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
this.agent_servers_settings_changed(cx);
@@ -517,6 +630,7 @@ impl AgentServerStore {
},
external_agents: external_agents.into_iter().collect(),
agent_icons: HashMap::default(),
+ agent_display_names: HashMap::default(),
}
}
@@ -525,6 +639,7 @@ impl AgentServerStore {
state: AgentServerStoreState::Collab,
external_agents: Default::default(),
agent_icons: Default::default(),
+ agent_display_names: Default::default(),
}
}
@@ -728,6 +843,55 @@ impl AgentServerStore {
})?
}
+ async fn handle_external_extension_agents_updated(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
+ mut cx: AsyncApp,
+ ) -> Result<()> {
+ this.update(&mut cx, |this, cx| {
+ let AgentServerStoreState::Local {
+ extension_agents, ..
+ } = &mut this.state
+ else {
+ panic!(
+ "handle_external_extension_agents_updated \
+ should not be called for a non-remote project"
+ );
+ };
+
+ for ExternalExtensionAgent {
+ name,
+ icon_path,
+ extension_id,
+ targets,
+ env,
+ } in envelope.payload.agents
+ {
+ let icon_path_string = icon_path.clone();
+ if let Some(icon_path) = icon_path {
+ this.agent_icons.insert(
+ ExternalAgentServerName(name.clone().into()),
+ icon_path.into(),
+ );
+ }
+ extension_agents.push((
+ Arc::from(&*name),
+ extension_id,
+ targets
+ .into_iter()
+ .map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
+ .collect(),
+ env.into_iter().collect(),
+ icon_path_string,
+ ));
+ }
+
+ this.reregister_agents(cx);
+ cx.emit(AgentServersUpdated);
+ Ok(())
+ })?
+ }
+
async fn handle_loading_status_updated(
this: Entity<Self>,
envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
@@ -759,6 +923,18 @@ impl AgentServerStore {
}
})
}
+
+ pub fn get_extension_id_for_agent(
+ &mut self,
+ name: &ExternalAgentServerName,
+ ) -> Option<Arc<str>> {
+ self.external_agents.get_mut(name).and_then(|agent| {
+ agent
+ .as_any_mut()
+ .downcast_ref::<LocalExtensionArchiveAgent>()
+ .map(|ext_agent| ext_agent.extension_id.clone())
+ })
+ }
}
fn get_or_npm_install_builtin_agent(
@@ -799,11 +975,10 @@ fn get_or_npm_install_builtin_agent(
}
versions.sort();
- let newest_version = if let Some((version, file_name)) = versions.last().cloned()
+ let newest_version = if let Some((version, _)) = versions.last().cloned()
&& minimum_version.is_none_or(|minimum_version| version >= minimum_version)
{
- versions.pop();
- Some(file_name)
+ versions.pop()
} else {
None
};
@@ -829,9 +1004,8 @@ fn get_or_npm_install_builtin_agent(
})
.detach();
- let version = if let Some(file_name) = newest_version {
+ let version = if let Some((version, file_name)) = newest_version {
cx.background_spawn({
- let file_name = file_name.clone();
let dir = dir.clone();
let fs = fs.clone();
async move {
@@ -840,7 +1014,7 @@ fn get_or_npm_install_builtin_agent(
.await
.ok();
if let Some(latest_version) = latest_version
- && &latest_version != &file_name.to_string_lossy()
+ && latest_version != version
{
let download_result = download_latest_version(
fs,
@@ -853,7 +1027,9 @@ fn get_or_npm_install_builtin_agent(
if let Some(mut new_version_available) = new_version_available
&& download_result.is_some()
{
- new_version_available.send(Some(latest_version)).ok();
+ new_version_available
+ .send(Some(latest_version.to_string()))
+ .ok();
}
}
}
@@ -872,6 +1048,7 @@ fn get_or_npm_install_builtin_agent(
package_name.clone(),
))
.await?
+ .to_string()
.into()
};
@@ -918,7 +1095,7 @@ async fn download_latest_version(
dir: PathBuf,
node_runtime: NodeRuntime,
package_name: SharedString,
-) -> Result<String> {
+) -> Result<Version> {
log::debug!("downloading latest version of {package_name}");
let tmp_dir = tempfile::tempdir_in(&dir)?;
@@ -934,10 +1111,11 @@ async fn download_latest_version(
fs.rename(
&tmp_dir.keep(),
- &dir.join(&version),
+ &dir.join(version.to_string()),
RenameOptions {
ignore_if_exists: true,
overwrite: true,
+ create_parents: false,
},
)
.await?;
@@ -998,7 +1176,7 @@ impl ExternalAgentServer for RemoteExternalAgentServer {
env: Some(command.env),
},
root_dir,
- None,
+ response.login.map(SpawnInTerminal::from_proto),
))
})
}
@@ -1203,7 +1381,7 @@ struct LocalCodex {
project_environment: Entity<ProjectEnvironment>,
http_client: Arc<dyn HttpClient>,
custom_command: Option<AgentServerCommand>,
- is_remote: bool,
+ no_browser: bool,
}
impl ExternalAgentServer for LocalCodex {
@@ -1211,7 +1389,7 @@ impl ExternalAgentServer for LocalCodex {
&mut self,
root_dir: Option<&str>,
extra_env: HashMap<String, String>,
- status_tx: Option<watch::Sender<SharedString>>,
+ mut status_tx: Option<watch::Sender<SharedString>>,
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
cx: &mut AsyncApp,
) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
@@ -1223,7 +1401,7 @@ impl ExternalAgentServer for LocalCodex {
.map(|root_dir| Path::new(root_dir))
.unwrap_or(paths::home_dir())
.into();
- let is_remote = self.is_remote;
+ let no_browser = self.no_browser;
cx.spawn(async move |cx| {
let mut env = project_environment
@@ -1236,7 +1414,7 @@ impl ExternalAgentServer for LocalCodex {
})?
.await
.unwrap_or_default();
- if is_remote {
+ if no_browser {
env.insert("NO_BROWSER".to_owned(), "1".to_owned());
}
@@ -1248,58 +1426,115 @@ impl ExternalAgentServer for LocalCodex {
let dir = paths::external_agents_dir().join(CODEX_NAME);
fs.create_dir(&dir).await?;
- // Find or install the latest Codex release (no update checks for now).
- let release = ::http_client::github::latest_github_release(
+ let bin_name = if cfg!(windows) {
+ "codex-acp.exe"
+ } else {
+ "codex-acp"
+ };
+
+ let find_latest_local_version = async || -> Option<PathBuf> {
+ let mut local_versions: Vec<(semver::Version, String)> = Vec::new();
+ let mut stream = fs.read_dir(&dir).await.ok()?;
+ while let Some(entry) = stream.next().await {
+ let Ok(entry) = entry else { continue };
+ let Some(file_name) = entry.file_name() else {
+ continue;
+ };
+ let version_path = dir.join(&file_name);
+ if fs.is_file(&version_path.join(bin_name)).await {
+ let version_str = file_name.to_string_lossy();
+ if let Ok(version) =
+ semver::Version::from_str(version_str.trim_start_matches('v'))
+ {
+ local_versions.push((version, version_str.into_owned()));
+ }
+ }
+ }
+ local_versions.sort_by(|(a, _), (b, _)| a.cmp(b));
+ local_versions.last().map(|(_, v)| dir.join(v))
+ };
+
+ let fallback_to_latest_local_version =
+ async |err: anyhow::Error| -> Result<PathBuf, anyhow::Error> {
+ if let Some(local) = find_latest_local_version().await {
+ log::info!(
+ "Falling back to locally installed Codex version: {}",
+ local.display()
+ );
+ Ok(local)
+ } else {
+ Err(err)
+ }
+ };
+
+ let version_dir = match ::http_client::github::latest_github_release(
CODEX_ACP_REPO,
true,
false,
http.clone(),
)
.await
- .context("fetching Codex latest release")?;
-
- let version_dir = dir.join(&release.tag_name);
- if !fs.is_dir(&version_dir).await {
- if let Some(mut status_tx) = status_tx {
- status_tx.send("Installing…".into()).ok();
- }
+ {
+ Ok(release) => {
+ let version_dir = dir.join(&release.tag_name);
+ if !fs.is_dir(&version_dir).await {
+ if let Some(ref mut status_tx) = status_tx {
+ status_tx.send("Installing…".into()).ok();
+ }
- let tag = release.tag_name.clone();
- let version_number = tag.trim_start_matches('v');
- let asset_name = asset_name(version_number)
- .context("codex acp is not supported for this architecture")?;
- let asset = release
- .assets
- .into_iter()
- .find(|asset| asset.name == asset_name)
- .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
- // Strip "sha256:" prefix from digest if present (GitHub API format)
- let digest = asset
- .digest
- .as_deref()
- .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
- ::http_client::github_download::download_server_binary(
- &*http,
- &asset.browser_download_url,
- digest,
- &version_dir,
- if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
- AssetKind::Zip
+ let tag = release.tag_name.clone();
+ let version_number = tag.trim_start_matches('v');
+ let asset_name = asset_name(version_number)
+ .context("codex acp is not supported for this architecture")?;
+ let asset = release
+ .assets
+ .into_iter()
+ .find(|asset| asset.name == asset_name)
+ .with_context(|| {
+ format!("no asset found matching `{asset_name:?}`")
+ })?;
+ // Strip "sha256:" prefix from digest if present (GitHub API format)
+ let digest = asset
+ .digest
+ .as_deref()
+ .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
+ match ::http_client::github_download::download_server_binary(
+ &*http,
+ &asset.browser_download_url,
+ digest,
+ &version_dir,
+ if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
+ AssetKind::Zip
+ } else {
+ AssetKind::TarGz
+ },
+ )
+ .await
+ {
+ Ok(()) => {
+ // remove older versions
+ util::fs::remove_matching(&dir, |entry| entry != version_dir)
+ .await;
+ version_dir
+ }
+ Err(err) => {
+ log::error!(
+ "Failed to download Codex release {}: {err:#}",
+ release.tag_name
+ );
+ fallback_to_latest_local_version(err).await?
+ }
+ }
} else {
- AssetKind::TarGz
- },
- )
- .await?;
-
- // remove older versions
- util::fs::remove_matching(&dir, |entry| entry != version_dir).await;
- }
-
- let bin_name = if cfg!(windows) {
- "codex-acp.exe"
- } else {
- "codex-acp"
+ version_dir
+ }
+ }
+ Err(err) => {
+ log::error!("Failed to fetch Codex latest release: {err:#}");
+ fallback_to_latest_local_version(err).await?
+ }
};
+
let bin_path = version_dir.join(bin_name);
anyhow::ensure!(
fs.is_file(&bin_path).await,
@@ -1347,8 +1582,8 @@ fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
return None;
};
- // Only Windows x86_64 uses .zip in release assets
- let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
+ // Windows uses .zip in release assets
+ let ext = if cfg!(target_os = "windows") {
"zip"
} else {
"tar.gz"
@@ -1632,6 +1867,7 @@ pub struct BuiltinAgentServerSettings {
pub env: Option<HashMap<String, String>>,
pub ignore_system_version: Option<bool>,
pub default_mode: Option<String>,
+ pub default_model: Option<String>,
}
impl BuiltinAgentServerSettings {
@@ -1654,6 +1890,7 @@ impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
env: value.env,
ignore_system_version: value.ignore_system_version,
default_mode: value.default_mode,
+ default_model: value.default_model,
}
}
}
@@ -1670,25 +1907,88 @@ impl From<AgentServerCommand> for BuiltinAgentServerSettings {
}
#[derive(Clone, JsonSchema, Debug, PartialEq)]
-pub struct CustomAgentServerSettings {
- pub command: AgentServerCommand,
- /// The default mode to use for this agent.
- ///
- /// Note: Not only all agents support modes.
- ///
- /// Default: None
- pub default_mode: Option<String>,
+pub enum CustomAgentServerSettings {
+ Custom {
+ command: AgentServerCommand,
+ /// The default mode to use for this agent.
+ ///
+ /// Note: Not only all agents support modes.
+ ///
+ /// Default: None
+ default_mode: Option<String>,
+ /// The default model to use for this agent.
+ ///
+ /// This should be the model ID as reported by the agent.
+ ///
+ /// Default: None
+ default_model: Option<String>,
+ },
+ Extension {
+ /// The default mode to use for this agent.
+ ///
+ /// Note: Not only all agents support modes.
+ ///
+ /// Default: None
+ default_mode: Option<String>,
+ /// The default model to use for this agent.
+ ///
+ /// This should be the model ID as reported by the agent.
+ ///
+ /// Default: None
+ default_model: Option<String>,
+ },
+}
+
+impl CustomAgentServerSettings {
+ pub fn command(&self) -> Option<&AgentServerCommand> {
+ match self {
+ CustomAgentServerSettings::Custom { command, .. } => Some(command),
+ CustomAgentServerSettings::Extension { .. } => None,
+ }
+ }
+
+ pub fn default_mode(&self) -> Option<&str> {
+ match self {
+ CustomAgentServerSettings::Custom { default_mode, .. }
+ | CustomAgentServerSettings::Extension { default_mode, .. } => default_mode.as_deref(),
+ }
+ }
+
+ pub fn default_model(&self) -> Option<&str> {
+ match self {
+ CustomAgentServerSettings::Custom { default_model, .. }
+ | CustomAgentServerSettings::Extension { default_model, .. } => {
+ default_model.as_deref()
+ }
+ }
+ }
}
impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
fn from(value: settings::CustomAgentServerSettings) -> Self {
- CustomAgentServerSettings {
- command: AgentServerCommand {
- path: PathBuf::from(shellexpand::tilde(&value.path.to_string_lossy()).as_ref()),
- args: value.args,
- env: value.env,
+ match value {
+ settings::CustomAgentServerSettings::Custom {
+ path,
+ args,
+ env,
+ default_mode,
+ default_model,
+ } => CustomAgentServerSettings::Custom {
+ command: AgentServerCommand {
+ path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
+ args,
+ env,
+ },
+ default_mode,
+ default_model,
+ },
+ settings::CustomAgentServerSettings::Extension {
+ default_mode,
+ default_model,
+ } => CustomAgentServerSettings::Extension {
+ default_mode,
+ default_model,
},
- default_mode: value.default_mode,
}
}
}
@@ -1764,6 +2064,7 @@ mod extension_agent_tests {
state: AgentServerStoreState::Collab,
external_agents: HashMap::default(),
agent_icons: HashMap::default(),
+ agent_display_names: HashMap::default(),
};
// Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
@@ -1818,6 +2119,7 @@ mod extension_agent_tests {
cmd: "./agent".into(),
args: vec![],
sha256: None,
+ env: Default::default(),
},
);
@@ -1858,6 +2160,7 @@ mod extension_agent_tests {
cmd: "./my-agent".into(),
args: vec!["--serve".into()],
sha256: None,
+ env: Default::default(),
},
);
map
@@ -1895,6 +2198,7 @@ mod extension_agent_tests {
cmd: "./release-agent".into(),
args: vec!["serve".into()],
sha256: None,
+ env: Default::default(),
},
);
@@ -1937,6 +2241,7 @@ mod extension_agent_tests {
cmd: "node".into(),
args: vec!["index.js".into()],
sha256: None,
+ env: Default::default(),
},
);
map
@@ -1983,6 +2288,7 @@ mod extension_agent_tests {
"./config.json".into(),
],
sha256: None,
+ env: Default::default(),
},
);
map
@@ -2006,6 +2312,7 @@ mod extension_agent_tests {
env: None,
ignore_system_version: None,
default_mode: None,
+ default_model: None,
};
let BuiltinAgentServerSettings { path, .. } = settings.into();
@@ -2016,17 +2323,22 @@ mod extension_agent_tests {
"Tilde should be expanded for builtin agent path"
);
- let settings = settings::CustomAgentServerSettings {
+ let settings = settings::CustomAgentServerSettings::Custom {
path: PathBuf::from("~/custom/agent"),
args: vec!["serve".into()],
env: None,
default_mode: None,
+ default_model: None,
};
- let CustomAgentServerSettings {
+ let converted: CustomAgentServerSettings = settings.into();
+ let CustomAgentServerSettings::Custom {
command: AgentServerCommand { path, .. },
..
- } = settings.into();
+ } = converted
+ else {
+ panic!("Expected Custom variant");
+ };
assert!(
!path.to_string_lossy().starts_with("~"),
@@ -1,14 +1,12 @@
use crate::{
- ProjectItem as _, ProjectPath,
+ ProjectPath,
lsp_store::OpenLspBufferHandle,
- search::SearchQuery,
worktree_store::{WorktreeStore, WorktreeStoreEvent},
};
use anyhow::{Context as _, Result, anyhow};
use client::Client;
use collections::{HashMap, HashSet, hash_map};
-use fs::Fs;
-use futures::{Future, FutureExt as _, StreamExt, channel::oneshot, future::Shared};
+use futures::{Future, FutureExt as _, channel::oneshot, future::Shared};
use gpui::{
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity,
};
@@ -23,10 +21,10 @@ use rpc::{
AnyProtoClient, ErrorCode, ErrorExt as _, TypedEnvelope,
proto::{self},
};
-use smol::channel::Receiver;
-use std::{io, pin::pin, sync::Arc, time::Instant};
+
+use std::{io, sync::Arc, time::Instant};
use text::{BufferId, ReplicaId};
-use util::{ResultExt as _, TryFutureExt, debug_panic, maybe, paths::PathStyle, rel_path::RelPath};
+use util::{ResultExt as _, TryFutureExt, debug_panic, maybe, rel_path::RelPath};
use worktree::{File, PathChange, ProjectEntryId, Worktree, WorktreeId};
/// A set of open buffers.
@@ -378,6 +376,8 @@ impl LocalBufferStore {
let text = buffer.as_rope().clone();
let line_ending = buffer.line_ending();
+ let encoding = buffer.encoding();
+ let has_bom = buffer.has_bom();
let version = buffer.version();
let buffer_id = buffer.remote_id();
let file = buffer.file().cloned();
@@ -389,7 +389,7 @@ impl LocalBufferStore {
}
let save = worktree.update(cx, |worktree, cx| {
- worktree.write_file(path, text, line_ending, cx)
+ worktree.write_file(path, text, line_ending, encoding, has_bom, cx)
});
cx.spawn(async move |this, cx| {
@@ -622,9 +622,7 @@ impl LocalBufferStore {
let load_file = worktree.update(cx, |worktree, cx| worktree.load_file(path.as_ref(), cx));
cx.spawn(async move |this, cx| {
let path = path.clone();
- let buffer = match load_file.await.with_context(|| {
- format!("Could not open path: {}", path.display(PathStyle::local()))
- }) {
+ let buffer = match load_file.await {
Ok(loaded) => {
let reservation = cx.reserve_entity::<Buffer>()?;
let buffer_id = BufferId::from(reservation.entity_id().as_non_zero_u64());
@@ -634,7 +632,11 @@ impl LocalBufferStore {
})
.await;
cx.insert_entity(reservation, |_| {
- Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite)
+ let mut buffer =
+ Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite);
+ buffer.set_encoding(loaded.encoding);
+ buffer.set_has_bom(loaded.has_bom);
+ buffer
})?
}
Err(error) if is_not_found_error(&error) => cx.new(|cx| {
@@ -978,6 +980,10 @@ impl BufferStore {
.filter_map(|buffer| buffer.upgrade())
}
+ pub(crate) fn is_searchable(&self, id: &BufferId) -> bool {
+ !self.non_searchable_buffers.contains(&id)
+ }
+
pub fn loading_buffers(
&self,
) -> impl Iterator<Item = (&ProjectPath, impl Future<Output = Result<Entity<Buffer>>>)> {
@@ -1102,63 +1108,6 @@ impl BufferStore {
Some(())
}
- pub fn find_search_candidates(
- &mut self,
- query: &SearchQuery,
- mut limit: usize,
- fs: Arc<dyn Fs>,
- cx: &mut Context<Self>,
- ) -> Receiver<Entity<Buffer>> {
- let (tx, rx) = smol::channel::unbounded();
- let mut open_buffers = HashSet::default();
- let mut unnamed_buffers = Vec::new();
- for handle in self.buffers() {
- let buffer = handle.read(cx);
- if self.non_searchable_buffers.contains(&buffer.remote_id()) {
- continue;
- } else if let Some(entry_id) = buffer.entry_id(cx) {
- open_buffers.insert(entry_id);
- } else {
- limit = limit.saturating_sub(1);
- unnamed_buffers.push(handle)
- };
- }
-
- const MAX_CONCURRENT_BUFFER_OPENS: usize = 64;
- let project_paths_rx = self
- .worktree_store
- .update(cx, |worktree_store, cx| {
- worktree_store.find_search_candidates(query.clone(), limit, open_buffers, fs, cx)
- })
- .chunks(MAX_CONCURRENT_BUFFER_OPENS);
-
- cx.spawn(async move |this, cx| {
- for buffer in unnamed_buffers {
- tx.send(buffer).await.ok();
- }
-
- let mut project_paths_rx = pin!(project_paths_rx);
- while let Some(project_paths) = project_paths_rx.next().await {
- let buffers = this.update(cx, |this, cx| {
- project_paths
- .into_iter()
- .map(|project_path| this.open_buffer(project_path, cx))
- .collect::<Vec<_>>()
- })?;
- for buffer_task in buffers {
- if let Some(buffer) = buffer_task.await.log_err()
- && tx.send(buffer).await.is_err()
- {
- return anyhow::Ok(());
- }
- }
- }
- anyhow::Ok(())
- })
- .detach();
- rx
- }
-
fn on_buffer_event(
&mut self,
buffer: Entity<Buffer>,
@@ -1184,7 +1133,7 @@ impl BufferStore {
})
.log_err();
}
- BufferEvent::LanguageChanged => {}
+ BufferEvent::LanguageChanged(_) => {}
_ => {}
}
}
@@ -99,13 +99,18 @@ pub enum ContextServerConfiguration {
command: ContextServerCommand,
settings: serde_json::Value,
},
+ Http {
+ url: url::Url,
+ headers: HashMap<String, String>,
+ },
}
impl ContextServerConfiguration {
- pub fn command(&self) -> &ContextServerCommand {
+ pub fn command(&self) -> Option<&ContextServerCommand> {
match self {
- ContextServerConfiguration::Custom { command } => command,
- ContextServerConfiguration::Extension { command, .. } => command,
+ ContextServerConfiguration::Custom { command } => Some(command),
+ ContextServerConfiguration::Extension { command, .. } => Some(command),
+ ContextServerConfiguration::Http { .. } => None,
}
}
@@ -117,7 +122,7 @@ impl ContextServerConfiguration {
cx: &AsyncApp,
) -> Option<Self> {
match settings {
- ContextServerSettings::Custom {
+ ContextServerSettings::Stdio {
enabled: _,
command,
} => Some(ContextServerConfiguration::Custom { command }),
@@ -142,6 +147,14 @@ impl ContextServerConfiguration {
}
}
}
+ ContextServerSettings::Http {
+ enabled: _,
+ url,
+ headers: auth,
+ } => {
+ let url = url::Url::parse(&url).log_err()?;
+ Some(ContextServerConfiguration::Http { url, headers: auth })
+ }
}
}
}
@@ -186,12 +199,12 @@ impl ContextServerStore {
)
}
- /// Returns all configured context server ids, regardless of enabled state.
+ /// Returns all configured context server ids, excluding the ones that are disabled
pub fn configured_server_ids(&self) -> Vec<ContextServerId> {
self.context_server_settings
- .keys()
- .cloned()
- .map(ContextServerId)
+ .iter()
+ .filter(|(_, settings)| settings.enabled())
+ .map(|(id, _)| ContextServerId(id.clone()))
.collect()
}
@@ -207,7 +220,7 @@ impl ContextServerStore {
#[cfg(any(test, feature = "test-support"))]
pub fn test_maintain_server_loop(
- context_server_factory: ContextServerFactory,
+ context_server_factory: Option<ContextServerFactory>,
registry: Entity<ContextServerDescriptorRegistry>,
worktree_store: Entity<WorktreeStore>,
weak_project: WeakEntity<Project>,
@@ -215,7 +228,7 @@ impl ContextServerStore {
) -> Self {
Self::new_internal(
true,
- Some(context_server_factory),
+ context_server_factory,
registry,
worktree_store,
weak_project,
@@ -385,17 +398,6 @@ impl ContextServerStore {
result
}
- pub fn restart_server(&mut self, id: &ContextServerId, cx: &mut Context<Self>) -> Result<()> {
- if let Some(state) = self.servers.get(id) {
- let configuration = state.configuration();
-
- self.stop_server(&state.server().id(), cx)?;
- let new_server = self.create_context_server(id.clone(), configuration.clone(), cx);
- self.run_server(new_server, configuration, cx);
- }
- Ok(())
- }
-
fn run_server(
&mut self,
server: Arc<ContextServer>,
@@ -409,11 +411,11 @@ impl ContextServerStore {
) {
self.stop_server(&id, cx).log_err();
}
-
let task = cx.spawn({
let id = server.id();
let server = server.clone();
let configuration = configuration.clone();
+
async move |this, cx| {
match server.clone().start(cx).await {
Ok(_) => {
@@ -479,33 +481,42 @@ impl ContextServerStore {
id: ContextServerId,
configuration: Arc<ContextServerConfiguration>,
cx: &mut Context<Self>,
- ) -> Arc<ContextServer> {
- let project = self.project.upgrade();
- let mut root_path = None;
- if let Some(project) = project {
- let project = project.read(cx);
- if project.is_local() {
- if let Some(path) = project.active_project_directory(cx) {
- root_path = Some(path);
- } else {
- for worktree in self.worktree_store.read(cx).visible_worktrees(cx) {
- if let Some(path) = worktree.read(cx).root_dir() {
- root_path = Some(path);
- break;
- }
- }
- }
- }
- };
-
+ ) -> Result<Arc<ContextServer>> {
if let Some(factory) = self.context_server_factory.as_ref() {
- factory(id, configuration)
- } else {
- Arc::new(ContextServer::stdio(
+ return Ok(factory(id, configuration));
+ }
+
+ match configuration.as_ref() {
+ ContextServerConfiguration::Http { url, headers } => Ok(Arc::new(ContextServer::http(
id,
- configuration.command().clone(),
- root_path,
- ))
+ url,
+ headers.clone(),
+ cx.http_client(),
+ cx.background_executor().clone(),
+ )?)),
+ _ => {
+ let root_path = self
+ .project
+ .read_with(cx, |project, cx| project.active_project_directory(cx))
+ .ok()
+ .flatten()
+ .or_else(|| {
+ self.worktree_store.read_with(cx, |store, cx| {
+ store.visible_worktrees(cx).fold(None, |acc, item| {
+ if acc.is_none() {
+ item.read(cx).root_dir()
+ } else {
+ acc
+ }
+ })
+ })
+ });
+ Ok(Arc::new(ContextServer::stdio(
+ id,
+ configuration.command().unwrap().clone(),
+ root_path,
+ )))
+ }
}
}
@@ -621,14 +632,16 @@ impl ContextServerStore {
let existing_config = state.as_ref().map(|state| state.configuration());
if existing_config.as_deref() != Some(&config) || is_stopped {
let config = Arc::new(config);
- let server = this.create_context_server(id.clone(), config.clone(), cx);
+ let server = this.create_context_server(id.clone(), config.clone(), cx)?;
servers_to_start.push((server, config));
if this.servers.contains_key(&id) {
servers_to_stop.insert(id);
}
}
}
- })?;
+
+ anyhow::Ok(())
+ })??;
this.update(cx, |this, cx| {
for id in servers_to_stop {
@@ -654,6 +667,7 @@ mod tests {
};
use context_server::test::create_fake_transport;
use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
+ use http_client::{FakeHttpClient, Response};
use serde_json::json;
use std::{cell::RefCell, path::PathBuf, rc::Rc};
use util::path;
@@ -894,12 +908,12 @@ mod tests {
});
let store = cx.new(|cx| {
ContextServerStore::test_maintain_server_loop(
- Box::new(move |id, _| {
+ Some(Box::new(move |id, _| {
Arc::new(ContextServer::new(
id.clone(),
Arc::new(create_fake_transport(id.0.to_string(), executor.clone())),
))
- }),
+ })),
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
@@ -989,7 +1003,7 @@ mod tests {
),
(
server_2_id.0.clone(),
- settings::ContextServerSettingsContent::Custom {
+ settings::ContextServerSettingsContent::Stdio {
enabled: true,
command: ContextServerCommand {
path: "somebinary".into(),
@@ -1030,7 +1044,7 @@ mod tests {
),
(
server_2_id.0.clone(),
- settings::ContextServerSettingsContent::Custom {
+ settings::ContextServerSettingsContent::Stdio {
enabled: true,
command: ContextServerCommand {
path: "somebinary".into(),
@@ -1113,7 +1127,7 @@ mod tests {
json!({"code.rs": ""}),
vec![(
SERVER_1_ID.into(),
- ContextServerSettings::Custom {
+ ContextServerSettings::Stdio {
enabled: true,
command: ContextServerCommand {
path: "somebinary".into(),
@@ -1130,12 +1144,12 @@ mod tests {
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
ContextServerStore::test_maintain_server_loop(
- Box::new(move |id, _| {
+ Some(Box::new(move |id, _| {
Arc::new(ContextServer::new(
id.clone(),
Arc::new(create_fake_transport(id.0.to_string(), executor.clone())),
))
- }),
+ })),
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
@@ -1166,7 +1180,7 @@ mod tests {
set_context_server_configuration(
vec![(
server_1_id.0.clone(),
- settings::ContextServerSettingsContent::Custom {
+ settings::ContextServerSettingsContent::Stdio {
enabled: false,
command: ContextServerCommand {
path: "somebinary".into(),
@@ -1195,7 +1209,7 @@ mod tests {
set_context_server_configuration(
vec![(
server_1_id.0.clone(),
- settings::ContextServerSettingsContent::Custom {
+ settings::ContextServerSettingsContent::Stdio {
enabled: true,
command: ContextServerCommand {
path: "somebinary".into(),
@@ -1228,6 +1242,73 @@ mod tests {
});
}
+ #[gpui::test]
+ async fn test_remote_context_server(cx: &mut TestAppContext) {
+ const SERVER_ID: &str = "remote-server";
+ let server_id = ContextServerId(SERVER_ID.into());
+ let server_url = "http://example.com/api";
+
+ let (_fs, project) = setup_context_server_test(
+ cx,
+ json!({ "code.rs": "" }),
+ vec![(
+ SERVER_ID.into(),
+ ContextServerSettings::Http {
+ enabled: true,
+ url: server_url.to_string(),
+ headers: Default::default(),
+ },
+ )],
+ )
+ .await;
+
+ let client = FakeHttpClient::create(|_| async move {
+ use http_client::AsyncBody;
+
+ let response = Response::builder()
+ .status(200)
+ .header("Content-Type", "application/json")
+ .body(AsyncBody::from(
+ serde_json::to_string(&json!({
+ "jsonrpc": "2.0",
+ "id": 0,
+ "result": {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {},
+ "serverInfo": {
+ "name": "test-server",
+ "version": "1.0.0"
+ }
+ }
+ }))
+ .unwrap(),
+ ))
+ .unwrap();
+ Ok(response)
+ });
+ cx.update(|cx| cx.set_http_client(client));
+ let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
+ let store = cx.new(|cx| {
+ ContextServerStore::test_maintain_server_loop(
+ None,
+ registry.clone(),
+ project.read(cx).worktree_store(),
+ project.downgrade(),
+ cx,
+ )
+ });
+
+ let _server_events = assert_server_events(
+ &store,
+ vec![
+ (server_id.clone(), ContextServerStatus::Starting),
+ (server_id.clone(), ContextServerStatus::Running),
+ ],
+ cx,
+ );
+ cx.run_until_parked();
+ }
+
struct ServerEvents {
received_event_count: Rc<RefCell<usize>>,
expected_event_count: usize,
@@ -1247,7 +1328,7 @@ mod tests {
}
fn dummy_server_settings() -> ContextServerSettings {
- ContextServerSettings::Custom {
+ ContextServerSettings::Stdio {
enabled: true,
command: ContextServerCommand {
path: "somebinary".into(),
@@ -262,8 +262,8 @@ impl DapStore {
let user_installed_path = dap_settings.and_then(|s| match &s.binary {
DapBinary::Default => None,
DapBinary::Custom(binary) => {
- // if `binary` is absolute, `.join()` will keep it unmodified
- Some(worktree.read(cx).abs_path().join(PathBuf::from(binary)))
+ let path = PathBuf::from(binary);
+ Some(worktree.read(cx).resolve_executable_path(path))
}
});
let user_args = dap_settings.map(|s| s.args.clone());
@@ -692,7 +692,7 @@ impl DapStore {
}
VariableLookupKind::Expression => {
let Ok(eval_task) = session.read_with(cx, |session, _| {
- session.mode.request_dap(EvaluateCommand {
+ session.state.request_dap(EvaluateCommand {
expression: inline_value_location.variable_name.clone(),
frame_id: Some(stack_frame_id),
source: None,
@@ -3,13 +3,10 @@ use async_trait::async_trait;
use dap::{DapLocator, DebugRequest, adapters::DebugAdapterName};
use gpui::SharedString;
use serde_json::{Value, json};
-use smol::{
- Timer,
- io::AsyncReadExt,
- process::{Command, Stdio},
-};
+use smol::{Timer, io::AsyncReadExt, process::Stdio};
use std::time::Duration;
use task::{BuildTaskDefinition, DebugScenario, ShellBuilder, SpawnInTerminal, TaskTemplate};
+use util::command::new_smol_command;
pub(crate) struct CargoLocator;
@@ -18,7 +15,7 @@ async fn find_best_executable(executables: &[String], test_name: &str) -> Option
return executables.first().cloned();
}
for executable in executables {
- let Some(mut child) = Command::new(&executable)
+ let Some(mut child) = new_smol_command(&executable)
.arg("--list")
.stdout(Stdio::piped())
.spawn()
@@ -118,18 +115,17 @@ impl DapLocator for CargoLocator {
.clone()
.context("Couldn't get cwd from debug config which is needed for locators")?;
let builder = ShellBuilder::new(&build_config.shell, cfg!(windows)).non_interactive();
- let (program, args) = builder.build(
- Some("cargo".into()),
- &build_config
- .args
- .iter()
- .cloned()
- .take_while(|arg| arg != "--")
- .chain(Some("--message-format=json".to_owned()))
- .collect::<Vec<_>>(),
- );
- let mut child = util::command::new_smol_command(program)
- .args(args)
+ let mut child = builder
+ .build_command(
+ Some("cargo".into()),
+ &build_config
+ .args
+ .iter()
+ .cloned()
+ .take_while(|arg| arg != "--")
+ .chain(Some("--message-format=json".to_owned()))
+ .collect::<Vec<_>>(),
+ )
.envs(build_config.env.iter().map(|(k, v)| (k.clone(), v.clone())))
.current_dir(cwd)
.stdout(Stdio::piped())
@@ -148,6 +144,8 @@ impl DapLocator for CargoLocator {
.first()
.is_some_and(|arg| arg == "test" || arg == "t");
+ let is_ignored = build_config.args.contains(&"--include-ignored".to_owned());
+
let executables = output
.lines()
.filter(|line| !line.trim().is_empty())
@@ -205,6 +203,10 @@ impl DapLocator for CargoLocator {
let mut args: Vec<_> = test_name.into_iter().collect();
if is_test {
args.push("--nocapture".to_owned());
+ if is_ignored {
+ args.push("--include-ignored".to_owned());
+ args.push("--exact".to_owned());
+ }
}
Ok(DebugRequest::Launch(task::LaunchRequest {
@@ -1,7 +1,3 @@
-use crate::debugger::breakpoint_store::BreakpointSessionState;
-use crate::debugger::dap_command::{DataBreakpointContext, ReadMemory};
-use crate::debugger::memory::{self, Memory, MemoryIterator, MemoryPageBuilder, PageAddress};
-
use super::breakpoint_store::{
BreakpointStore, BreakpointStoreEvent, BreakpointUpdatedReason, SourceBreakpoint,
};
@@ -14,6 +10,9 @@ use super::dap_command::{
TerminateCommand, TerminateThreadsCommand, ThreadsCommand, VariablesCommand,
};
use super::dap_store::DapStore;
+use crate::debugger::breakpoint_store::BreakpointSessionState;
+use crate::debugger::dap_command::{DataBreakpointContext, ReadMemory};
+use crate::debugger::memory::{self, Memory, MemoryIterator, MemoryPageBuilder, PageAddress};
use anyhow::{Context as _, Result, anyhow, bail};
use base64::Engine;
use collections::{HashMap, HashSet, IndexMap};
@@ -42,15 +41,13 @@ use gpui::{
Task, WeakEntity,
};
use http_client::HttpClient;
-
use node_runtime::NodeRuntime;
use remote::RemoteClient;
-use rpc::ErrorExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use smol::net::{TcpListener, TcpStream};
use std::any::TypeId;
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, VecDeque};
use std::net::Ipv4Addr;
use std::ops::RangeInclusive;
use std::path::PathBuf;
@@ -71,6 +68,9 @@ use util::command::new_smol_command;
use util::{ResultExt, debug_panic, maybe};
use worktree::Worktree;
+const MAX_TRACKED_OUTPUT_EVENTS: usize = 5000;
+const DEBUG_HISTORY_LIMIT: usize = 10;
+
#[derive(Debug, Copy, Clone, Hash, PartialEq, PartialOrd, Ord, Eq)]
#[repr(transparent)]
pub struct ThreadId(pub i64);
@@ -118,11 +118,11 @@ impl ThreadStatus {
}
}
-#[derive(Debug)]
+#[derive(Debug, Clone)]
pub struct Thread {
dap: dap::Thread,
stack_frames: Vec<StackFrame>,
- stack_frames_error: Option<anyhow::Error>,
+ stack_frames_error: Option<SharedString>,
_has_stopped: bool,
}
@@ -672,7 +672,18 @@ impl ThreadStates {
.any(|status| *status == ThreadStatus::Stopped)
}
}
-const MAX_TRACKED_OUTPUT_EVENTS: usize = 5000;
+
+// TODO(debugger): Wrap dap types with reference counting so the UI doesn't have to clone them on refresh
+#[derive(Default)]
+pub struct SessionSnapshot {
+ threads: IndexMap<ThreadId, Thread>,
+ thread_states: ThreadStates,
+ variables: HashMap<VariableReference, Vec<dap::Variable>>,
+ stack_frames: IndexMap<StackFrameId, StackFrame>,
+ locations: HashMap<u64, dap::LocationsResponse>,
+ modules: Vec<dap::Module>,
+ loaded_sources: Vec<dap::Source>,
+}
type IsEnabled = bool;
@@ -680,23 +691,19 @@ type IsEnabled = bool;
pub struct OutputToken(pub usize);
/// Represents a current state of a single debug adapter and provides ways to mutate it.
pub struct Session {
- pub mode: SessionState,
+ pub state: SessionState,
+ active_snapshot: SessionSnapshot,
+ snapshots: VecDeque<SessionSnapshot>,
+ selected_snapshot_index: Option<usize>,
id: SessionId,
label: Option<SharedString>,
adapter: DebugAdapterName,
pub(super) capabilities: Capabilities,
child_session_ids: HashSet<SessionId>,
parent_session: Option<Entity<Session>>,
- modules: Vec<dap::Module>,
- loaded_sources: Vec<dap::Source>,
output_token: OutputToken,
output: Box<circular_buffer::CircularBuffer<MAX_TRACKED_OUTPUT_EVENTS, dap::OutputEvent>>,
- threads: IndexMap<ThreadId, Thread>,
- thread_states: ThreadStates,
watchers: HashMap<SharedString, Watcher>,
- variables: HashMap<VariableReference, Vec<dap::Variable>>,
- stack_frames: IndexMap<StackFrameId, StackFrame>,
- locations: HashMap<u64, dap::LocationsResponse>,
is_session_terminated: bool,
requests: HashMap<TypeId, HashMap<RequestSlot, Shared<Task<Option<()>>>>>,
pub(crate) breakpoint_store: Entity<BreakpointStore>,
@@ -801,6 +808,7 @@ pub enum SessionEvent {
},
DataBreakpointInfo,
ConsoleOutput,
+ HistoricSnapshotSelected,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -858,24 +866,20 @@ impl Session {
.detach();
Self {
- mode: SessionState::Booting(None),
+ state: SessionState::Booting(None),
+ snapshots: VecDeque::with_capacity(DEBUG_HISTORY_LIMIT),
+ selected_snapshot_index: None,
+ active_snapshot: Default::default(),
id: session_id,
child_session_ids: HashSet::default(),
parent_session,
capabilities: Capabilities::default(),
watchers: HashMap::default(),
- variables: Default::default(),
- stack_frames: Default::default(),
- thread_states: ThreadStates::default(),
output_token: OutputToken(0),
output: circular_buffer::CircularBuffer::boxed(),
requests: HashMap::default(),
- modules: Vec::default(),
- loaded_sources: Vec::default(),
- threads: IndexMap::default(),
background_tasks: Vec::default(),
restart_task: None,
- locations: Default::default(),
is_session_terminated: false,
ignore_breakpoints: false,
breakpoint_store,
@@ -899,7 +903,7 @@ impl Session {
}
pub fn worktree(&self) -> Option<Entity<Worktree>> {
- match &self.mode {
+ match &self.state {
SessionState::Booting(_) => None,
SessionState::Running(local_mode) => local_mode.worktree.upgrade(),
}
@@ -960,7 +964,7 @@ impl Session {
)
.await?;
this.update(cx, |this, cx| {
- match &mut this.mode {
+ match &mut this.state {
SessionState::Booting(task) if task.is_some() => {
task.take().unwrap().detach_and_log_err(cx);
}
@@ -969,7 +973,7 @@ impl Session {
debug_panic!("Attempting to boot a session that is already running");
}
};
- this.mode = SessionState::Running(mode);
+ this.state = SessionState::Running(mode);
cx.emit(SessionStateEvent::Running);
})?;
@@ -1061,7 +1065,7 @@ impl Session {
}
pub fn binary(&self) -> Option<&DebugAdapterBinary> {
- match &self.mode {
+ match &self.state {
SessionState::Booting(_) => None,
SessionState::Running(running_mode) => Some(&running_mode.binary),
}
@@ -1107,25 +1111,25 @@ impl Session {
}
pub fn is_started(&self) -> bool {
- match &self.mode {
+ match &self.state {
SessionState::Booting(_) => false,
SessionState::Running(running) => running.is_started,
}
}
pub fn is_building(&self) -> bool {
- matches!(self.mode, SessionState::Booting(_))
+ matches!(self.state, SessionState::Booting(_))
}
pub fn as_running_mut(&mut self) -> Option<&mut RunningMode> {
- match &mut self.mode {
+ match &mut self.state {
SessionState::Running(local_mode) => Some(local_mode),
SessionState::Booting(_) => None,
}
}
pub fn as_running(&self) -> Option<&RunningMode> {
- match &self.mode {
+ match &self.state {
SessionState::Running(local_mode) => Some(local_mode),
SessionState::Booting(_) => None,
}
@@ -1269,7 +1273,7 @@ impl Session {
let adapter_id = self.adapter().to_string();
let request = Initialize { adapter_id };
- let SessionState::Running(running) = &self.mode else {
+ let SessionState::Running(running) = &self.state else {
return Task::ready(Err(anyhow!(
"Cannot send initialize request, task still building"
)));
@@ -1317,7 +1321,7 @@ impl Session {
dap_store: WeakEntity<DapStore>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
- match &self.mode {
+ match &self.state {
SessionState::Running(local_mode) => {
local_mode.initialize_sequence(&self.capabilities, initialize_rx, dap_store, cx)
}
@@ -1333,10 +1337,12 @@ impl Session {
active_thread_id: ThreadId,
cx: &mut Context<Self>,
) {
- match &mut self.mode {
+ match &mut self.state {
SessionState::Running(local_mode) => {
if !matches!(
- self.thread_states.thread_state(active_thread_id),
+ self.active_snapshot
+ .thread_states
+ .thread_state(active_thread_id),
Some(ThreadStatus::Stopped)
) {
return;
@@ -1411,8 +1417,55 @@ impl Session {
})
}
+ fn session_state(&self) -> &SessionSnapshot {
+ self.selected_snapshot_index
+ .and_then(|ix| self.snapshots.get(ix))
+ .unwrap_or_else(|| &self.active_snapshot)
+ }
+
+ fn push_to_history(&mut self) {
+ if !self.has_ever_stopped() {
+ return;
+ }
+
+ while self.snapshots.len() >= DEBUG_HISTORY_LIMIT {
+ self.snapshots.pop_front();
+ }
+
+ self.snapshots
+ .push_back(std::mem::take(&mut self.active_snapshot));
+ }
+
+ pub fn historic_snapshots(&self) -> &VecDeque<SessionSnapshot> {
+ &self.snapshots
+ }
+
+ pub fn select_historic_snapshot(&mut self, ix: Option<usize>, cx: &mut Context<Session>) {
+ if self.selected_snapshot_index == ix {
+ return;
+ }
+
+ if self
+ .selected_snapshot_index
+ .is_some_and(|ix| self.snapshots.len() <= ix)
+ {
+ debug_panic!("Attempted to select a debug session with an out of bounds index");
+ return;
+ }
+
+ self.selected_snapshot_index = ix;
+ cx.emit(SessionEvent::HistoricSnapshotSelected);
+ cx.notify();
+ }
+
+ pub fn active_snapshot_index(&self) -> Option<usize> {
+ self.selected_snapshot_index
+ }
+
fn handle_stopped_event(&mut self, event: StoppedEvent, cx: &mut Context<Self>) {
- self.mode.stopped();
+ self.push_to_history();
+
+ self.state.stopped();
// todo(debugger): Find a clean way to get around the clone
let breakpoint_store = self.breakpoint_store.clone();
if let Some((local, path)) = self.as_running_mut().and_then(|local| {
@@ -1431,14 +1484,16 @@ impl Session {
};
if event.all_threads_stopped.unwrap_or_default() || event.thread_id.is_none() {
- self.thread_states.stop_all_threads();
+ self.active_snapshot.thread_states.stop_all_threads();
self.invalidate_command_type::<StackTraceCommand>();
}
// Event if we stopped all threads we still need to insert the thread_id
// to our own data
if let Some(thread_id) = event.thread_id {
- self.thread_states.stop_thread(ThreadId(thread_id));
+ self.active_snapshot
+ .thread_states
+ .stop_thread(ThreadId(thread_id));
self.invalidate_state(
&StackTraceCommand {
@@ -1451,8 +1506,8 @@ impl Session {
}
self.invalidate_generic();
- self.threads.clear();
- self.variables.clear();
+ self.active_snapshot.threads.clear();
+ self.active_snapshot.variables.clear();
cx.emit(SessionEvent::Stopped(
event
.thread_id
@@ -1474,12 +1529,13 @@ impl Session {
Events::Stopped(event) => self.handle_stopped_event(event, cx),
Events::Continued(event) => {
if event.all_threads_continued.unwrap_or_default() {
- self.thread_states.continue_all_threads();
+ self.active_snapshot.thread_states.continue_all_threads();
self.breakpoint_store.update(cx, |store, cx| {
store.remove_active_position(Some(self.session_id()), cx)
});
} else {
- self.thread_states
+ self.active_snapshot
+ .thread_states
.continue_thread(ThreadId(event.thread_id));
}
// todo(debugger): We should be able to get away with only invalidating generic if all threads were continued
@@ -1496,10 +1552,12 @@ impl Session {
match event.reason {
dap::ThreadEventReason::Started => {
- self.thread_states.continue_thread(thread_id);
+ self.active_snapshot
+ .thread_states
+ .continue_thread(thread_id);
}
dap::ThreadEventReason::Exited => {
- self.thread_states.exit_thread(thread_id);
+ self.active_snapshot.thread_states.exit_thread(thread_id);
}
reason => {
log::error!("Unhandled thread event reason {:?}", reason);
@@ -1526,10 +1584,11 @@ impl Session {
Events::Module(event) => {
match event.reason {
dap::ModuleEventReason::New => {
- self.modules.push(event.module);
+ self.active_snapshot.modules.push(event.module);
}
dap::ModuleEventReason::Changed => {
if let Some(module) = self
+ .active_snapshot
.modules
.iter_mut()
.find(|other| event.module.id == other.id)
@@ -1538,7 +1597,9 @@ impl Session {
}
}
dap::ModuleEventReason::Removed => {
- self.modules.retain(|other| event.module.id != other.id);
+ self.active_snapshot
+ .modules
+ .retain(|other| event.module.id != other.id);
}
}
@@ -1612,8 +1673,9 @@ impl Session {
);
}
- if !self.thread_states.any_stopped_thread()
- && request.type_id() != TypeId::of::<ThreadsCommand>()
+ if (!self.active_snapshot.thread_states.any_stopped_thread()
+ && request.type_id() != TypeId::of::<ThreadsCommand>())
+ || self.selected_snapshot_index.is_some()
|| self.is_session_terminated
{
return;
@@ -1629,7 +1691,7 @@ impl Session {
let task = Self::request_inner::<Arc<T>>(
&self.capabilities,
- &self.mode,
+ &self.state,
command,
|this, result, cx| {
process_result(this, result, cx);
@@ -1697,7 +1759,7 @@ impl Session {
+ 'static,
cx: &mut Context<Self>,
) -> Task<Option<T::Response>> {
- Self::request_inner(&self.capabilities, &self.mode, request, process_result, cx)
+ Self::request_inner(&self.capabilities, &self.state, request, process_result, cx)
}
fn invalidate_command_type<Command: LocalDapCommand>(&mut self) {
@@ -1730,11 +1792,11 @@ impl Session {
}
pub fn any_stopped_thread(&self) -> bool {
- self.thread_states.any_stopped_thread()
+ self.active_snapshot.thread_states.any_stopped_thread()
}
pub fn thread_status(&self, thread_id: ThreadId) -> ThreadStatus {
- self.thread_states.thread_status(thread_id)
+ self.active_snapshot.thread_states.thread_status(thread_id)
}
pub fn threads(&mut self, cx: &mut Context<Self>) -> Vec<(dap::Thread, ThreadStatus)> {
@@ -1745,7 +1807,7 @@ impl Session {
return;
};
- this.threads = result
+ this.active_snapshot.threads = result
.into_iter()
.map(|thread| (ThreadId(thread.id), Thread::from(thread)))
.collect();
@@ -1757,12 +1819,14 @@ impl Session {
cx,
);
- self.threads
+ let state = self.session_state();
+ state
+ .threads
.values()
.map(|thread| {
(
thread.dap.clone(),
- self.thread_states.thread_status(ThreadId(thread.dap.id)),
+ state.thread_states.thread_status(ThreadId(thread.dap.id)),
)
})
.collect()
@@ -1776,14 +1840,14 @@ impl Session {
return;
};
- this.modules = result;
+ this.active_snapshot.modules = result;
cx.emit(SessionEvent::Modules);
cx.notify();
},
cx,
);
- &self.modules
+ &self.session_state().modules
}
// CodeLLDB returns the size of a pointed-to-memory, which we can use to make the experience of go-to-memory better.
@@ -2034,14 +2098,13 @@ impl Session {
let Some(result) = result.log_err() else {
return;
};
- this.loaded_sources = result;
+ this.active_snapshot.loaded_sources = result;
cx.emit(SessionEvent::LoadedSources);
cx.notify();
},
cx,
);
-
- &self.loaded_sources
+ &self.session_state().loaded_sources
}
fn fallback_to_manual_restart(
@@ -2073,7 +2136,7 @@ impl Session {
Some(response)
}
None => {
- this.thread_states.stop_thread(thread_id);
+ this.active_snapshot.thread_states.stop_thread(thread_id);
cx.notify();
None
}
@@ -2149,10 +2212,10 @@ impl Session {
}
self.is_session_terminated = true;
- self.thread_states.exit_all_threads();
+ self.active_snapshot.thread_states.exit_all_threads();
cx.notify();
- let task = match &mut self.mode {
+ let task = match &mut self.state {
SessionState::Running(_) => {
if self
.capabilities
@@ -2213,9 +2276,13 @@ impl Session {
}
pub fn continue_thread(&mut self, thread_id: ThreadId, cx: &mut Context<Self>) {
+ self.select_historic_snapshot(None, cx);
+
let supports_single_thread_execution_requests =
self.capabilities.supports_single_thread_execution_requests;
- self.thread_states.continue_thread(thread_id);
+ self.active_snapshot
+ .thread_states
+ .continue_thread(thread_id);
self.request(
ContinueCommand {
args: ContinueArguments {
@@ -2230,21 +2297,24 @@ impl Session {
}
pub fn adapter_client(&self) -> Option<Arc<DebugAdapterClient>> {
- match self.mode {
+ match self.state {
SessionState::Running(ref local) => Some(local.client.clone()),
SessionState::Booting(_) => None,
}
}
pub fn has_ever_stopped(&self) -> bool {
- self.mode.has_ever_stopped()
+ self.state.has_ever_stopped()
}
+
pub fn step_over(
&mut self,
thread_id: ThreadId,
granularity: SteppingGranularity,
cx: &mut Context<Self>,
) {
+ self.select_historic_snapshot(None, cx);
+
let supports_single_thread_execution_requests =
self.capabilities.supports_single_thread_execution_requests;
let supports_stepping_granularity = self
@@ -2260,7 +2330,7 @@ impl Session {
},
};
- self.thread_states.process_step(thread_id);
+ self.active_snapshot.thread_states.process_step(thread_id);
self.request(
command,
Self::on_step_response::<NextCommand>(thread_id),
@@ -2275,6 +2345,8 @@ impl Session {
granularity: SteppingGranularity,
cx: &mut Context<Self>,
) {
+ self.select_historic_snapshot(None, cx);
+
let supports_single_thread_execution_requests =
self.capabilities.supports_single_thread_execution_requests;
let supports_stepping_granularity = self
@@ -2290,7 +2362,7 @@ impl Session {
},
};
- self.thread_states.process_step(thread_id);
+ self.active_snapshot.thread_states.process_step(thread_id);
self.request(
command,
Self::on_step_response::<StepInCommand>(thread_id),
@@ -2305,6 +2377,8 @@ impl Session {
granularity: SteppingGranularity,
cx: &mut Context<Self>,
) {
+ self.select_historic_snapshot(None, cx);
+
let supports_single_thread_execution_requests =
self.capabilities.supports_single_thread_execution_requests;
let supports_stepping_granularity = self
@@ -2320,7 +2394,7 @@ impl Session {
},
};
- self.thread_states.process_step(thread_id);
+ self.active_snapshot.thread_states.process_step(thread_id);
self.request(
command,
Self::on_step_response::<StepOutCommand>(thread_id),
@@ -2335,6 +2409,8 @@ impl Session {
granularity: SteppingGranularity,
cx: &mut Context<Self>,
) {
+ self.select_historic_snapshot(None, cx);
+
let supports_single_thread_execution_requests =
self.capabilities.supports_single_thread_execution_requests;
let supports_stepping_granularity = self
@@ -2350,7 +2426,7 @@ impl Session {
},
};
- self.thread_states.process_step(thread_id);
+ self.active_snapshot.thread_states.process_step(thread_id);
self.request(
command,
@@ -2365,9 +2441,9 @@ impl Session {
thread_id: ThreadId,
cx: &mut Context<Self>,
) -> Result<Vec<StackFrame>> {
- if self.thread_states.thread_status(thread_id) == ThreadStatus::Stopped
+ if self.active_snapshot.thread_states.thread_status(thread_id) == ThreadStatus::Stopped
&& self.requests.contains_key(&ThreadsCommand.type_id())
- && self.threads.contains_key(&thread_id)
+ && self.active_snapshot.threads.contains_key(&thread_id)
// ^ todo(debugger): We need a better way to check that we're not querying stale data
// We could still be using an old thread id and have sent a new thread's request
// This isn't the biggest concern right now because it hasn't caused any issues outside of tests
@@ -2381,7 +2457,8 @@ impl Session {
},
move |this, stack_frames, cx| {
let entry =
- this.threads
+ this.active_snapshot
+ .threads
.entry(thread_id)
.and_modify(|thread| match &stack_frames {
Ok(stack_frames) => {
@@ -2394,7 +2471,7 @@ impl Session {
}
Err(error) => {
thread.stack_frames.clear();
- thread.stack_frames_error = Some(error.cloned());
+ thread.stack_frames_error = Some(error.to_string().into());
}
});
debug_assert!(
@@ -2402,7 +2479,7 @@ impl Session {
"Sent request for thread_id that doesn't exist"
);
if let Ok(stack_frames) = stack_frames {
- this.stack_frames.extend(
+ this.active_snapshot.stack_frames.extend(
stack_frames
.into_iter()
.filter(|frame| {
@@ -2427,10 +2504,10 @@ impl Session {
);
}
- match self.threads.get(&thread_id) {
+ match self.session_state().threads.get(&thread_id) {
Some(thread) => {
if let Some(error) = &thread.stack_frames_error {
- Err(error.cloned())
+ Err(anyhow!(error.to_string()))
} else {
Ok(thread.stack_frames.clone())
}
@@ -2457,6 +2534,7 @@ impl Session {
}
let entry = this
+ .active_snapshot
.stack_frames
.entry(stack_frame_id)
.and_modify(|stack_frame| {
@@ -2474,7 +2552,8 @@ impl Session {
);
}
- self.stack_frames
+ self.session_state()
+ .stack_frames
.get(&stack_frame_id)
.map(|frame| frame.scopes.as_slice())
.unwrap_or_default()
@@ -2486,7 +2565,8 @@ impl Session {
globals: bool,
locals: bool,
) -> Vec<dap::Variable> {
- let Some(stack_frame) = self.stack_frames.get(&stack_frame_id) else {
+ let state = self.session_state();
+ let Some(stack_frame) = state.stack_frames.get(&stack_frame_id) else {
return Vec::new();
};
@@ -2497,7 +2577,7 @@ impl Session {
(scope.name.to_lowercase().contains("local") && locals)
|| (scope.name.to_lowercase().contains("global") && globals)
})
- .filter_map(|scope| self.variables.get(&scope.variables_reference))
+ .filter_map(|scope| state.variables.get(&scope.variables_reference))
.flatten()
.cloned()
.collect()
@@ -2513,7 +2593,7 @@ impl Session {
frame_id: u64,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
- let request = self.mode.request_dap(EvaluateCommand {
+ let request = self.state.request_dap(EvaluateCommand {
expression: expression.to_string(),
context: Some(EvaluateArgumentsContext::Watch),
frame_id: Some(frame_id),
@@ -2570,7 +2650,9 @@ impl Session {
return;
};
- this.variables.insert(variables_reference, variables);
+ this.active_snapshot
+ .variables
+ .insert(variables_reference, variables);
cx.emit(SessionEvent::Variables);
cx.emit(SessionEvent::InvalidateInlineValue);
@@ -2578,7 +2660,8 @@ impl Session {
cx,
);
- self.variables
+ self.session_state()
+ .variables
.get(&variables_reference)
.cloned()
.unwrap_or_default()
@@ -2645,7 +2728,7 @@ impl Session {
location_reference: None,
};
self.push_output(event);
- let request = self.mode.request_dap(EvaluateCommand {
+ let request = self.state.request_dap(EvaluateCommand {
expression,
context,
frame_id,
@@ -2656,6 +2739,8 @@ impl Session {
this.update(cx, |this, cx| {
this.memory.clear(cx.background_executor());
this.invalidate_command_type::<ReadMemory>();
+ this.invalidate_command_type::<VariablesCommand>();
+ cx.emit(SessionEvent::Variables);
match response {
Ok(response) => {
let event = dap::OutputEvent {
@@ -2703,15 +2788,15 @@ impl Session {
let Some(response) = response.log_err() else {
return;
};
- this.locations.insert(reference, response);
+ this.active_snapshot.locations.insert(reference, response);
},
cx,
);
- self.locations.get(&reference).cloned()
+ self.session_state().locations.get(&reference).cloned()
}
pub fn is_attached(&self) -> bool {
- let SessionState::Running(local_mode) = &self.mode else {
+ let SessionState::Running(local_mode) = &self.state else {
return false;
};
local_mode.binary.request_args.request == StartDebuggingRequestArgumentsRequest::Attach
@@ -2747,7 +2832,7 @@ impl Session {
}
pub fn thread_state(&self, thread_id: ThreadId) -> Option<ThreadStatus> {
- self.thread_states.thread_state(thread_id)
+ self.session_state().thread_states.thread_state(thread_id)
}
pub fn quirks(&self) -> SessionQuirks {
@@ -3033,10 +3118,11 @@ async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Resul
.await
.context("getting installed companion version")?
.context("companion was not installed")?;
- smol::fs::rename(temp_dir.path(), dir.join(&version))
+ let version_folder = dir.join(version.to_string());
+ smol::fs::rename(temp_dir.path(), &version_folder)
.await
.context("moving companion package into place")?;
- Ok(dir.join(version))
+ Ok(version_folder)
}
let dir = paths::debug_adapters_dir().join("js-debug-companion");
@@ -3049,19 +3135,23 @@ async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Resul
.await
.context("creating companion installation directory")?;
- let mut children = smol::fs::read_dir(&dir)
+ let children = smol::fs::read_dir(&dir)
.await
.context("reading companion installation directory")?
.try_collect::<Vec<_>>()
.await
.context("reading companion installation directory entries")?;
- children
- .sort_by_key(|child| semver::Version::parse(child.file_name().to_str()?).ok());
- let latest_installed_version = children.last().and_then(|child| {
- let version = child.file_name().into_string().ok()?;
- Some((child.path(), version))
- });
+ let latest_installed_version = children
+ .iter()
+ .filter_map(|child| {
+ Some((
+ child.path(),
+ semver::Version::parse(child.file_name().to_str()?).ok()?,
+ ))
+ })
+ .max_by_key(|(_, version)| version.clone());
+
let latest_version = node
.npm_package_latest_version(PACKAGE_NAME)
.await
@@ -6,7 +6,7 @@ use rpc::proto::{self, REMOTE_SERVER_PROJECT_ID};
use std::{collections::VecDeque, path::Path, sync::Arc};
use task::{Shell, shell_to_proto};
use terminal::terminal_settings::TerminalSettings;
-use util::{ResultExt, rel_path::RelPath};
+use util::{ResultExt, command::new_smol_command, rel_path::RelPath};
use worktree::Worktree;
use collections::HashMap;
@@ -314,6 +314,10 @@ async fn load_directory_shell_environment(
load_direnv: DirenvSettings,
tx: mpsc::UnboundedSender<String>,
) -> anyhow::Result<HashMap<String, String>> {
+ if let DirenvSettings::Disabled = load_direnv {
+ return Ok(HashMap::default());
+ }
+
let meta = smol::fs::metadata(&abs_path).await.with_context(|| {
tx.unbounded_send(format!("Failed to open {}", abs_path.display()))
.ok();
@@ -355,6 +359,7 @@ async fn load_directory_shell_environment(
// even if direnv direct mode is enabled.
let direnv_environment = match load_direnv {
DirenvSettings::ShellHook => None,
+ DirenvSettings::Disabled => bail!("direnv integration is disabled"),
// Note: direnv is not available on Windows, so we skip direnv processing
// and just return the shell environment
DirenvSettings::Direct if cfg!(target_os = "windows") => None,
@@ -389,7 +394,7 @@ async fn load_direnv_environment(
};
let args = &["export", "json"];
- let direnv_output = smol::process::Command::new(&direnv_path)
+ let direnv_output = new_smol_command(&direnv_path)
.args(args)
.envs(env)
.env("TERM", "dumb")
@@ -25,7 +25,7 @@ use futures::{
stream::FuturesOrdered,
};
use git::{
- BuildPermalinkParams, GitHostingProviderRegistry, Oid,
+ BuildPermalinkParams, GitHostingProviderRegistry, Oid, RunHook,
blame::Blame,
parse_git_remote_url,
repository::{
@@ -48,7 +48,7 @@ use language::{
proto::{deserialize_version, serialize_version},
};
use parking_lot::Mutex;
-use pending_op::{PendingOp, PendingOpId, PendingOps};
+use pending_op::{PendingOp, PendingOpId, PendingOps, PendingOpsSummary};
use postage::stream::Stream as _;
use rpc::{
AnyProtoClient, TypedEnvelope,
@@ -56,6 +56,7 @@ use rpc::{
};
use serde::Deserialize;
use settings::WorktreeId;
+use smol::future::yield_now;
use std::{
cmp::Ordering,
collections::{BTreeSet, HashSet, VecDeque},
@@ -255,7 +256,6 @@ pub struct MergeDetails {
pub struct RepositorySnapshot {
pub id: RepositoryId,
pub statuses_by_path: SumTree<StatusEntry>,
- pub pending_ops_by_path: SumTree<PendingOps>,
pub work_directory_abs_path: Arc<Path>,
pub path_style: PathStyle,
pub branch: Option<Branch>,
@@ -285,9 +285,11 @@ pub struct Repository {
paths_needing_status_update: BTreeSet<RepoPath>,
job_sender: mpsc::UnboundedSender<GitJob>,
active_jobs: HashMap<JobId, JobInfo>,
+ pending_ops: SumTree<PendingOps>,
job_id: JobId,
askpass_delegates: Arc<Mutex<HashMap<u64, AskPassDelegate>>>,
latest_askpass_id: u64,
+ repository_state: Shared<Task<Result<RepositoryState, String>>>,
}
impl std::ops::Deref for Repository {
@@ -298,30 +300,73 @@ impl std::ops::Deref for Repository {
}
}
+#[derive(Clone)]
+pub struct LocalRepositoryState {
+ pub fs: Arc<dyn Fs>,
+ pub backend: Arc<dyn GitRepository>,
+ pub environment: Arc<HashMap<String, String>>,
+}
+
+impl LocalRepositoryState {
+ async fn new(
+ work_directory_abs_path: Arc<Path>,
+ dot_git_abs_path: Arc<Path>,
+ project_environment: WeakEntity<ProjectEnvironment>,
+ fs: Arc<dyn Fs>,
+ cx: &mut AsyncApp,
+ ) -> anyhow::Result<Self> {
+ let environment = project_environment
+ .update(cx, |project_environment, cx| {
+ project_environment.local_directory_environment(&Shell::System, work_directory_abs_path.clone(), cx)
+ })?
+ .await
+ .unwrap_or_else(|| {
+ log::error!("failed to get working directory environment for repository {work_directory_abs_path:?}");
+ HashMap::default()
+ });
+ let search_paths = environment.get("PATH").map(|val| val.to_owned());
+ let backend = cx
+ .background_spawn({
+ let fs = fs.clone();
+ async move {
+ let system_git_binary_path = search_paths
+ .and_then(|search_paths| {
+ which::which_in("git", Some(search_paths), &work_directory_abs_path)
+ .ok()
+ })
+ .or_else(|| which::which("git").ok());
+ fs.open_repo(&dot_git_abs_path, system_git_binary_path.as_deref())
+ .with_context(|| format!("opening repository at {dot_git_abs_path:?}"))
+ }
+ })
+ .await?;
+ Ok(LocalRepositoryState {
+ backend,
+ environment: Arc::new(environment),
+ fs,
+ })
+ }
+}
+
+#[derive(Clone)]
+pub struct RemoteRepositoryState {
+ pub project_id: ProjectId,
+ pub client: AnyProtoClient,
+}
+
#[derive(Clone)]
pub enum RepositoryState {
- Local {
- backend: Arc<dyn GitRepository>,
- environment: Arc<HashMap<String, String>>,
- },
- Remote {
- project_id: ProjectId,
- client: AnyProtoClient,
- },
+ Local(LocalRepositoryState),
+ Remote(RemoteRepositoryState),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RepositoryEvent {
- StatusesChanged {
- // TODO could report which statuses changed here
- full_scan: bool,
- },
+ StatusesChanged,
MergeHeadsChanged,
BranchChanged,
StashEntriesChanged,
- PendingOpsChanged {
- pending_ops: SumTree<pending_op::PendingOps>,
- },
+ PendingOpsChanged { pending_ops: SumTree<PendingOps> },
}
#[derive(Clone, Debug)]
@@ -427,6 +472,9 @@ impl GitStore {
client.add_entity_request_handler(Self::handle_change_branch);
client.add_entity_request_handler(Self::handle_create_branch);
client.add_entity_request_handler(Self::handle_rename_branch);
+ client.add_entity_request_handler(Self::handle_create_remote);
+ client.add_entity_request_handler(Self::handle_remove_remote);
+ client.add_entity_request_handler(Self::handle_delete_branch);
client.add_entity_request_handler(Self::handle_git_init);
client.add_entity_request_handler(Self::handle_push);
client.add_entity_request_handler(Self::handle_pull);
@@ -438,9 +486,11 @@ impl GitStore {
client.add_entity_request_handler(Self::handle_stash_apply);
client.add_entity_request_handler(Self::handle_stash_drop);
client.add_entity_request_handler(Self::handle_commit);
+ client.add_entity_request_handler(Self::handle_run_hook);
client.add_entity_request_handler(Self::handle_reset);
client.add_entity_request_handler(Self::handle_show);
client.add_entity_request_handler(Self::handle_load_commit_diff);
+ client.add_entity_request_handler(Self::handle_file_history);
client.add_entity_request_handler(Self::handle_checkout_files);
client.add_entity_request_handler(Self::handle_open_commit_message_buffer);
client.add_entity_request_handler(Self::handle_set_index_text);
@@ -969,7 +1019,7 @@ impl GitStore {
&self,
buffer: &Entity<Buffer>,
version: Option<clock::Global>,
- cx: &mut App,
+ cx: &mut Context<Self>,
) -> Task<Result<Option<Blame>>> {
let buffer = buffer.read(cx);
let Some((repo, repo_path)) =
@@ -981,30 +1031,56 @@ impl GitStore {
Some(version) => buffer.rope_for_version(version),
None => buffer.as_rope().clone(),
};
+ let line_ending = buffer.line_ending();
let version = version.unwrap_or(buffer.version());
let buffer_id = buffer.remote_id();
- let rx = repo.update(cx, |repo, _| {
- repo.send_job(None, move |state, _| async move {
- match state {
- RepositoryState::Local { backend, .. } => backend
- .blame(repo_path.clone(), content)
- .await
- .with_context(|| format!("Failed to blame {:?}", repo_path.as_ref()))
- .map(Some),
- RepositoryState::Remote { project_id, client } => {
- let response = client
- .request(proto::BlameBuffer {
- project_id: project_id.to_proto(),
- buffer_id: buffer_id.into(),
- version: serialize_version(&version),
- })
- .await?;
- Ok(deserialize_blame_buffer_response(response))
- }
+ let repo = repo.downgrade();
+ cx.spawn(async move |_, cx| {
+ let repository_state = repo
+ .update(cx, |repo, _| repo.repository_state.clone())?
+ .await
+ .map_err(|err| anyhow::anyhow!(err))?;
+ match repository_state {
+ RepositoryState::Local(LocalRepositoryState { backend, .. }) => backend
+ .blame(repo_path.clone(), content, line_ending)
+ .await
+ .with_context(|| format!("Failed to blame {:?}", repo_path.as_ref()))
+ .map(Some),
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+ let response = client
+ .request(proto::BlameBuffer {
+ project_id: project_id.to_proto(),
+ buffer_id: buffer_id.into(),
+ version: serialize_version(&version),
+ })
+ .await?;
+ Ok(deserialize_blame_buffer_response(response))
}
- })
- });
+ }
+ })
+ }
+
+ pub fn file_history(
+ &self,
+ repo: &Entity<Repository>,
+ path: RepoPath,
+ cx: &mut App,
+ ) -> Task<Result<git::repository::FileHistory>> {
+ let rx = repo.update(cx, |repo, _| repo.file_history(path));
+
+ cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
+ }
+
+ pub fn file_history_paginated(
+ &self,
+ repo: &Entity<Repository>,
+ path: RepoPath,
+ skip: usize,
+ limit: Option<usize>,
+ cx: &mut App,
+ ) -> Task<Result<git::repository::FileHistory>> {
+ let rx = repo.update(cx, |repo, _| repo.file_history_paginated(path, skip, limit));
cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
}
@@ -1054,9 +1130,10 @@ impl GitStore {
let rx = repo.update(cx, |repo, _| {
repo.send_job(None, move |state, cx| async move {
match state {
- RepositoryState::Local { backend, .. } => {
+ RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
let origin_url = backend
.remote_url(&remote)
+ .await
.with_context(|| format!("remote \"{remote}\" not found"))?;
let sha = backend.head_sha().await.context("reading HEAD SHA")?;
@@ -1073,7 +1150,7 @@ impl GitStore {
BuildPermalinkParams::new(&sha, &repo_path, Some(selection)),
))
}
- RepositoryState::Remote { project_id, client } => {
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
let response = client
.request(proto::GetPermalinkToLine {
project_id: project_id.to_proto(),
@@ -1312,8 +1389,8 @@ impl GitStore {
} else if let UpdatedGitRepository {
new_work_directory_abs_path: Some(work_directory_abs_path),
dot_git_abs_path: Some(dot_git_abs_path),
- repository_dir_abs_path: Some(repository_dir_abs_path),
- common_dir_abs_path: Some(common_dir_abs_path),
+ repository_dir_abs_path: Some(_repository_dir_abs_path),
+ common_dir_abs_path: Some(_common_dir_abs_path),
..
} = update
{
@@ -1324,8 +1401,6 @@ impl GitStore {
id,
work_directory_abs_path.clone(),
dot_git_abs_path.clone(),
- repository_dir_abs_path.clone(),
- common_dir_abs_path.clone(),
project_environment.downgrade(),
fs.clone(),
git_store,
@@ -1377,7 +1452,7 @@ impl GitStore {
match event {
BufferStoreEvent::BufferAdded(buffer) => {
cx.subscribe(buffer, |this, buffer, event, cx| {
- if let BufferEvent::LanguageChanged = event {
+ if let BufferEvent::LanguageChanged(_) = event {
let buffer_id = buffer.read(cx).remote_id();
if let Some(diff_state) = this.diffs.get(&buffer_id) {
diff_state.update(cx, |diff_state, cx| {
@@ -1597,6 +1672,59 @@ impl GitStore {
}
}
+ fn mark_entries_pending_by_project_paths(
+ &mut self,
+ project_paths: &[ProjectPath],
+ stage: bool,
+ cx: &mut Context<Self>,
+ ) {
+ let buffer_store = &self.buffer_store;
+
+ for project_path in project_paths {
+ let Some(buffer) = buffer_store.read(cx).get_by_path(project_path) else {
+ continue;
+ };
+
+ let buffer_id = buffer.read(cx).remote_id();
+ let Some(diff_state) = self.diffs.get(&buffer_id) else {
+ continue;
+ };
+
+ diff_state.update(cx, |diff_state, cx| {
+ let Some(uncommitted_diff) = diff_state.uncommitted_diff() else {
+ return;
+ };
+
+ let buffer_snapshot = buffer.read(cx).text_snapshot();
+ let file_exists = buffer
+ .read(cx)
+ .file()
+ .is_some_and(|file| file.disk_state().exists());
+
+ let all_hunks: Vec<_> = uncommitted_diff
+ .read(cx)
+ .hunks_intersecting_range(
+ text::Anchor::MIN..text::Anchor::MAX,
+ &buffer_snapshot,
+ cx,
+ )
+ .collect();
+
+ if !all_hunks.is_empty() {
+ uncommitted_diff.update(cx, |diff, cx| {
+ diff.stage_or_unstage_hunks(
+ stage,
+ &all_hunks,
+ &buffer_snapshot,
+ file_exists,
+ cx,
+ );
+ });
+ }
+ });
+ }
+ }
+
pub fn git_clone(
&self,
repo: String,
@@ -1987,6 +2115,22 @@ impl GitStore {
Ok(proto::Ack {})
}
+ async fn handle_run_hook(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::RunGitHook>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::Ack> {
+ let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+ let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+ let hook = RunHook::from_proto(envelope.payload.hook).context("invalid hook")?;
+ repository_handle
+ .update(&mut cx, |repository_handle, cx| {
+ repository_handle.run_hook(hook, cx)
+ })?
+ .await??;
+ Ok(proto::Ack {})
+ }
+
async fn handle_commit(
this: Entity<Self>,
envelope: TypedEnvelope<proto::Commit>,
@@ -2035,10 +2179,11 @@ impl GitStore {
let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
let branch_name = envelope.payload.branch_name;
+ let is_push = envelope.payload.is_push;
let remotes = repository_handle
.update(&mut cx, |repository_handle, _| {
- repository_handle.get_remotes(branch_name)
+ repository_handle.get_remotes(branch_name, is_push)
})?
.await??;
@@ -2185,6 +2330,61 @@ impl GitStore {
Ok(proto::Ack {})
}
+ async fn handle_create_remote(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::GitCreateRemote>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::Ack> {
+ let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+ let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+ let remote_name = envelope.payload.remote_name;
+ let remote_url = envelope.payload.remote_url;
+
+ repository_handle
+ .update(&mut cx, |repository_handle, _| {
+ repository_handle.create_remote(remote_name, remote_url)
+ })?
+ .await??;
+
+ Ok(proto::Ack {})
+ }
+
+ async fn handle_delete_branch(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::GitDeleteBranch>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::Ack> {
+ let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+ let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+ let branch_name = envelope.payload.branch_name;
+
+ repository_handle
+ .update(&mut cx, |repository_handle, _| {
+ repository_handle.delete_branch(branch_name)
+ })?
+ .await??;
+
+ Ok(proto::Ack {})
+ }
+
+ async fn handle_remove_remote(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::GitRemoveRemote>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::Ack> {
+ let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+ let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+ let remote_name = envelope.payload.remote_name;
+
+ repository_handle
+ .update(&mut cx, |repository_handle, _| {
+ repository_handle.remove_remote(remote_name)
+ })?
+ .await??;
+
+ Ok(proto::Ack {})
+ }
+
async fn handle_show(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitShow>,
@@ -2233,6 +2433,40 @@ impl GitStore {
})
}
+ async fn handle_file_history(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::GitFileHistory>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::GitFileHistoryResponse> {
+ let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+ let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+ let path = RepoPath::from_proto(&envelope.payload.path)?;
+ let skip = envelope.payload.skip as usize;
+ let limit = envelope.payload.limit.map(|l| l as usize);
+
+ let file_history = repository_handle
+ .update(&mut cx, |repository_handle, _| {
+ repository_handle.file_history_paginated(path, skip, limit)
+ })?
+ .await??;
+
+ Ok(proto::GitFileHistoryResponse {
+ entries: file_history
+ .entries
+ .into_iter()
+ .map(|entry| proto::FileHistoryEntry {
+ sha: entry.sha.to_string(),
+ subject: entry.subject.to_string(),
+ message: entry.message.to_string(),
+ commit_timestamp: entry.commit_timestamp,
+ author_name: entry.author_name.to_string(),
+ author_email: entry.author_email.to_string(),
+ })
+ .collect(),
+ path: file_history.path.to_proto(),
+ })
+ }
+
async fn handle_reset(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitReset>,
@@ -2924,6 +3158,10 @@ impl BufferGitState {
);
}
+ // Dropping BufferDiff can be expensive, so yield back to the event loop
+ // for a bit
+ yield_now().await;
+
let mut new_uncommitted_diff = None;
if let Some(uncommitted_diff) = &uncommitted_diff {
new_uncommitted_diff = if index_matches_head {
@@ -2945,6 +3183,10 @@ impl BufferGitState {
}
}
+ // Dropping BufferDiff can be expensive, so yield back to the event loop
+ // for a bit
+ yield_now().await;
+
let cancel = this.update(cx, |this, _| {
// This checks whether all pending stage/unstage operations
// have quiesced (i.e. both the corresponding write and the
@@ -2983,6 +3225,8 @@ impl BufferGitState {
None
};
+ yield_now().await;
+
if let Some((uncommitted_diff, new_uncommitted_diff)) =
uncommitted_diff.as_ref().zip(new_uncommitted_diff.clone())
{
@@ -3065,7 +3309,6 @@ impl RepositorySnapshot {
Self {
id,
statuses_by_path: Default::default(),
- pending_ops_by_path: Default::default(),
work_directory_abs_path,
branch: None,
head_commit: None,
@@ -3107,6 +3350,8 @@ impl RepositorySnapshot {
.iter()
.map(stash_to_proto)
.collect(),
+ remote_upstream_url: self.remote_upstream_url.clone(),
+ remote_origin_url: self.remote_origin_url.clone(),
}
}
@@ -3176,6 +3421,8 @@ impl RepositorySnapshot {
.iter()
.map(stash_to_proto)
.collect(),
+ remote_upstream_url: self.remote_upstream_url.clone(),
+ remote_origin_url: self.remote_origin_url.clone(),
}
}
@@ -3193,12 +3440,6 @@ impl RepositorySnapshot {
.cloned()
}
- pub fn pending_ops_for_path(&self, path: &RepoPath) -> Option<PendingOps> {
- self.pending_ops_by_path
- .get(&PathKey(path.as_ref().clone()), ())
- .cloned()
- }
-
pub fn abs_path_to_repo_path(&self, abs_path: &Path) -> Option<RepoPath> {
Self::abs_path_to_repo_path_inner(&self.work_directory_abs_path, abs_path, self.path_style)
}
@@ -3216,10 +3457,8 @@ impl RepositorySnapshot {
abs_path: &Path,
path_style: PathStyle,
) -> Option<RepoPath> {
- abs_path
- .strip_prefix(&work_directory_abs_path)
- .ok()
- .and_then(|path| RepoPath::from_std_path(path, path_style).ok())
+ let rel_path = path_style.strip_prefix(abs_path, work_directory_abs_path)?;
+ Some(RepoPath::from_rel_path(&rel_path))
}
pub fn had_conflict_on_last_merge_head_change(&self, repo_path: &RepoPath) -> bool {
@@ -3334,12 +3573,24 @@ impl Repository {
self.snapshot.clone()
}
+ pub fn pending_ops(&self) -> impl Iterator<Item = PendingOps> + '_ {
+ self.pending_ops.iter().cloned()
+ }
+
+ pub fn pending_ops_summary(&self) -> PathSummary<PendingOpsSummary> {
+ self.pending_ops.summary().clone()
+ }
+
+ pub fn pending_ops_for_path(&self, path: &RepoPath) -> Option<PendingOps> {
+ self.pending_ops
+ .get(&PathKey(path.as_ref().clone()), ())
+ .cloned()
+ }
+
fn local(
id: RepositoryId,
work_directory_abs_path: Arc<Path>,
dot_git_abs_path: Arc<Path>,
- repository_dir_abs_path: Arc<Path>,
- common_dir_abs_path: Arc<Path>,
project_environment: WeakEntity<ProjectEnvironment>,
fs: Arc<dyn Fs>,
git_store: WeakEntity<GitStore>,
@@ -3347,23 +3598,38 @@ impl Repository {
) -> Self {
let snapshot =
RepositorySnapshot::empty(id, work_directory_abs_path.clone(), PathStyle::local());
+ let state = cx
+ .spawn(async move |_, cx| {
+ LocalRepositoryState::new(
+ work_directory_abs_path,
+ dot_git_abs_path,
+ project_environment,
+ fs,
+ cx,
+ )
+ .await
+ .map_err(|err| err.to_string())
+ })
+ .shared();
+ let job_sender = Repository::spawn_local_git_worker(state.clone(), cx);
+ let state = cx
+ .spawn(async move |_, _| {
+ let state = state.await?;
+ Ok(RepositoryState::Local(state))
+ })
+ .shared();
+
Repository {
this: cx.weak_entity(),
git_store,
snapshot,
+ pending_ops: Default::default(),
+ repository_state: state,
commit_message_buffer: None,
askpass_delegates: Default::default(),
paths_needing_status_update: Default::default(),
latest_askpass_id: 0,
- job_sender: Repository::spawn_local_git_worker(
- work_directory_abs_path,
- dot_git_abs_path,
- repository_dir_abs_path,
- common_dir_abs_path,
- project_environment,
- fs,
- cx,
- ),
+ job_sender,
job_id: 0,
active_jobs: Default::default(),
}
@@ -3379,13 +3645,18 @@ impl Repository {
cx: &mut Context<Self>,
) -> Self {
let snapshot = RepositorySnapshot::empty(id, work_directory_abs_path, path_style);
+ let repository_state = RemoteRepositoryState { project_id, client };
+ let job_sender = Self::spawn_remote_git_worker(repository_state.clone(), cx);
+ let repository_state = Task::ready(Ok(RepositoryState::Remote(repository_state))).shared();
Self {
this: cx.weak_entity(),
snapshot,
commit_message_buffer: None,
git_store,
+ pending_ops: Default::default(),
paths_needing_status_update: Default::default(),
- job_sender: Self::spawn_remote_git_worker(project_id, client, cx),
+ job_sender,
+ repository_state,
askpass_delegates: Default::default(),
latest_askpass_id: 0,
active_jobs: Default::default(),
@@ -3404,7 +3675,7 @@ impl Repository {
Some(GitJobKey::ReloadBufferDiffBases),
None,
|state, mut cx| async move {
- let RepositoryState::Local { backend, .. } = state else {
+ let RepositoryState::Local(LocalRepositoryState { backend, .. }) = state else {
log::error!("tried to recompute diffs for a non-local repository");
return Ok(());
};
@@ -3690,13 +3961,13 @@ impl Repository {
bail!("git store was dropped");
};
match state {
- RepositoryState::Local { .. } => {
+ RepositoryState::Local(..) => {
this.update(&mut cx, |_, cx| {
Self::open_local_commit_buffer(languages, buffer_store, cx)
})?
.await
}
- RepositoryState::Remote { project_id, client } => {
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
let request = client.request(proto::OpenCommitMessageBuffer {
project_id: project_id.0,
repository_id: id.to_proto(),
@@ -3769,16 +4040,19 @@ impl Repository {
Some(format!("git checkout {}", commit).into()),
move |git_repo, _| async move {
match git_repo {
- RepositoryState::Local {
+ RepositoryState::Local(LocalRepositoryState {
backend,
environment,
..
- } => {
+ }) => {
backend
.checkout_files(commit, paths, environment.clone())
.await
}
- RepositoryState::Remote { project_id, client } => {
+ RepositoryState::Remote(RemoteRepositoryState {
+ project_id,
+ client,
+ }) => {
client
.request(proto::GitCheckoutFiles {
project_id: project_id.0,
@@ -3812,12 +4086,12 @@ impl Repository {
self.send_job(None, move |git_repo, _| async move {
match git_repo {
- RepositoryState::Local {
+ RepositoryState::Local(LocalRepositoryState {
backend,
environment,
..
- } => backend.reset(commit, reset_mode, environment).await,
- RepositoryState::Remote { project_id, client } => {
+ }) => backend.reset(commit, reset_mode, environment).await,
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
client
.request(proto::GitReset {
project_id: project_id.0,
@@ -3840,8 +4114,10 @@ impl Repository {
let id = self.id;
self.send_job(None, move |git_repo, _cx| async move {
match git_repo {
- RepositoryState::Local { backend, .. } => backend.show(commit).await,
- RepositoryState::Remote { project_id, client } => {
+ RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+ backend.show(commit).await
+ }
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
let resp = client
.request(proto::GitShow {
project_id: project_id.0,
@@ -3866,10 +4142,12 @@ impl Repository {
let id = self.id;
self.send_job(None, move |git_repo, cx| async move {
match git_repo {
- RepositoryState::Local { backend, .. } => backend.load_commit(commit, cx).await,
- RepositoryState::Remote {
+ RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+ backend.load_commit(commit, cx).await
+ }
+ RepositoryState::Remote(RemoteRepositoryState {
client, project_id, ..
- } => {
+ }) => {
let response = client
.request(proto::LoadCommitDiff {
project_id: project_id.0,
@@ -3895,6 +4173,55 @@ impl Repository {
})
}
+ pub fn file_history(
+ &mut self,
+ path: RepoPath,
+ ) -> oneshot::Receiver<Result<git::repository::FileHistory>> {
+ self.file_history_paginated(path, 0, None)
+ }
+
+ pub fn file_history_paginated(
+ &mut self,
+ path: RepoPath,
+ skip: usize,
+ limit: Option<usize>,
+ ) -> oneshot::Receiver<Result<git::repository::FileHistory>> {
+ let id = self.id;
+ self.send_job(None, move |git_repo, _cx| async move {
+ match git_repo {
+ RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+ backend.file_history_paginated(path, skip, limit).await
+ }
+ RepositoryState::Remote(RemoteRepositoryState { client, project_id }) => {
+ let response = client
+ .request(proto::GitFileHistory {
+ project_id: project_id.0,
+ repository_id: id.to_proto(),
+ path: path.to_proto(),
+ skip: skip as u64,
+ limit: limit.map(|l| l as u64),
+ })
+ .await?;
+ Ok(git::repository::FileHistory {
+ entries: response
+ .entries
+ .into_iter()
+ .map(|entry| git::repository::FileHistoryEntry {
+ sha: entry.sha.into(),
+ subject: entry.subject.into(),
+ message: entry.message.into(),
+ commit_timestamp: entry.commit_timestamp,
+ author_name: entry.author_name.into(),
+ author_email: entry.author_email.into(),
+ })
+ .collect(),
+ path: RepoPath::from_proto(&response.path)?,
+ })
+ }
+ }
+ })
+ }
+
fn buffer_store(&self, cx: &App) -> Option<Entity<BufferStore>> {
Some(self.git_store.upgrade()?.read(cx).buffer_store.clone())
}
@@ -3926,6 +4253,28 @@ impl Repository {
save_futures
}
+ fn mark_entries_pending_for_stage(
+ &self,
+ entries: &[RepoPath],
+ stage: bool,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(git_store) = self.git_store() else {
+ return;
+ };
+
+ let mut project_paths = Vec::new();
+ for repo_path in entries {
+ if let Some(project_path) = self.repo_path_to_project_path(repo_path, cx) {
+ project_paths.push(project_path);
+ }
+ }
+
+ git_store.update(cx, move |git_store, cx| {
+ git_store.mark_entries_pending_by_project_paths(&project_paths, stage, cx);
+ });
+ }
+
pub fn stage_entries(
&mut self,
entries: Vec<RepoPath>,
@@ -3934,6 +4283,9 @@ impl Repository {
if entries.is_empty() {
return Task::ready(Ok(()));
}
+
+ self.mark_entries_pending_for_stage(&entries, true, cx);
+
let id = self.id;
let save_tasks = self.save_buffers(&entries, cx);
let paths = entries
@@ -3959,12 +4311,15 @@ impl Repository {
Some(status.into()),
move |git_repo, _cx| async move {
match git_repo {
- RepositoryState::Local {
+ RepositoryState::Local(LocalRepositoryState {
backend,
environment,
..
- } => backend.stage_paths(entries, environment.clone()).await,
- RepositoryState::Remote { project_id, client } => {
+ }) => backend.stage_paths(entries, environment.clone()).await,
+ RepositoryState::Remote(RemoteRepositoryState {
+ project_id,
+ client,
+ }) => {
client
.request(proto::Stage {
project_id: project_id.0,
@@ -3996,6 +4351,9 @@ impl Repository {
if entries.is_empty() {
return Task::ready(Ok(()));
}
+
+ self.mark_entries_pending_for_stage(&entries, false, cx);
+
let id = self.id;
let save_tasks = self.save_buffers(&entries, cx);
let paths = entries
@@ -4021,12 +4379,15 @@ impl Repository {
Some(status.into()),
move |git_repo, _cx| async move {
match git_repo {
- RepositoryState::Local {
+ RepositoryState::Local(LocalRepositoryState {
backend,
environment,
..
- } => backend.unstage_paths(entries, environment).await,
- RepositoryState::Remote { project_id, client } => {
+ }) => backend.unstage_paths(entries, environment).await,
+ RepositoryState::Remote(RemoteRepositoryState {
+ project_id,
+ client,
+ }) => {
client
.request(proto::Unstage {
project_id: project_id.0,
@@ -4060,7 +4421,7 @@ impl Repository {
} else {
Some(entry.repo_path)
}
- } else if entry.status.staging().has_staged() {
+ } else if entry.status.staging().is_fully_staged() {
None
} else {
Some(entry.repo_path)
@@ -4080,7 +4441,7 @@ impl Repository {
} else {
Some(entry.repo_path)
}
- } else if entry.status.staging().has_unstaged() {
+ } else if entry.status.staging().is_fully_unstaged() {
None
} else {
Some(entry.repo_path)
@@ -4107,12 +4468,12 @@ impl Repository {
this.update(cx, |this, _| {
this.send_job(None, move |git_repo, _cx| async move {
match git_repo {
- RepositoryState::Local {
+ RepositoryState::Local(LocalRepositoryState {
backend,
environment,
..
- } => backend.stash_paths(entries, environment).await,
- RepositoryState::Remote { project_id, client } => {
+ }) => backend.stash_paths(entries, environment).await,
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
client
.request(proto::Stash {
project_id: project_id.0,
@@ -4144,12 +4505,12 @@ impl Repository {
this.update(cx, |this, _| {
this.send_job(None, move |git_repo, _cx| async move {
match git_repo {
- RepositoryState::Local {
+ RepositoryState::Local(LocalRepositoryState {
backend,
environment,
..
- } => backend.stash_pop(index, environment).await,
- RepositoryState::Remote { project_id, client } => {
+ }) => backend.stash_pop(index, environment).await,
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
client
.request(proto::StashPop {
project_id: project_id.0,
@@ -4178,12 +4539,12 @@ impl Repository {
this.update(cx, |this, _| {
this.send_job(None, move |git_repo, _cx| async move {
match git_repo {
- RepositoryState::Local {
+ RepositoryState::Local(LocalRepositoryState {
backend,
environment,
..
- } => backend.stash_apply(index, environment).await,
- RepositoryState::Remote { project_id, client } => {
+ }) => backend.stash_apply(index, environment).await,
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
client
.request(proto::StashApply {
project_id: project_id.0,
@@ -4219,11 +4580,11 @@ impl Repository {
let this = cx.weak_entity();
self.send_job(None, move |git_repo, mut cx| async move {
match git_repo {
- RepositoryState::Local {
+ RepositoryState::Local(LocalRepositoryState {
backend,
environment,
..
- } => {
+ }) => {
// TODO would be nice to not have to do this manually
let result = backend.stash_drop(index, environment).await;
if result.is_ok()
@@ -4243,7 +4604,7 @@ impl Repository {
result
}
- RepositoryState::Remote { project_id, client } => {
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
client
.request(proto::StashDrop {
project_id: project_id.0,
@@ -4258,30 +4619,61 @@ impl Repository {
})
}
+ pub fn run_hook(&mut self, hook: RunHook, _cx: &mut App) -> oneshot::Receiver<Result<()>> {
+ let id = self.id;
+ self.send_job(
+ Some(format!("git hook {}", hook.as_str()).into()),
+ move |git_repo, _cx| async move {
+ match git_repo {
+ RepositoryState::Local(LocalRepositoryState {
+ backend,
+ environment,
+ ..
+ }) => backend.run_hook(hook, environment.clone()).await,
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+ client
+ .request(proto::RunGitHook {
+ project_id: project_id.0,
+ repository_id: id.to_proto(),
+ hook: hook.to_proto(),
+ })
+ .await?;
+
+ Ok(())
+ }
+ }
+ },
+ )
+ }
+
pub fn commit(
&mut self,
message: SharedString,
name_and_email: Option<(SharedString, SharedString)>,
options: CommitOptions,
askpass: AskPassDelegate,
- _cx: &mut App,
+ cx: &mut App,
) -> oneshot::Receiver<Result<()>> {
let id = self.id;
let askpass_delegates = self.askpass_delegates.clone();
let askpass_id = util::post_inc(&mut self.latest_askpass_id);
+ let rx = self.run_hook(RunHook::PreCommit, cx);
+
self.send_job(Some("git commit".into()), move |git_repo, _cx| async move {
+ rx.await??;
+
match git_repo {
- RepositoryState::Local {
+ RepositoryState::Local(LocalRepositoryState {
backend,
environment,
..
- } => {
+ }) => {
backend
.commit(message, name_and_email, options, askpass, environment)
.await
}
- RepositoryState::Remote { project_id, client } => {
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
askpass_delegates.lock().insert(askpass_id, askpass);
let _defer = util::defer(|| {
let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
@@ -14,6 +14,7 @@ use gpui::{
use language::Buffer;
use text::BufferId;
use util::ResultExt;
+use ztracing::instrument;
use crate::{
Project,
@@ -63,11 +64,7 @@ impl BranchDiff {
window,
move |this, _git_store, event, _window, cx| match event {
GitStoreEvent::ActiveRepositoryChanged(_)
- | GitStoreEvent::RepositoryUpdated(
- _,
- RepositoryEvent::StatusesChanged { full_scan: _ },
- true,
- )
+ | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, true)
| GitStoreEvent::ConflictsUpdated => {
cx.emit(BranchDiffEvent::FileListChanged);
*this.update_needed.borrow_mut() = ();
@@ -258,6 +255,7 @@ impl BranchDiff {
self.repo.as_ref()
}
+ #[instrument(skip_all)]
pub fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<DiffBuffer> {
let mut output = Vec::default();
let Some(repo) = self.repo.clone() else {
@@ -322,6 +320,7 @@ impl BranchDiff {
output
}
+ #[instrument(skip_all)]
fn load_buffer(
branch_diff: Option<git::status::TreeDiffStatus>,
project_path: crate::ProjectPath,
@@ -1,4 +1,4 @@
-use gpui::{App, Context, Entity, EventEmitter};
+use gpui::{App, Context, Entity, EventEmitter, SharedString};
use std::{cmp::Ordering, ops::Range, sync::Arc};
use text::{Anchor, BufferId, OffsetRangeExt as _};
@@ -92,6 +92,8 @@ impl ConflictSetSnapshot {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictRegion {
+ pub ours_branch_name: SharedString,
+ pub theirs_branch_name: SharedString,
pub range: Range<Anchor>,
pub ours: Range<Anchor>,
pub theirs: Range<Anchor>,
@@ -179,18 +181,25 @@ impl ConflictSet {
let mut conflict_start: Option<usize> = None;
let mut ours_start: Option<usize> = None;
let mut ours_end: Option<usize> = None;
+ let mut ours_branch_name: Option<SharedString> = None;
let mut base_start: Option<usize> = None;
let mut base_end: Option<usize> = None;
let mut theirs_start: Option<usize> = None;
+ let mut theirs_branch_name: Option<SharedString> = None;
while let Some(line) = lines.next() {
let line_end = line_pos + line.len();
- if line.starts_with("<<<<<<< ") {
+ if let Some(branch_name) = line.strip_prefix("<<<<<<< ") {
// If we see a new conflict marker while already parsing one,
// abandon the previous one and start a new one
conflict_start = Some(line_pos);
ours_start = Some(line_end + 1);
+
+ let branch_name = branch_name.trim();
+ if !branch_name.is_empty() {
+ ours_branch_name = Some(SharedString::new(branch_name));
+ }
} else if line.starts_with("||||||| ")
&& conflict_start.is_some()
&& ours_start.is_some()
@@ -208,12 +217,17 @@ impl ConflictSet {
base_end = Some(line_pos);
}
theirs_start = Some(line_end + 1);
- } else if line.starts_with(">>>>>>> ")
+ } else if let Some(branch_name) = line.strip_prefix(">>>>>>> ")
&& conflict_start.is_some()
&& ours_start.is_some()
&& ours_end.is_some()
&& theirs_start.is_some()
{
+ let branch_name = branch_name.trim();
+ if !branch_name.is_empty() {
+ theirs_branch_name = Some(SharedString::new(branch_name));
+ }
+
let theirs_end = line_pos;
let conflict_end = (line_end + 1).min(buffer_len);
@@ -229,6 +243,12 @@ impl ConflictSet {
.map(|(start, end)| buffer.anchor_after(start)..buffer.anchor_before(end));
conflicts.push(ConflictRegion {
+ ours_branch_name: ours_branch_name
+ .take()
+ .unwrap_or_else(|| SharedString::new_static("HEAD")),
+ theirs_branch_name: theirs_branch_name
+ .take()
+ .unwrap_or_else(|| SharedString::new_static("Origin")),
range,
ours,
theirs,
@@ -304,6 +324,8 @@ mod tests {
let first = &conflict_snapshot.conflicts[0];
assert!(first.base.is_none());
+ assert_eq!(first.ours_branch_name.as_ref(), "HEAD");
+ assert_eq!(first.theirs_branch_name.as_ref(), "branch-name");
let our_text = snapshot
.text_for_range(first.ours.clone())
.collect::<String>();
@@ -315,6 +337,8 @@ mod tests {
let second = &conflict_snapshot.conflicts[1];
assert!(second.base.is_some());
+ assert_eq!(second.ours_branch_name.as_ref(), "HEAD");
+ assert_eq!(second.theirs_branch_name.as_ref(), "branch-name");
let our_text = snapshot
.text_for_range(second.ours.clone())
.collect::<String>();
@@ -381,6 +405,8 @@ mod tests {
// The conflict should have our version, their version, but no base
let conflict = &conflict_snapshot.conflicts[0];
assert!(conflict.base.is_none());
+ assert_eq!(conflict.ours_branch_name.as_ref(), "HEAD");
+ assert_eq!(conflict.theirs_branch_name.as_ref(), "branch-nested");
// Check that the nested conflict was detected correctly
let our_text = snapshot
@@ -407,6 +433,14 @@ mod tests {
let conflict_snapshot = ConflictSet::parse(&snapshot);
assert_eq!(conflict_snapshot.conflicts.len(), 1);
+ assert_eq!(
+ conflict_snapshot.conflicts[0].ours_branch_name.as_ref(),
+ "ours"
+ );
+ assert_eq!(
+ conflict_snapshot.conflicts[0].theirs_branch_name.as_ref(),
+ "Origin" // default branch name if there is none
+ );
}
#[test]
@@ -449,6 +483,38 @@ mod tests {
let conflict_snapshot = ConflictSet::parse(&snapshot);
assert_eq!(conflict_snapshot.conflicts.len(), 4);
+ assert_eq!(
+ conflict_snapshot.conflicts[0].ours_branch_name.as_ref(),
+ "HEAD1"
+ );
+ assert_eq!(
+ conflict_snapshot.conflicts[0].theirs_branch_name.as_ref(),
+ "branch1"
+ );
+ assert_eq!(
+ conflict_snapshot.conflicts[1].ours_branch_name.as_ref(),
+ "HEAD2"
+ );
+ assert_eq!(
+ conflict_snapshot.conflicts[1].theirs_branch_name.as_ref(),
+ "branch2"
+ );
+ assert_eq!(
+ conflict_snapshot.conflicts[2].ours_branch_name.as_ref(),
+ "HEAD3"
+ );
+ assert_eq!(
+ conflict_snapshot.conflicts[2].theirs_branch_name.as_ref(),
+ "branch3"
+ );
+ assert_eq!(
+ conflict_snapshot.conflicts[3].ours_branch_name.as_ref(),
+ "HEAD4"
+ );
+ assert_eq!(
+ conflict_snapshot.conflicts[3].theirs_branch_name.as_ref(),
+ "branch4"
+ );
let range = test_content.find("seven").unwrap()..test_content.find("eleven").unwrap();
let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
@@ -1,118 +0,0 @@
-use std::{path::Path, sync::Arc};
-
-use gpui::{EventEmitter, FocusHandle, Focusable};
-use ui::{
- App, Button, ButtonCommon, ButtonStyle, Clickable, Context, FluentBuilder, InteractiveElement,
- KeyBinding, Label, LabelCommon, LabelSize, ParentElement, Render, SharedString, Styled as _,
- Window, h_flex, v_flex,
-};
-use zed_actions::workspace::OpenWithSystem;
-
-use crate::Item;
-
-/// A view to display when a certain buffer fails to open.
-#[derive(Debug)]
-pub struct InvalidItemView {
- /// Which path was attempted to open.
- pub abs_path: Arc<Path>,
- /// An error message, happened when opening the buffer.
- pub error: SharedString,
- is_local: bool,
- focus_handle: FocusHandle,
-}
-
-impl InvalidItemView {
- pub fn new(
- abs_path: &Path,
- is_local: bool,
- e: &anyhow::Error,
- _: &mut Window,
- cx: &mut App,
- ) -> Self {
- Self {
- is_local,
- abs_path: Arc::from(abs_path),
- error: format!("{}", e.root_cause()).into(),
- focus_handle: cx.focus_handle(),
- }
- }
-}
-
-impl Item for InvalidItemView {
- type Event = ();
-
- fn tab_content_text(&self, mut detail: usize, _: &App) -> SharedString {
- // Ensure we always render at least the filename.
- detail += 1;
-
- let path = self.abs_path.as_ref();
-
- let mut prefix = path;
- while detail > 0 {
- if let Some(parent) = prefix.parent() {
- prefix = parent;
- detail -= 1;
- } else {
- break;
- }
- }
-
- let path = if detail > 0 {
- path
- } else {
- path.strip_prefix(prefix).unwrap_or(path)
- };
-
- SharedString::new(path.to_string_lossy())
- }
-}
-
-impl EventEmitter<()> for InvalidItemView {}
-
-impl Focusable for InvalidItemView {
- fn focus_handle(&self, _: &App) -> FocusHandle {
- self.focus_handle.clone()
- }
-}
-
-impl Render for InvalidItemView {
- fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
- let abs_path = self.abs_path.clone();
- v_flex()
- .size_full()
- .track_focus(&self.focus_handle(cx))
- .flex_none()
- .justify_center()
- .overflow_hidden()
- .key_context("InvalidBuffer")
- .child(
- h_flex().size_full().justify_center().child(
- v_flex()
- .justify_center()
- .gap_2()
- .child(h_flex().justify_center().child("Could not open file"))
- .child(
- h_flex()
- .justify_center()
- .child(Label::new(self.error.clone()).size(LabelSize::Small)),
- )
- .when(self.is_local, |contents| {
- contents.child(
- h_flex().justify_center().child(
- Button::new("open-with-system", "Open in Default App")
- .on_click(move |_, _, cx| {
- cx.open_with_system(&abs_path);
- })
- .style(ButtonStyle::Outlined)
- .key_binding(KeyBinding::for_action(
- &OpenWithSystem,
- window,
- cx,
- )),
- ),
- )
- }),
- ),
- )
- }
-}
@@ -14,7 +14,7 @@ use client::proto::{self, PeerId};
use clock::Global;
use collections::{HashMap, HashSet};
use futures::future;
-use gpui::{App, AsyncApp, Entity, Task};
+use gpui::{App, AsyncApp, Entity, SharedString, Task};
use language::{
Anchor, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CharKind, CharScopeContext,
OffsetRangeExt, PointUtf16, ToOffset, ToPointUtf16, Transaction, Unclipped,
@@ -26,8 +26,8 @@ use language::{
use lsp::{
AdapterServerCapabilities, CodeActionKind, CodeActionOptions, CodeDescription,
CompletionContext, CompletionListItemDefaultsEditRange, CompletionTriggerKind,
- DiagnosticServerCapabilities, DocumentHighlightKind, LanguageServer, LanguageServerId,
- LinkedEditingRangeServerCapabilities, OneOf, RenameOptions, ServerCapabilities,
+ DocumentHighlightKind, LanguageServer, LanguageServerId, LinkedEditingRangeServerCapabilities,
+ OneOf, RenameOptions, ServerCapabilities,
};
use serde_json::Value;
use signature_help::{lsp_to_proto_signature, proto_to_lsp_signature};
@@ -218,6 +218,7 @@ pub(crate) struct GetHover {
pub(crate) struct GetCompletions {
pub position: PointUtf16,
pub context: CompletionContext,
+ pub server_id: Option<lsp::LanguageServerId>,
}
#[derive(Clone, Debug)]
@@ -264,8 +265,9 @@ pub(crate) struct LinkedEditingRange {
pub(crate) struct GetDocumentDiagnostics {
/// We cannot blindly rely on server's capabilities.diagnostic_provider, as they're a singular field, whereas
/// a server can register multiple diagnostic providers post-mortem.
- pub dynamic_caps: DiagnosticServerCapabilities,
- pub previous_result_id: Option<String>,
+ pub registration_id: Option<SharedString>,
+ pub identifier: Option<String>,
+ pub previous_result_id: Option<SharedString>,
}
#[async_trait(?Send)]
@@ -2395,6 +2397,7 @@ impl LspCommand for GetCompletions {
buffer_id: buffer.remote_id().into(),
position: Some(language::proto::serialize_anchor(&anchor)),
version: serialize_version(&buffer.version()),
+ server_id: self.server_id.map(|id| id.to_proto()),
}
}
@@ -2423,6 +2426,9 @@ impl LspCommand for GetCompletions {
trigger_kind: CompletionTriggerKind::INVOKED,
trigger_character: None,
},
+ server_id: message
+ .server_id
+ .map(|id| lsp::LanguageServerId::from_proto(id)),
})
}
@@ -3750,15 +3756,16 @@ impl GetDocumentDiagnostics {
.into_iter()
.filter_map(|diagnostics| {
Some(LspPullDiagnostics::Response {
+ registration_id: diagnostics.registration_id.map(SharedString::from),
server_id: LanguageServerId::from_proto(diagnostics.server_id),
uri: lsp::Uri::from_str(diagnostics.uri.as_str()).log_err()?,
diagnostics: if diagnostics.changed {
PulledDiagnostics::Unchanged {
- result_id: diagnostics.result_id?,
+ result_id: SharedString::new(diagnostics.result_id?),
}
} else {
PulledDiagnostics::Changed {
- result_id: diagnostics.result_id,
+ result_id: diagnostics.result_id.map(SharedString::new),
diagnostics: diagnostics
.diagnostics
.into_iter()
@@ -3922,6 +3929,7 @@ impl GetDocumentDiagnostics {
pub fn deserialize_workspace_diagnostics_report(
report: lsp::WorkspaceDiagnosticReportResult,
server_id: LanguageServerId,
+ registration_id: Option<SharedString>,
) -> Vec<WorkspaceLspPullDiagnostics> {
let mut pulled_diagnostics = HashMap::default();
match report {
@@ -3933,6 +3941,7 @@ impl GetDocumentDiagnostics {
&mut pulled_diagnostics,
server_id,
report,
+ registration_id.clone(),
)
}
lsp::WorkspaceDocumentDiagnosticReport::Unchanged(report) => {
@@ -3940,6 +3949,7 @@ impl GetDocumentDiagnostics {
&mut pulled_diagnostics,
server_id,
report,
+ registration_id.clone(),
)
}
}
@@ -3955,6 +3965,7 @@ impl GetDocumentDiagnostics {
&mut pulled_diagnostics,
server_id,
report,
+ registration_id.clone(),
)
}
lsp::WorkspaceDocumentDiagnosticReport::Unchanged(report) => {
@@ -3962,6 +3973,7 @@ impl GetDocumentDiagnostics {
&mut pulled_diagnostics,
server_id,
report,
+ registration_id.clone(),
)
}
}
@@ -3982,6 +3994,7 @@ fn process_full_workspace_diagnostics_report(
diagnostics: &mut HashMap<lsp::Uri, WorkspaceLspPullDiagnostics>,
server_id: LanguageServerId,
report: lsp::WorkspaceFullDocumentDiagnosticReport,
+ registration_id: Option<SharedString>,
) {
let mut new_diagnostics = HashMap::default();
process_full_diagnostics_report(
@@ -3989,6 +4002,7 @@ fn process_full_workspace_diagnostics_report(
server_id,
report.uri,
report.full_document_diagnostic_report,
+ registration_id,
);
diagnostics.extend(new_diagnostics.into_iter().map(|(uri, diagnostics)| {
(
@@ -4005,6 +4019,7 @@ fn process_unchanged_workspace_diagnostics_report(
diagnostics: &mut HashMap<lsp::Uri, WorkspaceLspPullDiagnostics>,
server_id: LanguageServerId,
report: lsp::WorkspaceUnchangedDocumentDiagnosticReport,
+ registration_id: Option<SharedString>,
) {
let mut new_diagnostics = HashMap::default();
process_unchanged_diagnostics_report(
@@ -4012,6 +4027,7 @@ fn process_unchanged_workspace_diagnostics_report(
server_id,
report.uri,
report.unchanged_document_diagnostic_report,
+ registration_id,
);
diagnostics.extend(new_diagnostics.into_iter().map(|(uri, diagnostics)| {
(
@@ -4045,19 +4061,12 @@ impl LspCommand for GetDocumentDiagnostics {
_: &Arc<LanguageServer>,
_: &App,
) -> Result<lsp::DocumentDiagnosticParams> {
- let identifier = match &self.dynamic_caps {
- lsp::DiagnosticServerCapabilities::Options(options) => options.identifier.clone(),
- lsp::DiagnosticServerCapabilities::RegistrationOptions(options) => {
- options.diagnostic_options.identifier.clone()
- }
- };
-
Ok(lsp::DocumentDiagnosticParams {
text_document: lsp::TextDocumentIdentifier {
uri: file_path_to_lsp_url(path)?,
},
- identifier,
- previous_result_id: self.previous_result_id.clone(),
+ identifier: self.identifier.clone(),
+ previous_result_id: self.previous_result_id.clone().map(|id| id.to_string()),
partial_result_params: Default::default(),
work_done_progress_params: Default::default(),
})
@@ -4092,6 +4101,7 @@ impl LspCommand for GetDocumentDiagnostics {
&mut pulled_diagnostics,
server_id,
related_documents,
+ self.registration_id.clone(),
);
}
process_full_diagnostics_report(
@@ -4099,6 +4109,7 @@ impl LspCommand for GetDocumentDiagnostics {
server_id,
url,
report.full_document_diagnostic_report,
+ self.registration_id,
);
}
lsp::DocumentDiagnosticReport::Unchanged(report) => {
@@ -4107,6 +4118,7 @@ impl LspCommand for GetDocumentDiagnostics {
&mut pulled_diagnostics,
server_id,
related_documents,
+ self.registration_id.clone(),
);
}
process_unchanged_diagnostics_report(
@@ -4114,6 +4126,7 @@ impl LspCommand for GetDocumentDiagnostics {
server_id,
url,
report.unchanged_document_diagnostic_report,
+ self.registration_id,
);
}
},
@@ -4123,6 +4136,7 @@ impl LspCommand for GetDocumentDiagnostics {
&mut pulled_diagnostics,
server_id,
related_documents,
+ self.registration_id,
);
}
}
@@ -4165,6 +4179,7 @@ impl LspCommand for GetDocumentDiagnostics {
server_id,
uri,
diagnostics,
+ registration_id,
} => {
let mut changed = false;
let (diagnostics, result_id) = match diagnostics {
@@ -4179,7 +4194,7 @@ impl LspCommand for GetDocumentDiagnostics {
};
Some(proto::PulledDiagnostics {
changed,
- result_id,
+ result_id: result_id.map(|id| id.to_string()),
uri: uri.to_string(),
server_id: server_id.to_proto(),
diagnostics: diagnostics
@@ -4190,6 +4205,7 @@ impl LspCommand for GetDocumentDiagnostics {
.log_err()
})
.collect(),
+ registration_id: registration_id.as_ref().map(ToString::to_string),
})
}
})
@@ -4360,14 +4376,25 @@ fn process_related_documents(
diagnostics: &mut HashMap<lsp::Uri, LspPullDiagnostics>,
server_id: LanguageServerId,
documents: impl IntoIterator<Item = (lsp::Uri, lsp::DocumentDiagnosticReportKind)>,
+ registration_id: Option<SharedString>,
) {
for (url, report_kind) in documents {
match report_kind {
- lsp::DocumentDiagnosticReportKind::Full(report) => {
- process_full_diagnostics_report(diagnostics, server_id, url, report)
- }
+ lsp::DocumentDiagnosticReportKind::Full(report) => process_full_diagnostics_report(
+ diagnostics,
+ server_id,
+ url,
+ report,
+ registration_id.clone(),
+ ),
lsp::DocumentDiagnosticReportKind::Unchanged(report) => {
- process_unchanged_diagnostics_report(diagnostics, server_id, url, report)
+ process_unchanged_diagnostics_report(
+ diagnostics,
+ server_id,
+ url,
+ report,
+ registration_id.clone(),
+ )
}
}
}
@@ -4378,8 +4405,9 @@ fn process_unchanged_diagnostics_report(
server_id: LanguageServerId,
uri: lsp::Uri,
report: lsp::UnchangedDocumentDiagnosticReport,
+ registration_id: Option<SharedString>,
) {
- let result_id = report.result_id;
+ let result_id = SharedString::new(report.result_id);
match diagnostics.entry(uri.clone()) {
hash_map::Entry::Occupied(mut o) => match o.get_mut() {
LspPullDiagnostics::Default => {
@@ -4387,12 +4415,14 @@ fn process_unchanged_diagnostics_report(
server_id,
uri,
diagnostics: PulledDiagnostics::Unchanged { result_id },
+ registration_id,
});
}
LspPullDiagnostics::Response {
server_id: existing_server_id,
uri: existing_uri,
diagnostics: existing_diagnostics,
+ ..
} => {
if server_id != *existing_server_id || &uri != existing_uri {
debug_panic!(
@@ -4412,6 +4442,7 @@ fn process_unchanged_diagnostics_report(
server_id,
uri,
diagnostics: PulledDiagnostics::Unchanged { result_id },
+ registration_id,
});
}
}
@@ -4422,8 +4453,9 @@ fn process_full_diagnostics_report(
server_id: LanguageServerId,
uri: lsp::Uri,
report: lsp::FullDocumentDiagnosticReport,
+ registration_id: Option<SharedString>,
) {
- let result_id = report.result_id;
+ let result_id = report.result_id.map(SharedString::new);
match diagnostics.entry(uri.clone()) {
hash_map::Entry::Occupied(mut o) => match o.get_mut() {
LspPullDiagnostics::Default => {
@@ -4434,12 +4466,14 @@ fn process_full_diagnostics_report(
result_id,
diagnostics: report.items,
},
+ registration_id,
});
}
LspPullDiagnostics::Response {
server_id: existing_server_id,
uri: existing_uri,
diagnostics: existing_diagnostics,
+ ..
} => {
if server_id != *existing_server_id || &uri != existing_uri {
debug_panic!(
@@ -4473,6 +4507,7 @@ fn process_full_diagnostics_report(
result_id,
diagnostics: report.items,
},
+ registration_id,
});
}
}
@@ -29,7 +29,6 @@ use crate::{
lsp_command::{self, *},
lsp_store::{
self,
- inlay_hint_cache::BufferChunk,
log_store::{GlobalLogStore, LanguageServerKind},
},
manifest_tree::{
@@ -39,6 +38,7 @@ use crate::{
prettier_store::{self, PrettierStore, PrettierStoreEvent},
project_settings::{LspSettings, ProjectSettings},
toolchain_store::{LocalToolchainStore, ToolchainStoreEvent},
+ trusted_worktrees::{PathTrust, TrustedWorktrees, TrustedWorktreesEvent},
worktree_store::{WorktreeStore, WorktreeStoreEvent},
yarn::YarnPathStore,
};
@@ -55,8 +55,8 @@ use futures::{
};
use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
use gpui::{
- App, AppContext, AsyncApp, Context, Entity, EventEmitter, PromptLevel, SharedString, Task,
- WeakEntity,
+ App, AppContext, AsyncApp, Context, Entity, EventEmitter, PromptLevel, SharedString,
+ Subscription, Task, WeakEntity,
};
use http_client::HttpClient;
use itertools::Itertools as _;
@@ -73,6 +73,7 @@ use language::{
serialize_lsp_edit, serialize_version,
},
range_from_lsp, range_to_lsp,
+ row_chunk::RowChunk,
};
use lsp::{
AdapterServerCapabilities, CodeActionKind, CompletionContext, CompletionOptions,
@@ -92,16 +93,19 @@ use rpc::{
AnyProtoClient, ErrorCode, ErrorExt as _,
proto::{LspRequestId, LspRequestMessage as _},
};
+use semver::Version;
use serde::Serialize;
+use serde_json::Value;
use settings::{Settings, SettingsLocation, SettingsStore};
use sha2::{Digest, Sha256};
-use smol::channel::Sender;
+use smol::channel::{Receiver, Sender};
use snippet::Snippet;
use std::{
any::TypeId,
borrow::Cow,
cell::RefCell,
cmp::{Ordering, Reverse},
+ collections::hash_map,
convert::TryInto,
ffi::OsStr,
future::ready,
@@ -115,9 +119,10 @@ use std::{
atomic::{self, AtomicUsize},
},
time::{Duration, Instant},
+ vec,
};
use sum_tree::Dimensions;
-use text::{Anchor, BufferId, LineEnding, OffsetRangeExt, Point, ToPoint as _};
+use text::{Anchor, BufferId, LineEnding, OffsetRangeExt, ToPoint as _};
use util::{
ConnectionResult, ResultExt as _, debug_panic, defer, maybe, merge_json_value_into,
@@ -139,6 +144,7 @@ pub use worktree::{
const SERVER_LAUNCHING_BEFORE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
pub const SERVER_PROGRESS_THROTTLE_TIMEOUT: Duration = Duration::from_millis(100);
const WORKSPACE_DIAGNOSTICS_TOKEN_START: &str = "id:";
+const SERVER_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub enum ProgressToken {
@@ -198,7 +204,10 @@ pub enum LspFormatTarget {
Ranges(BTreeMap<BufferId, Vec<Range<Anchor>>>),
}
-pub type OpenLspBufferHandle = Entity<Entity<Buffer>>;
+#[derive(Clone, PartialEq, Eq, Hash)]
+pub struct OpenLspBufferHandle(Entity<OpenLspBuffer>);
+
+struct OpenLspBuffer(Entity<Buffer>);
impl FormatTrigger {
fn from_proto(value: i32) -> FormatTrigger {
@@ -216,7 +225,7 @@ struct UnifiedLanguageServer {
project_roots: HashSet<Arc<RelPath>>,
}
-#[derive(Clone, Hash, PartialEq, Eq)]
+#[derive(Clone, Debug, Hash, PartialEq, Eq)]
struct LanguageServerSeed {
worktree_id: WorktreeId,
name: LanguageServerName,
@@ -227,7 +236,8 @@ struct LanguageServerSeed {
#[derive(Debug)]
pub struct DocumentDiagnosticsUpdate<'a, D> {
pub diagnostics: D,
- pub result_id: Option<String>,
+ pub result_id: Option<SharedString>,
+ pub registration_id: Option<SharedString>,
pub server_id: LanguageServerId,
pub disk_based_sources: Cow<'a, [String]>,
}
@@ -281,7 +291,15 @@ pub struct LocalLspStore {
lsp_tree: LanguageServerTree,
registered_buffers: HashMap<BufferId, usize>,
buffers_opened_in_servers: HashMap<BufferId, HashSet<LanguageServerId>>,
- buffer_pull_diagnostics_result_ids: HashMap<LanguageServerId, HashMap<PathBuf, Option<String>>>,
+ buffer_pull_diagnostics_result_ids: HashMap<
+ LanguageServerId,
+ HashMap<Option<SharedString>, HashMap<PathBuf, Option<SharedString>>>,
+ >,
+ workspace_pull_diagnostics_result_ids: HashMap<
+ LanguageServerId,
+ HashMap<Option<SharedString>, HashMap<PathBuf, Option<SharedString>>>,
+ >,
+ restricted_worktrees_tasks: HashMap<WorktreeId, (Subscription, Receiver<()>)>,
}
impl LocalLspStore {
@@ -353,7 +371,8 @@ impl LocalLspStore {
) -> LanguageServerId {
let worktree = worktree_handle.read(cx);
- let root_path = worktree.abs_path();
+ let worktree_id = worktree.id();
+ let worktree_abs_path = worktree.abs_path();
let toolchain = key.toolchain.clone();
let override_options = settings.initialization_options.clone();
@@ -361,19 +380,49 @@ impl LocalLspStore {
let server_id = self.languages.next_language_server_id();
log::trace!(
- "attempting to start language server {:?}, path: {root_path:?}, id: {server_id}",
+ "attempting to start language server {:?}, path: {worktree_abs_path:?}, id: {server_id}",
adapter.name.0
);
+ let untrusted_worktree_task =
+ TrustedWorktrees::try_get_global(cx).and_then(|trusted_worktrees| {
+ let can_trust = trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+ trusted_worktrees.can_trust(worktree_id, cx)
+ });
+ if can_trust {
+ self.restricted_worktrees_tasks.remove(&worktree_id);
+ None
+ } else {
+ match self.restricted_worktrees_tasks.entry(worktree_id) {
+ hash_map::Entry::Occupied(o) => Some(o.get().1.clone()),
+ hash_map::Entry::Vacant(v) => {
+ let (tx, rx) = smol::channel::bounded::<()>(1);
+ let subscription = cx.subscribe(&trusted_worktrees, move |_, e, _| {
+ if let TrustedWorktreesEvent::Trusted(_, trusted_paths) = e {
+ if trusted_paths.contains(&PathTrust::Worktree(worktree_id)) {
+ tx.send_blocking(()).ok();
+ }
+ }
+ });
+ v.insert((subscription, rx.clone()));
+ Some(rx)
+ }
+ }
+ }
+ });
+ let update_binary_status = untrusted_worktree_task.is_none();
+
let binary = self.get_language_server_binary(
+ worktree_abs_path.clone(),
adapter.clone(),
settings,
toolchain.clone(),
delegate.clone(),
true,
+ untrusted_worktree_task,
cx,
);
- let pending_workspace_folders: Arc<Mutex<BTreeSet<Uri>>> = Default::default();
+ let pending_workspace_folders = Arc::<Mutex<BTreeSet<Uri>>>::default();
let pending_server = cx.spawn({
let adapter = adapter.clone();
@@ -406,7 +455,7 @@ impl LocalLspStore {
server_id,
server_name,
binary,
- &root_path,
+ &worktree_abs_path,
code_action_kinds,
Some(pending_workspace_folders),
cx,
@@ -434,6 +483,7 @@ impl LocalLspStore {
adapter.adapter.clone(),
&delegate,
toolchain,
+ None,
cx,
)
.await?;
@@ -541,8 +591,10 @@ impl LocalLspStore {
pending_workspace_folders,
};
- self.languages
- .update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
+ if update_binary_status {
+ self.languages
+ .update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
+ }
self.language_servers.insert(server_id, state);
self.language_server_ids
@@ -556,25 +608,39 @@ impl LocalLspStore {
fn get_language_server_binary(
&self,
+ worktree_abs_path: Arc<Path>,
adapter: Arc<CachedLspAdapter>,
settings: Arc<LspSettings>,
toolchain: Option<Toolchain>,
delegate: Arc<dyn LspAdapterDelegate>,
allow_binary_download: bool,
+ untrusted_worktree_task: Option<Receiver<()>>,
cx: &mut App,
) -> Task<Result<LanguageServerBinary>> {
if let Some(settings) = &settings.binary
&& let Some(path) = settings.path.as_ref().map(PathBuf::from)
{
let settings = settings.clone();
-
+ let languages = self.languages.clone();
return cx.background_spawn(async move {
+ if let Some(untrusted_worktree_task) = untrusted_worktree_task {
+ log::info!(
+ "Waiting for worktree {worktree_abs_path:?} to be trusted, before starting language server {}",
+ adapter.name(),
+ );
+ untrusted_worktree_task.recv().await.ok();
+ log::info!(
+ "Worktree {worktree_abs_path:?} is trusted, starting language server {}",
+ adapter.name(),
+ );
+ languages
+ .update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
+ }
let mut env = delegate.shell_env().await;
env.extend(settings.env.unwrap_or_default());
Ok(LanguageServerBinary {
- // if `path` is absolute, `.join()` will keep it unmodified
- path: delegate.worktree_root_path().join(path),
+ path: delegate.resolve_executable_path(path),
env: Some(env),
arguments: settings
.arguments
@@ -600,14 +666,48 @@ impl LocalLspStore {
};
cx.spawn(async move |cx| {
- let binary_result = adapter
+ if let Some(untrusted_worktree_task) = untrusted_worktree_task {
+ log::info!(
+ "Waiting for worktree {worktree_abs_path:?} to be trusted, before starting language server {}",
+ adapter.name(),
+ );
+ untrusted_worktree_task.recv().await.ok();
+ log::info!(
+ "Worktree {worktree_abs_path:?} is trusted, starting language server {}",
+ adapter.name(),
+ );
+ }
+
+ let (existing_binary, maybe_download_binary) = adapter
.clone()
.get_language_server_command(delegate.clone(), toolchain, lsp_binary_options, cx)
+ .await
.await;
delegate.update_status(adapter.name.clone(), BinaryStatus::None);
- let mut binary = binary_result?;
+ let mut binary = match (existing_binary, maybe_download_binary) {
+ (binary, None) => binary?,
+ (Err(_), Some(downloader)) => downloader.await?,
+ (Ok(existing_binary), Some(downloader)) => {
+ let mut download_timeout = cx
+ .background_executor()
+ .timer(SERVER_DOWNLOAD_TIMEOUT)
+ .fuse();
+ let mut downloader = downloader.fuse();
+ futures::select! {
+ _ = download_timeout => {
+ // Return existing binary and kick the existing work to the background.
+ cx.spawn(async move |_| downloader.await).detach();
+ Ok(existing_binary)
+ },
+ downloaded_or_existing_binary = downloader => {
+ // If download fails, this results in the existing binary.
+ downloaded_or_existing_binary
+ }
+ }?
+ }
+ };
let mut shell_env = delegate.shell_env().await;
shell_env.extend(binary.env.unwrap_or_default());
@@ -661,6 +761,7 @@ impl LocalLspStore {
disk_based_sources: Cow::Borrowed(
&adapter.disk_based_diagnostic_sources,
),
+ registration_id: None,
}],
|_, diagnostic, cx| match diagnostic.source_kind {
DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => {
@@ -697,25 +798,42 @@ impl LocalLspStore {
)
})?
.context("Expected the LSP store to be in a local mode")?;
- let workspace_config = Self::workspace_configuration_for_adapter(
- adapter.clone(),
- &delegate,
- toolchain_for_id,
- &mut cx,
- )
- .await?;
+
+ let mut scope_uri_to_workspace_config = BTreeMap::new();
+ for item in ¶ms.items {
+ let scope_uri = item.scope_uri.clone();
+ let std::collections::btree_map::Entry::Vacant(new_scope_uri) =
+ scope_uri_to_workspace_config.entry(scope_uri.clone())
+ else {
+ // We've already queried workspace configuration of this URI.
+ continue;
+ };
+ let workspace_config = Self::workspace_configuration_for_adapter(
+ adapter.clone(),
+ &delegate,
+ toolchain_for_id.clone(),
+ scope_uri,
+ &mut cx,
+ )
+ .await?;
+ new_scope_uri.insert(workspace_config);
+ }
Ok(params
.items
.into_iter()
- .map(|item| {
+ .filter_map(|item| {
+ let workspace_config =
+ scope_uri_to_workspace_config.get(&item.scope_uri)?;
if let Some(section) = &item.section {
- workspace_config
- .get(section)
- .cloned()
- .unwrap_or(serde_json::Value::Null)
+ Some(
+ workspace_config
+ .get(section)
+ .cloned()
+ .unwrap_or(serde_json::Value::Null),
+ )
} else {
- workspace_config.clone()
+ Some(workspace_config.clone())
}
})
.collect())
@@ -938,12 +1056,15 @@ impl LocalLspStore {
.on_request::<lsp::request::ShowMessageRequest, _, _>({
let this = lsp_store.clone();
let name = name.to_string();
+ let adapter = adapter.clone();
move |params, cx| {
let this = this.clone();
let name = name.to_string();
+ let adapter = adapter.clone();
let mut cx = cx.clone();
async move {
let actions = params.actions.unwrap_or_default();
+ let message = params.message.clone();
let (tx, rx) = smol::channel::bounded(1);
let request = LanguageServerPromptRequest {
level: match params.typ {
@@ -964,6 +1085,14 @@ impl LocalLspStore {
.is_ok();
if did_update {
let response = rx.recv().await.ok();
+ if let Some(ref selected_action) = response {
+ let context = language::PromptResponseContext {
+ message,
+ selected_action: selected_action.clone(),
+ };
+ adapter.process_prompt_response(&context, &mut cx)
+ }
+
Ok(response)
} else {
Ok(None)
@@ -2168,12 +2297,10 @@ impl LocalLspStore {
&& lsp_action.data.is_some()
&& (lsp_action.command.is_none() || lsp_action.edit.is_none())
{
- *lsp_action = Box::new(
- lang_server
- .request::<lsp::request::CodeActionResolveRequest>(*lsp_action.clone())
- .await
- .into_response()?,
- );
+ **lsp_action = lang_server
+ .request::<lsp::request::CodeActionResolveRequest>(*lsp_action.clone())
+ .await
+ .into_response()?;
}
}
LspAction::CodeLens(lens) => {
@@ -2215,8 +2342,9 @@ impl LocalLspStore {
server_id,
None,
None,
- diagnostics,
+ None,
Vec::new(),
+ diagnostics,
cx,
)
.log_err();
@@ -2294,7 +2422,8 @@ impl LocalLspStore {
&mut self,
buffer: &Entity<Buffer>,
server_id: LanguageServerId,
- result_id: Option<String>,
+ registration_id: Option<Option<SharedString>>,
+ result_id: Option<SharedString>,
version: Option<i32>,
new_diagnostics: Vec<DiagnosticEntry<Unclipped<PointUtf16>>>,
reused_diagnostics: Vec<DiagnosticEntry<Unclipped<PointUtf16>>>,
@@ -2367,11 +2496,15 @@ impl LocalLspStore {
let set = DiagnosticSet::new(sanitized_diagnostics, &snapshot);
buffer.update(cx, |buffer, cx| {
- if let Some(abs_path) = File::from_dyn(buffer.file()).map(|f| f.abs_path(cx)) {
- self.buffer_pull_diagnostics_result_ids
- .entry(server_id)
- .or_default()
- .insert(abs_path, result_id);
+ if let Some(registration_id) = registration_id {
+ if let Some(abs_path) = File::from_dyn(buffer.file()).map(|f| f.abs_path(cx)) {
+ self.buffer_pull_diagnostics_result_ids
+ .entry(server_id)
+ .or_default()
+ .entry(registration_id)
+ .or_default()
+ .insert(abs_path, result_id);
+ }
}
buffer.update_diagnostics(server_id, set, cx)
@@ -2661,10 +2794,15 @@ impl LocalLspStore {
cx: &mut App,
) {
buffer.update(cx, |buffer, cx| {
- let _ = self.buffer_snapshots.remove(&buffer.remote_id());
+ let mut snapshots = self.buffer_snapshots.remove(&buffer.remote_id());
for (_, language_server) in self.language_servers_for_buffer(buffer, cx) {
- language_server.unregister_buffer(file_url.clone());
+ if snapshots
+ .as_mut()
+ .is_some_and(|map| map.remove(&language_server.server_id()).is_some())
+ {
+ language_server.unregister_buffer(file_url.clone());
+ }
}
});
}
@@ -2718,7 +2856,7 @@ impl LocalLspStore {
let actions = lsp_store
.update(cx, move |this, cx| {
let request = GetCodeActions {
- range: text::Anchor::MIN..text::Anchor::MAX,
+ range: text::Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()),
kinds: Some(code_action_kinds),
};
let server = LanguageServerToQuery::Other(language_server_id);
@@ -2993,17 +3131,23 @@ impl LocalLspStore {
.new_uri
.to_file_path()
.map_err(|()| anyhow!("can't convert URI to path"))?;
- fs.rename(
- &source_abs_path,
- &target_abs_path,
- op.options
- .map(|options| fs::RenameOptions {
- overwrite: options.overwrite.unwrap_or(false),
- ignore_if_exists: options.ignore_if_exists.unwrap_or(false),
- })
- .unwrap_or_default(),
- )
- .await?;
+
+ let options = fs::RenameOptions {
+ overwrite: op
+ .options
+ .as_ref()
+ .and_then(|options| options.overwrite)
+ .unwrap_or(false),
+ ignore_if_exists: op
+ .options
+ .as_ref()
+ .and_then(|options| options.ignore_if_exists)
+ .unwrap_or(false),
+ create_parents: true,
+ };
+
+ fs.rename(&source_abs_path, &target_abs_path, options)
+ .await?;
}
lsp::DocumentChangeOperation::Op(lsp::ResourceOp::Delete(op)) => {
@@ -3167,8 +3311,10 @@ impl LocalLspStore {
)
.await
.log_err();
- this.update(cx, |this, _| {
+ this.update(cx, |this, cx| {
if let Some(transaction) = transaction {
+ cx.emit(LspStoreEvent::WorkspaceEditApplied(transaction.clone()));
+
this.as_local_mut()
.unwrap()
.last_workspace_edits_by_language_server
@@ -3187,6 +3333,7 @@ impl LocalLspStore {
id_to_remove: WorktreeId,
cx: &mut Context<LspStore>,
) -> Vec<LanguageServerId> {
+ self.restricted_worktrees_tasks.remove(&id_to_remove);
self.diagnostics.remove(&id_to_remove);
self.prettier_store.update(cx, |prettier_store, cx| {
prettier_store.remove_worktree(id_to_remove, cx);
@@ -3214,6 +3361,8 @@ impl LocalLspStore {
self.language_servers.remove(server_id_to_remove);
self.buffer_pull_diagnostics_result_ids
.remove(server_id_to_remove);
+ self.workspace_pull_diagnostics_result_ids
+ .remove(server_id_to_remove);
for buffer_servers in self.buffers_opened_in_servers.values_mut() {
buffer_servers.remove(server_id_to_remove);
}
@@ -3493,11 +3642,12 @@ impl LocalLspStore {
adapter: Arc<dyn LspAdapter>,
delegate: &Arc<dyn LspAdapterDelegate>,
toolchain: Option<Toolchain>,
+ requested_uri: Option<Uri>,
cx: &mut AsyncApp,
) -> Result<serde_json::Value> {
let mut workspace_config = adapter
.clone()
- .workspace_configuration(delegate, toolchain, cx)
+ .workspace_configuration(delegate, toolchain, requested_uri, cx)
.await?;
for other_adapter in delegate.registered_lsp_adapters() {
@@ -3535,6 +3685,21 @@ fn notify_server_capabilities_updated(server: &LanguageServer, cx: &mut Context<
message: proto::update_language_server::Variant::MetadataUpdated(
proto::ServerMetadataUpdated {
capabilities: Some(capabilities),
+ binary: Some(proto::LanguageServerBinaryInfo {
+ path: server.binary().path.to_string_lossy().into_owned(),
+ arguments: server
+ .binary()
+ .arguments
+ .iter()
+ .map(|arg| arg.to_string_lossy().into_owned())
+ .collect(),
+ }),
+ configuration: serde_json::to_string(server.configuration()).ok(),
+ workspace_folders: server
+ .workspace_folders()
+ .iter()
+ .map(|uri| uri.to_string())
+ .collect(),
},
),
});
@@ -3591,7 +3756,7 @@ pub struct BufferLspData {
code_lens: Option<CodeLensData>,
inlay_hints: BufferInlayHints,
lsp_requests: HashMap<LspKey, HashMap<LspRequestId, Task<()>>>,
- chunk_lsp_requests: HashMap<LspKey, HashMap<BufferChunk, LspRequestId>>,
+ chunk_lsp_requests: HashMap<LspKey, HashMap<RowChunk, LspRequestId>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -3689,6 +3854,7 @@ pub enum LspStoreEvent {
edits: Vec<(lsp::Range, Snippet)>,
most_recent_edit: clock::Lamport,
},
+ WorkspaceEditApplied(ProjectTransaction),
}
#[derive(Clone, Debug, Serialize)]
@@ -3696,8 +3862,11 @@ pub struct LanguageServerStatus {
pub name: LanguageServerName,
pub pending_work: BTreeMap<ProgressToken, LanguageServerProgress>,
pub has_pending_diagnostic_updates: bool,
- progress_tokens: HashSet<ProgressToken>,
+ pub progress_tokens: HashSet<ProgressToken>,
pub worktree: Option<WorktreeId>,
+ pub binary: Option<LanguageServerBinary>,
+ pub configuration: Option<Value>,
+ pub workspace_folders: BTreeSet<Uri>,
}
#[derive(Clone, Debug)]
@@ -3755,7 +3924,7 @@ impl LspStore {
client.add_entity_request_handler(Self::handle_register_buffer_with_language_servers);
client.add_entity_request_handler(Self::handle_rename_project_entry);
client.add_entity_request_handler(Self::handle_pull_workspace_diagnostics);
- client.add_entity_request_handler(Self::handle_lsp_command::<GetCompletions>);
+ client.add_entity_request_handler(Self::handle_lsp_get_completions);
client.add_entity_request_handler(Self::handle_lsp_command::<GetDocumentHighlights>);
client.add_entity_request_handler(Self::handle_lsp_command::<GetDocumentSymbols>);
client.add_entity_request_handler(Self::handle_lsp_command::<PrepareRename>);
@@ -3881,6 +4050,8 @@ impl LspStore {
registered_buffers: HashMap::default(),
buffers_opened_in_servers: HashMap::default(),
buffer_pull_diagnostics_result_ids: HashMap::default(),
+ workspace_pull_diagnostics_result_ids: HashMap::default(),
+ restricted_worktrees_tasks: HashMap::default(),
watched_manifest_filenames: ManifestProvidersStore::global(cx)
.manifest_file_names(),
}),
@@ -4117,7 +4288,7 @@ impl LspStore {
cx: &mut Context<Self>,
) -> OpenLspBufferHandle {
let buffer_id = buffer.read(cx).remote_id();
- let handle = cx.new(|_| buffer.clone());
+ let handle = OpenLspBufferHandle(cx.new(|_| OpenLspBuffer(buffer.clone())));
if let Some(local) = self.as_local_mut() {
let refcount = local.registered_buffers.entry(buffer_id).or_insert(0);
if !ignore_refcounts {
@@ -4139,7 +4310,7 @@ impl LspStore {
local.register_buffer_with_language_servers(buffer, only_register_servers, cx);
}
if !ignore_refcounts {
- cx.observe_release(&handle, move |lsp_store, buffer, cx| {
+ cx.observe_release(&handle.0, move |lsp_store, buffer, cx| {
let refcount = {
let local = lsp_store.as_local_mut().unwrap();
let Some(refcount) = local.registered_buffers.get_mut(&buffer_id) else {
@@ -4154,9 +4325,50 @@ impl LspStore {
lsp_store.lsp_data.remove(&buffer_id);
let local = lsp_store.as_local_mut().unwrap();
local.registered_buffers.remove(&buffer_id);
+
local.buffers_opened_in_servers.remove(&buffer_id);
- if let Some(file) = File::from_dyn(buffer.read(cx).file()).cloned() {
- local.unregister_old_buffer_from_language_servers(buffer, &file, cx);
+ if let Some(file) = File::from_dyn(buffer.0.read(cx).file()).cloned() {
+ local.unregister_old_buffer_from_language_servers(&buffer.0, &file, cx);
+
+ let buffer_abs_path = file.abs_path(cx);
+ for (_, buffer_pull_diagnostics_result_ids) in
+ &mut local.buffer_pull_diagnostics_result_ids
+ {
+ buffer_pull_diagnostics_result_ids.retain(
+ |_, buffer_result_ids| {
+ buffer_result_ids.remove(&buffer_abs_path);
+ !buffer_result_ids.is_empty()
+ },
+ );
+ }
+
+ let diagnostic_updates = local
+ .language_servers
+ .keys()
+ .cloned()
+ .map(|server_id| DocumentDiagnosticsUpdate {
+ diagnostics: DocumentDiagnostics {
+ document_abs_path: buffer_abs_path.clone(),
+ version: None,
+ diagnostics: Vec::new(),
+ },
+ result_id: None,
+ registration_id: None,
+ server_id: server_id,
+ disk_based_sources: Cow::Borrowed(&[]),
+ })
+ .collect::<Vec<_>>();
+
+ lsp_store
+ .merge_diagnostic_entries(
+ diagnostic_updates,
+ |_, diagnostic, _| {
+ diagnostic.source_kind != DiagnosticSourceKind::Pulled
+ },
+ cx,
+ )
+ .context("Clearing diagnostics for the closed buffer")
+ .log_err();
}
}
})
@@ -4218,8 +4430,9 @@ impl LspStore {
for buffer in buffer_store.buffers() {
if let Some(f) = File::from_dyn(buffer.read(cx).file()).cloned()
{
- buffer
- .update(cx, |buffer, cx| buffer.set_language(None, cx));
+ buffer.update(cx, |buffer, cx| {
+ buffer.set_language_async(None, cx)
+ });
if let Some(local) = this.as_local_mut() {
local.reset_buffer(&buffer, &f, cx);
@@ -4282,7 +4495,7 @@ impl LspStore {
}
for buffer in buffers_with_unknown_injections {
- buffer.update(cx, |buffer, cx| buffer.reparse(cx));
+ buffer.update(cx, |buffer, cx| buffer.reparse(cx, false));
}
})
.ok();
@@ -4342,7 +4555,7 @@ impl LspStore {
.language()
.is_none_or(|old_language| !Arc::ptr_eq(old_language, &new_language))
{
- buffer.set_language(Some(new_language.clone()), cx);
+ buffer.set_language_async(Some(new_language.clone()), cx);
}
});
@@ -4464,6 +4677,41 @@ impl LspStore {
.any(check)
}
+ fn all_capable_for_proto_request<F>(
+ &self,
+ buffer: &Entity<Buffer>,
+ mut check: F,
+ cx: &App,
+ ) -> Vec<lsp::LanguageServerId>
+ where
+ F: FnMut(&lsp::LanguageServerName, &lsp::ServerCapabilities) -> bool,
+ {
+ let Some(language) = buffer.read(cx).language().cloned() else {
+ return Vec::default();
+ };
+ let relevant_language_servers = self
+ .languages
+ .lsp_adapters(&language.name())
+ .into_iter()
+ .map(|lsp_adapter| lsp_adapter.name())
+ .collect::<HashSet<_>>();
+ self.language_server_statuses
+ .iter()
+ .filter_map(|(server_id, server_status)| {
+ relevant_language_servers
+ .contains(&server_status.name)
+ .then_some((server_id, &server_status.name))
+ })
+ .filter_map(|(server_id, server_name)| {
+ self.lsp_server_capabilities
+ .get(server_id)
+ .map(|c| (server_id, server_name, c))
+ })
+ .filter(|(_, server_name, capabilities)| check(server_name, capabilities))
+ .map(|(server_id, _, _)| *server_id)
+ .collect()
+ }
+
pub fn request_lsp<R>(
&mut self,
buffer: Entity<Buffer>,
@@ -5903,17 +6151,24 @@ impl LspStore {
let language_registry = self.languages.clone();
if let Some((upstream_client, project_id)) = self.upstream_client() {
- let request = GetCompletions { position, context };
- if !self.is_capable_for_proto_request(buffer, &request, cx) {
- return Task::ready(Ok(Vec::new()));
- }
- let task = self.send_lsp_proto_request(
- buffer.clone(),
- upstream_client,
- project_id,
- request,
+ let snapshot = buffer.read(cx).snapshot();
+ let offset = position.to_offset(&snapshot);
+ let scope = snapshot.language_scope_at(offset);
+ let capable_lsps = self.all_capable_for_proto_request(
+ buffer,
+ |server_name, capabilities| {
+ capabilities.completion_provider.is_some()
+ && scope
+ .as_ref()
+ .map(|scope| scope.language_allowed(server_name))
+ .unwrap_or(true)
+ },
cx,
);
+ if capable_lsps.is_empty() {
+ return Task::ready(Ok(Vec::new()));
+ }
+
let language = buffer.read(cx).language().cloned();
// In the future, we should provide project guests with the names of LSP adapters,
@@ -5926,19 +6181,53 @@ impl LspStore {
.cloned()
});
- cx.foreground_executor().spawn(async move {
- let completion_response = task.await?;
- let completions = populate_labels_for_completions(
- completion_response.completions,
- language,
- lsp_adapter,
- )
- .await;
- Ok(vec![CompletionResponse {
- completions,
- display_options: CompletionDisplayOptions::default(),
- is_incomplete: completion_response.is_incomplete,
- }])
+ let buffer = buffer.clone();
+
+ cx.spawn(async move |this, cx| {
+ let requests = join_all(
+ capable_lsps
+ .into_iter()
+ .map(|id| {
+ let request = GetCompletions {
+ position,
+ context: context.clone(),
+ server_id: Some(id),
+ };
+ let buffer = buffer.clone();
+ let language = language.clone();
+ let lsp_adapter = lsp_adapter.clone();
+ let upstream_client = upstream_client.clone();
+ let response = this
+ .update(cx, |this, cx| {
+ this.send_lsp_proto_request(
+ buffer,
+ upstream_client,
+ project_id,
+ request,
+ cx,
+ )
+ })
+ .log_err();
+ async move {
+ let response = response?.await.log_err()?;
+
+ let completions = populate_labels_for_completions(
+ response.completions,
+ language,
+ lsp_adapter,
+ )
+ .await;
+
+ Some(CompletionResponse {
+ completions,
+ display_options: CompletionDisplayOptions::default(),
+ is_incomplete: response.is_incomplete,
+ })
+ }
+ })
+ .collect::<Vec<_>>(),
+ );
+ Ok(requests.await.into_iter().flatten().collect::<Vec<_>>())
})
} else if let Some(local) = self.as_local() {
let snapshot = buffer.read(cx).snapshot();
@@ -5999,6 +6288,7 @@ impl LspStore {
GetCompletions {
position,
context: context.clone(),
+ server_id: Some(server_id),
},
cx,
).fuse();
@@ -6203,7 +6493,7 @@ impl LspStore {
server_id == *completion_server_id,
"server_id mismatch, applying completion resolve for {server_id} but completion server id is {completion_server_id}"
);
- *lsp_completion = Box::new(resolved_completion);
+ **lsp_completion = resolved_completion;
*resolved = true;
}
Ok(())
@@ -6362,7 +6652,7 @@ impl LspStore {
server_id == *completion_server_id,
"remote server_id mismatch, applying completion resolve for {server_id} but completion server id is {completion_server_id}"
);
- *lsp_completion = Box::new(resolved_lsp_completion);
+ **lsp_completion = resolved_lsp_completion;
*resolved = true;
}
@@ -6551,9 +6841,11 @@ impl LspStore {
};
assert!(any_server_has_diagnostics_provider);
+ let identifier = buffer_diagnostic_identifier(&dynamic_caps);
let request = GetDocumentDiagnostics {
previous_result_id: None,
- dynamic_caps,
+ identifier,
+ registration_id: None,
};
let request_task = client.request_lsp(
upstream_project_id,
@@ -6571,7 +6863,7 @@ impl LspStore {
})
} else {
let servers = buffer.update(cx, |buffer, cx| {
- self.language_servers_for_local_buffer(buffer, cx)
+ self.running_language_servers_for_local_buffer(buffer, cx)
.map(|(_, server)| server.clone())
.collect::<Vec<_>>()
});
@@ -6586,19 +6878,27 @@ impl LspStore {
.language_server_dynamic_registrations
.get(&server_id)
.into_iter()
- .flat_map(|registrations| registrations.diagnostics.values().cloned())
+ .flat_map(|registrations| registrations.diagnostics.clone())
.collect::<Vec<_>>();
Some(
providers_with_identifiers
.into_iter()
- .map(|dynamic_caps| {
- let result_id = self.result_id(server_id, buffer_id, cx);
+ .map(|(registration_id, dynamic_caps)| {
+ let identifier = buffer_diagnostic_identifier(&dynamic_caps);
+ let registration_id = registration_id.map(SharedString::from);
+ let result_id = self.result_id_for_buffer_pull(
+ server_id,
+ buffer_id,
+ ®istration_id,
+ cx,
+ );
self.request_lsp(
buffer.clone(),
LanguageServerToQuery::Other(server_id),
GetDocumentDiagnostics {
previous_result_id: result_id,
- dynamic_caps,
+ registration_id,
+ identifier,
},
cx,
)
@@ -6627,10 +6927,16 @@ impl LspStore {
ranges: &[Range<text::Anchor>],
cx: &mut Context<Self>,
) -> Vec<Range<BufferRow>> {
+ let buffer_snapshot = buffer.read(cx).snapshot();
+ let ranges = ranges
+ .iter()
+ .map(|range| range.to_point(&buffer_snapshot))
+ .collect::<Vec<_>>();
+
self.latest_lsp_data(buffer, cx)
.inlay_hints
- .applicable_chunks(ranges)
- .map(|chunk| chunk.start..chunk.end)
+ .applicable_chunks(ranges.as_slice())
+ .map(|chunk| chunk.row_range())
.collect()
}
@@ -6653,9 +6959,9 @@ impl LspStore {
known_chunks: Option<(clock::Global, HashSet<Range<BufferRow>>)>,
cx: &mut Context<Self>,
) -> HashMap<Range<BufferRow>, Task<Result<CacheInlayHints>>> {
- let buffer_snapshot = buffer.read(cx).snapshot();
let next_hint_id = self.next_hint_id.clone();
let lsp_data = self.latest_lsp_data(&buffer, cx);
+ let query_version = lsp_data.buffer_version.clone();
let mut lsp_refresh_requested = false;
let for_server = if let InvalidationStrategy::RefreshRequested {
server_id,
@@ -6676,19 +6982,23 @@ impl LspStore {
.map(|(_, known_chunks)| known_chunks)
.unwrap_or_default();
+ let buffer_snapshot = buffer.read(cx).snapshot();
+ let ranges = ranges
+ .iter()
+ .map(|range| range.to_point(&buffer_snapshot))
+ .collect::<Vec<_>>();
+
let mut hint_fetch_tasks = Vec::new();
let mut cached_inlay_hints = None;
let mut ranges_to_query = None;
let applicable_chunks = existing_inlay_hints
.applicable_chunks(ranges.as_slice())
- .filter(|chunk| !known_chunks.contains(&(chunk.start..chunk.end)))
+ .filter(|chunk| !known_chunks.contains(&chunk.row_range()))
.collect::<Vec<_>>();
if applicable_chunks.is_empty() {
return HashMap::default();
}
- let last_chunk_number = existing_inlay_hints.buffer_chunks_len() - 1;
-
for row_chunk in applicable_chunks {
match (
existing_inlay_hints
@@ -90,6 +90,7 @@ pub fn register_notifications(
disk_based_sources: Cow::Borrowed(
&adapter.disk_based_diagnostic_sources,
),
+ registration_id: None,
}],
|_, diag, _| !is_inactive_region(diag),
cx,
@@ -3,9 +3,12 @@ use std::{collections::hash_map, ops::Range, sync::Arc};
use collections::HashMap;
use futures::future::Shared;
use gpui::{App, Entity, Task};
-use language::{Buffer, BufferRow, BufferSnapshot};
+use language::{
+ Buffer,
+ row_chunk::{RowChunk, RowChunks},
+};
use lsp::LanguageServerId;
-use text::OffsetRangeExt;
+use text::Point;
use crate::{InlayHint, InlayId};
@@ -45,8 +48,7 @@ impl InvalidationStrategy {
}
pub struct BufferInlayHints {
- snapshot: BufferSnapshot,
- buffer_chunks: Vec<BufferChunk>,
+ chunks: RowChunks,
hints_by_chunks: Vec<Option<CacheInlayHints>>,
fetches_by_chunks: Vec<Option<CacheInlayHintsTask>>,
hints_by_id: HashMap<InlayId, HintForId>,
@@ -61,25 +63,10 @@ struct HintForId {
position: usize,
}
-/// An range of rows, exclusive as [`lsp::Range`] and
-/// <https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#range>
-/// denote.
-///
-/// Represents an area in a text editor, adjacent to other ones.
-/// Together, chunks form entire document at a particular version [`clock::Global`].
-/// Each chunk is queried for inlays as `(start_row, 0)..(end_exclusive, 0)` via
-/// <https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#inlayHintParams>
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-pub struct BufferChunk {
- pub id: usize,
- pub start: BufferRow,
- pub end: BufferRow,
-}
-
impl std::fmt::Debug for BufferInlayHints {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BufferInlayHints")
- .field("buffer_chunks", &self.buffer_chunks)
+ .field("buffer_chunks", &self.chunks)
.field("hints_by_chunks", &self.hints_by_chunks)
.field("fetches_by_chunks", &self.fetches_by_chunks)
.field("hints_by_id", &self.hints_by_id)
@@ -91,59 +78,27 @@ const MAX_ROWS_IN_A_CHUNK: u32 = 50;
impl BufferInlayHints {
pub fn new(buffer: &Entity<Buffer>, cx: &mut App) -> Self {
- let buffer = buffer.read(cx);
- let snapshot = buffer.snapshot();
- let buffer_point_range = (0..buffer.len()).to_point(&snapshot);
- let last_row = buffer_point_range.end.row;
- let buffer_chunks = (buffer_point_range.start.row..=last_row)
- .step_by(MAX_ROWS_IN_A_CHUNK as usize)
- .enumerate()
- .map(|(id, chunk_start)| BufferChunk {
- id,
- start: chunk_start,
- end: (chunk_start + MAX_ROWS_IN_A_CHUNK).min(last_row),
- })
- .collect::<Vec<_>>();
+ let chunks = RowChunks::new(buffer.read(cx).text_snapshot(), MAX_ROWS_IN_A_CHUNK);
Self {
- hints_by_chunks: vec![None; buffer_chunks.len()],
- fetches_by_chunks: vec![None; buffer_chunks.len()],
+ hints_by_chunks: vec![None; chunks.len()],
+ fetches_by_chunks: vec![None; chunks.len()],
latest_invalidation_requests: HashMap::default(),
hints_by_id: HashMap::default(),
hint_resolves: HashMap::default(),
- snapshot,
- buffer_chunks,
+ chunks,
}
}
- pub fn applicable_chunks(
- &self,
- ranges: &[Range<text::Anchor>],
- ) -> impl Iterator<Item = BufferChunk> {
- let row_ranges = ranges
- .iter()
- .map(|range| range.to_point(&self.snapshot))
- .map(|point_range| point_range.start.row..=point_range.end.row)
- .collect::<Vec<_>>();
- self.buffer_chunks
- .iter()
- .filter(move |chunk| -> bool {
- // Be lenient and yield multiple chunks if they "touch" the exclusive part of the range.
- // This will result in LSP hints [re-]queried for more ranges, but also more hints already visible when scrolling around.
- let chunk_range = chunk.start..=chunk.end;
- row_ranges.iter().any(|row_range| {
- chunk_range.contains(&row_range.start())
- || chunk_range.contains(&row_range.end())
- })
- })
- .copied()
+ pub fn applicable_chunks(&self, ranges: &[Range<Point>]) -> impl Iterator<Item = RowChunk> {
+ self.chunks.applicable_chunks(ranges)
}
- pub fn cached_hints(&mut self, chunk: &BufferChunk) -> Option<&CacheInlayHints> {
+ pub fn cached_hints(&mut self, chunk: &RowChunk) -> Option<&CacheInlayHints> {
self.hints_by_chunks[chunk.id].as_ref()
}
- pub fn fetched_hints(&mut self, chunk: &BufferChunk) -> &mut Option<CacheInlayHintsTask> {
+ pub fn fetched_hints(&mut self, chunk: &RowChunk) -> &mut Option<CacheInlayHintsTask> {
&mut self.fetches_by_chunks[chunk.id]
}
@@ -177,8 +132,8 @@ impl BufferInlayHints {
}
pub fn clear(&mut self) {
- self.hints_by_chunks = vec![None; self.buffer_chunks.len()];
- self.fetches_by_chunks = vec![None; self.buffer_chunks.len()];
+ self.hints_by_chunks = vec![None; self.chunks.len()];
+ self.fetches_by_chunks = vec![None; self.chunks.len()];
self.hints_by_id.clear();
self.hint_resolves.clear();
self.latest_invalidation_requests.clear();
@@ -186,7 +141,7 @@ impl BufferInlayHints {
pub fn insert_new_hints(
&mut self,
- chunk: BufferChunk,
+ chunk: RowChunk,
server_id: LanguageServerId,
new_hints: Vec<(InlayId, InlayHint)>,
) {
@@ -225,10 +180,6 @@ impl BufferInlayHints {
Some(hint)
}
- pub fn buffer_chunks_len(&self) -> usize {
- self.buffer_chunks.len()
- }
-
pub(crate) fn invalidate_for_server_refresh(
&mut self,
for_server: LanguageServerId,
@@ -263,7 +214,7 @@ impl BufferInlayHints {
true
}
- pub(crate) fn invalidate_for_chunk(&mut self, chunk: BufferChunk) {
+ pub(crate) fn invalidate_for_chunk(&mut self, chunk: RowChunk) {
self.fetches_by_chunks[chunk.id] = None;
if let Some(hints_by_server) = self.hints_by_chunks[chunk.id].take() {
for (hint_id, _) in hints_by_server.into_values().flatten() {
@@ -344,22 +344,7 @@ impl LogStore {
enabled,
toggled_log_kind,
} => {
- if let Some(server_state) =
- log_store.get_language_server_state(*server_id)
- {
- if *enabled {
- server_state.toggled_log_kind = Some(*toggled_log_kind);
- } else {
- server_state.toggled_log_kind = None;
- }
- }
- if LogKind::Rpc == *toggled_log_kind {
- if *enabled {
- log_store.enable_rpc_trace_for_language_server(*server_id);
- } else {
- log_store.disable_rpc_trace_for_language_server(*server_id);
- }
- }
+ log_store.toggle_lsp_logs(*server_id, *enabled, *toggled_log_kind);
}
_ => {}
}
@@ -676,7 +661,6 @@ impl LogStore {
}
fn emit_event(&mut self, e: Event, cx: &mut Context<Self>) {
- let on_headless_host = self.on_headless_host;
match &e {
Event::NewServerLogEntry { id, kind, text } => {
if let Some(state) = self.get_language_server_state(*id) {
@@ -690,9 +674,7 @@ impl LogStore {
}
.and_then(|lsp_store| lsp_store.read(cx).downstream_client());
if let Some((client, project_id)) = downstream_client {
- if on_headless_host
- || Some(LogKind::from_server_log_type(kind)) == state.toggled_log_kind
- {
+ if Some(LogKind::from_server_log_type(kind)) == state.toggled_log_kind {
client
.send(proto::LanguageServerLog {
project_id,
@@ -709,4 +691,26 @@ impl LogStore {
cx.emit(e);
}
+
+ pub fn toggle_lsp_logs(
+ &mut self,
+ server_id: LanguageServerId,
+ enabled: bool,
+ toggled_log_kind: LogKind,
+ ) {
+ if let Some(server_state) = self.get_language_server_state(server_id) {
+ if enabled {
+ server_state.toggled_log_kind = Some(toggled_log_kind);
+ } else {
+ server_state.toggled_log_kind = None;
+ }
+ }
+ if LogKind::Rpc == toggled_log_kind {
+ if enabled {
+ self.enable_rpc_trace_for_language_server(server_id);
+ } else {
+ self.disable_rpc_trace_for_language_server(server_id);
+ }
+ }
+ }
}
@@ -0,0 +1,60 @@
+use collections::{HashMap, HashSet};
+use gpui::{App, Entity, SharedString};
+use std::path::PathBuf;
+
+use db::{
+ query,
+ sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
+ sqlez_macros::sql,
+};
+
+use crate::{
+ trusted_worktrees::{PathTrust, RemoteHostLocation, find_worktree_in_store},
+ worktree_store::WorktreeStore,
+};
+
+// https://www.sqlite.org/limits.html
+// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
+// > which defaults to <..> 32766 for SQLite versions after 3.32.0.
+#[allow(unused)]
+const MAX_QUERY_PLACEHOLDERS: usize = 32000;
+
+#[allow(unused)]
+pub struct ProjectDb(ThreadSafeConnection);
+
+impl Domain for ProjectDb {
+ const NAME: &str = stringify!(ProjectDb);
+
+ const MIGRATIONS: &[&str] = &[sql!(
+ CREATE TABLE IF NOT EXISTS trusted_worktrees (
+ trust_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ absolute_path TEXT,
+ user_name TEXT,
+ host_name TEXT
+ ) STRICT;
+ )];
+}
+
+db::static_connection!(PROJECT_DB, ProjectDb, []);
+
+impl ProjectDb {}
+
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
+
+ use collections::{HashMap, HashSet};
+ use gpui::{SharedString, TestAppContext};
+ use serde_json::json;
+ use settings::SettingsStore;
+ use smol::lock::Mutex;
+ use util::path;
+
+ use crate::{
+ FakeFs, Project,
+ persistence::PROJECT_DB,
+ trusted_worktrees::{PathTrust, RemoteHostLocation},
+ };
+
+ static TEST_WORKTREE_TRUST_LOCK: Mutex<()> = Mutex::new(());
+}
@@ -905,7 +905,7 @@ async fn install_prettier_packages(
.with_context(|| {
format!("fetching latest npm version for package {returned_package_name}")
})?;
- anyhow::Ok((returned_package_name, latest_version))
+ anyhow::Ok((returned_package_name, latest_version.to_string()))
}),
)
.await
@@ -11,6 +11,7 @@ pub mod lsp_command;
pub mod lsp_store;
mod manifest_tree;
pub mod prettier_store;
+mod project_search;
pub mod project_settings;
pub mod search;
mod task_inventory;
@@ -18,6 +19,7 @@ pub mod task_store;
pub mod telemetry_snapshot;
pub mod terminals;
pub mod toolchain_store;
+pub mod trusted_worktrees;
pub mod worktree_store;
#[cfg(test)]
@@ -37,6 +39,8 @@ use dap::inline_value::{InlineValueLocation, VariableLookupKind, VariableScope};
use crate::{
git_store::GitStore,
lsp_store::{SymbolLocation, log_store::LogKind},
+ project_search::SearchResultsHandle,
+ trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
};
pub use agent_server_store::{AgentServerStore, AgentServersUpdated, ExternalAgentServerName};
pub use git_store::{
@@ -44,6 +48,7 @@ pub use git_store::{
git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal},
};
pub use manifest_tree::ManifestTree;
+pub use project_search::Search;
use anyhow::{Context as _, Result, anyhow};
use buffer_store::{BufferStore, BufferStoreEvent};
@@ -60,6 +65,7 @@ use debugger::{
dap_store::{DapStore, DapStoreEvent},
session::Session,
};
+use encoding_rs;
pub use environment::ProjectEnvironment;
#[cfg(test)]
use futures::future::join_all;
@@ -84,7 +90,8 @@ use language::{
};
use lsp::{
CodeActionKind, CompletionContext, CompletionItemKind, DocumentHighlightKind, InsertTextMode,
- LanguageServerId, LanguageServerName, LanguageServerSelector, MessageActionItem,
+ LanguageServerBinary, LanguageServerId, LanguageServerName, LanguageServerSelector,
+ MessageActionItem,
};
use lsp_command::*;
use lsp_store::{CompletionDocumentation, LspFormatTarget, OpenLspBufferHandle};
@@ -103,14 +110,16 @@ use search_history::SearchHistory;
use settings::{InvalidSettingsError, RegisterSetting, Settings, SettingsLocation, SettingsStore};
use smol::channel::Receiver;
use snippet::Snippet;
+pub use snippet_provider;
use snippet_provider::SnippetProvider;
use std::{
borrow::Cow,
collections::BTreeMap,
- ops::Range,
+ ffi::OsString,
+ ops::{Not as _, Range},
path::{Path, PathBuf},
pin::pin,
- str,
+ str::{self, FromStr},
sync::Arc,
time::Duration,
};
@@ -121,7 +130,7 @@ use text::{Anchor, BufferId, OffsetRangeExt, Point, Rope};
use toolchain_store::EmptyToolchainStore;
use util::{
ResultExt as _, maybe,
- paths::{PathStyle, SanitizedPath, compare_paths, is_absolute},
+ paths::{PathStyle, SanitizedPath, is_absolute},
rel_path::RelPath,
};
use worktree::{CreatedEntry, Snapshot, Traversal};
@@ -148,8 +157,6 @@ pub use lsp_store::{
};
pub use toolchain_store::{ToolchainStore, Toolchains};
const MAX_PROJECT_SEARCH_HISTORY_SIZE: usize = 500;
-const MAX_SEARCH_RESULT_FILES: usize = 5_000;
-const MAX_SEARCH_RESULT_RANGES: usize = 10_000;
pub trait ProjectItem: 'static {
fn try_open(
@@ -344,6 +351,7 @@ pub enum Event {
SnippetEdit(BufferId, Vec<(lsp::Range, Snippet)>),
ExpandedAllForEntry(WorktreeId, ProjectEntryId),
EntryRenamed(ProjectTransaction, ProjectPath, PathBuf),
+ WorkspaceEditApplied(ProjectTransaction),
AgentLocationChanged,
}
@@ -476,6 +484,12 @@ pub struct Completion {
pub source: CompletionSource,
/// A path to an icon for this completion that is shown in the menu.
pub icon_path: Option<SharedString>,
+ /// Text starting here and ending at the cursor will be used as the query for filtering this completion.
+ ///
+ /// If None, the start of the surrounding word is used.
+ pub match_start: Option<text::Anchor>,
+ /// Key used for de-duplicating snippets. If None, always considered unique.
+ pub snippet_deduplication_key: Option<(usize, usize)>,
/// Whether to adjust indentation (the default) or not.
pub insert_text_mode: Option<InsertTextMode>,
/// An optional callback to invoke when this completion is confirmed.
@@ -917,7 +931,7 @@ impl DirectoryLister {
.map(|worktree| worktree.read(cx).abs_path().to_string_lossy().into_owned())
.or_else(|| std::env::home_dir().map(|dir| dir.to_string_lossy().into_owned()))
.map(|mut s| {
- s.push_str(path_style.separator());
+ s.push_str(path_style.primary_separator());
s
})
.unwrap_or_else(|| {
@@ -956,6 +970,14 @@ impl DirectoryLister {
}
}
}
+
+ pub fn path_style(&self, cx: &App) -> PathStyle {
+ match self {
+ Self::Local(project, ..) | Self::Project(project, ..) => {
+ project.read(cx).path_style(cx)
+ }
+ }
+ }
}
#[cfg(any(test, feature = "test-support"))]
@@ -974,6 +996,8 @@ pub enum LspPullDiagnostics {
server_id: LanguageServerId,
/// URI of the resource,
uri: lsp::Uri,
+ /// The ID provided by the dynamic registration that produced diagnostics.
+ registration_id: Option<SharedString>,
/// The diagnostics produced by this language server.
diagnostics: PulledDiagnostics,
},
@@ -984,10 +1008,10 @@ pub enum PulledDiagnostics {
Unchanged {
/// An ID the current pulled batch for this file.
/// If given, can be used to query workspace diagnostics partially.
- result_id: String,
+ result_id: SharedString,
},
Changed {
- result_id: Option<String>,
+ result_id: Option<SharedString>,
diagnostics: Vec<lsp::Diagnostic>,
},
}
@@ -1049,6 +1073,7 @@ impl Project {
languages: Arc<LanguageRegistry>,
fs: Arc<dyn Fs>,
env: Option<HashMap<String, String>>,
+ init_worktree_trust: bool,
cx: &mut App,
) -> Entity<Self> {
cx.new(|cx: &mut Context<Self>| {
@@ -1057,6 +1082,15 @@ impl Project {
.detach();
let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx);
let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
+ if init_worktree_trust {
+ trusted_worktrees::track_worktree_trust(
+ worktree_store.clone(),
+ None,
+ None,
+ None,
+ cx,
+ );
+ }
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
@@ -1230,18 +1264,23 @@ impl Project {
user_store: Entity<UserStore>,
languages: Arc<LanguageRegistry>,
fs: Arc<dyn Fs>,
+ init_worktree_trust: bool,
cx: &mut App,
) -> Entity<Self> {
cx.new(|cx: &mut Context<Self>| {
let (tx, rx) = mpsc::unbounded();
cx.spawn(async move |this, cx| Self::send_buffer_ordered_messages(this, rx, cx).await)
.detach();
- let global_snippets_dir = paths::snippets_dir().to_owned();
- let snippets =
- SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx);
+ let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx);
- let (remote_proto, path_style) =
- remote.read_with(cx, |remote, _| (remote.proto_client(), remote.path_style()));
+ let (remote_proto, path_style, connection_options) =
+ remote.read_with(cx, |remote, _| {
+ (
+ remote.proto_client(),
+ remote.path_style(),
+ remote.connection_options(),
+ )
+ });
let worktree_store = cx.new(|_| {
WorktreeStore::remote(
false,
@@ -1250,8 +1289,23 @@ impl Project {
path_style,
)
});
+
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
+ if init_worktree_trust {
+ match &connection_options {
+ RemoteConnectionOptions::Wsl(..) | RemoteConnectionOptions::Ssh(..) => {
+ trusted_worktrees::track_worktree_trust(
+ worktree_store.clone(),
+ Some(RemoteHostLocation::from(connection_options)),
+ None,
+ Some((remote_proto.clone(), REMOTE_SERVER_PROJECT_ID)),
+ cx,
+ );
+ }
+ RemoteConnectionOptions::Docker(..) => {}
+ }
+ }
let weak_self = cx.weak_entity();
let context_server_store =
@@ -1432,6 +1486,9 @@ impl Project {
remote_proto.add_entity_request_handler(Self::handle_language_server_prompt_request);
remote_proto.add_entity_message_handler(Self::handle_hide_toast);
remote_proto.add_entity_request_handler(Self::handle_update_buffer_from_remote_server);
+ remote_proto.add_entity_request_handler(Self::handle_trust_worktrees);
+ remote_proto.add_entity_request_handler(Self::handle_restrict_worktrees);
+
BufferStore::init(&remote_proto);
LspStore::init(&remote_proto);
SettingsObserver::init(&remote_proto);
@@ -1792,6 +1849,7 @@ impl Project {
Arc::new(languages),
fs,
None,
+ false,
cx,
)
})
@@ -1816,6 +1874,25 @@ impl Project {
fs: Arc<dyn Fs>,
root_paths: impl IntoIterator<Item = &Path>,
cx: &mut gpui::TestAppContext,
+ ) -> Entity<Project> {
+ Self::test_project(fs, root_paths, false, cx).await
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub async fn test_with_worktree_trust(
+ fs: Arc<dyn Fs>,
+ root_paths: impl IntoIterator<Item = &Path>,
+ cx: &mut gpui::TestAppContext,
+ ) -> Entity<Project> {
+ Self::test_project(fs, root_paths, true, cx).await
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ async fn test_project(
+ fs: Arc<dyn Fs>,
+ root_paths: impl IntoIterator<Item = &Path>,
+ init_worktree_trust: bool,
+ cx: &mut gpui::TestAppContext,
) -> Entity<Project> {
use clock::FakeSystemClock;
@@ -1832,6 +1909,7 @@ impl Project {
Arc::new(languages),
fs,
None,
+ init_worktree_trust,
cx,
)
});
@@ -2407,13 +2485,11 @@ impl Project {
cx: &mut Context<Self>,
) -> Result<()> {
cx.update_global::<SettingsStore, _>(|store, cx| {
- self.worktree_store.update(cx, |worktree_store, cx| {
- for worktree in worktree_store.worktrees() {
- store
- .clear_local_settings(worktree.read(cx).id(), cx)
- .log_err();
- }
- });
+ for worktree_metadata in &message.worktrees {
+ store
+ .clear_local_settings(WorktreeId::from_proto(worktree_metadata.id), cx)
+ .log_err();
+ }
});
self.join_project_response_message_id = message_id;
@@ -2612,6 +2688,12 @@ impl Project {
!self.is_local()
}
+ pub fn disable_worktree_scanner(&mut self, cx: &mut Context<Self>) {
+ self.worktree_store.update(cx, |worktree_store, _cx| {
+ worktree_store.disable_scanner();
+ });
+ }
+
#[inline]
pub fn create_buffer(
&mut self,
@@ -3103,17 +3185,45 @@ impl Project {
match message {
proto::update_language_server::Variant::MetadataUpdated(update) => {
- if let Some(capabilities) = update
- .capabilities
- .as_ref()
- .and_then(|capabilities| serde_json::from_str(capabilities).ok())
- {
- self.lsp_store.update(cx, |lsp_store, _| {
+ self.lsp_store.update(cx, |lsp_store, _| {
+ if let Some(capabilities) = update
+ .capabilities
+ .as_ref()
+ .and_then(|capabilities| serde_json::from_str(capabilities).ok())
+ {
lsp_store
.lsp_server_capabilities
.insert(*language_server_id, capabilities);
- });
- }
+ }
+
+ if let Some(language_server_status) = lsp_store
+ .language_server_statuses
+ .get_mut(language_server_id)
+ {
+ if let Some(binary) = &update.binary {
+ language_server_status.binary = Some(LanguageServerBinary {
+ path: PathBuf::from(&binary.path),
+ arguments: binary
+ .arguments
+ .iter()
+ .map(OsString::from)
+ .collect(),
+ env: None,
+ });
+ }
+
+ language_server_status.configuration = update
+ .configuration
+ .as_ref()
+ .and_then(|config_str| serde_json::from_str(config_str).ok());
+
+ language_server_status.workspace_folders = update
+ .workspace_folders
+ .iter()
+ .filter_map(|uri_str| lsp::Uri::from_str(uri_str).ok())
+ .collect();
+ }
+ });
}
proto::update_language_server::Variant::RegisteredForBuffer(update) => {
if let Some(buffer_id) = BufferId::new(update.buffer_id).ok() {
@@ -3141,6 +3251,9 @@ impl Project {
cx.emit(Event::SnippetEdit(*buffer_id, edits.clone()))
}
}
+ LspStoreEvent::WorkspaceEditApplied(transaction) => {
+ cx.emit(Event::WorkspaceEditApplied(transaction.clone()))
+ }
}
}
@@ -3966,7 +4079,8 @@ impl Project {
) -> Task<anyhow::Result<Vec<InlayHint>>> {
let snapshot = buffer_handle.read(cx).snapshot();
- let captures = snapshot.debug_variables_query(Anchor::MIN..range.end);
+ let captures =
+ snapshot.debug_variables_query(Anchor::min_for_buffer(snapshot.remote_id())..range.end);
let row = snapshot
.summary_for_anchor::<text::PointUtf16>(&range.end)
@@ -3991,179 +4105,44 @@ impl Project {
})
}
- pub fn search(&mut self, query: SearchQuery, cx: &mut Context<Self>) -> Receiver<SearchResult> {
- let (result_tx, result_rx) = smol::channel::unbounded();
-
- let matching_buffers_rx = if query.is_opened_only() {
- self.sort_search_candidates(&query, cx)
- } else {
- self.find_search_candidate_buffers(&query, MAX_SEARCH_RESULT_FILES + 1, cx)
- };
-
- cx.spawn(async move |_, cx| {
- let mut range_count = 0;
- let mut buffer_count = 0;
- let mut limit_reached = false;
- let query = Arc::new(query);
- let chunks = matching_buffers_rx.ready_chunks(64);
-
- // Now that we know what paths match the query, we will load at most
- // 64 buffers at a time to avoid overwhelming the main thread. For each
- // opened buffer, we will spawn a background task that retrieves all the
- // ranges in the buffer matched by the query.
- let mut chunks = pin!(chunks);
- 'outer: while let Some(matching_buffer_chunk) = chunks.next().await {
- let mut chunk_results = Vec::with_capacity(matching_buffer_chunk.len());
- for buffer in matching_buffer_chunk {
- let query = query.clone();
- let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
- chunk_results.push(cx.background_spawn(async move {
- let ranges = query
- .search(&snapshot, None)
- .await
- .iter()
- .map(|range| {
- snapshot.anchor_before(range.start)
- ..snapshot.anchor_after(range.end)
- })
- .collect::<Vec<_>>();
- anyhow::Ok((buffer, ranges))
- }));
- }
-
- let chunk_results = futures::future::join_all(chunk_results).await;
- for result in chunk_results {
- if let Some((buffer, ranges)) = result.log_err() {
- range_count += ranges.len();
- buffer_count += 1;
- result_tx
- .send(SearchResult::Buffer { buffer, ranges })
- .await?;
- if buffer_count > MAX_SEARCH_RESULT_FILES
- || range_count > MAX_SEARCH_RESULT_RANGES
- {
- limit_reached = true;
- break 'outer;
- }
- }
- }
- }
-
- if limit_reached {
- result_tx.send(SearchResult::LimitReached).await?;
- }
-
- anyhow::Ok(())
- })
- .detach();
-
- result_rx
- }
-
- pub fn find_search_candidate_buffers(
- &mut self,
- query: &SearchQuery,
- limit: usize,
- cx: &mut Context<Project>,
- ) -> Receiver<Entity<Buffer>> {
- if self.is_local() {
- let fs = self.fs.clone();
- self.buffer_store.update(cx, |buffer_store, cx| {
- buffer_store.find_search_candidates(query, limit, fs, cx)
- })
- } else {
- self.find_search_candidates_remote(query, limit, cx)
- }
- }
-
- fn sort_search_candidates(
- &mut self,
- search_query: &SearchQuery,
- cx: &mut Context<Project>,
- ) -> Receiver<Entity<Buffer>> {
- let worktree_store = self.worktree_store.read(cx);
- let mut buffers = search_query
- .buffers()
- .into_iter()
- .flatten()
- .filter(|buffer| {
- let b = buffer.read(cx);
- if let Some(file) = b.file() {
- if !search_query.match_path(file.path().as_std_path()) {
- return false;
- }
- if let Some(entry) = b
- .entry_id(cx)
- .and_then(|entry_id| worktree_store.entry_for_id(entry_id, cx))
- && entry.is_ignored
- && !search_query.include_ignored()
- {
- return false;
- }
- }
- true
- })
- .collect::<Vec<_>>();
- let (tx, rx) = smol::channel::unbounded();
- buffers.sort_by(|a, b| match (a.read(cx).file(), b.read(cx).file()) {
- (None, None) => a.read(cx).remote_id().cmp(&b.read(cx).remote_id()),
- (None, Some(_)) => std::cmp::Ordering::Less,
- (Some(_), None) => std::cmp::Ordering::Greater,
- (Some(a), Some(b)) => compare_paths(
- (a.path().as_std_path(), true),
- (b.path().as_std_path(), true),
- ),
- });
- for buffer in buffers {
- tx.send_blocking(buffer.clone()).unwrap()
- }
-
- rx
- }
-
- fn find_search_candidates_remote(
- &mut self,
- query: &SearchQuery,
- limit: usize,
- cx: &mut Context<Project>,
- ) -> Receiver<Entity<Buffer>> {
- let (tx, rx) = smol::channel::unbounded();
-
- let (client, remote_id): (AnyProtoClient, _) = if let Some(ssh_client) = &self.remote_client
- {
- (ssh_client.read(cx).proto_client(), 0)
+ fn search_impl(&mut self, query: SearchQuery, cx: &mut Context<Self>) -> SearchResultsHandle {
+ let client: Option<(AnyProtoClient, _)> = if let Some(ssh_client) = &self.remote_client {
+ Some((ssh_client.read(cx).proto_client(), 0))
} else if let Some(remote_id) = self.remote_id() {
- (self.collab_client.clone().into(), remote_id)
+ self.is_local()
+ .not()
+ .then(|| (self.collab_client.clone().into(), remote_id))
} else {
- return rx;
+ None
};
-
- let request = client.request(proto::FindSearchCandidates {
- project_id: remote_id,
- query: Some(query.to_proto()),
- limit: limit as _,
- });
- let guard = self.retain_remotely_created_models(cx);
-
- cx.spawn(async move |project, cx| {
- let response = request.await?;
- for buffer_id in response.buffer_ids {
- let buffer_id = BufferId::new(buffer_id)?;
- let buffer = project
- .update(cx, |project, cx| {
- project.buffer_store.update(cx, |buffer_store, cx| {
- buffer_store.wait_for_remote_buffer(buffer_id, cx)
- })
- })?
- .await?;
- let _ = tx.send(buffer).await;
+ let searcher = if query.is_opened_only() {
+ project_search::Search::open_buffers_only(
+ self.buffer_store.clone(),
+ self.worktree_store.clone(),
+ project_search::Search::MAX_SEARCH_RESULT_FILES + 1,
+ )
+ } else {
+ match client {
+ Some((client, remote_id)) => project_search::Search::remote(
+ self.buffer_store.clone(),
+ self.worktree_store.clone(),
+ project_search::Search::MAX_SEARCH_RESULT_FILES + 1,
+ (client, remote_id, self.remotely_created_models.clone()),
+ ),
+ None => project_search::Search::local(
+ self.fs.clone(),
+ self.buffer_store.clone(),
+ self.worktree_store.clone(),
+ project_search::Search::MAX_SEARCH_RESULT_FILES + 1,
+ cx,
+ ),
}
+ };
+ searcher.into_handle(query, cx)
+ }
- drop(guard);
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
- rx
+ pub fn search(&mut self, query: SearchQuery, cx: &mut Context<Self>) -> Receiver<SearchResult> {
+ self.search_impl(query, cx).results(cx)
}
pub fn request_lsp<R: LspCommand>(
@@ -4753,6 +4732,14 @@ impl Project {
this.update(&mut cx, |this, cx| {
// Don't handle messages that were sent before the response to us joining the project
if envelope.message_id > this.join_project_response_message_id {
+ cx.update_global::<SettingsStore, _>(|store, cx| {
+ for worktree_metadata in &envelope.payload.worktrees {
+ store
+ .clear_local_settings(WorktreeId::from_proto(worktree_metadata.id), cx)
+ .log_err();
+ }
+ });
+
this.set_worktrees_from_proto(envelope.payload.worktrees, cx)?;
}
Ok(())
@@ -4839,9 +4826,14 @@ impl Project {
envelope: TypedEnvelope<proto::UpdateWorktree>,
mut cx: AsyncApp,
) -> Result<()> {
- this.update(&mut cx, |this, cx| {
+ this.update(&mut cx, |project, cx| {
let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
- if let Some(worktree) = this.worktree_for_id(worktree_id, cx) {
+ if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+ trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+ trusted_worktrees.can_trust(worktree_id, cx)
+ });
+ }
+ if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
worktree.update(cx, |worktree, _| {
let worktree = worktree.as_remote_mut().unwrap();
worktree.update_from_remote(envelope.payload);
@@ -4868,6 +4860,58 @@ impl Project {
BufferStore::handle_update_buffer(buffer_store, envelope, cx).await
}
+ async fn handle_trust_worktrees(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::TrustWorktrees>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::Ack> {
+ let trusted_worktrees = cx
+ .update(|cx| TrustedWorktrees::try_get_global(cx))?
+ .context("missing trusted worktrees")?;
+ trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
+ let remote_host = this
+ .read(cx)
+ .remote_connection_options(cx)
+ .map(RemoteHostLocation::from);
+ trusted_worktrees.trust(
+ envelope
+ .payload
+ .trusted_paths
+ .into_iter()
+ .filter_map(|proto_path| PathTrust::from_proto(proto_path))
+ .collect(),
+ remote_host,
+ cx,
+ );
+ })?;
+ Ok(proto::Ack {})
+ }
+
+ async fn handle_restrict_worktrees(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::RestrictWorktrees>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::Ack> {
+ let trusted_worktrees = cx
+ .update(|cx| TrustedWorktrees::try_get_global(cx))?
+ .context("missing trusted worktrees")?;
+ trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
+ let restricted_paths = envelope
+ .payload
+ .worktree_ids
+ .into_iter()
+ .map(WorktreeId::from_proto)
+ .map(PathTrust::Worktree)
+ .collect::<HashSet<_>>();
+ let remote_host = this
+ .read(cx)
+ .remote_connection_options(cx)
+ .map(RemoteHostLocation::from);
+ trusted_worktrees.restrict(restricted_paths, remote_host, cx);
+ })?;
+ Ok(proto::Ack {})
+ }
+
async fn handle_update_buffer(
this: Entity<Self>,
envelope: TypedEnvelope<proto::UpdateBuffer>,
@@ -4888,18 +4932,31 @@ impl Project {
fn retain_remotely_created_models(
&mut self,
cx: &mut Context<Self>,
+ ) -> RemotelyCreatedModelGuard {
+ Self::retain_remotely_created_models_impl(
+ &self.remotely_created_models,
+ &self.buffer_store,
+ &self.worktree_store,
+ cx,
+ )
+ }
+
+ fn retain_remotely_created_models_impl(
+ models: &Arc<Mutex<RemotelyCreatedModels>>,
+ buffer_store: &Entity<BufferStore>,
+ worktree_store: &Entity<WorktreeStore>,
+ cx: &mut App,
) -> RemotelyCreatedModelGuard {
{
- let mut remotely_create_models = self.remotely_created_models.lock();
+ let mut remotely_create_models = models.lock();
if remotely_create_models.retain_count == 0 {
- remotely_create_models.buffers = self.buffer_store.read(cx).buffers().collect();
- remotely_create_models.worktrees =
- self.worktree_store.read(cx).worktrees().collect();
+ remotely_create_models.buffers = buffer_store.read(cx).buffers().collect();
+ remotely_create_models.worktrees = worktree_store.read(cx).worktrees().collect();
}
remotely_create_models.retain_count += 1;
}
RemotelyCreatedModelGuard {
- remote_models: Arc::downgrade(&self.remotely_created_models),
+ remote_models: Arc::downgrade(&models),
}
}
@@ -4969,7 +5026,7 @@ impl Project {
let query =
SearchQuery::from_proto(message.query.context("missing query field")?, path_style)?;
let results = this.update(&mut cx, |this, cx| {
- this.find_search_candidate_buffers(&query, message.limit as _, cx)
+ this.search_impl(query, cx).matching_buffers(cx)
})?;
let mut response = proto::FindSearchCandidatesResponse {
@@ -5267,7 +5324,7 @@ impl Project {
#[cfg(any(test, feature = "test-support"))]
pub fn has_language_servers_for(&self, buffer: &Buffer, cx: &mut App) -> bool {
self.lsp_store.update(cx, |this, cx| {
- this.language_servers_for_local_buffer(buffer, cx)
+ this.running_language_servers_for_local_buffer(buffer, cx)
.next()
.is_some()
})
@@ -5405,13 +5462,22 @@ impl Project {
.await
.context("Failed to load settings file")?;
+ let has_bom = file.has_bom;
+
let new_text = cx.read_global::<SettingsStore, _>(|store, cx| {
store.new_text_for_update(file.text, move |settings| update(settings, cx))
})?;
worktree
.update(cx, |worktree, cx| {
let line_ending = text::LineEnding::detect(&new_text);
- worktree.write_file(rel_path.clone(), new_text.into(), line_ending, cx)
+ worktree.write_file(
+ rel_path.clone(),
+ new_text.into(),
+ line_ending,
+ encoding_rs::UTF_8,
+ has_bom,
+ cx,
+ )
})?
.await
.context("Failed to write settings file")?;
@@ -5643,6 +5709,15 @@ impl Completion {
}
/// Whether this completion is a snippet.
+ pub fn is_snippet_kind(&self) -> bool {
+ matches!(
+ &self.source,
+ CompletionSource::Lsp { lsp_completion, .. }
+ if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
+ )
+ }
+
+ /// Whether this completion is a snippet or snippet-style LSP completion.
pub fn is_snippet(&self) -> bool {
self.source
// `lsp::CompletionListItemDefaults` has `insert_text_format` field
@@ -0,0 +1,1002 @@
+use std::{
+ cell::LazyCell,
+ collections::BTreeSet,
+ io::{BufRead, BufReader},
+ ops::Range,
+ path::{Path, PathBuf},
+ pin::pin,
+ sync::Arc,
+};
+
+use anyhow::Context;
+use collections::HashSet;
+use fs::Fs;
+use futures::{SinkExt, StreamExt, select_biased, stream::FuturesOrdered};
+use gpui::{App, AppContext, AsyncApp, Entity, Task};
+use language::{Buffer, BufferSnapshot};
+use parking_lot::Mutex;
+use postage::oneshot;
+use rpc::{AnyProtoClient, proto};
+use smol::{
+ channel::{Receiver, Sender, bounded, unbounded},
+ future::FutureExt,
+};
+
+use text::BufferId;
+use util::{ResultExt, maybe, paths::compare_rel_paths, rel_path::RelPath};
+use worktree::{Entry, ProjectEntryId, Snapshot, Worktree, WorktreeSettings};
+
+use crate::{
+ Project, ProjectItem, ProjectPath, RemotelyCreatedModels,
+ buffer_store::BufferStore,
+ search::{SearchQuery, SearchResult},
+ worktree_store::WorktreeStore,
+};
+
+pub struct Search {
+ buffer_store: Entity<BufferStore>,
+ worktree_store: Entity<WorktreeStore>,
+ limit: usize,
+ kind: SearchKind,
+}
+
+/// Represents search setup, before it is actually kicked off with Search::into_results
+enum SearchKind {
+ /// Search for candidates by inspecting file contents on file system, avoiding loading the buffer unless we know that a given file contains a match.
+ Local {
+ fs: Arc<dyn Fs>,
+ worktrees: Vec<Entity<Worktree>>,
+ },
+ /// Query remote host for candidates. As of writing, the host runs a local search in "buffers with matches only" mode.
+ Remote {
+ client: AnyProtoClient,
+ remote_id: u64,
+ models: Arc<Mutex<RemotelyCreatedModels>>,
+ },
+ /// Run search against a known set of candidates. Even when working with a remote host, this won't round-trip to host.
+ OpenBuffersOnly,
+}
+
+/// Represents results of project search and allows one to either obtain match positions OR
+/// just the handles to buffers that may match the search. Grabbing the handles is cheaper than obtaining full match positions, because in that case we'll look for
+/// at most one match in each file.
+#[must_use]
+pub struct SearchResultsHandle {
+ results: Receiver<SearchResult>,
+ matching_buffers: Receiver<Entity<Buffer>>,
+ trigger_search: Box<dyn FnOnce(&mut App) -> Task<()> + Send + Sync>,
+}
+
+impl SearchResultsHandle {
+ pub fn results(self, cx: &mut App) -> Receiver<SearchResult> {
+ (self.trigger_search)(cx).detach();
+ self.results
+ }
+ pub fn matching_buffers(self, cx: &mut App) -> Receiver<Entity<Buffer>> {
+ (self.trigger_search)(cx).detach();
+ self.matching_buffers
+ }
+}
+
+#[derive(Clone)]
+enum FindSearchCandidates {
+ Local {
+ fs: Arc<dyn Fs>,
+ /// Start off with all paths in project and filter them based on:
+ /// - Include filters
+ /// - Exclude filters
+ /// - Only open buffers
+ /// - Scan ignored files
+ /// Put another way: filter out files that can't match (without looking at file contents)
+ input_paths_rx: Receiver<InputPath>,
+ /// After that, if the buffer is not yet loaded, we'll figure out if it contains at least one match
+ /// based on disk contents of a buffer. This step is not performed for buffers we already have in memory.
+ confirm_contents_will_match_tx: Sender<MatchingEntry>,
+ confirm_contents_will_match_rx: Receiver<MatchingEntry>,
+ },
+ Remote,
+ OpenBuffersOnly,
+}
+
+impl Search {
+ pub fn local(
+ fs: Arc<dyn Fs>,
+ buffer_store: Entity<BufferStore>,
+ worktree_store: Entity<WorktreeStore>,
+ limit: usize,
+ cx: &mut App,
+ ) -> Self {
+ let worktrees = worktree_store.read(cx).visible_worktrees(cx).collect();
+ Self {
+ kind: SearchKind::Local { fs, worktrees },
+ buffer_store,
+ worktree_store,
+ limit,
+ }
+ }
+
+ pub(crate) fn remote(
+ buffer_store: Entity<BufferStore>,
+ worktree_store: Entity<WorktreeStore>,
+ limit: usize,
+ client_state: (AnyProtoClient, u64, Arc<Mutex<RemotelyCreatedModels>>),
+ ) -> Self {
+ Self {
+ kind: SearchKind::Remote {
+ client: client_state.0,
+ remote_id: client_state.1,
+ models: client_state.2,
+ },
+ buffer_store,
+ worktree_store,
+ limit,
+ }
+ }
+ pub(crate) fn open_buffers_only(
+ buffer_store: Entity<BufferStore>,
+ worktree_store: Entity<WorktreeStore>,
+ limit: usize,
+ ) -> Self {
+ Self {
+ kind: SearchKind::OpenBuffersOnly,
+ buffer_store,
+ worktree_store,
+ limit,
+ }
+ }
+
+ pub(crate) const MAX_SEARCH_RESULT_FILES: usize = 5_000;
+ pub(crate) const MAX_SEARCH_RESULT_RANGES: usize = 10_000;
+ /// Prepares a project search run. The resulting [`SearchResultsHandle`] has to be used to specify whether you're interested in matching buffers
+ /// or full search results.
+ pub fn into_handle(mut self, query: SearchQuery, cx: &mut App) -> SearchResultsHandle {
+ let mut open_buffers = HashSet::default();
+ let mut unnamed_buffers = Vec::new();
+ const MAX_CONCURRENT_BUFFER_OPENS: usize = 64;
+ let buffers = self.buffer_store.read(cx);
+ for handle in buffers.buffers() {
+ let buffer = handle.read(cx);
+ if !buffers.is_searchable(&buffer.remote_id()) {
+ continue;
+ } else if let Some(entry_id) = buffer.entry_id(cx) {
+ open_buffers.insert(entry_id);
+ } else {
+ self.limit = self.limit.saturating_sub(1);
+ unnamed_buffers.push(handle)
+ };
+ }
+ let executor = cx.background_executor().clone();
+ let (tx, rx) = unbounded();
+ let (grab_buffer_snapshot_tx, grab_buffer_snapshot_rx) = unbounded();
+ let matching_buffers = grab_buffer_snapshot_rx.clone();
+ let trigger_search = Box::new(move |cx: &mut App| {
+ cx.spawn(async move |cx| {
+ for buffer in unnamed_buffers {
+ _ = grab_buffer_snapshot_tx.send(buffer).await;
+ }
+
+ let (find_all_matches_tx, find_all_matches_rx) =
+ bounded(MAX_CONCURRENT_BUFFER_OPENS);
+ let query = Arc::new(query);
+ let (candidate_searcher, tasks) = match self.kind {
+ SearchKind::OpenBuffersOnly => {
+ let Ok(open_buffers) = cx.update(|cx| self.all_loaded_buffers(&query, cx))
+ else {
+ return;
+ };
+ let fill_requests = cx
+ .background_spawn(async move {
+ for buffer in open_buffers {
+ if let Err(_) = grab_buffer_snapshot_tx.send(buffer).await {
+ return;
+ }
+ }
+ })
+ .boxed_local();
+ (FindSearchCandidates::OpenBuffersOnly, vec![fill_requests])
+ }
+ SearchKind::Local {
+ fs,
+ ref mut worktrees,
+ } => {
+ let (get_buffer_for_full_scan_tx, get_buffer_for_full_scan_rx) =
+ unbounded();
+ let (confirm_contents_will_match_tx, confirm_contents_will_match_rx) =
+ bounded(64);
+ let (sorted_search_results_tx, sorted_search_results_rx) = unbounded();
+
+ let (input_paths_tx, input_paths_rx) = unbounded();
+ let tasks = vec![
+ cx.spawn(Self::provide_search_paths(
+ std::mem::take(worktrees),
+ query.clone(),
+ input_paths_tx,
+ sorted_search_results_tx,
+ ))
+ .boxed_local(),
+ Self::open_buffers(
+ &self.buffer_store,
+ get_buffer_for_full_scan_rx,
+ grab_buffer_snapshot_tx,
+ cx.clone(),
+ )
+ .boxed_local(),
+ cx.background_spawn(Self::maintain_sorted_search_results(
+ sorted_search_results_rx,
+ get_buffer_for_full_scan_tx,
+ self.limit,
+ ))
+ .boxed_local(),
+ ];
+ (
+ FindSearchCandidates::Local {
+ fs,
+ confirm_contents_will_match_tx,
+ confirm_contents_will_match_rx,
+ input_paths_rx,
+ },
+ tasks,
+ )
+ }
+ SearchKind::Remote {
+ client,
+ remote_id,
+ models,
+ } => {
+ let request = client.request(proto::FindSearchCandidates {
+ project_id: remote_id,
+ query: Some(query.to_proto()),
+ limit: self.limit as _,
+ });
+ let Ok(guard) = cx.update(|cx| {
+ Project::retain_remotely_created_models_impl(
+ &models,
+ &self.buffer_store,
+ &self.worktree_store,
+ cx,
+ )
+ }) else {
+ return;
+ };
+ let buffer_store = self.buffer_store.downgrade();
+ let issue_remote_buffers_request = cx
+ .spawn(async move |cx| {
+ let _ = maybe!(async move {
+ let response = request.await?;
+ for buffer_id in response.buffer_ids {
+ let buffer_id = BufferId::new(buffer_id)?;
+ let buffer = buffer_store
+ .update(cx, |buffer_store, cx| {
+ buffer_store.wait_for_remote_buffer(buffer_id, cx)
+ })?
+ .await?;
+ let _ = grab_buffer_snapshot_tx.send(buffer).await;
+ }
+
+ drop(guard);
+ anyhow::Ok(())
+ })
+ .await
+ .log_err();
+ })
+ .boxed_local();
+ (
+ FindSearchCandidates::Remote,
+ vec![issue_remote_buffers_request],
+ )
+ }
+ };
+
+ let should_find_all_matches = !tx.is_closed();
+
+ let worker_pool = executor.scoped(|scope| {
+ let num_cpus = executor.num_cpus();
+
+ assert!(num_cpus > 0);
+ for _ in 0..executor.num_cpus() - 1 {
+ let worker = Worker {
+ query: &query,
+ open_buffers: &open_buffers,
+ candidates: candidate_searcher.clone(),
+ find_all_matches_rx: find_all_matches_rx.clone(),
+ };
+ scope.spawn(worker.run());
+ }
+
+ drop(find_all_matches_rx);
+ drop(candidate_searcher);
+ });
+
+ let (sorted_matches_tx, sorted_matches_rx) = unbounded();
+ // The caller of `into_handle` decides whether they're interested in all matches (files that matched + all matching ranges) or
+ // just the files. *They are using the same stream as the guts of the project search do*.
+ // This means that we cannot grab values off of that stream unless it's strictly needed for making a progress in project search.
+ //
+ // Grabbing buffer snapshots is only necessary when we're looking for all matches. If the caller decided that they're not interested
+ // in all matches, running that task unconditionally would hinder caller's ability to observe all matching file paths.
+ let buffer_snapshots = if should_find_all_matches {
+ Some(
+ Self::grab_buffer_snapshots(
+ grab_buffer_snapshot_rx,
+ find_all_matches_tx,
+ sorted_matches_tx,
+ cx.clone(),
+ )
+ .boxed_local(),
+ )
+ } else {
+ drop(find_all_matches_tx);
+
+ None
+ };
+ let ensure_matches_are_reported_in_order = if should_find_all_matches {
+ Some(
+ Self::ensure_matched_ranges_are_reported_in_order(sorted_matches_rx, tx)
+ .boxed_local(),
+ )
+ } else {
+ drop(tx);
+ None
+ };
+
+ futures::future::join_all(
+ [worker_pool.boxed_local()]
+ .into_iter()
+ .chain(buffer_snapshots)
+ .chain(ensure_matches_are_reported_in_order)
+ .chain(tasks),
+ )
+ .await;
+ })
+ });
+
+ SearchResultsHandle {
+ results: rx,
+ matching_buffers,
+ trigger_search,
+ }
+ }
+
+ fn provide_search_paths(
+ worktrees: Vec<Entity<Worktree>>,
+ query: Arc<SearchQuery>,
+ tx: Sender<InputPath>,
+ results: Sender<oneshot::Receiver<ProjectPath>>,
+ ) -> impl AsyncFnOnce(&mut AsyncApp) {
+ async move |cx| {
+ _ = maybe!(async move {
+ let gitignored_tracker = PathInclusionMatcher::new(query.clone());
+ for worktree in worktrees {
+ let (mut snapshot, worktree_settings) = worktree
+ .read_with(cx, |this, _| {
+ Some((this.snapshot(), this.as_local()?.settings()))
+ })?
+ .context("The worktree is not local")?;
+ if query.include_ignored() {
+ // Pre-fetch all of the ignored directories as they're going to be searched.
+ let mut entries_to_refresh = vec![];
+
+ for entry in snapshot.entries(query.include_ignored(), 0) {
+ if gitignored_tracker.should_scan_gitignored_dir(
+ entry,
+ &snapshot,
+ &worktree_settings,
+ ) {
+ entries_to_refresh.push(entry.path.clone());
+ }
+ }
+ let barrier = worktree.update(cx, |this, _| {
+ let local = this.as_local_mut()?;
+ let barrier = entries_to_refresh
+ .into_iter()
+ .map(|path| local.add_path_prefix_to_scan(path).into_future())
+ .collect::<Vec<_>>();
+ Some(barrier)
+ })?;
+ if let Some(barriers) = barrier {
+ futures::future::join_all(barriers).await;
+ }
+ snapshot = worktree.read_with(cx, |this, _| this.snapshot())?;
+ }
+ cx.background_executor()
+ .scoped(|scope| {
+ scope.spawn(async {
+ for entry in snapshot.files(query.include_ignored(), 0) {
+ let (should_scan_tx, should_scan_rx) = oneshot::channel();
+
+ let Ok(_) = tx
+ .send(InputPath {
+ entry: entry.clone(),
+ snapshot: snapshot.clone(),
+ should_scan_tx,
+ })
+ .await
+ else {
+ return;
+ };
+ if results.send(should_scan_rx).await.is_err() {
+ return;
+ };
+ }
+ })
+ })
+ .await;
+ }
+ anyhow::Ok(())
+ })
+ .await;
+ }
+ }
+
+ async fn maintain_sorted_search_results(
+ rx: Receiver<oneshot::Receiver<ProjectPath>>,
+ paths_for_full_scan: Sender<ProjectPath>,
+ limit: usize,
+ ) {
+ let mut rx = pin!(rx);
+ let mut matched = 0;
+ while let Some(mut next_path_result) = rx.next().await {
+ let Some(successful_path) = next_path_result.next().await else {
+ // This file did not produce a match, hence skip it.
+ continue;
+ };
+ if paths_for_full_scan.send(successful_path).await.is_err() {
+ return;
+ };
+ matched += 1;
+ if matched >= limit {
+ break;
+ }
+ }
+ }
+
+ /// Background workers cannot open buffers by themselves, hence main thread will do it on their behalf.
+ async fn open_buffers(
+ buffer_store: &Entity<BufferStore>,
+ rx: Receiver<ProjectPath>,
+ find_all_matches_tx: Sender<Entity<Buffer>>,
+ mut cx: AsyncApp,
+ ) {
+ let mut rx = pin!(rx.ready_chunks(64));
+ _ = maybe!(async move {
+ while let Some(requested_paths) = rx.next().await {
+ let mut buffers = buffer_store.update(&mut cx, |this, cx| {
+ requested_paths
+ .into_iter()
+ .map(|path| this.open_buffer(path, cx))
+ .collect::<FuturesOrdered<_>>()
+ })?;
+
+ while let Some(buffer) = buffers.next().await {
+ if let Some(buffer) = buffer.log_err() {
+ find_all_matches_tx.send(buffer).await?;
+ }
+ }
+ }
+ Result::<_, anyhow::Error>::Ok(())
+ })
+ .await;
+ }
+
+ async fn grab_buffer_snapshots(
+ rx: Receiver<Entity<Buffer>>,
+ find_all_matches_tx: Sender<(
+ Entity<Buffer>,
+ BufferSnapshot,
+ oneshot::Sender<(Entity<Buffer>, Vec<Range<language::Anchor>>)>,
+ )>,
+ results: Sender<oneshot::Receiver<(Entity<Buffer>, Vec<Range<language::Anchor>>)>>,
+ mut cx: AsyncApp,
+ ) {
+ _ = maybe!(async move {
+ while let Ok(buffer) = rx.recv().await {
+ let snapshot = buffer.read_with(&mut cx, |this, _| this.snapshot())?;
+ let (tx, rx) = oneshot::channel();
+ find_all_matches_tx.send((buffer, snapshot, tx)).await?;
+ results.send(rx).await?;
+ }
+ debug_assert!(rx.is_empty());
+ Result::<_, anyhow::Error>::Ok(())
+ })
+ .await;
+ }
+
+ async fn ensure_matched_ranges_are_reported_in_order(
+ rx: Receiver<oneshot::Receiver<(Entity<Buffer>, Vec<Range<language::Anchor>>)>>,
+ tx: Sender<SearchResult>,
+ ) {
+ use postage::stream::Stream;
+ _ = maybe!(async move {
+ let mut matched_buffers = 0;
+ let mut matches = 0;
+ while let Ok(mut next_buffer_matches) = rx.recv().await {
+ let Some((buffer, ranges)) = next_buffer_matches.recv().await else {
+ continue;
+ };
+
+ if matched_buffers > Search::MAX_SEARCH_RESULT_FILES
+ || matches > Search::MAX_SEARCH_RESULT_RANGES
+ {
+ _ = tx.send(SearchResult::LimitReached).await;
+ break;
+ }
+ matched_buffers += 1;
+ matches += ranges.len();
+
+ _ = tx.send(SearchResult::Buffer { buffer, ranges }).await?;
+ }
+ anyhow::Ok(())
+ })
+ .await;
+ }
+
+ fn all_loaded_buffers(&self, search_query: &SearchQuery, cx: &App) -> Vec<Entity<Buffer>> {
+ let worktree_store = self.worktree_store.read(cx);
+ let mut buffers = search_query
+ .buffers()
+ .into_iter()
+ .flatten()
+ .filter(|buffer| {
+ let b = buffer.read(cx);
+ if let Some(file) = b.file() {
+ if !search_query.match_path(file.path()) {
+ return false;
+ }
+ if !search_query.include_ignored()
+ && let Some(entry) = b
+ .entry_id(cx)
+ .and_then(|entry_id| worktree_store.entry_for_id(entry_id, cx))
+ && entry.is_ignored
+ {
+ return false;
+ }
+ }
+ true
+ })
+ .cloned()
+ .collect::<Vec<_>>();
+ buffers.sort_by(|a, b| {
+ let a = a.read(cx);
+ let b = b.read(cx);
+ match (a.file(), b.file()) {
+ (None, None) => a.remote_id().cmp(&b.remote_id()),
+ (None, Some(_)) => std::cmp::Ordering::Less,
+ (Some(_), None) => std::cmp::Ordering::Greater,
+ (Some(a), Some(b)) => compare_rel_paths((a.path(), true), (b.path(), true)),
+ }
+ });
+
+ buffers
+ }
+}
+
+struct Worker<'search> {
+ query: &'search SearchQuery,
+ open_buffers: &'search HashSet<ProjectEntryId>,
+ candidates: FindSearchCandidates,
+ /// Ok, we're back in background: run full scan & find all matches in a given buffer snapshot.
+ /// Then, when you're done, share them via the channel you were given.
+ find_all_matches_rx: Receiver<(
+ Entity<Buffer>,
+ BufferSnapshot,
+ oneshot::Sender<(Entity<Buffer>, Vec<Range<language::Anchor>>)>,
+ )>,
+}
+
+impl Worker<'_> {
+ async fn run(self) {
+ let (
+ input_paths_rx,
+ confirm_contents_will_match_rx,
+ mut confirm_contents_will_match_tx,
+ fs,
+ ) = match self.candidates {
+ FindSearchCandidates::Local {
+ fs,
+ input_paths_rx,
+ confirm_contents_will_match_rx,
+ confirm_contents_will_match_tx,
+ } => (
+ input_paths_rx,
+ confirm_contents_will_match_rx,
+ confirm_contents_will_match_tx,
+ Some(fs),
+ ),
+ FindSearchCandidates::Remote | FindSearchCandidates::OpenBuffersOnly => {
+ (unbounded().1, unbounded().1, unbounded().0, None)
+ }
+ };
+ // WorkerA: grabs a request for "find all matches in file/a" <- takes 5 minutes
+ // right after: WorkerB: grabs a request for "find all matches in file/b" <- takes 5 seconds
+ let mut find_all_matches = pin!(self.find_all_matches_rx.fuse());
+ let mut find_first_match = pin!(confirm_contents_will_match_rx.fuse());
+ let mut scan_path = pin!(input_paths_rx.fuse());
+
+ loop {
+ let handler = RequestHandler {
+ query: self.query,
+ open_entries: &self.open_buffers,
+ fs: fs.as_deref(),
+ confirm_contents_will_match_tx: &confirm_contents_will_match_tx,
+ };
+ // Whenever we notice that some step of a pipeline is closed, we don't want to close subsequent
+ // steps straight away. Another worker might be about to produce a value that will
+ // be pushed there, thus we'll replace current worker's pipe with a dummy one.
+ // That way, we'll only ever close a next-stage channel when ALL workers do so.
+ select_biased! {
+ find_all_matches = find_all_matches.next() => {
+ let Some(matches) = find_all_matches else {
+ continue;
+ };
+ handler.handle_find_all_matches(matches).await;
+ },
+ find_first_match = find_first_match.next() => {
+ if let Some(buffer_with_at_least_one_match) = find_first_match {
+ handler.handle_find_first_match(buffer_with_at_least_one_match).await;
+ }
+ },
+ scan_path = scan_path.next() => {
+ if let Some(path_to_scan) = scan_path {
+ handler.handle_scan_path(path_to_scan).await;
+ } else {
+ // If we're the last worker to notice that this is not producing values, close the upstream.
+ confirm_contents_will_match_tx = bounded(1).0;
+ }
+
+ }
+ complete => {
+ break
+ },
+
+ }
+ }
+ }
+}
+
+struct RequestHandler<'worker> {
+ query: &'worker SearchQuery,
+ fs: Option<&'worker dyn Fs>,
+ open_entries: &'worker HashSet<ProjectEntryId>,
+ confirm_contents_will_match_tx: &'worker Sender<MatchingEntry>,
+}
+
+impl RequestHandler<'_> {
+ async fn handle_find_all_matches(
+ &self,
+ (buffer, snapshot, mut report_matches): (
+ Entity<Buffer>,
+ BufferSnapshot,
+ oneshot::Sender<(Entity<Buffer>, Vec<Range<language::Anchor>>)>,
+ ),
+ ) {
+ let ranges = self
+ .query
+ .search(&snapshot, None)
+ .await
+ .iter()
+ .map(|range| snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end))
+ .collect::<Vec<_>>();
+
+ _ = report_matches.send((buffer, ranges)).await;
+ }
+
+ async fn handle_find_first_match(&self, mut entry: MatchingEntry) {
+ _=maybe!(async move {
+ let abs_path = entry.worktree_root.join(entry.path.path.as_std_path());
+ let Some(file) = self.fs.context("Trying to query filesystem in remote project search")?.open_sync(&abs_path).await.log_err() else {
+ return anyhow::Ok(());
+ };
+
+ let mut file = BufReader::new(file);
+ let file_start = file.fill_buf()?;
+
+ if let Err(Some(starting_position)) =
+ std::str::from_utf8(file_start).map_err(|e| e.error_len())
+ {
+ // Before attempting to match the file content, throw away files that have invalid UTF-8 sequences early on;
+ // That way we can still match files in a streaming fashion without having look at "obviously binary" files.
+ log::debug!(
+ "Invalid UTF-8 sequence in file {abs_path:?} at byte position {starting_position}"
+ );
+ return Ok(());
+ }
+
+ if self.query.detect(file).unwrap_or(false) {
+ // Yes, we should scan the whole file.
+ entry.should_scan_tx.send(entry.path).await?;
+ }
+ Ok(())
+ }).await;
+ }
+
+ async fn handle_scan_path(&self, req: InputPath) {
+ _ = maybe!(async move {
+ let InputPath {
+ entry,
+ snapshot,
+ mut should_scan_tx,
+ } = req;
+
+ if entry.is_fifo || !entry.is_file() {
+ return Ok(());
+ }
+
+ if self.query.filters_path() {
+ let matched_path = if self.query.match_full_paths() {
+ let mut full_path = snapshot.root_name().to_owned();
+ full_path.push(&entry.path);
+ self.query.match_path(&full_path)
+ } else {
+ self.query.match_path(&entry.path)
+ };
+ if !matched_path {
+ return Ok(());
+ }
+ }
+
+ if self.open_entries.contains(&entry.id) {
+ // The buffer is already in memory and that's the version we want to scan;
+ // hence skip the dilly-dally and look for all matches straight away.
+ should_scan_tx
+ .send(ProjectPath {
+ worktree_id: snapshot.id(),
+ path: entry.path.clone(),
+ })
+ .await?;
+ } else {
+ self.confirm_contents_will_match_tx
+ .send(MatchingEntry {
+ should_scan_tx: should_scan_tx,
+ worktree_root: snapshot.abs_path().clone(),
+ path: ProjectPath {
+ worktree_id: snapshot.id(),
+ path: entry.path.clone(),
+ },
+ })
+ .await?;
+ }
+
+ anyhow::Ok(())
+ })
+ .await;
+ }
+}
+
+struct InputPath {
+ entry: Entry,
+ snapshot: Snapshot,
+ should_scan_tx: oneshot::Sender<ProjectPath>,
+}
+
+struct MatchingEntry {
+ worktree_root: Arc<Path>,
+ path: ProjectPath,
+ should_scan_tx: oneshot::Sender<ProjectPath>,
+}
+
+/// This struct encapsulates the logic to decide whether a given gitignored directory should be
+/// scanned based on include/exclude patterns of a search query (as include/exclude parameters may match paths inside it).
+/// It is kind-of doing an inverse of glob. Given a glob pattern like `src/**/` and a parent path like `src`, we need to decide whether the parent
+/// may contain glob hits.
+struct PathInclusionMatcher {
+ included: BTreeSet<PathBuf>,
+ query: Arc<SearchQuery>,
+}
+
+impl PathInclusionMatcher {
+ fn new(query: Arc<SearchQuery>) -> Self {
+ let mut included = BTreeSet::new();
+ // To do an inverse glob match, we split each glob into it's prefix and the glob part.
+ // For example, `src/**/*.rs` becomes `src/` and `**/*.rs`. The glob part gets dropped.
+ // Then, when checking whether a given directory should be scanned, we check whether it is a non-empty substring of any glob prefix.
+ if query.filters_path() {
+ included.extend(
+ query
+ .files_to_include()
+ .sources()
+ .flat_map(|glob| Some(wax::Glob::new(glob).ok()?.partition().0)),
+ );
+ }
+ Self { included, query }
+ }
+
+ fn should_scan_gitignored_dir(
+ &self,
+ entry: &Entry,
+ snapshot: &Snapshot,
+ worktree_settings: &WorktreeSettings,
+ ) -> bool {
+ if !entry.is_ignored || !entry.kind.is_unloaded() {
+ return false;
+ }
+ if !self.query.include_ignored() {
+ return false;
+ }
+ if worktree_settings.is_path_excluded(&entry.path) {
+ return false;
+ }
+ if !self.query.filters_path() {
+ return true;
+ }
+
+ let as_abs_path = LazyCell::new(move || snapshot.absolutize(&entry.path));
+ let entry_path = &entry.path;
+ // 3. Check Exclusions (Pruning)
+ // If the current path is a child of an excluded path, we stop.
+ let is_excluded = self.path_is_definitely_excluded(&entry_path, snapshot);
+
+ if is_excluded {
+ return false;
+ }
+
+ // 4. Check Inclusions (Traversal)
+ if self.included.is_empty() {
+ return true;
+ }
+
+ // We scan if the current path is a descendant of an include prefix
+ // OR if the current path is an ancestor of an include prefix (we need to go deeper to find it).
+ let is_included = self.included.iter().any(|prefix| {
+ let (prefix_matches_entry, entry_matches_prefix) = if prefix.is_absolute() {
+ (
+ prefix.starts_with(&**as_abs_path),
+ as_abs_path.starts_with(prefix),
+ )
+ } else {
+ RelPath::new(prefix, snapshot.path_style()).map_or((false, false), |prefix| {
+ (
+ prefix.starts_with(entry_path),
+ entry_path.starts_with(&prefix),
+ )
+ })
+ };
+
+ // Logic:
+ // 1. entry_matches_prefix: We are inside the target zone (e.g. glob: src/, current: src/lib/). Keep scanning.
+ // 2. prefix_matches_entry: We are above the target zone (e.g. glob: src/foo/, current: src/). Keep scanning to reach foo.
+ prefix_matches_entry || entry_matches_prefix
+ });
+
+ is_included
+ }
+ fn path_is_definitely_excluded(&self, path: &RelPath, snapshot: &Snapshot) -> bool {
+ if !self.query.files_to_exclude().sources().next().is_none() {
+ let mut path = if self.query.match_full_paths() {
+ let mut full_path = snapshot.root_name().to_owned();
+ full_path.push(path);
+ full_path
+ } else {
+ path.to_owned()
+ };
+ loop {
+ if self.query.files_to_exclude().is_match(&path) {
+ return true;
+ } else if !path.pop() {
+ return false;
+ }
+ }
+ } else {
+ false
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fs::FakeFs;
+ use serde_json::json;
+ use settings::Settings;
+ use util::{
+ path,
+ paths::{PathMatcher, PathStyle},
+ rel_path::RelPath,
+ };
+ use worktree::{Entry, EntryKind, WorktreeSettings};
+
+ use crate::{
+ Project, project_search::PathInclusionMatcher, project_tests::init_test,
+ search::SearchQuery,
+ };
+
+ #[gpui::test]
+ async fn test_path_inclusion_matcher(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(
+ "/root",
+ json!({
+ ".gitignore": "src/data/\n",
+ "src": {
+ "data": {
+ "main.csv": "field_1,field_2,field_3",
+ },
+ "lib": {
+ "main.txt": "Are you familiar with fields?",
+ },
+ },
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
+ let (worktree_settings, worktree_snapshot) = worktree.update(cx, |worktree, cx| {
+ let settings_location = worktree.settings_location(cx);
+ return (
+ WorktreeSettings::get(Some(settings_location), cx).clone(),
+ worktree.snapshot(),
+ );
+ });
+
+ // Manually create a test entry for the gitignored directory since it won't
+ // be loaded by the worktree
+ let entry = Entry {
+ id: ProjectEntryId::from_proto(1),
+ kind: EntryKind::UnloadedDir,
+ path: Arc::from(RelPath::unix(Path::new("src/data")).unwrap()),
+ inode: 0,
+ mtime: None,
+ canonical_path: None,
+ is_ignored: true,
+ is_hidden: false,
+ is_always_included: false,
+ is_external: false,
+ is_private: false,
+ size: 0,
+ char_bag: Default::default(),
+ is_fifo: false,
+ };
+
+ // 1. Test searching for `field`, including ignored files without any
+ // inclusion and exclusion filters.
+ let include_ignored = true;
+ let files_to_include = PathMatcher::default();
+ let files_to_exclude = PathMatcher::default();
+ let match_full_paths = false;
+ let search_query = SearchQuery::text(
+ "field",
+ false,
+ false,
+ include_ignored,
+ files_to_include,
+ files_to_exclude,
+ match_full_paths,
+ None,
+ )
+ .unwrap();
+
+ let path_matcher = PathInclusionMatcher::new(Arc::new(search_query));
+ assert!(path_matcher.should_scan_gitignored_dir(
+ &entry,
+ &worktree_snapshot,
+ &worktree_settings
+ ));
+
+ // 2. Test searching for `field`, including ignored files but updating
+ // `files_to_include` to only include files under `src/lib`.
+ let include_ignored = true;
+ let files_to_include = PathMatcher::new(vec!["src/lib"], PathStyle::Posix).unwrap();
+ let files_to_exclude = PathMatcher::default();
+ let match_full_paths = false;
+ let search_query = SearchQuery::text(
+ "field",
+ false,
+ false,
+ include_ignored,
+ files_to_include,
+ files_to_exclude,
+ match_full_paths,
+ None,
+ )
+ .unwrap();
+
+ let path_matcher = PathInclusionMatcher::new(Arc::new(search_query));
+ assert!(!path_matcher.should_scan_gitignored_dir(
+ &entry,
+ &worktree_snapshot,
+ &worktree_settings
+ ));
+ }
+}
@@ -23,13 +23,14 @@ use settings::{
DapSettingsContent, InvalidSettingsError, LocalSettingsKind, RegisterSetting, Settings,
SettingsLocation, SettingsStore, parse_json_with_comments, watch_config_file,
};
-use std::{path::PathBuf, sync::Arc, time::Duration};
+use std::{cell::OnceCell, collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration};
use task::{DebugTaskFile, TaskTemplates, VsCodeDebugTaskFile, VsCodeTaskFile};
use util::{ResultExt, rel_path::RelPath, serde::default_true};
use worktree::{PathChange, UpdatedEntriesSet, Worktree, WorktreeId};
use crate::{
task_store::{TaskSettingsLocation, TaskStore},
+ trusted_worktrees::{PathTrust, TrustedWorktrees, TrustedWorktreesEvent},
worktree_store::{WorktreeStore, WorktreeStoreEvent},
};
@@ -83,6 +84,12 @@ pub struct SessionSettings {
///
/// Default: true
pub restore_unsaved_buffers: bool,
+ /// Whether or not to skip worktree trust checks.
+ /// When trusted, project settings are synchronized automatically,
+ /// language and MCP servers are downloaded and started automatically.
+ ///
+ /// Default: false
+ pub trust_all_worktrees: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
@@ -117,7 +124,7 @@ pub struct GlobalLspSettings {
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
#[serde(tag = "source", rename_all = "snake_case")]
pub enum ContextServerSettings {
- Custom {
+ Stdio {
/// Whether the context server is enabled.
#[serde(default = "default_true")]
enabled: bool,
@@ -125,6 +132,16 @@ pub enum ContextServerSettings {
#[serde(flatten)]
command: ContextServerCommand,
},
+ Http {
+ /// Whether the context server is enabled.
+ #[serde(default = "default_true")]
+ enabled: bool,
+ /// The URL of the remote context server.
+ url: String,
+ /// Optional authentication configuration for the remote server.
+ #[serde(skip_serializing_if = "HashMap::is_empty", default)]
+ headers: HashMap<String, String>,
+ },
Extension {
/// Whether the context server is enabled.
#[serde(default = "default_true")]
@@ -140,24 +157,42 @@ pub enum ContextServerSettings {
impl From<settings::ContextServerSettingsContent> for ContextServerSettings {
fn from(value: settings::ContextServerSettingsContent) -> Self {
match value {
- settings::ContextServerSettingsContent::Custom { enabled, command } => {
- ContextServerSettings::Custom { enabled, command }
+ settings::ContextServerSettingsContent::Stdio { enabled, command } => {
+ ContextServerSettings::Stdio { enabled, command }
}
settings::ContextServerSettingsContent::Extension { enabled, settings } => {
ContextServerSettings::Extension { enabled, settings }
}
+ settings::ContextServerSettingsContent::Http {
+ enabled,
+ url,
+ headers,
+ } => ContextServerSettings::Http {
+ enabled,
+ url,
+ headers,
+ },
}
}
}
impl Into<settings::ContextServerSettingsContent> for ContextServerSettings {
fn into(self) -> settings::ContextServerSettingsContent {
match self {
- ContextServerSettings::Custom { enabled, command } => {
- settings::ContextServerSettingsContent::Custom { enabled, command }
+ ContextServerSettings::Stdio { enabled, command } => {
+ settings::ContextServerSettingsContent::Stdio { enabled, command }
}
ContextServerSettings::Extension { enabled, settings } => {
settings::ContextServerSettingsContent::Extension { enabled, settings }
}
+ ContextServerSettings::Http {
+ enabled,
+ url,
+ headers,
+ } => settings::ContextServerSettingsContent::Http {
+ enabled,
+ url,
+ headers,
+ },
}
}
}
@@ -172,14 +207,16 @@ impl ContextServerSettings {
pub fn enabled(&self) -> bool {
match self {
- ContextServerSettings::Custom { enabled, .. } => *enabled,
+ ContextServerSettings::Stdio { enabled, .. } => *enabled,
+ ContextServerSettings::Http { enabled, .. } => *enabled,
ContextServerSettings::Extension { enabled, .. } => *enabled,
}
}
pub fn set_enabled(&mut self, enabled: bool) {
match self {
- ContextServerSettings::Custom { enabled: e, .. } => *e = enabled,
+ ContextServerSettings::Stdio { enabled: e, .. } => *e = enabled,
+ ContextServerSettings::Http { enabled: e, .. } => *e = enabled,
ContextServerSettings::Extension { enabled: e, .. } => *e = enabled,
}
}
@@ -318,6 +355,26 @@ pub struct GitSettings {
///
/// Default: staged_hollow
pub hunk_style: settings::GitHunkStyleSetting,
+ /// How file paths are displayed in the git gutter.
+ ///
+ /// Default: file_name_first
+ pub path_style: GitPathStyle,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Default)]
+pub enum GitPathStyle {
+ #[default]
+ FileNameFirst,
+ FilePathFirst,
+}
+
+impl From<settings::GitPathStyle> for GitPathStyle {
+ fn from(style: settings::GitPathStyle) -> Self {
+ match style {
+ settings::GitPathStyle::FileNameFirst => GitPathStyle::FileNameFirst,
+ settings::GitPathStyle::FilePathFirst => GitPathStyle::FilePathFirst,
+ }
+ }
}
#[derive(Clone, Copy, Debug)]
@@ -471,6 +528,7 @@ impl Settings for ProjectSettings {
}
},
hunk_style: git.hunk_style.unwrap(),
+ path_style: git.path_style.unwrap().into(),
};
Self {
context_servers: project
@@ -519,6 +577,7 @@ impl Settings for ProjectSettings {
load_direnv: project.load_direnv.clone().unwrap(),
session: SessionSettings {
restore_unsaved_buffers: content.session.unwrap().restore_unsaved_buffers.unwrap(),
+ trust_all_worktrees: content.session.unwrap().trust_all_worktrees.unwrap(),
},
}
}
@@ -544,6 +603,9 @@ pub struct SettingsObserver {
worktree_store: Entity<WorktreeStore>,
project_id: u64,
task_store: Entity<TaskStore>,
+ pending_local_settings:
+ HashMap<PathTrust, BTreeMap<(WorktreeId, Arc<RelPath>), Option<String>>>,
+ _trusted_worktrees_watcher: Option<Subscription>,
_user_settings_watcher: Option<Subscription>,
_global_task_config_watcher: Task<()>,
_global_debug_config_watcher: Task<()>,
@@ -569,11 +631,61 @@ impl SettingsObserver {
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
+ let _trusted_worktrees_watcher =
+ TrustedWorktrees::try_get_global(cx).map(|trusted_worktrees| {
+ cx.subscribe(
+ &trusted_worktrees,
+ move |settings_observer, _, e, cx| match e {
+ TrustedWorktreesEvent::Trusted(_, trusted_paths) => {
+ for trusted_path in trusted_paths {
+ if let Some(pending_local_settings) = settings_observer
+ .pending_local_settings
+ .remove(trusted_path)
+ {
+ for ((worktree_id, directory_path), settings_contents) in
+ pending_local_settings
+ {
+ apply_local_settings(
+ worktree_id,
+ &directory_path,
+ LocalSettingsKind::Settings,
+ &settings_contents,
+ cx,
+ );
+ if let Some(downstream_client) =
+ &settings_observer.downstream_client
+ {
+ downstream_client
+ .send(proto::UpdateWorktreeSettings {
+ project_id: settings_observer.project_id,
+ worktree_id: worktree_id.to_proto(),
+ path: directory_path.to_proto(),
+ content: settings_contents,
+ kind: Some(
+ local_settings_kind_to_proto(
+ LocalSettingsKind::Settings,
+ )
+ .into(),
+ ),
+ })
+ .log_err();
+ }
+ }
+ }
+ }
+ }
+ TrustedWorktreesEvent::Restricted(..) => {}
+ },
+ )
+ });
+
Self {
worktree_store,
task_store,
mode: SettingsObserverMode::Local(fs.clone()),
downstream_client: None,
+ _trusted_worktrees_watcher,
+ pending_local_settings: HashMap::default(),
_user_settings_watcher: None,
project_id: REMOTE_SERVER_PROJECT_ID,
_global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
@@ -626,6 +738,8 @@ impl SettingsObserver {
mode: SettingsObserverMode::Remote,
downstream_client: None,
project_id: REMOTE_SERVER_PROJECT_ID,
+ _trusted_worktrees_watcher: None,
+ pending_local_settings: HashMap::default(),
_user_settings_watcher: user_settings_watcher,
_global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
fs.clone(),
@@ -741,13 +855,20 @@ impl SettingsObserver {
event: &WorktreeStoreEvent,
cx: &mut Context<Self>,
) {
- if let WorktreeStoreEvent::WorktreeAdded(worktree) = event {
- cx.subscribe(worktree, |this, worktree, event, cx| {
- if let worktree::Event::UpdatedEntries(changes) = event {
- this.update_local_worktree_settings(&worktree, changes, cx)
- }
- })
- .detach()
+ match event {
+ WorktreeStoreEvent::WorktreeAdded(worktree) => cx
+ .subscribe(worktree, |this, worktree, event, cx| {
+ if let worktree::Event::UpdatedEntries(changes) = event {
+ this.update_local_worktree_settings(&worktree, changes, cx)
+ }
+ })
+ .detach(),
+ WorktreeStoreEvent::WorktreeRemoved(_, worktree_id) => {
+ cx.update_global::<SettingsStore, _>(|store, cx| {
+ store.clear_local_settings(*worktree_id, cx).log_err();
+ });
+ }
+ _ => {}
}
}
@@ -917,36 +1038,32 @@ impl SettingsObserver {
let worktree_id = worktree.read(cx).id();
let remote_worktree_id = worktree.read(cx).id();
let task_store = self.task_store.clone();
-
+ let can_trust_worktree = OnceCell::new();
for (directory, kind, file_content) in settings_contents {
+ let mut applied = true;
match kind {
- LocalSettingsKind::Settings | LocalSettingsKind::Editorconfig => cx
- .update_global::<SettingsStore, _>(|store, cx| {
- let result = store.set_local_settings(
- worktree_id,
- directory.clone(),
- kind,
- file_content.as_deref(),
- cx,
- );
-
- match result {
- Err(InvalidSettingsError::LocalSettings { path, message }) => {
- log::error!("Failed to set local settings in {path:?}: {message}");
- cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Err(
- InvalidSettingsError::LocalSettings { path, message },
- )));
- }
- Err(e) => {
- log::error!("Failed to set local settings: {e}");
- }
- Ok(()) => {
- cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(directory
- .as_std_path()
- .join(local_settings_file_relative_path().as_std_path()))));
- }
+ LocalSettingsKind::Settings => {
+ if *can_trust_worktree.get_or_init(|| {
+ if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+ trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+ trusted_worktrees.can_trust(worktree_id, cx)
+ })
+ } else {
+ true
}
- }),
+ }) {
+ apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
+ } else {
+ applied = false;
+ self.pending_local_settings
+ .entry(PathTrust::Worktree(worktree_id))
+ .or_default()
+ .insert((worktree_id, directory.clone()), file_content.clone());
+ }
+ }
+ LocalSettingsKind::Editorconfig => {
+ apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
+ }
LocalSettingsKind::Tasks => {
let result = task_store.update(cx, |task_store, cx| {
task_store.update_user_tasks(
@@ -1009,16 +1126,18 @@ impl SettingsObserver {
}
};
- if let Some(downstream_client) = &self.downstream_client {
- downstream_client
- .send(proto::UpdateWorktreeSettings {
- project_id: self.project_id,
- worktree_id: remote_worktree_id.to_proto(),
- path: directory.to_proto(),
- content: file_content.clone(),
- kind: Some(local_settings_kind_to_proto(kind).into()),
- })
- .log_err();
+ if applied {
+ if let Some(downstream_client) = &self.downstream_client {
+ downstream_client
+ .send(proto::UpdateWorktreeSettings {
+ project_id: self.project_id,
+ worktree_id: remote_worktree_id.to_proto(),
+ path: directory.to_proto(),
+ content: file_content.clone(),
+ kind: Some(local_settings_kind_to_proto(kind).into()),
+ })
+ .log_err();
+ }
}
}
}
@@ -1135,6 +1254,37 @@ impl SettingsObserver {
}
}
+fn apply_local_settings(
+ worktree_id: WorktreeId,
+ directory: &Arc<RelPath>,
+ kind: LocalSettingsKind,
+ file_content: &Option<String>,
+ cx: &mut Context<'_, SettingsObserver>,
+) {
+ cx.update_global::<SettingsStore, _>(|store, cx| {
+ let result = store.set_local_settings(
+ worktree_id,
+ directory.clone(),
+ kind,
+ file_content.as_deref(),
+ cx,
+ );
+
+ match result {
+ Err(InvalidSettingsError::LocalSettings { path, message }) => {
+ log::error!("Failed to set local settings in {path:?}: {message}");
+ cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Err(
+ InvalidSettingsError::LocalSettings { path, message },
+ )));
+ }
+ Err(e) => log::error!("Failed to set local settings: {e}"),
+ Ok(()) => cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(directory
+ .as_std_path()
+ .join(local_settings_file_relative_path().as_std_path())))),
+ }
+ })
+}
+
pub fn local_settings_kind_from_proto(kind: proto::LocalSettingsKind) -> LocalSettingsKind {
match kind {
proto::LocalSettingsKind::Settings => LocalSettingsKind::Settings,
@@ -20,7 +20,7 @@ use git::{
status::{StatusCode, TrackedStatus},
};
use git2::RepositoryInitOptions;
-use gpui::{App, BackgroundExecutor, FutureExt, SemanticVersion, UpdateGlobal};
+use gpui::{App, BackgroundExecutor, FutureExt, UpdateGlobal};
use itertools::Itertools;
use language::{
Diagnostic, DiagnosticEntry, DiagnosticEntryRef, DiagnosticSet, DiagnosticSourceKind,
@@ -28,7 +28,7 @@ use language::{
ManifestName, ManifestProvider, ManifestQuery, OffsetRangeExt, Point, ToPoint, ToolchainList,
ToolchainLister,
language_settings::{LanguageSettingsContent, language_settings},
- tree_sitter_rust, tree_sitter_typescript,
+ rust_lang, tree_sitter_typescript,
};
use lsp::{
DiagnosticSeverity, DocumentChanges, FileOperationFilter, NumberOrString, TextDocumentEdit,
@@ -691,7 +691,7 @@ async fn test_running_multiple_instances_of_a_single_server_in_one_worktree(
let servers = project.update(cx, |project, cx| {
project.lsp_store.update(cx, |this, cx| {
first_buffer.update(cx, |buffer, cx| {
- this.language_servers_for_local_buffer(buffer, cx)
+ this.running_language_servers_for_local_buffer(buffer, cx)
.map(|(adapter, server)| (adapter.clone(), server.clone()))
.collect::<Vec<_>>()
})
@@ -720,7 +720,7 @@ async fn test_running_multiple_instances_of_a_single_server_in_one_worktree(
let servers = project.update(cx, |project, cx| {
project.lsp_store.update(cx, |this, cx| {
second_project_buffer.update(cx, |buffer, cx| {
- this.language_servers_for_local_buffer(buffer, cx)
+ this.running_language_servers_for_local_buffer(buffer, cx)
.map(|(adapter, server)| (adapter.clone(), server.clone()))
.collect::<Vec<_>>()
})
@@ -746,7 +746,7 @@ async fn test_running_multiple_instances_of_a_single_server_in_one_worktree(
worktree_id,
path: rel_path("project-b/source_file.py").into(),
},
- LanguageName::new("Python"),
+ LanguageName::new_static("Python"),
cx,
)
})
@@ -762,7 +762,7 @@ async fn test_running_multiple_instances_of_a_single_server_in_one_worktree(
worktree_id,
path: rel_path("project-b/source_file.py").into(),
},
- LanguageName::new("Python"),
+ LanguageName::new_static("Python"),
cx,
)
})
@@ -791,7 +791,7 @@ async fn test_running_multiple_instances_of_a_single_server_in_one_worktree(
let servers = project.update(cx, |project, cx| {
project.lsp_store.update(cx, |this, cx| {
second_project_buffer.update(cx, |buffer, cx| {
- this.language_servers_for_local_buffer(buffer, cx)
+ this.running_language_servers_for_local_buffer(buffer, cx)
.map(|(adapter, server)| (adapter.clone(), server.clone()))
.collect::<Vec<_>>()
})
@@ -1215,13 +1215,20 @@ async fn test_language_server_relative_path(cx: &mut gpui::TestAppContext) {
let settings_json_contents = json!({
"languages": {
"Rust": {
- "language_servers": ["my_fake_lsp"]
+ "language_servers": ["my_fake_lsp", "lsp_on_path"]
}
},
"lsp": {
"my_fake_lsp": {
"binary": {
- "path": path!("relative_path/to/my_fake_lsp_binary.exe").to_string(),
+ // file exists, so this is treated as a relative path
+ "path": path!(".relative_path/to/my_fake_lsp_binary.exe").to_string(),
+ }
+ },
+ "lsp_on_path": {
+ "binary": {
+ // file doesn't exist, so it will fall back on PATH env var
+ "path": path!("lsp_on_path.exe").to_string(),
}
}
},
@@ -1234,7 +1241,7 @@ async fn test_language_server_relative_path(cx: &mut gpui::TestAppContext) {
".zed": {
"settings.json": settings_json_contents.to_string(),
},
- "relative_path": {
+ ".relative_path": {
"to": {
"my_fake_lsp.exe": "",
},
@@ -1250,13 +1257,20 @@ async fn test_language_server_relative_path(cx: &mut gpui::TestAppContext) {
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
- let mut fake_rust_servers = language_registry.register_fake_lsp(
+ let mut my_fake_lsp = language_registry.register_fake_lsp(
"Rust",
FakeLspAdapter {
name: "my_fake_lsp",
..Default::default()
},
);
+ let mut lsp_on_path = language_registry.register_fake_lsp(
+ "Rust",
+ FakeLspAdapter {
+ name: "lsp_on_path",
+ ..Default::default()
+ },
+ );
cx.run_until_parked();
@@ -1268,10 +1282,74 @@ async fn test_language_server_relative_path(cx: &mut gpui::TestAppContext) {
.await
.unwrap();
- let lsp_path = fake_rust_servers.next().await.unwrap().binary.path;
+ let lsp_path = my_fake_lsp.next().await.unwrap().binary.path;
assert_eq!(
lsp_path.to_string_lossy(),
- path!("/the-root/relative_path/to/my_fake_lsp_binary.exe"),
+ path!("/the-root/.relative_path/to/my_fake_lsp_binary.exe"),
+ );
+
+ let lsp_path = lsp_on_path.next().await.unwrap().binary.path;
+ assert_eq!(lsp_path.to_string_lossy(), path!("lsp_on_path.exe"));
+}
+
+#[gpui::test]
+async fn test_language_server_tilde_path(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ let settings_json_contents = json!({
+ "languages": {
+ "Rust": {
+ "language_servers": ["tilde_lsp"]
+ }
+ },
+ "lsp": {
+ "tilde_lsp": {
+ "binary": {
+ "path": "~/.local/bin/rust-analyzer",
+ }
+ }
+ },
+ });
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ json!({
+ ".zed": {
+ "settings.json": settings_json_contents.to_string(),
+ },
+ "src": {
+ "main.rs": "fn main() {}",
+ }
+ }),
+ )
+ .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(rust_lang());
+
+ let mut tilde_lsp = language_registry.register_fake_lsp(
+ "Rust",
+ FakeLspAdapter {
+ name: "tilde_lsp",
+ ..Default::default()
+ },
+ );
+ cx.run_until_parked();
+
+ project
+ .update(cx, |project, cx| {
+ project.open_local_buffer_with_lsp(path!("/root/src/main.rs"), cx)
+ })
+ .await
+ .unwrap();
+
+ let lsp_path = tilde_lsp.next().await.unwrap().binary.path;
+ let expected_path = paths::home_dir().join(".local/bin/rust-analyzer");
+ assert_eq!(
+ lsp_path, expected_path,
+ "Tilde path should expand to home directory"
);
}
@@ -2672,11 +2750,13 @@ async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) {
);
let fs = FakeFs::new(cx.executor());
- fs.insert_tree("/dir", json!({ "a.rs": text })).await;
+ fs.insert_tree(path!("/dir"), json!({ "a.rs": text })).await;
- let project = Project::test(fs, ["/dir".as_ref()], cx).await;
+ let project = Project::test(fs, [Path::new(path!("/dir"))], cx).await;
let buffer = project
- .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/dir/a.rs"), cx)
+ })
.await
.unwrap();
@@ -2685,7 +2765,7 @@ async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) {
lsp_store
.update_diagnostic_entries(
LanguageServerId(0),
- PathBuf::from("/dir/a.rs"),
+ PathBuf::from(path!("/dir/a.rs")),
None,
None,
vec![
@@ -2742,17 +2822,17 @@ async fn test_diagnostics_from_multiple_language_servers(cx: &mut gpui::TestAppC
init_test(cx);
let fs = FakeFs::new(cx.executor());
- fs.insert_tree("/dir", json!({ "a.rs": "one two three" }))
+ fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" }))
.await;
- let project = Project::test(fs, ["/dir".as_ref()], cx).await;
+ let project = Project::test(fs, [Path::new(path!("/dir"))], cx).await;
let lsp_store = project.read_with(cx, |project, _| project.lsp_store.clone());
lsp_store.update(cx, |lsp_store, cx| {
lsp_store
.update_diagnostic_entries(
LanguageServerId(0),
- Path::new("/dir/a.rs").to_owned(),
+ Path::new(path!("/dir/a.rs")).to_owned(),
None,
None,
vec![DiagnosticEntry {
@@ -2771,7 +2851,7 @@ async fn test_diagnostics_from_multiple_language_servers(cx: &mut gpui::TestAppC
lsp_store
.update_diagnostic_entries(
LanguageServerId(1),
- Path::new("/dir/a.rs").to_owned(),
+ Path::new(path!("/dir/a.rs")).to_owned(),
None,
None,
vec![DiagnosticEntry {
@@ -8096,6 +8176,91 @@ async fn test_single_file_diffs(cx: &mut gpui::TestAppContext) {
});
}
+// TODO: Should we test this on Windows also?
+#[gpui::test]
+#[cfg(not(windows))]
+async fn test_staging_hunk_preserve_executable_permission(cx: &mut gpui::TestAppContext) {
+ use std::os::unix::fs::PermissionsExt;
+ init_test(cx);
+ cx.executor().allow_parking();
+ let committed_contents = "bar\n";
+ let file_contents = "baz\n";
+ let root = TempTree::new(json!({
+ "project": {
+ "foo": committed_contents
+ },
+ }));
+
+ let work_dir = root.path().join("project");
+ let file_path = work_dir.join("foo");
+ let repo = git_init(work_dir.as_path());
+ let mut perms = std::fs::metadata(&file_path).unwrap().permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(&file_path, perms).unwrap();
+ git_add("foo", &repo);
+ git_commit("Initial commit", &repo);
+ std::fs::write(&file_path, file_contents).unwrap();
+
+ let project = Project::test(
+ Arc::new(RealFs::new(None, cx.executor())),
+ [root.path()],
+ cx,
+ )
+ .await;
+
+ let buffer = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(file_path.as_path(), cx)
+ })
+ .await
+ .unwrap();
+
+ let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
+
+ let uncommitted_diff = project
+ .update(cx, |project, cx| {
+ project.open_uncommitted_diff(buffer.clone(), cx)
+ })
+ .await
+ .unwrap();
+
+ uncommitted_diff.update(cx, |diff, cx| {
+ let hunks = diff.hunks(&snapshot, cx).collect::<Vec<_>>();
+ diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx);
+ });
+
+ cx.run_until_parked();
+
+ let output = smol::process::Command::new("git")
+ .current_dir(&work_dir)
+ .args(["diff", "--staged"])
+ .output()
+ .await
+ .unwrap();
+
+ let staged_diff = String::from_utf8_lossy(&output.stdout);
+
+ assert!(
+ !staged_diff.contains("new mode 100644"),
+ "Staging should not change file mode from 755 to 644.\ngit diff --staged:\n{}",
+ staged_diff
+ );
+
+ let output = smol::process::Command::new("git")
+ .current_dir(&work_dir)
+ .args(["ls-files", "-s"])
+ .output()
+ .await
+ .unwrap();
+ let index_contents = String::from_utf8_lossy(&output.stdout);
+
+ assert!(
+ index_contents.contains("100755"),
+ "Index should show file as executable (100755).\ngit ls-files -s:\n{}",
+ index_contents
+ );
+}
+
#[gpui::test]
async fn test_repository_and_path_for_project_path(
background_executor: BackgroundExecutor,
@@ -8375,6 +8540,7 @@ async fn test_git_repository_status(cx: &mut gpui::TestAppContext) {
}
#[gpui::test]
+#[ignore]
async fn test_git_status_postprocessing(cx: &mut gpui::TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
@@ -8533,7 +8699,7 @@ async fn test_repository_pending_ops_staging(
// Ensure we have no pending ops for any of the untracked files
repo.read_with(cx, |repo, _cx| {
- assert!(repo.pending_ops_by_path.is_empty());
+ assert!(repo.pending_ops().next().is_none());
});
let mut id = 1u16;
@@ -9497,7 +9663,7 @@ async fn test_ignored_dirs_events(cx: &mut gpui::TestAppContext) {
assert_eq!(
repository_updates.lock().drain(..).collect::<Vec<_>>(),
vec![
- RepositoryEvent::StatusesChanged { full_scan: true },
+ RepositoryEvent::StatusesChanged,
RepositoryEvent::MergeHeadsChanged,
],
"Initial worktree scan should produce a repo update event"
@@ -9569,11 +9735,14 @@ async fn test_ignored_dirs_events(cx: &mut gpui::TestAppContext) {
("project/target/debug/deps".to_string(), PathChange::Added),
("project/target/debug/deps".to_string(), PathChange::Removed),
],
- "Due to `debug` directory being tracket, it should get updates for entries inside it.
+ "Due to `debug` directory being tracked, it should get updates for entries inside it.
No updates for more nested directories should happen as those are ignored",
);
}
+// todo(jk): turning this test off until we rework it in such a way so that it is not so susceptible
+// to different timings/ordering of events.
+#[ignore]
#[gpui::test]
async fn test_odd_events_for_ignored_dirs(
executor: BackgroundExecutor,
@@ -9665,8 +9834,8 @@ async fn test_odd_events_for_ignored_dirs(
vec![
RepositoryEvent::MergeHeadsChanged,
RepositoryEvent::BranchChanged,
- RepositoryEvent::StatusesChanged { full_scan: false },
- RepositoryEvent::StatusesChanged { full_scan: false },
+ RepositoryEvent::StatusesChanged,
+ RepositoryEvent::StatusesChanged,
],
"Initial worktree scan should produce a repo update event"
);
@@ -10267,7 +10436,7 @@ pub fn init_test(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
- release_channel::init(SemanticVersion::default(), cx);
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
});
}
@@ -10299,20 +10468,6 @@ fn js_lang() -> Arc<Language> {
))
}
-fn rust_lang() -> Arc<Language> {
- 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()),
- ))
-}
-
fn python_lang(fs: Arc<FakeFs>) -> Arc<Language> {
struct PythonMootToolchainLister(Arc<FakeFs>);
#[async_trait]
@@ -3,17 +3,20 @@ use anyhow::Result;
use client::proto;
use fancy_regex::{Captures, Regex, RegexBuilder};
use gpui::Entity;
+use itertools::Itertools as _;
use language::{Buffer, BufferSnapshot, CharKind};
use smol::future::yield_now;
use std::{
borrow::Cow,
io::{BufRead, BufReader, Read},
ops::Range,
- path::Path,
sync::{Arc, LazyLock},
};
use text::Anchor;
-use util::paths::{PathMatcher, PathStyle};
+use util::{
+ paths::{PathMatcher, PathStyle},
+ rel_path::RelPath,
+};
#[derive(Debug)]
pub enum SearchResult {
@@ -306,16 +309,16 @@ impl SearchQuery {
}
pub fn to_proto(&self) -> proto::SearchQuery {
- let files_to_include = self.files_to_include().sources().to_vec();
- let files_to_exclude = self.files_to_exclude().sources().to_vec();
+ let mut files_to_include = self.files_to_include().sources();
+ let mut files_to_exclude = self.files_to_exclude().sources();
proto::SearchQuery {
query: self.as_str().to_string(),
regex: self.is_regex(),
whole_word: self.whole_word(),
case_sensitive: self.case_sensitive(),
include_ignored: self.include_ignored(),
- files_to_include: files_to_include.clone(),
- files_to_exclude: files_to_exclude.clone(),
+ files_to_include: files_to_include.clone().map(ToOwned::to_owned).collect(),
+ files_to_exclude: files_to_exclude.clone().map(ToOwned::to_owned).collect(),
match_full_paths: self.match_full_paths(),
// Populate legacy fields for backwards compatibility
files_to_include_legacy: files_to_include.join(","),
@@ -551,8 +554,8 @@ impl SearchQuery {
}
pub fn filters_path(&self) -> bool {
- !(self.files_to_exclude().sources().is_empty()
- && self.files_to_include().sources().is_empty())
+ !(self.files_to_exclude().sources().next().is_none()
+ && self.files_to_include().sources().next().is_none())
}
pub fn match_full_paths(&self) -> bool {
@@ -561,12 +564,12 @@ impl SearchQuery {
/// Check match full paths to determine whether you're required to pass a fully qualified
/// project path (starts with a project root).
- pub fn match_path(&self, file_path: &Path) -> bool {
- let mut path = file_path.to_path_buf();
+ pub fn match_path(&self, file_path: &RelPath) -> bool {
+ let mut path = file_path.to_rel_path_buf();
loop {
if self.files_to_exclude().is_match(&path) {
return false;
- } else if self.files_to_include().sources().is_empty()
+ } else if self.files_to_include().sources().next().is_none()
|| self.files_to_include().is_match(&path)
{
return true;
@@ -608,14 +611,14 @@ mod tests {
"~/dir/another_dir/",
"./dir/file",
"dir/[a-z].txt",
- "../dir/filé",
] {
let path_matcher = PathMatcher::new(&[valid_path.to_owned()], PathStyle::local())
.unwrap_or_else(|e| {
panic!("Valid path {valid_path} should be accepted, but got: {e}")
});
assert!(
- path_matcher.is_match(valid_path),
+ path_matcher
+ .is_match(&RelPath::new(valid_path.as_ref(), PathStyle::local()).unwrap()),
"Path matcher for valid path {valid_path} should match itself"
)
}
@@ -5,7 +5,7 @@ use worktree::Worktree;
use crate::{
Project,
- git_store::{GitStore, RepositoryState},
+ git_store::{GitStore, LocalRepositoryState, RepositoryState},
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -85,7 +85,9 @@ impl TelemetryWorktreeSnapshot {
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 {
+ let RepositoryState::Local(LocalRepositoryState { backend, .. }) =
+ state
+ else {
return GitState {
remote_url: None,
head_sha: None,
@@ -94,7 +96,7 @@ impl TelemetryWorktreeSnapshot {
};
};
- let remote_url = backend.remote_url("origin");
+ let remote_url = backend.remote_url("origin").await;
let head_sha = backend.head_sha().await;
let diff = backend.diff(DiffType::HeadToWorktree).await.ok();
@@ -16,7 +16,7 @@ use task::{Shell, ShellBuilder, ShellKind, SpawnInTerminal};
use terminal::{
TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings,
};
-use util::{get_default_system_shell, maybe, rel_path::RelPath};
+use util::{command::new_std_command, get_default_system_shell, maybe, rel_path::RelPath};
use crate::{Project, ProjectPath};
@@ -111,7 +111,7 @@ impl Project {
);
let toolchains = project_path_contexts
.filter(|_| detect_venv)
- .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
+ .map(|p| self.active_toolchain(p, LanguageName::new_static("Python"), cx))
.collect::<Vec<_>>();
let lang_registry = self.languages.clone();
cx.spawn(async move |project, cx| {
@@ -240,6 +240,8 @@ impl Project {
settings.cursor_shape,
settings.alternate_scroll,
settings.max_scroll_history_lines,
+ settings.path_hyperlink_regexes,
+ settings.path_hyperlink_timeout_ms,
is_via_remote,
cx.entity_id().as_u64(),
Some(completion_tx),
@@ -309,7 +311,7 @@ impl Project {
);
let toolchains = project_path_contexts
.filter(|_| detect_venv)
- .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
+ .map(|p| self.active_toolchain(p, LanguageName::new_static("Python"), cx))
.collect::<Vec<_>>();
let remote_client = self.remote_client.clone();
let shell = match &remote_client {
@@ -369,6 +371,8 @@ impl Project {
settings.cursor_shape,
settings.alternate_scroll,
settings.max_scroll_history_lines,
+ settings.path_hyperlink_regexes,
+ settings.path_hyperlink_timeout_ms,
is_via_remote,
cx.entity_id().as_u64(),
None,
@@ -505,13 +509,13 @@ impl Project {
None,
None,
)?;
- let mut command = std::process::Command::new(command_template.program);
+ let mut command = new_std_command(command_template.program);
command.args(command_template.args);
command.envs(command_template.env);
Ok(command)
}
None => {
- let mut command = std::process::Command::new(command);
+ let mut command = new_std_command(command);
command.args(args);
command.envs(env);
if let Some(path) = path {
@@ -0,0 +1,1378 @@
+//! A module, responsible for managing the trust logic in Zed.
+//!
+//! It deals with multiple hosts, distinguished by [`RemoteHostLocation`].
+//! Each [`crate::Project`] and `HeadlessProject` should call [`init_global`], if wants to establish the trust mechanism.
+//! This will set up a [`gpui::Global`] with [`TrustedWorktrees`] entity that will persist, restore and allow querying for worktree trust.
+//! It's also possible to subscribe on [`TrustedWorktreesEvent`] events of this entity to track trust changes dynamically.
+//!
+//! The implementation can synchronize trust information with the remote hosts: currently, WSL and SSH.
+//! Docker and Collab remotes do not employ trust mechanism, as manage that themselves.
+//!
+//! Unless `trust_all_worktrees` auto trust is enabled, does not trust anything that was not persisted before.
+//! When dealing with "restricted" and other related concepts in the API, it means all explicitly restricted, after any of the [`TrustedWorktreesStore::can_trust`] and [`TrustedWorktreesStore::can_trust_global`] calls.
+//!
+//!
+//!
+//!
+//! Path rust hierarchy.
+//!
+//! Zed has multiple layers of trust, based on the requests and [`PathTrust`] enum variants.
+//! From the least to the most trusted level:
+//!
+//! * "single file worktree"
+//!
+//! After opening an empty Zed it's possible to open just a file, same as after opening a directory in Zed it's possible to open a file outside of this directory.
+//! Usual scenario for both cases is opening Zed's settings.json file via `zed: open settings file` command: that starts a language server for a new file open, which originates from a newly created, single file worktree.
+//!
+//! Spawning a language server is potentially dangerous, and Zed needs to restrict that by default.
+//! Each single file worktree requires a separate trust permission, unless a more global level is trusted.
+//!
+//! * "directory worktree"
+//!
+//! If a directory is open in Zed, it's a full worktree which may spawn multiple language servers associated with it.
+//! Each such worktree requires a separate trust permission, so each separate directory worktree has to be trusted separately, unless a more global level is trusted.
+//!
+//! When a directory worktree is trusted and language servers are allowed to be downloaded and started, hence, "single file worktree" level of trust also.
+//!
+//! * "path override"
+//!
+//! To ease trusting multiple directory worktrees at once, it's possible to trust a parent directory of a certain directory worktree opened in Zed.
+//! Trusting a directory means trusting all its subdirectories as well, including all current and potential directory worktrees.
+
+use collections::{HashMap, HashSet};
+use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, SharedString, WeakEntity};
+use remote::RemoteConnectionOptions;
+use rpc::{AnyProtoClient, proto};
+use settings::{Settings as _, WorktreeId};
+use std::{
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+use util::debug_panic;
+
+use crate::{project_settings::ProjectSettings, worktree_store::WorktreeStore};
+
+pub fn init(
+ db_trusted_paths: TrustedPaths,
+ downstream_client: Option<(AnyProtoClient, u64)>,
+ upstream_client: Option<(AnyProtoClient, u64)>,
+ cx: &mut App,
+) {
+ if TrustedWorktrees::try_get_global(cx).is_none() {
+ let trusted_worktrees = cx.new(|_| {
+ TrustedWorktreesStore::new(
+ db_trusted_paths,
+ None,
+ None,
+ downstream_client,
+ upstream_client,
+ )
+ });
+ cx.set_global(TrustedWorktrees(trusted_worktrees))
+ }
+}
+
+/// An initialization call to set up trust global for a particular project (remote or local).
+pub fn track_worktree_trust(
+ worktree_store: Entity<WorktreeStore>,
+ remote_host: Option<RemoteHostLocation>,
+ downstream_client: Option<(AnyProtoClient, u64)>,
+ upstream_client: Option<(AnyProtoClient, u64)>,
+ cx: &mut App,
+) {
+ match TrustedWorktrees::try_get_global(cx) {
+ Some(trusted_worktrees) => {
+ trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+ let sync_upstream = trusted_worktrees.upstream_client.as_ref().map(|(_, id)| id)
+ != upstream_client.as_ref().map(|(_, id)| id);
+ trusted_worktrees.downstream_client = downstream_client;
+ trusted_worktrees.upstream_client = upstream_client;
+ trusted_worktrees.add_worktree_store(worktree_store, remote_host, cx);
+
+ if sync_upstream {
+ if let Some((upstream_client, upstream_project_id)) =
+ &trusted_worktrees.upstream_client
+ {
+ let trusted_paths = trusted_worktrees
+ .trusted_paths
+ .iter()
+ .flat_map(|(_, paths)| {
+ paths.iter().map(|trusted_path| trusted_path.to_proto())
+ })
+ .collect::<Vec<_>>();
+ if !trusted_paths.is_empty() {
+ upstream_client
+ .send(proto::TrustWorktrees {
+ project_id: *upstream_project_id,
+ trusted_paths,
+ })
+ .ok();
+ }
+ }
+ }
+ });
+ }
+ None => log::debug!("No TrustedWorktrees initialized, not tracking worktree trust"),
+ }
+}
+
+/// A collection of worktree trust metadata, can be accessed globally (if initialized) and subscribed to.
+pub struct TrustedWorktrees(Entity<TrustedWorktreesStore>);
+
+impl Global for TrustedWorktrees {}
+
+impl TrustedWorktrees {
+ pub fn try_get_global(cx: &App) -> Option<Entity<TrustedWorktreesStore>> {
+ cx.try_global::<Self>().map(|this| this.0.clone())
+ }
+}
+
+/// A collection of worktrees that are considered trusted and not trusted.
+/// This can be used when checking for this criteria before enabling certain features.
+///
+/// Emits an event each time the worktree was checked and found not trusted,
+/// or a certain worktree had been trusted.
+pub struct TrustedWorktreesStore {
+ downstream_client: Option<(AnyProtoClient, u64)>,
+ upstream_client: Option<(AnyProtoClient, u64)>,
+ worktree_stores: HashMap<WeakEntity<WorktreeStore>, Option<RemoteHostLocation>>,
+ trusted_paths: TrustedPaths,
+ restricted: HashSet<WorktreeId>,
+}
+
+/// An identifier of a host to split the trust questions by.
+/// Each trusted data change and event is done for a particular host.
+/// A host may contain more than one worktree or even project open concurrently.
+#[derive(Debug, PartialEq, Eq, Clone, Hash)]
+pub struct RemoteHostLocation {
+ pub user_name: Option<SharedString>,
+ pub host_identifier: SharedString,
+}
+
+impl From<RemoteConnectionOptions> for RemoteHostLocation {
+ fn from(options: RemoteConnectionOptions) -> Self {
+ let (user_name, host_name) = match options {
+ RemoteConnectionOptions::Ssh(ssh) => (
+ ssh.username.map(SharedString::new),
+ SharedString::new(ssh.host.to_string()),
+ ),
+ RemoteConnectionOptions::Wsl(wsl) => (
+ wsl.user.map(SharedString::new),
+ SharedString::new(wsl.distro_name),
+ ),
+ RemoteConnectionOptions::Docker(docker_connection_options) => (
+ Some(SharedString::new(docker_connection_options.name)),
+ SharedString::new(docker_connection_options.container_id),
+ ),
+ };
+ RemoteHostLocation {
+ user_name,
+ host_identifier: host_name,
+ }
+ }
+}
+
+/// A unit of trust consideration inside a particular host:
+/// either a familiar worktree, or a path that may influence other worktrees' trust.
+/// See module-level documentation on the trust model.
+#[derive(Debug, PartialEq, Eq, Clone, Hash)]
+pub enum PathTrust {
+ /// A worktree that is familiar to this workspace.
+ /// Either a single file or a directory worktree.
+ Worktree(WorktreeId),
+ /// A path that may be another worktree yet not loaded into any workspace (hence, without any `WorktreeId`),
+ /// or a parent path coming out of the security modal.
+ AbsPath(PathBuf),
+}
+
+impl PathTrust {
+ fn to_proto(&self) -> proto::PathTrust {
+ match self {
+ Self::Worktree(worktree_id) => proto::PathTrust {
+ content: Some(proto::path_trust::Content::WorktreeId(
+ worktree_id.to_proto(),
+ )),
+ },
+ Self::AbsPath(path_buf) => proto::PathTrust {
+ content: Some(proto::path_trust::Content::AbsPath(
+ path_buf.to_string_lossy().to_string(),
+ )),
+ },
+ }
+ }
+
+ pub fn from_proto(proto: proto::PathTrust) -> Option<Self> {
+ Some(match proto.content? {
+ proto::path_trust::Content::WorktreeId(id) => {
+ Self::Worktree(WorktreeId::from_proto(id))
+ }
+ proto::path_trust::Content::AbsPath(path) => Self::AbsPath(PathBuf::from(path)),
+ })
+ }
+}
+
+/// A change of trust on a certain host.
+#[derive(Debug)]
+pub enum TrustedWorktreesEvent {
+ Trusted(Option<RemoteHostLocation>, HashSet<PathTrust>),
+ Restricted(Option<RemoteHostLocation>, HashSet<PathTrust>),
+}
+
+impl EventEmitter<TrustedWorktreesEvent> for TrustedWorktreesStore {}
+
+pub type TrustedPaths = HashMap<Option<RemoteHostLocation>, HashSet<PathTrust>>;
+
+impl TrustedWorktreesStore {
+ fn new(
+ trusted_paths: TrustedPaths,
+ worktree_store: Option<Entity<WorktreeStore>>,
+ remote_host: Option<RemoteHostLocation>,
+ downstream_client: Option<(AnyProtoClient, u64)>,
+ upstream_client: Option<(AnyProtoClient, u64)>,
+ ) -> Self {
+ if let Some((upstream_client, upstream_project_id)) = &upstream_client {
+ let trusted_paths = trusted_paths
+ .iter()
+ .flat_map(|(_, paths)| paths.iter().map(|trusted_path| trusted_path.to_proto()))
+ .collect::<Vec<_>>();
+ if !trusted_paths.is_empty() {
+ upstream_client
+ .send(proto::TrustWorktrees {
+ project_id: *upstream_project_id,
+ trusted_paths,
+ })
+ .ok();
+ }
+ }
+
+ let worktree_stores = match worktree_store {
+ Some(worktree_store) => HashMap::from_iter([(worktree_store.downgrade(), remote_host)]),
+ None => HashMap::default(),
+ };
+
+ Self {
+ trusted_paths,
+ downstream_client,
+ upstream_client,
+ restricted: HashSet::default(),
+ worktree_stores,
+ }
+ }
+
+ /// Whether a particular worktree store has associated worktrees that are restricted, or an associated host is restricted.
+ pub fn has_restricted_worktrees(
+ &self,
+ worktree_store: &Entity<WorktreeStore>,
+ cx: &App,
+ ) -> bool {
+ self.worktree_stores
+ .contains_key(&worktree_store.downgrade())
+ && self.restricted.iter().any(|restricted_worktree| {
+ worktree_store
+ .read(cx)
+ .worktree_for_id(*restricted_worktree, cx)
+ .is_some()
+ })
+ }
+
+ /// Adds certain entities on this host to the trusted list.
+ /// This will emit [`TrustedWorktreesEvent::Trusted`] event for all passed entries
+ /// and the ones that got auto trusted based on trust hierarchy (see module-level docs).
+ pub fn trust(
+ &mut self,
+ mut trusted_paths: HashSet<PathTrust>,
+ remote_host: Option<RemoteHostLocation>,
+ cx: &mut Context<Self>,
+ ) {
+ let mut new_trusted_single_file_worktrees = HashSet::default();
+ let mut new_trusted_other_worktrees = HashSet::default();
+ let mut new_trusted_abs_paths = HashSet::default();
+ for trusted_path in trusted_paths.iter().chain(
+ self.trusted_paths
+ .remove(&remote_host)
+ .iter()
+ .flat_map(|current_trusted| current_trusted.iter()),
+ ) {
+ match trusted_path {
+ PathTrust::Worktree(worktree_id) => {
+ self.restricted.remove(worktree_id);
+ if let Some((abs_path, is_file, host)) =
+ self.find_worktree_data(*worktree_id, cx)
+ {
+ if host == remote_host {
+ if is_file {
+ new_trusted_single_file_worktrees.insert(*worktree_id);
+ } else {
+ new_trusted_other_worktrees.insert((abs_path, *worktree_id));
+ }
+ }
+ }
+ }
+ PathTrust::AbsPath(path) => {
+ debug_assert!(
+ path.is_absolute(),
+ "Cannot trust non-absolute path {path:?}"
+ );
+ new_trusted_abs_paths.insert(path.clone());
+ }
+ }
+ }
+
+ new_trusted_other_worktrees.retain(|(worktree_abs_path, _)| {
+ new_trusted_abs_paths
+ .iter()
+ .all(|new_trusted_path| !worktree_abs_path.starts_with(new_trusted_path))
+ });
+ if !new_trusted_other_worktrees.is_empty() {
+ new_trusted_single_file_worktrees.clear();
+ }
+ self.restricted = std::mem::take(&mut self.restricted)
+ .into_iter()
+ .filter(|restricted_worktree| {
+ let Some((restricted_worktree_path, is_file, restricted_host)) =
+ self.find_worktree_data(*restricted_worktree, cx)
+ else {
+ return false;
+ };
+ if restricted_host != remote_host {
+ return true;
+ }
+ let retain = (!is_file || new_trusted_other_worktrees.is_empty())
+ && new_trusted_abs_paths.iter().all(|new_trusted_path| {
+ !restricted_worktree_path.starts_with(new_trusted_path)
+ });
+ if !retain {
+ trusted_paths.insert(PathTrust::Worktree(*restricted_worktree));
+ }
+ retain
+ })
+ .collect();
+
+ {
+ let trusted_paths = self.trusted_paths.entry(remote_host.clone()).or_default();
+ trusted_paths.extend(new_trusted_abs_paths.into_iter().map(PathTrust::AbsPath));
+ trusted_paths.extend(
+ new_trusted_other_worktrees
+ .into_iter()
+ .map(|(_, worktree_id)| PathTrust::Worktree(worktree_id)),
+ );
+ trusted_paths.extend(
+ new_trusted_single_file_worktrees
+ .into_iter()
+ .map(PathTrust::Worktree),
+ );
+ }
+
+ cx.emit(TrustedWorktreesEvent::Trusted(
+ remote_host,
+ trusted_paths.clone(),
+ ));
+
+ if let Some((upstream_client, upstream_project_id)) = &self.upstream_client {
+ let trusted_paths = trusted_paths
+ .iter()
+ .map(|trusted_path| trusted_path.to_proto())
+ .collect::<Vec<_>>();
+ if !trusted_paths.is_empty() {
+ upstream_client
+ .send(proto::TrustWorktrees {
+ project_id: *upstream_project_id,
+ trusted_paths,
+ })
+ .ok();
+ }
+ }
+ }
+
+ /// Restricts certain entities on this host.
+ /// This will emit [`TrustedWorktreesEvent::Restricted`] event for all passed entries.
+ pub fn restrict(
+ &mut self,
+ restricted_paths: HashSet<PathTrust>,
+ remote_host: Option<RemoteHostLocation>,
+ cx: &mut Context<Self>,
+ ) {
+ for restricted_path in restricted_paths {
+ match restricted_path {
+ PathTrust::Worktree(worktree_id) => {
+ self.restricted.insert(worktree_id);
+ cx.emit(TrustedWorktreesEvent::Restricted(
+ remote_host.clone(),
+ HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+ ));
+ }
+ PathTrust::AbsPath(..) => debug_panic!("Unexpected: cannot restrict an abs path"),
+ }
+ }
+ }
+
+ /// Erases all trust information.
+ /// Requires Zed's restart to take proper effect.
+ pub fn clear_trusted_paths(&mut self) {
+ self.trusted_paths.clear();
+ }
+
+ /// Checks whether a certain worktree is trusted (or on a larger trust level).
+ /// If not, emits [`TrustedWorktreesEvent::Restricted`] event if for the first time and not trusted, or no corresponding worktree store was found.
+ ///
+ /// No events or data adjustment happens when `trust_all_worktrees` auto trust is enabled.
+ pub fn can_trust(&mut self, worktree_id: WorktreeId, cx: &mut Context<Self>) -> bool {
+ if ProjectSettings::get_global(cx).session.trust_all_worktrees {
+ return true;
+ }
+ if self.restricted.contains(&worktree_id) {
+ return false;
+ }
+
+ let Some((worktree_path, is_file, remote_host)) = self.find_worktree_data(worktree_id, cx)
+ else {
+ return false;
+ };
+
+ if self
+ .trusted_paths
+ .get(&remote_host)
+ .is_some_and(|trusted_paths| trusted_paths.contains(&PathTrust::Worktree(worktree_id)))
+ {
+ return true;
+ }
+
+ // See module documentation for details on trust level.
+ if is_file && self.trusted_paths.contains_key(&remote_host) {
+ return true;
+ }
+
+ let parent_path_trusted =
+ self.trusted_paths
+ .get(&remote_host)
+ .is_some_and(|trusted_paths| {
+ trusted_paths.iter().any(|trusted_path| {
+ let PathTrust::AbsPath(trusted_path) = trusted_path else {
+ return false;
+ };
+ worktree_path.starts_with(trusted_path)
+ })
+ });
+ if parent_path_trusted {
+ return true;
+ }
+
+ self.restricted.insert(worktree_id);
+ cx.emit(TrustedWorktreesEvent::Restricted(
+ remote_host,
+ HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+ ));
+ if let Some((downstream_client, downstream_project_id)) = &self.downstream_client {
+ downstream_client
+ .send(proto::RestrictWorktrees {
+ project_id: *downstream_project_id,
+ worktree_ids: vec![worktree_id.to_proto()],
+ })
+ .ok();
+ }
+ if let Some((upstream_client, upstream_project_id)) = &self.upstream_client {
+ upstream_client
+ .send(proto::RestrictWorktrees {
+ project_id: *upstream_project_id,
+ worktree_ids: vec![worktree_id.to_proto()],
+ })
+ .ok();
+ }
+ false
+ }
+
+ /// Lists all explicitly restricted worktrees (via [`TrustedWorktreesStore::can_trust`] method calls) for a particular worktree store on a particular host.
+ pub fn restricted_worktrees(
+ &self,
+ worktree_store: &WorktreeStore,
+ cx: &App,
+ ) -> HashSet<(WorktreeId, Arc<Path>)> {
+ let mut single_file_paths = HashSet::default();
+ let other_paths = self
+ .restricted
+ .iter()
+ .filter_map(|&restricted_worktree_id| {
+ let worktree = worktree_store.worktree_for_id(restricted_worktree_id, cx)?;
+ let worktree = worktree.read(cx);
+ let abs_path = worktree.abs_path();
+ if worktree.is_single_file() {
+ single_file_paths.insert((restricted_worktree_id, abs_path));
+ None
+ } else {
+ Some((restricted_worktree_id, abs_path))
+ }
+ })
+ .collect::<HashSet<_>>();
+
+ if !other_paths.is_empty() {
+ return other_paths;
+ } else {
+ single_file_paths
+ }
+ }
+
+ /// Switches the "trust nothing" mode to "automatically trust everything".
+ /// This does not influence already persisted data, but stops adding new worktrees there.
+ pub fn auto_trust_all(&mut self, cx: &mut Context<Self>) {
+ for (remote_host, worktrees) in std::mem::take(&mut self.restricted)
+ .into_iter()
+ .flat_map(|restricted_worktree| {
+ let (_, _, host) = self.find_worktree_data(restricted_worktree, cx)?;
+ Some((restricted_worktree, host))
+ })
+ .fold(HashMap::default(), |mut acc, (worktree_id, remote_host)| {
+ acc.entry(remote_host)
+ .or_insert_with(HashSet::default)
+ .insert(PathTrust::Worktree(worktree_id));
+ acc
+ })
+ {
+ self.trust(worktrees, remote_host, cx);
+ }
+ }
+
+ /// Returns a normalized representation of the trusted paths to store in the DB.
+ pub fn trusted_paths_for_serialization(
+ &mut self,
+ cx: &mut Context<Self>,
+ ) -> HashMap<Option<RemoteHostLocation>, HashSet<PathBuf>> {
+ let new_trusted_worktrees = self
+ .trusted_paths
+ .clone()
+ .into_iter()
+ .map(|(host, paths)| {
+ let abs_paths = paths
+ .into_iter()
+ .flat_map(|path| match path {
+ PathTrust::Worktree(worktree_id) => self
+ .find_worktree_data(worktree_id, cx)
+ .map(|(abs_path, ..)| abs_path.to_path_buf()),
+ PathTrust::AbsPath(abs_path) => Some(abs_path),
+ })
+ .collect();
+ (host, abs_paths)
+ })
+ .collect();
+ new_trusted_worktrees
+ }
+
+ fn find_worktree_data(
+ &mut self,
+ worktree_id: WorktreeId,
+ cx: &mut Context<Self>,
+ ) -> Option<(Arc<Path>, bool, Option<RemoteHostLocation>)> {
+ let mut worktree_data = None;
+ self.worktree_stores.retain(
+ |worktree_store, remote_host| match worktree_store.upgrade() {
+ Some(worktree_store) => {
+ if worktree_data.is_none() {
+ if let Some(worktree) =
+ worktree_store.read(cx).worktree_for_id(worktree_id, cx)
+ {
+ worktree_data = Some((
+ worktree.read(cx).abs_path(),
+ worktree.read(cx).is_single_file(),
+ remote_host.clone(),
+ ));
+ }
+ }
+ true
+ }
+ None => false,
+ },
+ );
+ worktree_data
+ }
+
+ fn add_worktree_store(
+ &mut self,
+ worktree_store: Entity<WorktreeStore>,
+ remote_host: Option<RemoteHostLocation>,
+ cx: &mut Context<Self>,
+ ) {
+ self.worktree_stores
+ .insert(worktree_store.downgrade(), remote_host.clone());
+
+ if let Some(trusted_paths) = self.trusted_paths.remove(&remote_host) {
+ self.trusted_paths.insert(
+ remote_host.clone(),
+ trusted_paths
+ .into_iter()
+ .map(|path_trust| match path_trust {
+ PathTrust::AbsPath(abs_path) => {
+ find_worktree_in_store(worktree_store.read(cx), &abs_path, cx)
+ .map(PathTrust::Worktree)
+ .unwrap_or_else(|| PathTrust::AbsPath(abs_path))
+ }
+ other => other,
+ })
+ .collect(),
+ );
+ }
+ }
+}
+
+pub fn find_worktree_in_store(
+ worktree_store: &WorktreeStore,
+ abs_path: &Path,
+ cx: &App,
+) -> Option<WorktreeId> {
+ let (worktree, path_in_worktree) = worktree_store.find_worktree(&abs_path, cx)?;
+ if path_in_worktree.is_empty() {
+ Some(worktree.read(cx).id())
+ } else {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{cell::RefCell, path::PathBuf, rc::Rc};
+
+ use collections::HashSet;
+ use gpui::TestAppContext;
+ use serde_json::json;
+ use settings::SettingsStore;
+ use util::path;
+
+ use crate::{FakeFs, Project};
+
+ use super::*;
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ if cx.try_global::<SettingsStore>().is_none() {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ }
+ if cx.try_global::<TrustedWorktrees>().is_some() {
+ cx.remove_global::<TrustedWorktrees>();
+ }
+ });
+ }
+
+ fn init_trust_global(
+ worktree_store: Entity<WorktreeStore>,
+ cx: &mut TestAppContext,
+ ) -> Entity<TrustedWorktreesStore> {
+ cx.update(|cx| {
+ init(HashMap::default(), None, None, cx);
+ track_worktree_trust(worktree_store, None, None, None, cx);
+ TrustedWorktrees::try_get_global(cx).expect("global should be set")
+ })
+ }
+
+ #[gpui::test]
+ async fn test_single_worktree_trust(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/root"), json!({ "main.rs": "fn main() {}" }))
+ .await;
+
+ let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+ let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+ let worktree_id = worktree_store.read_with(cx, |store, cx| {
+ store.worktrees().next().unwrap().read(cx).id()
+ });
+
+ let trusted_worktrees = init_trust_global(worktree_store.clone(), cx);
+
+ let events: Rc<RefCell<Vec<TrustedWorktreesEvent>>> = Rc::default();
+ cx.update({
+ let events = events.clone();
+ |cx| {
+ cx.subscribe(&trusted_worktrees, move |_, event, _| {
+ events.borrow_mut().push(match event {
+ TrustedWorktreesEvent::Trusted(host, paths) => {
+ TrustedWorktreesEvent::Trusted(host.clone(), paths.clone())
+ }
+ TrustedWorktreesEvent::Restricted(host, paths) => {
+ TrustedWorktreesEvent::Restricted(host.clone(), paths.clone())
+ }
+ });
+ })
+ }
+ })
+ .detach();
+
+ let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(!can_trust, "worktree should be restricted by default");
+
+ {
+ let events = events.borrow();
+ assert_eq!(events.len(), 1);
+ match &events[0] {
+ TrustedWorktreesEvent::Restricted(host, paths) => {
+ assert!(host.is_none());
+ assert!(paths.contains(&PathTrust::Worktree(worktree_id)));
+ }
+ _ => panic!("expected Restricted event"),
+ }
+ }
+
+ let has_restricted = trusted_worktrees.read_with(cx, |store, cx| {
+ store.has_restricted_worktrees(&worktree_store, cx)
+ });
+ assert!(has_restricted, "should have restricted worktrees");
+
+ let restricted = worktree_store.read_with(cx, |ws, cx| {
+ trusted_worktrees.read(cx).restricted_worktrees(ws, cx)
+ });
+ assert!(restricted.iter().any(|(id, _)| *id == worktree_id));
+
+ events.borrow_mut().clear();
+
+ let can_trust_again =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(!can_trust_again, "worktree should still be restricted");
+ assert!(
+ events.borrow().is_empty(),
+ "no duplicate Restricted event on repeated can_trust"
+ );
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+ None,
+ cx,
+ );
+ });
+
+ {
+ let events = events.borrow();
+ assert_eq!(events.len(), 1);
+ match &events[0] {
+ TrustedWorktreesEvent::Trusted(host, paths) => {
+ assert!(host.is_none());
+ assert!(paths.contains(&PathTrust::Worktree(worktree_id)));
+ }
+ _ => panic!("expected Trusted event"),
+ }
+ }
+
+ let can_trust_after =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(can_trust_after, "worktree should be trusted after trust()");
+
+ let has_restricted_after = trusted_worktrees.read_with(cx, |store, cx| {
+ store.has_restricted_worktrees(&worktree_store, cx)
+ });
+ assert!(
+ !has_restricted_after,
+ "should have no restricted worktrees after trust"
+ );
+
+ let restricted_after = worktree_store.read_with(cx, |ws, cx| {
+ trusted_worktrees.read(cx).restricted_worktrees(ws, cx)
+ });
+ assert!(
+ restricted_after.is_empty(),
+ "restricted set should be empty"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_single_file_worktree_trust(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/root"), json!({ "foo.rs": "fn foo() {}" }))
+ .await;
+
+ let project = Project::test(fs, [path!("/root/foo.rs").as_ref()], cx).await;
+ let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+ let worktree_id = worktree_store.read_with(cx, |store, cx| {
+ let worktree = store.worktrees().next().unwrap();
+ let worktree = worktree.read(cx);
+ assert!(worktree.is_single_file(), "expected single-file worktree");
+ worktree.id()
+ });
+
+ let trusted_worktrees = init_trust_global(worktree_store, cx);
+
+ let events: Rc<RefCell<Vec<TrustedWorktreesEvent>>> = Rc::default();
+ cx.update({
+ let events = events.clone();
+ |cx| {
+ cx.subscribe(&trusted_worktrees, move |_, event, _| {
+ events.borrow_mut().push(match event {
+ TrustedWorktreesEvent::Trusted(host, paths) => {
+ TrustedWorktreesEvent::Trusted(host.clone(), paths.clone())
+ }
+ TrustedWorktreesEvent::Restricted(host, paths) => {
+ TrustedWorktreesEvent::Restricted(host.clone(), paths.clone())
+ }
+ });
+ })
+ }
+ })
+ .detach();
+
+ let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(
+ !can_trust,
+ "single-file worktree should be restricted by default"
+ );
+
+ {
+ let events = events.borrow();
+ assert_eq!(events.len(), 1);
+ match &events[0] {
+ TrustedWorktreesEvent::Restricted(host, paths) => {
+ assert!(host.is_none());
+ assert!(paths.contains(&PathTrust::Worktree(worktree_id)));
+ }
+ _ => panic!("expected Restricted event"),
+ }
+ }
+
+ events.borrow_mut().clear();
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+ None,
+ cx,
+ );
+ });
+
+ {
+ let events = events.borrow();
+ assert_eq!(events.len(), 1);
+ match &events[0] {
+ TrustedWorktreesEvent::Trusted(host, paths) => {
+ assert!(host.is_none());
+ assert!(paths.contains(&PathTrust::Worktree(worktree_id)));
+ }
+ _ => panic!("expected Trusted event"),
+ }
+ }
+
+ let can_trust_after =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(
+ can_trust_after,
+ "single-file worktree should be trusted after trust()"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_multiple_single_file_worktrees_trust_one(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ json!({
+ "a.rs": "fn a() {}",
+ "b.rs": "fn b() {}",
+ "c.rs": "fn c() {}"
+ }),
+ )
+ .await;
+
+ let project = Project::test(
+ fs,
+ [
+ path!("/root/a.rs").as_ref(),
+ path!("/root/b.rs").as_ref(),
+ path!("/root/c.rs").as_ref(),
+ ],
+ cx,
+ )
+ .await;
+ let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+ let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| {
+ store
+ .worktrees()
+ .map(|worktree| {
+ let worktree = worktree.read(cx);
+ assert!(worktree.is_single_file());
+ worktree.id()
+ })
+ .collect()
+ });
+ assert_eq!(worktree_ids.len(), 3);
+
+ let trusted_worktrees = init_trust_global(worktree_store, cx);
+
+ for &worktree_id in &worktree_ids {
+ let can_trust =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(
+ !can_trust,
+ "worktree {worktree_id:?} should be restricted initially"
+ );
+ }
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]),
+ None,
+ cx,
+ );
+ });
+
+ let can_trust_0 =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[0], cx));
+ let can_trust_1 =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[1], cx));
+ let can_trust_2 =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[2], cx));
+
+ assert!(!can_trust_0, "worktree 0 should still be restricted");
+ assert!(can_trust_1, "worktree 1 should be trusted");
+ assert!(!can_trust_2, "worktree 2 should still be restricted");
+ }
+
+ #[gpui::test]
+ async fn test_two_directory_worktrees_separate_trust(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/projects"),
+ json!({
+ "project_a": { "main.rs": "fn main() {}" },
+ "project_b": { "lib.rs": "pub fn lib() {}" }
+ }),
+ )
+ .await;
+
+ let project = Project::test(
+ fs,
+ [
+ path!("/projects/project_a").as_ref(),
+ path!("/projects/project_b").as_ref(),
+ ],
+ cx,
+ )
+ .await;
+ let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+ let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| {
+ store
+ .worktrees()
+ .map(|worktree| {
+ let worktree = worktree.read(cx);
+ assert!(!worktree.is_single_file());
+ worktree.id()
+ })
+ .collect()
+ });
+ assert_eq!(worktree_ids.len(), 2);
+
+ let trusted_worktrees = init_trust_global(worktree_store, cx);
+
+ let can_trust_a =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[0], cx));
+ let can_trust_b =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[1], cx));
+ assert!(!can_trust_a, "project_a should be restricted initially");
+ assert!(!can_trust_b, "project_b should be restricted initially");
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(worktree_ids[0])]),
+ None,
+ cx,
+ );
+ });
+
+ let can_trust_a =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[0], cx));
+ let can_trust_b =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[1], cx));
+ assert!(can_trust_a, "project_a should be trusted after trust()");
+ assert!(!can_trust_b, "project_b should still be restricted");
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]),
+ None,
+ cx,
+ );
+ });
+
+ let can_trust_a =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[0], cx));
+ let can_trust_b =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[1], cx));
+ assert!(can_trust_a, "project_a should remain trusted");
+ assert!(can_trust_b, "project_b should now be trusted");
+ }
+
+ #[gpui::test]
+ async fn test_directory_worktree_trust_enables_single_file(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/"),
+ json!({
+ "project": { "main.rs": "fn main() {}" },
+ "standalone.rs": "fn standalone() {}"
+ }),
+ )
+ .await;
+
+ let project = Project::test(
+ fs,
+ [path!("/project").as_ref(), path!("/standalone.rs").as_ref()],
+ cx,
+ )
+ .await;
+ let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+ let (dir_worktree_id, file_worktree_id) = worktree_store.read_with(cx, |store, cx| {
+ let worktrees: Vec<_> = store.worktrees().collect();
+ assert_eq!(worktrees.len(), 2);
+ let (dir_worktree, file_worktree) = if worktrees[0].read(cx).is_single_file() {
+ (&worktrees[1], &worktrees[0])
+ } else {
+ (&worktrees[0], &worktrees[1])
+ };
+ assert!(!dir_worktree.read(cx).is_single_file());
+ assert!(file_worktree.read(cx).is_single_file());
+ (dir_worktree.read(cx).id(), file_worktree.read(cx).id())
+ });
+
+ let trusted_worktrees = init_trust_global(worktree_store, cx);
+
+ let can_trust_file =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(file_worktree_id, cx));
+ assert!(
+ !can_trust_file,
+ "single-file worktree should be restricted initially"
+ );
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(dir_worktree_id)]),
+ None,
+ cx,
+ );
+ });
+
+ let can_trust_dir =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(dir_worktree_id, cx));
+ let can_trust_file_after =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(file_worktree_id, cx));
+ assert!(can_trust_dir, "directory worktree should be trusted");
+ assert!(
+ can_trust_file_after,
+ "single-file worktree should be trusted after directory worktree trust"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_abs_path_trust_covers_multiple_worktrees(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ json!({
+ "project_a": { "main.rs": "fn main() {}" },
+ "project_b": { "lib.rs": "pub fn lib() {}" }
+ }),
+ )
+ .await;
+
+ let project = Project::test(
+ fs,
+ [
+ path!("/root/project_a").as_ref(),
+ path!("/root/project_b").as_ref(),
+ ],
+ cx,
+ )
+ .await;
+ let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+ let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| {
+ store
+ .worktrees()
+ .map(|worktree| worktree.read(cx).id())
+ .collect()
+ });
+ assert_eq!(worktree_ids.len(), 2);
+
+ let trusted_worktrees = init_trust_global(worktree_store, cx);
+
+ for &worktree_id in &worktree_ids {
+ let can_trust =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(!can_trust, "worktree should be restricted initially");
+ }
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::AbsPath(PathBuf::from(path!("/root")))]),
+ None,
+ cx,
+ );
+ });
+
+ for &worktree_id in &worktree_ids {
+ let can_trust =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(
+ can_trust,
+ "worktree should be trusted after parent path trust"
+ );
+ }
+ }
+
+ #[gpui::test]
+ async fn test_auto_trust_all(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/"),
+ json!({
+ "project_a": { "main.rs": "fn main() {}" },
+ "project_b": { "lib.rs": "pub fn lib() {}" },
+ "single.rs": "fn single() {}"
+ }),
+ )
+ .await;
+
+ let project = Project::test(
+ fs,
+ [
+ path!("/project_a").as_ref(),
+ path!("/project_b").as_ref(),
+ path!("/single.rs").as_ref(),
+ ],
+ cx,
+ )
+ .await;
+ let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+ let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| {
+ store
+ .worktrees()
+ .map(|worktree| worktree.read(cx).id())
+ .collect()
+ });
+ assert_eq!(worktree_ids.len(), 3);
+
+ let trusted_worktrees = init_trust_global(worktree_store.clone(), cx);
+
+ let events: Rc<RefCell<Vec<TrustedWorktreesEvent>>> = Rc::default();
+ cx.update({
+ let events = events.clone();
+ |cx| {
+ cx.subscribe(&trusted_worktrees, move |_, event, _| {
+ events.borrow_mut().push(match event {
+ TrustedWorktreesEvent::Trusted(host, paths) => {
+ TrustedWorktreesEvent::Trusted(host.clone(), paths.clone())
+ }
+ TrustedWorktreesEvent::Restricted(host, paths) => {
+ TrustedWorktreesEvent::Restricted(host.clone(), paths.clone())
+ }
+ });
+ })
+ }
+ })
+ .detach();
+
+ for &worktree_id in &worktree_ids {
+ let can_trust =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(!can_trust, "worktree should be restricted initially");
+ }
+
+ let has_restricted = trusted_worktrees.read_with(cx, |store, cx| {
+ store.has_restricted_worktrees(&worktree_store, cx)
+ });
+ assert!(has_restricted, "should have restricted worktrees");
+
+ events.borrow_mut().clear();
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.auto_trust_all(cx);
+ });
+
+ for &worktree_id in &worktree_ids {
+ let can_trust =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(
+ can_trust,
+ "worktree {worktree_id:?} should be trusted after auto_trust_all"
+ );
+ }
+
+ let has_restricted_after = trusted_worktrees.read_with(cx, |store, cx| {
+ store.has_restricted_worktrees(&worktree_store, cx)
+ });
+ assert!(
+ !has_restricted_after,
+ "should have no restricted worktrees after auto_trust_all"
+ );
+
+ let trusted_event_count = events
+ .borrow()
+ .iter()
+ .filter(|e| matches!(e, TrustedWorktreesEvent::Trusted(..)))
+ .count();
+ assert!(
+ trusted_event_count > 0,
+ "should have emitted Trusted events"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_trust_restrict_trust_cycle(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/root"), json!({ "main.rs": "fn main() {}" }))
+ .await;
+
+ let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+ let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+ let worktree_id = worktree_store.read_with(cx, |store, cx| {
+ store.worktrees().next().unwrap().read(cx).id()
+ });
+
+ let trusted_worktrees = init_trust_global(worktree_store.clone(), cx);
+
+ let events: Rc<RefCell<Vec<TrustedWorktreesEvent>>> = Rc::default();
+ cx.update({
+ let events = events.clone();
+ |cx| {
+ cx.subscribe(&trusted_worktrees, move |_, event, _| {
+ events.borrow_mut().push(match event {
+ TrustedWorktreesEvent::Trusted(host, paths) => {
+ TrustedWorktreesEvent::Trusted(host.clone(), paths.clone())
+ }
+ TrustedWorktreesEvent::Restricted(host, paths) => {
+ TrustedWorktreesEvent::Restricted(host.clone(), paths.clone())
+ }
+ });
+ })
+ }
+ })
+ .detach();
+
+ let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(!can_trust, "should be restricted initially");
+ assert_eq!(events.borrow().len(), 1);
+ events.borrow_mut().clear();
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+ None,
+ cx,
+ );
+ });
+ let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(can_trust, "should be trusted after trust()");
+ assert_eq!(events.borrow().len(), 1);
+ assert!(matches!(
+ &events.borrow()[0],
+ TrustedWorktreesEvent::Trusted(..)
+ ));
+ events.borrow_mut().clear();
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.restrict(
+ HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+ None,
+ cx,
+ );
+ });
+ let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(!can_trust, "should be restricted after restrict()");
+ assert_eq!(events.borrow().len(), 1);
+ assert!(matches!(
+ &events.borrow()[0],
+ TrustedWorktreesEvent::Restricted(..)
+ ));
+
+ let has_restricted = trusted_worktrees.read_with(cx, |store, cx| {
+ store.has_restricted_worktrees(&worktree_store, cx)
+ });
+ assert!(has_restricted);
+ events.borrow_mut().clear();
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+ None,
+ cx,
+ );
+ });
+ let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(can_trust, "should be trusted again after second trust()");
+ assert_eq!(events.borrow().len(), 1);
+ assert!(matches!(
+ &events.borrow()[0],
+ TrustedWorktreesEvent::Trusted(..)
+ ));
+
+ let has_restricted = trusted_worktrees.read_with(cx, |store, cx| {
+ store.has_restricted_worktrees(&worktree_store, cx)
+ });
+ assert!(!has_restricted);
+ }
+
+ #[gpui::test]
+ async fn test_multi_host_trust_isolation(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/"),
+ json!({
+ "local_project": { "main.rs": "fn main() {}" },
+ "remote_project": { "lib.rs": "pub fn lib() {}" }
+ }),
+ )
+ .await;
+
+ let project = Project::test(
+ fs,
+ [
+ path!("/local_project").as_ref(),
+ path!("/remote_project").as_ref(),
+ ],
+ cx,
+ )
+ .await;
+ let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+ let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| {
+ store
+ .worktrees()
+ .map(|worktree| worktree.read(cx).id())
+ .collect()
+ });
+ assert_eq!(worktree_ids.len(), 2);
+ let local_worktree = worktree_ids[0];
+ let _remote_worktree = worktree_ids[1];
+
+ let trusted_worktrees = init_trust_global(worktree_store, cx);
+
+ let host_a: Option<RemoteHostLocation> = None;
+
+ let can_trust_local =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(local_worktree, cx));
+ assert!(!can_trust_local, "local worktree restricted on host_a");
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(local_worktree)]),
+ host_a.clone(),
+ cx,
+ );
+ });
+
+ let can_trust_local_after =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(local_worktree, cx));
+ assert!(
+ can_trust_local_after,
+ "local worktree should be trusted on host_a"
+ );
+ }
+}
@@ -8,10 +8,7 @@ use std::{
use anyhow::{Context as _, Result, anyhow, bail};
use collections::{HashMap, HashSet};
use fs::{Fs, copy_recursive};
-use futures::{
- FutureExt, SinkExt,
- future::{BoxFuture, Shared},
-};
+use futures::{FutureExt, SinkExt, future::Shared};
use gpui::{
App, AppContext as _, AsyncApp, Context, Entity, EntityId, EventEmitter, Task, WeakEntity,
};
@@ -60,6 +57,7 @@ pub struct WorktreeStore {
retain_worktrees: bool,
worktrees: Vec<WorktreeHandle>,
worktrees_reordered: bool,
+ scanning_enabled: bool,
#[allow(clippy::type_complexity)]
loading_worktrees:
HashMap<Arc<SanitizedPath>, Shared<Task<Result<Entity<Worktree>, Arc<anyhow::Error>>>>>,
@@ -96,6 +94,7 @@ impl WorktreeStore {
downstream_client: None,
worktrees: Vec::new(),
worktrees_reordered: false,
+ scanning_enabled: true,
retain_worktrees,
state: WorktreeStoreState::Local { fs },
}
@@ -113,6 +112,7 @@ impl WorktreeStore {
downstream_client: None,
worktrees: Vec::new(),
worktrees_reordered: false,
+ scanning_enabled: true,
retain_worktrees,
state: WorktreeStoreState::Remote {
upstream_client,
@@ -122,6 +122,10 @@ impl WorktreeStore {
}
}
+ pub fn disable_scanner(&mut self) {
+ self.scanning_enabled = false;
+ }
+
/// Iterates through all worktrees, including ones that don't appear in the project panel
pub fn worktrees(&self) -> impl '_ + DoubleEndedIterator<Item = Entity<Worktree>> {
self.worktrees
@@ -579,6 +583,7 @@ impl WorktreeStore {
cx: &mut Context<Self>,
) -> Task<Result<Entity<Worktree>, Arc<anyhow::Error>>> {
let next_entry_id = self.next_entry_id.clone();
+ let scanning_enabled = self.scanning_enabled;
cx.spawn(async move |this, cx| {
let worktree = Worktree::local(
@@ -586,6 +591,7 @@ impl WorktreeStore {
visible,
fs,
next_entry_id,
+ scanning_enabled,
cx,
)
.await;
@@ -999,148 +1005,14 @@ impl WorktreeStore {
matching_paths_rx
}
- fn scan_ignored_dir<'a>(
- fs: &'a Arc<dyn Fs>,
- snapshot: &'a worktree::Snapshot,
- path: &'a RelPath,
- query: &'a SearchQuery,
- filter_tx: &'a Sender<MatchingEntry>,
- output_tx: &'a Sender<oneshot::Receiver<ProjectPath>>,
- ) -> BoxFuture<'a, Result<()>> {
- async move {
- let abs_path = snapshot.absolutize(path);
- let Some(mut files) = fs
- .read_dir(&abs_path)
- .await
- .with_context(|| format!("listing ignored path {abs_path:?}"))
- .log_err()
- else {
- return Ok(());
- };
-
- let mut results = Vec::new();
-
- while let Some(Ok(file)) = files.next().await {
- let Some(metadata) = fs
- .metadata(&file)
- .await
- .with_context(|| format!("fetching fs metadata for {abs_path:?}"))
- .log_err()
- .flatten()
- else {
- continue;
- };
- if metadata.is_symlink || metadata.is_fifo {
- continue;
- }
- let relative_path = file.strip_prefix(snapshot.abs_path())?;
- let relative_path = RelPath::new(&relative_path, snapshot.path_style())
- .context("getting relative path")?;
- results.push((relative_path.into_arc(), !metadata.is_dir))
- }
- results.sort_by(|(a_path, _), (b_path, _)| a_path.cmp(b_path));
- for (path, is_file) in results {
- if is_file {
- if query.filters_path() {
- let matched_path = if query.match_full_paths() {
- let mut full_path = snapshot.root_name().as_std_path().to_owned();
- full_path.push(path.as_std_path());
- query.match_path(&full_path)
- } else {
- query.match_path(&path.as_std_path())
- };
- if !matched_path {
- continue;
- }
- }
- let (tx, rx) = oneshot::channel();
- output_tx.send(rx).await?;
- filter_tx
- .send(MatchingEntry {
- respond: tx,
- worktree_root: snapshot.abs_path().clone(),
- path: ProjectPath {
- worktree_id: snapshot.id(),
- path: path.into_arc(),
- },
- })
- .await?;
- } else {
- Self::scan_ignored_dir(fs, snapshot, &path, query, filter_tx, output_tx)
- .await?;
- }
- }
- Ok(())
- }
- .boxed()
- }
-
async fn find_candidate_paths(
- fs: Arc<dyn Fs>,
- snapshots: Vec<(worktree::Snapshot, WorktreeSettings)>,
- open_entries: HashSet<ProjectEntryId>,
- query: SearchQuery,
- filter_tx: Sender<MatchingEntry>,
- output_tx: Sender<oneshot::Receiver<ProjectPath>>,
+ _: Arc<dyn Fs>,
+ _: Vec<(worktree::Snapshot, WorktreeSettings)>,
+ _: HashSet<ProjectEntryId>,
+ _: SearchQuery,
+ _: Sender<MatchingEntry>,
+ _: Sender<oneshot::Receiver<ProjectPath>>,
) -> Result<()> {
- for (snapshot, settings) in snapshots {
- for entry in snapshot.entries(query.include_ignored(), 0) {
- if entry.is_dir() && entry.is_ignored {
- if !settings.is_path_excluded(&entry.path) {
- Self::scan_ignored_dir(
- &fs,
- &snapshot,
- &entry.path,
- &query,
- &filter_tx,
- &output_tx,
- )
- .await?;
- }
- continue;
- }
-
- if entry.is_fifo || !entry.is_file() {
- continue;
- }
-
- if query.filters_path() {
- let matched_path = if query.match_full_paths() {
- let mut full_path = snapshot.root_name().as_std_path().to_owned();
- full_path.push(entry.path.as_std_path());
- query.match_path(&full_path)
- } else {
- query.match_path(entry.path.as_std_path())
- };
- if !matched_path {
- continue;
- }
- }
-
- let (mut tx, rx) = oneshot::channel();
-
- if open_entries.contains(&entry.id) {
- tx.send(ProjectPath {
- worktree_id: snapshot.id(),
- path: entry.path.clone(),
- })
- .await?;
- } else {
- filter_tx
- .send(MatchingEntry {
- respond: tx,
- worktree_root: snapshot.abs_path().clone(),
- path: ProjectPath {
- worktree_id: snapshot.id(),
- path: entry.path.clone(),
- },
- })
- .await?;
- }
-
- output_tx.send(rx).await?;
- }
- }
Ok(())
}
@@ -0,0 +1,21 @@
+[package]
+name = "project_benchmarks"
+version = "0.1.0"
+publish.workspace = true
+edition.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+clap.workspace = true
+client.workspace = true
+futures.workspace = true
+gpui = { workspace = true, features = ["windows-manifest"] }
+http_client = { workspace = true, features = ["test-support"]}
+language.workspace = true
+node_runtime.workspace = true
+project.workspace = true
+settings.workspace = true
+watch.workspace = true
+
+[lints]
+workspace = true
@@ -0,0 +1,136 @@
+use std::sync::Arc;
+
+use clap::Parser;
+use client::{Client, UserStore};
+use gpui::{AppContext as _, Application};
+use http_client::FakeHttpClient;
+use language::LanguageRegistry;
+use node_runtime::NodeRuntime;
+use project::{
+ Project, RealFs,
+ search::{SearchQuery, SearchResult},
+};
+
+#[derive(Parser)]
+struct Args {
+ /// List of worktrees to run the search against.
+ worktrees: Vec<String>,
+ #[clap(short)]
+ query: String,
+ /// Treat query as a regex.
+ #[clap(short, long)]
+ regex: bool,
+ /// Matches have to be standalone words.
+ #[clap(long)]
+ whole_word: bool,
+ /// Make matching case-sensitive.
+ #[clap(long, default_value_t = false)]
+ case_sensitive: bool,
+ /// Include gitignored files in the search.
+ #[clap(long)]
+ include_ignored: bool,
+}
+
+fn main() -> Result<(), anyhow::Error> {
+ let args = Args::parse();
+ let query = if args.regex {
+ SearchQuery::regex(
+ args.query,
+ args.whole_word,
+ args.case_sensitive,
+ args.include_ignored,
+ false,
+ Default::default(),
+ Default::default(),
+ false,
+ None,
+ )
+ } else {
+ SearchQuery::text(
+ args.query,
+ args.whole_word,
+ args.case_sensitive,
+ args.include_ignored,
+ Default::default(),
+ Default::default(),
+ false,
+ None,
+ )
+ }?;
+ Application::headless().run(|cx| {
+ settings::init(cx);
+ let client = Client::production(cx);
+ let http_client = FakeHttpClient::with_200_response();
+ let (_, rx) = watch::channel(None);
+ let node = NodeRuntime::new(http_client, None, rx);
+ let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
+ let registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
+ let fs = Arc::new(RealFs::new(None, cx.background_executor().clone()));
+ let project = Project::local(
+ client,
+ node,
+ user_store,
+ registry,
+ fs,
+ Some(Default::default()),
+ false,
+ cx,
+ );
+
+ project.clone().update(cx, move |_, cx| {
+ cx.spawn(async move |_, cx| {
+ println!("Loading worktrees");
+ let worktrees = project.update(cx, |this, cx| {
+ args.worktrees
+ .into_iter()
+ .map(|worktree| this.find_or_create_worktree(worktree, true, cx))
+ .collect::<Vec<_>>()
+ })?;
+
+ let worktrees = futures::future::join_all(worktrees)
+ .await
+ .into_iter()
+ .collect::<Result<Vec<_>, anyhow::Error>>()?;
+
+ for (worktree, _) in &worktrees {
+ worktree
+ .update(cx, |this, _| this.as_local().unwrap().scan_complete())?
+ .await;
+ }
+ println!("Worktrees loaded");
+
+ println!("Starting a project search");
+ let timer = std::time::Instant::now();
+ let mut first_match = None;
+ let matches = project
+ .update(cx, |this, cx| this.search(query, cx))
+ .unwrap();
+ let mut matched_files = 0;
+ let mut matched_chunks = 0;
+ while let Ok(match_result) = matches.recv().await {
+ if first_match.is_none() {
+ let time = timer.elapsed();
+ first_match = Some(time);
+ println!("First match found after {time:?}");
+ }
+ if let SearchResult::Buffer { ranges, .. } = match_result {
+ matched_files += 1;
+ matched_chunks += ranges.len();
+ } else {
+ break;
+ }
+ }
+ let elapsed = timer.elapsed();
+ println!(
+ "Finished project search after {elapsed:?}. Matched {matched_files} files and {matched_chunks} excerpts"
+ );
+ drop(project);
+ cx.update(|cx| cx.quit())?;
+
+ anyhow::Ok(())
+ })
+ .detach();
+ });
+ });
+ Ok(())
+}
@@ -53,4 +53,5 @@ editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] }
serde_json.workspace = true
+tempfile.workspace = true
workspace = { workspace = true, features = ["test-support"] }
@@ -1,13 +1,15 @@
use criterion::{Criterion, criterion_group, criterion_main};
use project::{Entry, EntryKind, GitEntry, ProjectEntryId};
-use project_panel::par_sort_worktree_entries;
+use project_panel::par_sort_worktree_entries_with_mode;
+use settings::ProjectPanelSortMode;
use std::sync::Arc;
use util::rel_path::RelPath;
fn load_linux_repo_snapshot() -> Vec<GitEntry> {
- let file = std::fs::read_to_string(
- "/Users/hiro/Projects/zed/crates/project_panel/benches/linux_repo_snapshot.txt",
- )
+ let file = std::fs::read_to_string(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/benches/linux_repo_snapshot.txt"
+ ))
.expect("Failed to read file");
file.lines()
.filter_map(|line| {
@@ -42,10 +44,36 @@ fn load_linux_repo_snapshot() -> Vec<GitEntry> {
}
fn criterion_benchmark(c: &mut Criterion) {
let snapshot = load_linux_repo_snapshot();
+
c.bench_function("Sort linux worktree snapshot", |b| {
b.iter_batched(
|| snapshot.clone(),
- |mut snapshot| par_sort_worktree_entries(&mut snapshot),
+ |mut snapshot| {
+ par_sort_worktree_entries_with_mode(
+ &mut snapshot,
+ ProjectPanelSortMode::DirectoriesFirst,
+ )
+ },
+ criterion::BatchSize::LargeInput,
+ );
+ });
+
+ c.bench_function("Sort linux worktree snapshot (Mixed)", |b| {
+ b.iter_batched(
+ || snapshot.clone(),
+ |mut snapshot| {
+ par_sort_worktree_entries_with_mode(&mut snapshot, ProjectPanelSortMode::Mixed)
+ },
+ criterion::BatchSize::LargeInput,
+ );
+ });
+
+ c.bench_function("Sort linux worktree snapshot (FilesFirst)", |b| {
+ b.iter_batched(
+ || snapshot.clone(),
+ |mut snapshot| {
+ par_sort_worktree_entries_with_mode(&mut snapshot, ProjectPanelSortMode::FilesFirst)
+ },
criterion::BatchSize::LargeInput,
);
});
@@ -7,14 +7,16 @@ use collections::{BTreeSet, HashMap, hash_map};
use command_palette_hooks::CommandPaletteFilter;
use db::kvp::KEY_VALUE_STORE;
use editor::{
- Editor, EditorEvent,
+ Editor, EditorEvent, MultiBufferOffset,
items::{
entry_diagnostic_aware_icon_decoration_and_color,
entry_diagnostic_aware_icon_name_and_color, entry_git_aware_label_color,
},
};
use file_icons::FileIcons;
+use git;
use git::status::GitSummary;
+use git_ui;
use git_ui::file_diff_view::FileDiffView;
use gpui::{
Action, AnyElement, App, AsyncWindowContext, Bounds, ClipboardItem, Context, CursorStyle,
@@ -67,7 +69,7 @@ use workspace::{
notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt},
};
use worktree::CreatedEntry;
-use zed_actions::workspace::OpenWithSystem;
+use zed_actions::{project_panel::ToggleFocus, workspace::OpenWithSystem};
const PROJECT_PANEL_KEY: &str = "ProjectPanel";
const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
@@ -136,10 +138,26 @@ pub struct ProjectPanel {
previous_drag_position: Option<Point<Pixels>>,
sticky_items_count: usize,
last_reported_update: Instant,
- update_visible_entries_task: Task<()>,
+ update_visible_entries_task: UpdateVisibleEntriesTask,
state: State,
}
+struct UpdateVisibleEntriesTask {
+ _visible_entries_task: Task<()>,
+ focus_filename_editor: bool,
+ autoscroll: bool,
+}
+
+impl Default for UpdateVisibleEntriesTask {
+ fn default() -> Self {
+ UpdateVisibleEntriesTask {
+ _visible_entries_task: Task::ready(()),
+ focus_filename_editor: Default::default(),
+ autoscroll: Default::default(),
+ }
+ }
+}
+
enum DragTarget {
/// Dragging on an entry
Entry {
@@ -290,8 +308,6 @@ actions!(
OpenSplitVertical,
/// Opens the selected file in a horizontal split.
OpenSplitHorizontal,
- /// Toggles focus on the project panel.
- ToggleFocus,
/// Toggles visibility of git-ignored files.
ToggleHideGitIgnore,
/// Toggles visibility of hidden files.
@@ -428,6 +444,72 @@ pub fn init(cx: &mut App) {
panel.update(cx, |panel, cx| panel.delete(action, window, cx));
}
});
+
+ workspace.register_action(|workspace, _: &git::FileHistory, window, cx| {
+ // First try to get from project panel if it's focused
+ if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
+ let maybe_project_path = panel.read(cx).state.selection.and_then(|selection| {
+ let project = workspace.project().read(cx);
+ let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
+ let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
+ if entry.is_file() {
+ Some(ProjectPath {
+ worktree_id: selection.worktree_id,
+ path: entry.path.clone(),
+ })
+ } else {
+ None
+ }
+ });
+
+ if let Some(project_path) = maybe_project_path {
+ let project = workspace.project();
+ let git_store = project.read(cx).git_store();
+ if let Some((repo, repo_path)) = git_store
+ .read(cx)
+ .repository_and_path_for_project_path(&project_path, cx)
+ {
+ git_ui::file_history_view::FileHistoryView::open(
+ repo_path,
+ git_store.downgrade(),
+ repo.downgrade(),
+ workspace.weak_handle(),
+ window,
+ cx,
+ );
+ return;
+ }
+ }
+ }
+
+ // Fallback: try to get from active editor
+ if let Some(active_item) = workspace.active_item(cx)
+ && let Some(editor) = active_item.downcast::<Editor>()
+ && let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton()
+ && let Some(file) = buffer.read(cx).file()
+ {
+ 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();
+ if let Some((repo, repo_path)) = git_store
+ .read(cx)
+ .repository_and_path_for_project_path(&project_path, cx)
+ {
+ git_ui::file_history_view::FileHistoryView::open(
+ repo_path,
+ git_store.downgrade(),
+ repo.downgrade(),
+ workspace.weak_handle(),
+ window,
+ cx,
+ );
+ }
+ }
+ });
})
.detach();
}
@@ -505,11 +587,7 @@ impl ProjectPanel {
&git_store,
window,
|this, _, event, window, cx| match event {
- GitStoreEvent::RepositoryUpdated(
- _,
- RepositoryEvent::StatusesChanged { full_scan: _ },
- _,
- )
+ GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _)
| GitStoreEvent::RepositoryAdded
| GitStoreEvent::RepositoryRemoved(_) => {
this.update_visible_entries(None, false, false, window, cx);
@@ -691,6 +769,9 @@ impl ProjectPanel {
if project_panel_settings.hide_hidden != new_settings.hide_hidden {
this.update_visible_entries(None, false, false, window, cx);
}
+ if project_panel_settings.sort_mode != new_settings.sort_mode {
+ this.update_visible_entries(None, false, false, window, cx);
+ }
if project_panel_settings.sticky_scroll && !new_settings.sticky_scroll {
this.sticky_items_count = 0;
}
@@ -737,7 +818,7 @@ impl ProjectPanel {
expanded_dir_ids: Default::default(),
unfolded_dir_ids: Default::default(),
},
- update_visible_entries_task: Task::ready(()),
+ update_visible_entries_task: Default::default(),
};
this.update_visible_entries(None, false, false, window, cx);
@@ -799,7 +880,7 @@ impl ProjectPanel {
});
if !focus_opened_item {
let focus_handle = project_panel.read(cx).focus_handle.clone();
- window.focus(&focus_handle);
+ window.focus(&focus_handle, cx);
}
}
}
@@ -997,6 +1078,18 @@ impl ProjectPanel {
|| (settings.hide_root && visible_worktrees_count == 1));
let should_show_compare = !is_dir && self.file_abs_paths_to_diff(cx).is_some();
+ let has_git_repo = !is_dir && {
+ let project_path = project::ProjectPath {
+ worktree_id,
+ path: entry.path.clone(),
+ };
+ project
+ .git_store()
+ .read(cx)
+ .repository_and_path_for_project_path(&project_path, cx)
+ .is_some()
+ };
+
let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
menu.context(self.focus_handle.clone()).map(|menu| {
if is_read_only {
@@ -1047,6 +1140,10 @@ impl ProjectPanel {
"Copy Relative Path",
Box::new(zed_actions::workspace::CopyRelativePath),
)
+ .when(has_git_repo, |menu| {
+ menu.separator()
+ .action("View File History", Box::new(git::FileHistory))
+ })
.when(!should_hide_rename, |menu| {
menu.separator().action("Rename", Box::new(Rename))
})
@@ -1072,7 +1169,7 @@ impl ProjectPanel {
})
});
- window.focus(&context_menu.focus_handle(cx));
+ window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
this.context_menu.take();
cx.notify();
@@ -1279,7 +1376,7 @@ impl ProjectPanel {
}
});
self.update_visible_entries(Some((worktree_id, entry_id)), false, false, window, cx);
- window.focus(&self.focus_handle);
+ window.focus(&self.focus_handle, cx);
cx.notify();
}
}
@@ -1302,7 +1399,7 @@ impl ProjectPanel {
}
}
self.update_visible_entries(Some((worktree_id, entry_id)), false, false, window, cx);
- window.focus(&self.focus_handle);
+ window.focus(&self.focus_handle, cx);
cx.notify();
}
}
@@ -1432,7 +1529,8 @@ impl ProjectPanel {
}
fn open(&mut self, _: &Open, window: &mut Window, cx: &mut Context<Self>) {
- let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
+ let preview_tabs_enabled =
+ PreviewTabsSettings::get_global(cx).enable_preview_from_project_panel;
self.open_internal(true, !preview_tabs_enabled, None, window, cx);
}
@@ -1565,12 +1663,20 @@ impl ProjectPanel {
let edit_state = self.state.edit_state.as_mut()?;
let worktree_id = edit_state.worktree_id;
let is_new_entry = edit_state.is_new_entry();
- let filename = self.filename_editor.read(cx).text(cx);
+ let mut filename = self.filename_editor.read(cx).text(cx);
+ let path_style = self.project.read(cx).path_style(cx);
+ if path_style.is_windows() {
+ // on windows, trailing dots are ignored in paths
+ // this can cause project panel to create a new entry with a trailing dot
+ // while the actual one without the dot gets populated by the file watcher
+ while let Some(trimmed) = filename.strip_suffix('.') {
+ filename = trimmed.to_string();
+ }
+ }
if filename.trim().is_empty() {
return None;
}
- let path_style = self.project.read(cx).path_style(cx);
let filename_indicates_dir = if path_style.is_windows() {
filename.ends_with('/') || filename.ends_with('\\')
} else {
@@ -1613,7 +1719,7 @@ impl ProjectPanel {
};
if let Some(existing) = worktree.read(cx).entry_for_path(&new_path) {
if existing.id == entry.id && refocus {
- window.focus(&self.focus_handle);
+ window.focus(&self.focus_handle, cx);
}
return None;
}
@@ -1624,7 +1730,7 @@ impl ProjectPanel {
};
if refocus {
- window.focus(&self.focus_handle);
+ window.focus(&self.focus_handle, cx);
}
edit_state.processing_filename = Some(filename);
cx.notify();
@@ -1638,24 +1744,30 @@ impl ProjectPanel {
match new_entry {
Err(e) => {
- project_panel.update_in( cx, |project_panel, window, cx| {
- project_panel.marked_entries.clear();
- project_panel.update_visible_entries(None, false, false, window, cx);
- }).ok();
+ project_panel
+ .update_in(cx, |project_panel, window, cx| {
+ project_panel.marked_entries.clear();
+ project_panel.update_visible_entries(None, false, false, window, cx);
+ })
+ .ok();
Err(e)?;
}
Ok(CreatedEntry::Included(new_entry)) => {
- project_panel.update_in( cx, |project_panel, window, cx| {
+ project_panel.update_in(cx, |project_panel, window, cx| {
if let Some(selection) = &mut project_panel.state.selection
- && selection.entry_id == edited_entry_id {
- selection.worktree_id = worktree_id;
- selection.entry_id = new_entry.id;
- project_panel.marked_entries.clear();
- project_panel.expand_to_selection(cx);
- }
+ && selection.entry_id == edited_entry_id
+ {
+ selection.worktree_id = worktree_id;
+ selection.entry_id = new_entry.id;
+ project_panel.marked_entries.clear();
+ project_panel.expand_to_selection(cx);
+ }
project_panel.update_visible_entries(None, false, false, window, cx);
if is_new_entry && !is_dir {
- project_panel.open_entry(new_entry.id, true, false, cx);
+ let settings = ProjectPanelSettings::get_global(cx);
+ if settings.auto_open.should_open_on_create() {
+ project_panel.open_entry(new_entry.id, true, false, cx);
+ }
}
cx.notify();
})?;
@@ -1670,7 +1782,14 @@ impl ProjectPanel {
project_panel.project.update(cx, |_, cx| {
cx.emit(project::Event::Toast {
notification_id: "excluded-directory".into(),
- message: format!("Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel")
+ message: format!(
+ concat!(
+ "Created an excluded directory at {:?}.\n",
+ "Alter `file_scan_exclusions` in the settings ",
+ "to show it in the panel"
+ ),
+ abs_path
+ ),
})
});
None
@@ -1678,7 +1797,15 @@ impl ProjectPanel {
project_panel
.workspace
.update(cx, |workspace, cx| {
- workspace.open_abs_path(abs_path, OpenOptions { visible: Some(OpenVisible::All), ..Default::default() }, window, cx)
+ workspace.open_abs_path(
+ abs_path,
+ OpenOptions {
+ visible: Some(OpenVisible::All),
+ ..Default::default()
+ },
+ window,
+ cx,
+ )
})
.ok()
}
@@ -1712,7 +1839,7 @@ impl ProjectPanel {
self.autoscroll(cx);
}
- window.focus(&self.focus_handle);
+ window.focus(&self.focus_handle, cx);
cx.notify();
}
@@ -1824,6 +1951,9 @@ impl ProjectPanel {
depth: 0,
validation_state: ValidationState::None,
});
+ self.filename_editor.update(cx, |editor, cx| {
+ editor.clear(window, cx);
+ });
self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), true, true, window, cx);
cx.notify();
}
@@ -1888,11 +2018,12 @@ impl ProjectPanel {
self.filename_editor.update(cx, |editor, cx| {
editor.set_text(file_name, window, cx);
editor.change_selections(Default::default(), window, cx, |s| {
- s.select_ranges([selection])
+ s.select_ranges([
+ MultiBufferOffset(selection.start)..MultiBufferOffset(selection.end)
+ ])
});
- window.focus(&editor.focus_handle(cx));
});
- self.update_visible_entries(None, false, true, window, cx);
+ self.update_visible_entries(None, true, true, window, cx);
cx.notify();
}
}
@@ -2085,7 +2216,8 @@ impl ProjectPanel {
.map(|entry| entry.to_owned())
.collect();
- sort_worktree_entries(&mut siblings);
+ let mode = ProjectPanelSettings::get_global(cx).sort_mode;
+ sort_worktree_entries_with_mode(&mut siblings, mode);
let sibling_entry_index = siblings
.iter()
.position(|sibling| sibling.id == latest_entry.id)?;
@@ -2709,15 +2841,16 @@ impl ProjectPanel {
if item_count == 1 {
// open entry if not dir, setting is enabled, and only focus if rename is not pending
- if !entry.is_dir()
- && ProjectPanelSettings::get_global(cx).open_file_on_paste
- {
- project_panel.open_entry(
- entry.id,
- disambiguation_range.is_none(),
- false,
- cx,
- );
+ if !entry.is_dir() {
+ let settings = ProjectPanelSettings::get_global(cx);
+ if settings.auto_open.should_open_on_paste() {
+ project_panel.open_entry(
+ entry.id,
+ disambiguation_range.is_none(),
+ false,
+ cx,
+ );
+ }
}
// if only one entry was pasted and it was disambiguated, open the rename editor
@@ -3211,6 +3344,7 @@ impl ProjectPanel {
let settings = ProjectPanelSettings::get_global(cx);
let auto_collapse_dirs = settings.auto_fold_dirs;
let hide_gitignore = settings.hide_gitignore;
+ let sort_mode = settings.sort_mode;
let project = self.project.read(cx);
let repo_snapshots = project.git_store().read(cx).repo_snapshots(cx);
@@ -3229,7 +3363,8 @@ impl ProjectPanel {
.collect();
let hide_root = settings.hide_root && visible_worktrees.len() == 1;
let hide_hidden = settings.hide_hidden;
- self.update_visible_entries_task = cx.spawn_in(window, async move |this, cx| {
+
+ let visible_entries_task = cx.spawn_in(window, async move |this, cx| {
let new_state = cx
.background_spawn(async move {
for worktree_snapshot in visible_worktrees {
@@ -3421,7 +3556,10 @@ impl ProjectPanel {
entry_iter.advance();
}
- par_sort_worktree_entries(&mut visible_worktree_entries);
+ par_sort_worktree_entries_with_mode(
+ &mut visible_worktree_entries,
+ sort_mode,
+ );
new_state.visible_entries.push(VisibleEntriesForWorktree {
worktree_id,
entries: visible_worktree_entries,
@@ -3475,19 +3613,27 @@ impl ProjectPanel {
.sum::<usize>(),
)
}
- if focus_filename_editor {
+ if this.update_visible_entries_task.focus_filename_editor {
+ this.update_visible_entries_task.focus_filename_editor = false;
this.filename_editor.update(cx, |editor, cx| {
- editor.clear(window, cx);
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
});
}
- if autoscroll {
+ if this.update_visible_entries_task.autoscroll {
+ this.update_visible_entries_task.autoscroll = false;
this.autoscroll(cx);
}
cx.notify();
})
.ok();
});
+
+ self.update_visible_entries_task = UpdateVisibleEntriesTask {
+ _visible_entries_task: visible_entries_task,
+ focus_filename_editor: focus_filename_editor
+ || self.update_visible_entries_task.focus_filename_editor,
+ autoscroll: autoscroll || self.update_visible_entries_task.autoscroll,
+ };
}
fn expand_entry(
@@ -3565,39 +3711,55 @@ impl ProjectPanel {
cx.spawn_in(window, async move |this, cx| {
async move {
for (filename, original_path) in &paths_to_replace {
- let answer = cx.update(|window, cx| {
- window
- .prompt(
+ let prompt_message = format!(
+ concat!(
+ "A file or folder with name {} ",
+ "already exists in the destination folder. ",
+ "Do you want to replace it?"
+ ),
+ filename
+ );
+ let answer = cx
+ .update(|window, cx| {
+ window.prompt(
PromptLevel::Info,
- format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
+ &prompt_message,
None,
&["Replace", "Cancel"],
cx,
)
- })?.await?;
+ })?
+ .await?;
if answer == 1
- && let Some(item_idx) = paths.iter().position(|p| p == original_path) {
- paths.remove(item_idx);
- }
+ && let Some(item_idx) = paths.iter().position(|p| p == original_path)
+ {
+ paths.remove(item_idx);
+ }
}
if paths.is_empty() {
return Ok(());
}
- let task = worktree.update( cx, |worktree, cx| {
+ let task = worktree.update(cx, |worktree, cx| {
worktree.copy_external_entries(target_directory, paths, fs, cx)
})?;
- let opened_entries = task.await.with_context(|| "failed to copy external paths")?;
+ let opened_entries = task
+ .await
+ .with_context(|| "failed to copy external paths")?;
this.update(cx, |this, cx| {
if open_file_after_drop && !opened_entries.is_empty() {
- this.open_entry(opened_entries[0], true, false, cx);
+ let settings = ProjectPanelSettings::get_global(cx);
+ if settings.auto_open.should_open_on_drop() {
+ this.open_entry(opened_entries[0], true, false, cx);
+ }
}
})
}
- .log_err().await
+ .log_err()
+ .await
})
.detach();
}
@@ -4666,9 +4828,9 @@ impl ProjectPanel {
project_panel.toggle_expanded(entry_id, window, cx);
}
} else {
- let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
+ let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enable_preview_from_project_panel;
let click_count = event.click_count();
- let focus_opened_item = !preview_tabs_enabled || click_count > 1;
+ let focus_opened_item = click_count > 1;
let allow_preview = preview_tabs_enabled && click_count == 1;
project_panel.open_entry(entry_id, focus_opened_item, allow_preview, cx);
}
@@ -4768,7 +4930,7 @@ impl ProjectPanel {
.collect::<Vec<_>>();
let active_index = folded_ancestors.active_index();
let components_len = components.len();
- let delimiter = SharedString::new(path_style.separator());
+ let delimiter = SharedString::new(path_style.primary_separator());
for (index, component) in components.iter().enumerate() {
if index != 0 {
let delimiter_target_index = index - 1;
@@ -5696,7 +5858,7 @@ impl Render for ProjectPanel {
ListHorizontalSizingBehavior::Unconstrained,
)
.with_width_from_item(self.state.max_width_item_index)
- .track_scroll(self.scroll_handle.clone()),
+ .track_scroll(&self.scroll_handle),
)
.child(
div()
@@ -5790,7 +5952,7 @@ impl Render for ProjectPanel {
cx.stop_propagation();
this.state.selection = None;
this.marked_entries.clear();
- this.focus_handle(cx).focus(window);
+ this.focus_handle(cx).focus(window, cx);
}))
.on_mouse_down(
MouseButton::Right,
@@ -5839,7 +6001,7 @@ impl Render for ProjectPanel {
)
.custom_scrollbars(
Scrollbars::for_settings::<ProjectPanelSettings>()
- .tracked_scroll_handle(self.scroll_handle.clone())
+ .tracked_scroll_handle(&self.scroll_handle)
.with_track_along(
ScrollAxes::Horizontal,
cx.theme().colors().panel_background,
@@ -6071,21 +6233,42 @@ impl ClipboardEntry {
}
}
-fn cmp<T: AsRef<Entry>>(lhs: T, rhs: T) -> cmp::Ordering {
- let entry_a = lhs.as_ref();
- let entry_b = rhs.as_ref();
- util::paths::compare_rel_paths(
- (&entry_a.path, entry_a.is_file()),
- (&entry_b.path, entry_b.is_file()),
- )
+#[inline]
+fn cmp_directories_first(a: &Entry, b: &Entry) -> cmp::Ordering {
+ util::paths::compare_rel_paths((&a.path, a.is_file()), (&b.path, b.is_file()))
+}
+
+#[inline]
+fn cmp_mixed(a: &Entry, b: &Entry) -> cmp::Ordering {
+ util::paths::compare_rel_paths_mixed((&a.path, a.is_file()), (&b.path, b.is_file()))
+}
+
+#[inline]
+fn cmp_files_first(a: &Entry, b: &Entry) -> cmp::Ordering {
+ util::paths::compare_rel_paths_files_first((&a.path, a.is_file()), (&b.path, b.is_file()))
+}
+
+#[inline]
+fn cmp_with_mode(a: &Entry, b: &Entry, mode: &settings::ProjectPanelSortMode) -> cmp::Ordering {
+ match mode {
+ settings::ProjectPanelSortMode::DirectoriesFirst => cmp_directories_first(a, b),
+ settings::ProjectPanelSortMode::Mixed => cmp_mixed(a, b),
+ settings::ProjectPanelSortMode::FilesFirst => cmp_files_first(a, b),
+ }
}
-pub fn sort_worktree_entries(entries: &mut [impl AsRef<Entry>]) {
- entries.sort_by(|lhs, rhs| cmp(lhs, rhs));
+pub fn sort_worktree_entries_with_mode(
+ entries: &mut [impl AsRef<Entry>],
+ mode: settings::ProjectPanelSortMode,
+) {
+ entries.sort_by(|lhs, rhs| cmp_with_mode(lhs.as_ref(), rhs.as_ref(), &mode));
}
-pub fn par_sort_worktree_entries(entries: &mut Vec<GitEntry>) {
- entries.par_sort_by(|lhs, rhs| cmp(lhs, rhs));
+pub fn par_sort_worktree_entries_with_mode(
+ entries: &mut Vec<GitEntry>,
+ mode: settings::ProjectPanelSortMode,
+) {
+ entries.par_sort_by(|lhs, rhs| cmp_with_mode(lhs, rhs, &mode));
}
#[cfg(test)]
@@ -3,8 +3,8 @@ use gpui::Pixels;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{
- DockSide, ProjectPanelEntrySpacing, RegisterSetting, Settings, ShowDiagnostics,
- ShowIndentGuides,
+ DockSide, ProjectPanelEntrySpacing, ProjectPanelSortMode, RegisterSetting, Settings,
+ ShowDiagnostics, ShowIndentGuides,
};
use ui::{
px,
@@ -32,7 +32,8 @@ pub struct ProjectPanelSettings {
pub hide_root: bool,
pub hide_hidden: bool,
pub drag_and_drop: bool,
- pub open_file_on_paste: bool,
+ pub auto_open: AutoOpenSettings,
+ pub sort_mode: ProjectPanelSortMode,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -48,6 +49,30 @@ pub struct ScrollbarSettings {
pub show: Option<ShowScrollbar>,
}
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+pub struct AutoOpenSettings {
+ pub on_create: bool,
+ pub on_paste: bool,
+ pub on_drop: bool,
+}
+
+impl AutoOpenSettings {
+ #[inline]
+ pub fn should_open_on_create(self) -> bool {
+ self.on_create
+ }
+
+ #[inline]
+ pub fn should_open_on_paste(self) -> bool {
+ self.on_paste
+ }
+
+ #[inline]
+ pub fn should_open_on_drop(self) -> bool {
+ self.on_drop
+ }
+}
+
impl ScrollbarVisibility for ProjectPanelSettings {
fn visibility(&self, cx: &ui::App) -> ShowScrollbar {
self.scrollbar
@@ -83,7 +108,17 @@ impl Settings for ProjectPanelSettings {
hide_root: project_panel.hide_root.unwrap(),
hide_hidden: project_panel.hide_hidden.unwrap(),
drag_and_drop: project_panel.drag_and_drop.unwrap(),
- open_file_on_paste: project_panel.open_file_on_paste.unwrap(),
+ auto_open: {
+ let auto_open = project_panel.auto_open.unwrap();
+ AutoOpenSettings {
+ on_create: auto_open.on_create.unwrap(),
+ on_paste: auto_open.on_paste.unwrap(),
+ on_drop: auto_open.on_drop.unwrap(),
+ }
+ },
+ sort_mode: project_panel
+ .sort_mode
+ .unwrap_or(ProjectPanelSortMode::DirectoriesFirst),
}
}
}
@@ -1,10 +1,11 @@
use super::*;
use collections::HashSet;
+use editor::MultiBufferOffset;
use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle};
use pretty_assertions::assert_eq;
use project::FakeFs;
use serde_json::json;
-use settings::SettingsStore;
+use settings::{ProjectPanelAutoOpenSettings, SettingsStore};
use std::path::{Path, PathBuf};
use util::{path, paths::PathStyle, rel_path::rel_path};
use workspace::{
@@ -326,6 +327,7 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
ProjectPanelSettings::override_global(
ProjectPanelSettings {
auto_fold_dirs: true,
+ sort_mode: settings::ProjectPanelSortMode::DirectoriesFirst,
..settings
},
cx,
@@ -657,7 +659,9 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) {
let confirm = panel.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
- let file_name_selections = editor.selections.all::<usize>(&editor.display_snapshot(cx));
+ let file_name_selections = editor
+ .selections
+ .all::<MultiBufferOffset>(&editor.display_snapshot(cx));
assert_eq!(
file_name_selections.len(),
1,
@@ -665,12 +669,13 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) {
);
let file_name_selection = &file_name_selections[0];
assert_eq!(
- file_name_selection.start, 0,
+ file_name_selection.start,
+ MultiBufferOffset(0),
"Should select the file name from the start"
);
assert_eq!(
file_name_selection.end,
- "another-filename".len(),
+ MultiBufferOffset("another-filename".len()),
"Should not select file extension"
);
@@ -731,11 +736,11 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) {
panel.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
- let file_name_selections = editor.selections.all::<usize>(&editor.display_snapshot(cx));
+ let file_name_selections = editor.selections.all::<MultiBufferOffset>(&editor.display_snapshot(cx));
assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
let file_name_selection = &file_name_selections[0];
- assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
- assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot..");
+ assert_eq!(file_name_selection.start, MultiBufferOffset(0), "Should select the file name from the start");
+ assert_eq!(file_name_selection.end, MultiBufferOffset("a-different-filename.tar".len()), "Should not select file extension, but still may select anything up to the last dot..");
});
panel.cancel(&menu::Cancel, window, cx)
@@ -807,6 +812,7 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) {
panel.update_in(cx, |panel, window, cx| {
panel.rename(&Default::default(), window, cx)
});
+ cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
@@ -1200,7 +1206,9 @@ async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
-
+ panel.update_in(cx, |panel, window, cx| {
+ assert!(panel.filename_editor.read(cx).is_focused(window));
+ });
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
@@ -1214,7 +1222,9 @@ async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
panel.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
- let file_name_selections = editor.selections.all::<usize>(&editor.display_snapshot(cx));
+ let file_name_selections = editor
+ .selections
+ .all::<MultiBufferOffset>(&editor.display_snapshot(cx));
assert_eq!(
file_name_selections.len(),
1,
@@ -1223,12 +1233,12 @@ async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
let file_name_selection = &file_name_selections[0];
assert_eq!(
file_name_selection.start,
- "one".len(),
+ MultiBufferOffset("one".len()),
"Should select the file name disambiguation after the original file name"
);
assert_eq!(
file_name_selection.end,
- "one copy".len(),
+ MultiBufferOffset("one copy".len()),
"Should select the file name disambiguation until the extension"
);
});
@@ -1239,7 +1249,9 @@ async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
-
+ panel.update_in(cx, |panel, window, cx| {
+ assert!(panel.filename_editor.read(cx).is_focused(window));
+ });
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
@@ -1998,6 +2010,248 @@ async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
ensure_no_open_items_and_panes(&workspace, cx);
}
+#[gpui::test]
+async fn test_auto_open_new_file_when_enabled(cx: &mut gpui::TestAppContext) {
+ init_test_with_editor(cx);
+ set_auto_open_settings(
+ cx,
+ ProjectPanelAutoOpenSettings {
+ on_create: Some(true),
+ ..Default::default()
+ },
+ );
+
+ 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 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, ProjectPanel::new).unwrap();
+ cx.run_until_parked();
+
+ panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
+ cx.run_until_parked();
+ panel
+ .update_in(cx, |panel, window, cx| {
+ panel.filename_editor.update(cx, |editor, cx| {
+ editor.set_text("auto-open.rs", window, cx);
+ });
+ panel.confirm_edit(true, window, cx).unwrap()
+ })
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ ensure_single_file_is_opened(&workspace, "auto-open.rs", cx);
+}
+
+#[gpui::test]
+async fn test_auto_open_new_file_when_disabled(cx: &mut gpui::TestAppContext) {
+ init_test_with_editor(cx);
+ set_auto_open_settings(
+ cx,
+ ProjectPanelAutoOpenSettings {
+ on_create: Some(false),
+ ..Default::default()
+ },
+ );
+
+ 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 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, ProjectPanel::new).unwrap();
+ cx.run_until_parked();
+
+ panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
+ cx.run_until_parked();
+ panel
+ .update_in(cx, |panel, window, cx| {
+ panel.filename_editor.update(cx, |editor, cx| {
+ editor.set_text("manual-open.rs", window, cx);
+ });
+ panel.confirm_edit(true, window, cx).unwrap()
+ })
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ ensure_no_open_items_and_panes(&workspace, cx);
+}
+
+#[gpui::test]
+async fn test_auto_open_on_paste_when_enabled(cx: &mut gpui::TestAppContext) {
+ init_test_with_editor(cx);
+ set_auto_open_settings(
+ cx,
+ ProjectPanelAutoOpenSettings {
+ on_paste: Some(true),
+ ..Default::default()
+ },
+ );
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ json!({
+ "src": {
+ "original.rs": ""
+ },
+ "target": {}
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+ cx.run_until_parked();
+
+ toggle_expand_dir(&panel, "root/src", cx);
+ toggle_expand_dir(&panel, "root/target", cx);
+
+ select_path(&panel, "root/src/original.rs", cx);
+ panel.update_in(cx, |panel, window, cx| {
+ panel.copy(&Default::default(), window, cx);
+ });
+
+ select_path(&panel, "root/target", cx);
+ panel.update_in(cx, |panel, window, cx| {
+ panel.paste(&Default::default(), window, cx);
+ });
+ cx.executor().run_until_parked();
+
+ ensure_single_file_is_opened(&workspace, "target/original.rs", cx);
+}
+
+#[gpui::test]
+async fn test_auto_open_on_paste_when_disabled(cx: &mut gpui::TestAppContext) {
+ init_test_with_editor(cx);
+ set_auto_open_settings(
+ cx,
+ ProjectPanelAutoOpenSettings {
+ on_paste: Some(false),
+ ..Default::default()
+ },
+ );
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ json!({
+ "src": {
+ "original.rs": ""
+ },
+ "target": {}
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+ cx.run_until_parked();
+
+ toggle_expand_dir(&panel, "root/src", cx);
+ toggle_expand_dir(&panel, "root/target", cx);
+
+ select_path(&panel, "root/src/original.rs", cx);
+ panel.update_in(cx, |panel, window, cx| {
+ panel.copy(&Default::default(), window, cx);
+ });
+
+ select_path(&panel, "root/target", cx);
+ panel.update_in(cx, |panel, window, cx| {
+ panel.paste(&Default::default(), window, cx);
+ });
+ cx.executor().run_until_parked();
+
+ ensure_no_open_items_and_panes(&workspace, cx);
+ assert!(
+ find_project_entry(&panel, "root/target/original.rs", cx).is_some(),
+ "Pasted entry should exist even when auto-open is disabled"
+ );
+}
+
+#[gpui::test]
+async fn test_auto_open_on_drop_when_enabled(cx: &mut gpui::TestAppContext) {
+ init_test_with_editor(cx);
+ set_auto_open_settings(
+ cx,
+ ProjectPanelAutoOpenSettings {
+ on_drop: Some(true),
+ ..Default::default()
+ },
+ );
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/root"), json!({})).await;
+
+ let temp_dir = tempfile::tempdir().unwrap();
+ let external_path = temp_dir.path().join("dropped.rs");
+ std::fs::write(&external_path, "// dropped").unwrap();
+ fs.insert_tree_from_real_fs(temp_dir.path(), temp_dir.path())
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+ cx.run_until_parked();
+
+ let root_entry = find_project_entry(&panel, "root", cx).unwrap();
+ panel.update_in(cx, |panel, window, cx| {
+ panel.drop_external_files(std::slice::from_ref(&external_path), root_entry, window, cx);
+ });
+ cx.executor().run_until_parked();
+
+ ensure_single_file_is_opened(&workspace, "dropped.rs", cx);
+}
+
+#[gpui::test]
+async fn test_auto_open_on_drop_when_disabled(cx: &mut gpui::TestAppContext) {
+ init_test_with_editor(cx);
+ set_auto_open_settings(
+ cx,
+ ProjectPanelAutoOpenSettings {
+ on_drop: Some(false),
+ ..Default::default()
+ },
+ );
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/root"), json!({})).await;
+
+ let temp_dir = tempfile::tempdir().unwrap();
+ let external_path = temp_dir.path().join("manual.rs");
+ std::fs::write(&external_path, "// dropped").unwrap();
+ fs.insert_tree_from_real_fs(temp_dir.path(), temp_dir.path())
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+ cx.run_until_parked();
+
+ let root_entry = find_project_entry(&panel, "root", cx).unwrap();
+ panel.update_in(cx, |panel, window, cx| {
+ panel.drop_external_files(std::slice::from_ref(&external_path), root_entry, window, cx);
+ });
+ cx.executor().run_until_parked();
+
+ ensure_no_open_items_and_panes(&workspace, cx);
+ assert!(
+ find_project_entry(&panel, "root/manual.rs", cx).is_some(),
+ "Dropped entry should exist even when auto-open is disabled"
+ );
+}
+
#[gpui::test]
async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
@@ -2156,6 +2410,7 @@ async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
],
);
panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
+ cx.executor().run_until_parked();
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
@@ -2361,6 +2616,7 @@ async fn test_create_duplicate_items_and_check_history(cx: &mut gpui::TestAppCon
],
);
panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
+ cx.executor().run_until_parked();
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
@@ -3931,6 +4187,106 @@ async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) {
);
}
+#[gpui::test]
+async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root_a",
+ json!({
+ "src": {
+ "lib.rs": "",
+ "main.rs": ""
+ },
+ "docs": {
+ "guide.md": ""
+ },
+ "multi": {
+ "alpha.txt": "",
+ "beta.txt": ""
+ }
+ }),
+ )
+ .await;
+ fs.insert_tree(
+ "/root_b",
+ json!({
+ "dst": {
+ "existing.md": ""
+ },
+ "target.txt": ""
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), ["/root_a".as_ref(), "/root_b".as_ref()], cx).await;
+ let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+ cx.run_until_parked();
+
+ // Case 1: move a file onto a directory in another worktree.
+ select_path(&panel, "root_a/src/main.rs", cx);
+ drag_selection_to(&panel, "root_b/dst", false, cx);
+ assert!(
+ find_project_entry(&panel, "root_b/dst/main.rs", cx).is_some(),
+ "Dragged file should appear under destination worktree"
+ );
+ assert_eq!(
+ find_project_entry(&panel, "root_a/src/main.rs", cx),
+ None,
+ "Dragged file should be removed from the source worktree"
+ );
+
+ // Case 2: drop a file onto another worktree file so it lands in the parent directory.
+ select_path(&panel, "root_a/docs/guide.md", cx);
+ drag_selection_to(&panel, "root_b/dst/existing.md", true, cx);
+ assert!(
+ find_project_entry(&panel, "root_b/dst/guide.md", cx).is_some(),
+ "Dropping onto a file should place the entry beside the target file"
+ );
+ assert_eq!(
+ find_project_entry(&panel, "root_a/docs/guide.md", cx),
+ None,
+ "Source file should be removed after the move"
+ );
+
+ // Case 3: move an entire directory.
+ select_path(&panel, "root_a/src", cx);
+ drag_selection_to(&panel, "root_b/dst", false, cx);
+ assert!(
+ find_project_entry(&panel, "root_b/dst/src/lib.rs", cx).is_some(),
+ "Dragging a directory should move its nested contents"
+ );
+ assert_eq!(
+ find_project_entry(&panel, "root_a/src", cx),
+ None,
+ "Directory should no longer exist in the source worktree"
+ );
+
+ // Case 4: multi-selection drag between worktrees.
+ panel.update(cx, |panel, _| panel.marked_entries.clear());
+ select_path_with_mark(&panel, "root_a/multi/alpha.txt", cx);
+ select_path_with_mark(&panel, "root_a/multi/beta.txt", cx);
+ drag_selection_to(&panel, "root_b/dst", false, cx);
+ assert!(
+ find_project_entry(&panel, "root_b/dst/alpha.txt", cx).is_some()
+ && find_project_entry(&panel, "root_b/dst/beta.txt", cx).is_some(),
+ "All marked entries should move to the destination worktree"
+ );
+ assert_eq!(
+ find_project_entry(&panel, "root_a/multi/alpha.txt", cx),
+ None,
+ "Marked entries should be removed from the origin worktree"
+ );
+ assert_eq!(
+ find_project_entry(&panel, "root_a/multi/beta.txt", cx),
+ None,
+ "Marked entries should be removed from the origin worktree"
+ );
+}
+
#[gpui::test]
async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
@@ -6256,6 +6612,74 @@ async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppC
);
}
+#[cfg(windows)]
+#[gpui::test]
+async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ json!({
+ "dir1": {
+ "file1.txt": "",
+ },
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+ let panel = workspace
+ .update(cx, |workspace, window, cx| {
+ let panel = ProjectPanel::new(workspace, window, cx);
+ workspace.add_panel(panel.clone(), window, cx);
+ panel
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ #[rustfmt::skip]
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &[
+ "v root",
+ " > dir1",
+ ],
+ "Initial state with nothing selected"
+ );
+
+ panel.update_in(cx, |panel, window, cx| {
+ panel.new_file(&NewFile, window, cx);
+ });
+ cx.run_until_parked();
+ panel.update_in(cx, |panel, window, cx| {
+ assert!(panel.filename_editor.read(cx).is_focused(window));
+ });
+ panel
+ .update_in(cx, |panel, window, cx| {
+ panel
+ .filename_editor
+ .update(cx, |editor, cx| editor.set_text("foo.", window, cx));
+ panel.confirm_edit(true, window, cx).unwrap()
+ })
+ .await
+ .unwrap();
+ cx.run_until_parked();
+ #[rustfmt::skip]
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &[
+ "v root",
+ " > dir1",
+ " foo <== selected <== marked",
+ ],
+ "A new file is created under the root directory without the trailing dot"
+ );
+}
+
#[gpui::test]
async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
init_test(cx);
@@ -7254,6 +7678,32 @@ fn select_path_with_mark(panel: &Entity<ProjectPanel>, path: &str, cx: &mut Visu
});
}
+fn drag_selection_to(
+ panel: &Entity<ProjectPanel>,
+ target_path: &str,
+ is_file: bool,
+ cx: &mut VisualTestContext,
+) {
+ let target_entry = find_project_entry(panel, target_path, cx)
+ .unwrap_or_else(|| panic!("no entry for target path {target_path:?}"));
+
+ panel.update_in(cx, |panel, window, cx| {
+ let selection = panel
+ .state
+ .selection
+ .expect("a selection is required before dragging");
+ let drag = DraggedSelection {
+ active_selection: SelectedEntry {
+ worktree_id: selection.worktree_id,
+ entry_id: panel.resolve_entry(selection.entry_id),
+ },
+ marked_selections: Arc::from(panel.marked_entries.clone()),
+ };
+ panel.drag_onto(&drag, target_entry, is_file, window, cx);
+ });
+ cx.executor().run_until_parked();
+}
+
fn find_project_entry(
panel: &Entity<ProjectPanel>,
path: &str,
@@ -7329,6 +7779,215 @@ fn visible_entries_as_strings(
result
}
+/// Test that missing sort_mode field defaults to DirectoriesFirst
+#[gpui::test]
+async fn test_sort_mode_default_fallback(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ // Verify that when sort_mode is not specified, it defaults to DirectoriesFirst
+ let default_settings = cx.read(|cx| *ProjectPanelSettings::get_global(cx));
+ assert_eq!(
+ default_settings.sort_mode,
+ settings::ProjectPanelSortMode::DirectoriesFirst,
+ "sort_mode should default to DirectoriesFirst"
+ );
+}
+
+/// Test sort modes: DirectoriesFirst (default) vs Mixed
+#[gpui::test]
+async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "zebra.txt": "",
+ "Apple": {},
+ "banana.rs": "",
+ "Carrot": {},
+ "aardvark.txt": "",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+ let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+ cx.run_until_parked();
+
+ // Default sort mode should be DirectoriesFirst
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..50, cx),
+ &[
+ "v root",
+ " > Apple",
+ " > Carrot",
+ " aardvark.txt",
+ " banana.rs",
+ " zebra.txt",
+ ]
+ );
+}
+
+#[gpui::test]
+async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "Zebra.txt": "",
+ "apple": {},
+ "Banana.rs": "",
+ "carrot": {},
+ "Aardvark.txt": "",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+ let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+ // Switch to Mixed mode
+ cx.update(|_, cx| {
+ cx.update_global::<SettingsStore, _>(|store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.project_panel.get_or_insert_default().sort_mode =
+ Some(settings::ProjectPanelSortMode::Mixed);
+ });
+ });
+ });
+
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+ cx.run_until_parked();
+
+ // Mixed mode: case-insensitive sorting
+ // Aardvark < apple < Banana < carrot < Zebra (all case-insensitive)
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..50, cx),
+ &[
+ "v root",
+ " Aardvark.txt",
+ " > apple",
+ " Banana.rs",
+ " > carrot",
+ " Zebra.txt",
+ ]
+ );
+}
+
+#[gpui::test]
+async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "Zebra.txt": "",
+ "apple": {},
+ "Banana.rs": "",
+ "carrot": {},
+ "Aardvark.txt": "",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+ let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+ // Switch to FilesFirst mode
+ cx.update(|_, cx| {
+ cx.update_global::<SettingsStore, _>(|store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.project_panel.get_or_insert_default().sort_mode =
+ Some(settings::ProjectPanelSortMode::FilesFirst);
+ });
+ });
+ });
+
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+ cx.run_until_parked();
+
+ // FilesFirst mode: files first, then directories (both case-insensitive)
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..50, cx),
+ &[
+ "v root",
+ " Aardvark.txt",
+ " Banana.rs",
+ " Zebra.txt",
+ " > apple",
+ " > carrot",
+ ]
+ );
+}
+
+#[gpui::test]
+async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "file2.txt": "",
+ "dir1": {},
+ "file1.txt": "",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+ let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+ cx.run_until_parked();
+
+ // Initially DirectoriesFirst
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..50, cx),
+ &["v root", " > dir1", " file1.txt", " file2.txt",]
+ );
+
+ // Toggle to Mixed
+ cx.update(|_, cx| {
+ cx.update_global::<SettingsStore, _>(|store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.project_panel.get_or_insert_default().sort_mode =
+ Some(settings::ProjectPanelSortMode::Mixed);
+ });
+ });
+ });
+ cx.run_until_parked();
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..50, cx),
+ &["v root", " > dir1", " file1.txt", " file2.txt",]
+ );
+
+ // Toggle back to DirectoriesFirst
+ cx.update(|_, cx| {
+ cx.update_global::<SettingsStore, _>(|store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.project_panel.get_or_insert_default().sort_mode =
+ Some(settings::ProjectPanelSortMode::DirectoriesFirst);
+ });
+ });
+ });
+ cx.run_until_parked();
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..50, cx),
+ &["v root", " > dir1", " file1.txt", " file2.txt",]
+ );
+}
+
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
@@ -7368,6 +8027,19 @@ fn init_test_with_editor(cx: &mut TestAppContext) {
});
}
+fn set_auto_open_settings(
+ cx: &mut TestAppContext,
+ auto_open_settings: ProjectPanelAutoOpenSettings,
+) {
+ cx.update(|cx| {
+ cx.update_global::<SettingsStore, _>(|store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.project_panel.get_or_insert_default().auto_open = Some(auto_open_settings);
+ });
+ })
+ });
+}
+
fn ensure_single_file_is_opened(
window: &WindowHandle<Workspace>,
expected_path: &str,
@@ -34,6 +34,7 @@ language = { workspace = true, features = ["test-support"] }
lsp = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
release_channel.workspace = true
+semver.workspace = true
settings = { workspace = true, features = ["test-support"] }
theme = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
@@ -133,8 +133,9 @@ impl PickerDelegate for ProjectSymbolsDelegate {
workspace.active_pane().clone()
};
- let editor =
- workspace.open_project_item::<Editor>(pane, buffer, true, true, window, cx);
+ let editor = workspace.open_project_item::<Editor>(
+ pane, buffer, true, true, true, true, window, cx,
+ );
editor.update(cx, |editor, cx| {
editor.change_selections(
@@ -224,7 +225,9 @@ impl PickerDelegate for ProjectSymbolsDelegate {
let path_style = self.project.read(cx).path_style(cx);
let string_match = &self.matches.get(ix)?;
let symbol = &self.symbols.get(string_match.candidate_id)?;
- let syntax_runs = styled_runs_for_code_label(&symbol.label, cx.theme().syntax());
+ let theme = cx.theme();
+ let local_player = theme.players().local();
+ let syntax_runs = styled_runs_for_code_label(&symbol.label, theme.syntax(), &local_player);
let path = match &symbol.path {
SymbolLocation::InProject(project_path) => {
@@ -289,7 +292,7 @@ impl PickerDelegate for ProjectSymbolsDelegate {
mod tests {
use super::*;
use futures::StreamExt;
- use gpui::{SemanticVersion, TestAppContext, VisualContext};
+ use gpui::{TestAppContext, VisualContext};
use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher};
use lsp::OneOf;
use project::FakeFs;
@@ -438,7 +441,7 @@ mod tests {
let store = SettingsStore::test(cx);
cx.set_global(store);
theme::init(theme::LoadThemes::JustBase, cx);
- release_channel::init(SemanticVersion::default(), cx);
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
editor::init(cx);
});
}
@@ -55,7 +55,7 @@ pub struct PromptMetadata {
#[serde(tag = "kind")]
pub enum PromptId {
User { uuid: UserPromptId },
- EditWorkflow,
+ CommitMessage,
}
impl PromptId {
@@ -63,8 +63,31 @@ impl PromptId {
UserPromptId::new().into()
}
+ pub fn user_id(&self) -> Option<UserPromptId> {
+ match self {
+ Self::User { uuid } => Some(*uuid),
+ _ => None,
+ }
+ }
+
pub fn is_built_in(&self) -> bool {
- !matches!(self, PromptId::User { .. })
+ match self {
+ Self::User { .. } => false,
+ Self::CommitMessage => true,
+ }
+ }
+
+ pub fn can_edit(&self) -> bool {
+ match self {
+ Self::User { .. } | Self::CommitMessage => true,
+ }
+ }
+
+ pub fn default_content(&self) -> Option<&'static str> {
+ match self {
+ Self::User { .. } => None,
+ Self::CommitMessage => Some(include_str!("../../git_ui/src/commit_message_prompt.txt")),
+ }
}
}
@@ -94,7 +117,7 @@ impl std::fmt::Display for PromptId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PromptId::User { uuid } => write!(f, "{}", uuid.0),
- PromptId::EditWorkflow => write!(f, "Edit workflow"),
+ PromptId::CommitMessage => write!(f, "Commit message"),
}
}
}
@@ -176,10 +199,8 @@ impl PromptStore {
let metadata = db_env.create_database(&mut txn, Some("metadata.v2"))?;
let bodies = db_env.create_database(&mut txn, Some("bodies.v2"))?;
- // Remove edit workflow prompt, as we decided to opt into it using
- // a slash command instead.
- metadata.delete(&mut txn, &PromptId::EditWorkflow).ok();
- bodies.delete(&mut txn, &PromptId::EditWorkflow).ok();
+ metadata.delete(&mut txn, &PromptId::CommitMessage)?;
+ bodies.delete(&mut txn, &PromptId::CommitMessage)?;
txn.commit()?;
@@ -387,8 +408,8 @@ impl PromptStore {
body: Rope,
cx: &Context<Self>,
) -> Task<Result<()>> {
- if id.is_built_in() {
- return Task::ready(Err(anyhow!("built-in prompts cannot be saved")));
+ if !id.can_edit() {
+ return Task::ready(Err(anyhow!("this prompt cannot be edited")));
}
let prompt_metadata = PromptMetadata {
@@ -430,7 +451,7 @@ impl PromptStore {
) -> Task<Result<()>> {
let mut cache = self.metadata_cache.write();
- if id.is_built_in() {
+ if !id.can_edit() {
title = cache
.metadata_by_id
.get(&id)
@@ -20,6 +20,18 @@ use util::{
use crate::UserPromptId;
+pub const RULES_FILE_NAMES: &[&str] = &[
+ ".rules",
+ ".cursorrules",
+ ".windsurfrules",
+ ".clinerules",
+ ".github/copilot-instructions.md",
+ "CLAUDE.md",
+ "AGENT.md",
+ "AGENTS.md",
+ "GEMINI.md",
+];
+
#[derive(Default, Debug, Clone, Serialize)]
pub struct ProjectContext {
pub worktrees: Vec<WorktreeContext>,
@@ -94,6 +106,16 @@ pub struct ContentPromptContext {
pub diagnostic_errors: Vec<ContentPromptDiagnosticContext>,
}
+#[derive(Serialize)]
+pub struct ContentPromptContextV2 {
+ pub content_type: String,
+ pub language_name: Option<String>,
+ pub is_truncated: bool,
+ pub document_content: String,
+ pub rewrite_section: String,
+ pub diagnostic_errors: Vec<ContentPromptDiagnosticContext>,
+}
+
#[derive(Serialize)]
pub struct TerminalAssistantPromptContext {
pub os: String,
@@ -276,6 +298,78 @@ impl PromptBuilder {
Ok(())
}
+ pub fn generate_inline_transformation_prompt_tools(
+ &self,
+ language_name: Option<&LanguageName>,
+ buffer: BufferSnapshot,
+ range: Range<usize>,
+ ) -> Result<String, RenderError> {
+ let content_type = match language_name.as_ref().map(|l| l.as_ref()) {
+ None | Some("Markdown" | "Plain Text") => "text",
+ Some(_) => "code",
+ };
+
+ const MAX_CTX: usize = 50000;
+ let mut is_truncated = false;
+
+ let before_range = 0..range.start;
+ let truncated_before = if before_range.len() > MAX_CTX {
+ is_truncated = true;
+ let start = buffer.clip_offset(range.start - MAX_CTX, text::Bias::Right);
+ start..range.start
+ } else {
+ before_range
+ };
+
+ let after_range = range.end..buffer.len();
+ let truncated_after = if after_range.len() > MAX_CTX {
+ is_truncated = true;
+ let end = buffer.clip_offset(range.end + MAX_CTX, text::Bias::Left);
+ range.end..end
+ } else {
+ after_range
+ };
+
+ let mut document_content = String::new();
+ for chunk in buffer.text_for_range(truncated_before) {
+ document_content.push_str(chunk);
+ }
+
+ document_content.push_str("<rewrite_this>\n");
+ for chunk in buffer.text_for_range(range.clone()) {
+ document_content.push_str(chunk);
+ }
+ document_content.push_str("\n</rewrite_this>");
+
+ for chunk in buffer.text_for_range(truncated_after) {
+ document_content.push_str(chunk);
+ }
+
+ let rewrite_section: String = buffer.text_for_range(range.clone()).collect();
+
+ let diagnostics = buffer.diagnostics_in_range::<_, Point>(range, false);
+ let diagnostic_errors: Vec<ContentPromptDiagnosticContext> = diagnostics
+ .map(|entry| {
+ let start = entry.range.start;
+ ContentPromptDiagnosticContext {
+ line_number: (start.row + 1) as usize,
+ error_message: entry.diagnostic.message.clone(),
+ code_content: buffer.text_for_range(entry.range).collect(),
+ }
+ })
+ .collect();
+
+ let context = ContentPromptContextV2 {
+ content_type: content_type.to_string(),
+ language_name: language_name.map(|s| s.to_string()),
+ is_truncated,
+ document_content,
+ rewrite_section,
+ diagnostic_errors,
+ };
+ self.handlebars.lock().render("content_prompt_v2", &context)
+ }
+
pub fn generate_inline_transformation_prompt(
&self,
user_prompt: String,
@@ -186,6 +186,27 @@ message ExternalAgentsUpdated {
repeated string names = 2;
}
+message ExternalExtensionAgentTarget {
+ string archive = 1;
+ string cmd = 2;
+ repeated string args = 3;
+ optional string sha256 = 4;
+ map<string, string> env = 5;
+}
+
+message ExternalExtensionAgent {
+ string name = 1;
+ optional string icon_path = 2;
+ string extension_id = 3;
+ map<string, ExternalExtensionAgentTarget> targets = 4;
+ map<string, string> env = 5;
+}
+
+message ExternalExtensionAgentsUpdated {
+ uint64 project_id = 1;
+ repeated ExternalExtensionAgent agents = 2;
+}
+
message ExternalAgentLoadingStatusUpdated {
uint64 project_id = 1;
string name = 2;
@@ -258,6 +258,7 @@ message Diagnostic {
Anchor start = 1;
Anchor end = 2;
optional string source = 3;
+ optional string registration_id = 17;
enum SourceKind {
Pulled = 0;
@@ -124,6 +124,8 @@ message UpdateRepository {
optional GitCommitDetails head_commit_details = 11;
optional string merge_message = 12;
repeated StashEntry stash_entries = 13;
+ optional string remote_upstream_url = 14;
+ optional string remote_origin_url = 15;
}
message RemoveRepository {
@@ -190,6 +192,25 @@ message GitRenameBranch {
string new_name = 4;
}
+message GitCreateRemote {
+ uint64 project_id = 1;
+ uint64 repository_id = 2;
+ string remote_name = 3;
+ string remote_url = 4;
+}
+
+message GitRemoveRemote {
+ uint64 project_id = 1;
+ uint64 repository_id = 2;
+ string remote_name = 3;
+}
+
+message GitDeleteBranch {
+ uint64 project_id = 1;
+ uint64 repository_id = 2;
+ string branch_name = 3;
+}
+
message GitDiff {
uint64 project_id = 1;
reserved 2;
@@ -284,6 +305,29 @@ message GitCheckoutFiles {
repeated string paths = 5;
}
+message GitFileHistory {
+ uint64 project_id = 1;
+ reserved 2;
+ uint64 repository_id = 3;
+ string path = 4;
+ uint64 skip = 5;
+ optional uint64 limit = 6;
+}
+
+message GitFileHistoryResponse {
+ repeated FileHistoryEntry entries = 1;
+ string path = 2;
+}
+
+message FileHistoryEntry {
+ string sha = 1;
+ string subject = 2;
+ string message = 3;
+ int64 commit_timestamp = 4;
+ string author_name = 5;
+ string author_email = 6;
+}
+
// Move to `git.proto` once collab's min version is >=0.171.0.
message StatusEntry {
string repo_path = 1;
@@ -389,6 +433,7 @@ message GetRemotes {
reserved 2;
uint64 repository_id = 3;
optional string branch_name = 4;
+ bool is_push = 5;
}
message GetRemotesResponse {
@@ -457,8 +502,8 @@ message BlameBufferResponse {
message BlameResponse {
repeated BlameEntry entries = 1;
repeated CommitMessage messages = 2;
- optional string remote_url = 4;
reserved 3;
+ reserved 4;
}
optional BlameResponse blame_response = 5;
@@ -531,3 +576,14 @@ message GitCreateWorktree {
string directory = 4;
optional string commit = 5;
}
+
+message RunGitHook {
+ enum GitHook {
+ PRE_COMMIT = 0;
+ reserved 1;
+ }
+
+ uint64 project_id = 1;
+ uint64 repository_id = 2;
+ GitHook hook = 3;
+}
@@ -615,8 +615,16 @@ message RegisteredForBuffer {
uint64 buffer_id = 2;
}
+message LanguageServerBinaryInfo {
+ string path = 1;
+ repeated string arguments = 2;
+}
+
message ServerMetadataUpdated {
optional string capabilities = 1;
+ optional LanguageServerBinaryInfo binary = 2;
+ optional string configuration = 3;
+ repeated string workspace_folders = 4;
}
message LanguageServerLog {
@@ -703,6 +711,7 @@ message GetCompletions {
uint64 buffer_id = 2;
Anchor position = 3;
repeated VectorClockEntry version = 4;
+ optional uint64 server_id = 5;
}
message CancelLanguageServerWork {
@@ -940,6 +949,7 @@ message PulledDiagnostics {
optional string result_id = 3;
bool changed = 4;
repeated LspDiagnostic diagnostics = 5;
+ optional string registration_id = 6;
}
message PullWorkspaceDiagnostics {
@@ -158,3 +158,24 @@ message UpdateUserSettings {
uint64 project_id = 1;
string contents = 2;
}
+
+message TrustWorktrees {
+ uint64 project_id = 1;
+ repeated PathTrust trusted_paths = 2;
+}
+
+message PathTrust {
+ oneof content {
+ uint64 worktree_id = 2;
+ string abs_path = 3;
+ }
+
+ reserved 1;
+}
+
+message RestrictWorktrees {
+ uint64 project_id = 1;
+ repeated uint64 worktree_ids = 3;
+
+ reserved 2;
+}
@@ -410,7 +410,6 @@ message Envelope {
AgentServerCommand agent_server_command = 374;
ExternalAgentsUpdated external_agents_updated = 375;
-
ExternalAgentLoadingStatusUpdated external_agent_loading_status_updated = 376;
NewExternalAgentVersionAvailable new_external_agent_version_available = 377;
@@ -436,10 +435,26 @@ message Envelope {
OpenImageByPath open_image_by_path = 391;
OpenImageResponse open_image_response = 392;
- CreateImageForPeer create_image_for_peer = 393; // current max
+ CreateImageForPeer create_image_for_peer = 393;
+
+
+ GitFileHistory git_file_history = 397;
+ GitFileHistoryResponse git_file_history_response = 398;
+
+ RunGitHook run_git_hook = 399;
+
+ GitDeleteBranch git_delete_branch = 400;
+
+ ExternalExtensionAgentsUpdated external_extension_agents_updated = 401;
+
+ GitCreateRemote git_create_remote = 402;
+ GitRemoveRemote git_remove_remote = 403;
+
+ TrustWorktrees trust_worktrees = 404;
+ RestrictWorktrees restrict_worktrees = 405; // current max
}
- reserved 87 to 88;
+ reserved 87 to 88, 396;
reserved 102 to 103;
reserved 158 to 161;
reserved 164;
@@ -463,6 +478,7 @@ message Envelope {
reserved 270;
reserved 280 to 281;
reserved 332 to 333;
+ reserved 394 to 395;
}
message Hello {
@@ -126,7 +126,7 @@ impl ErrorExt for anyhow::Error {
if let Some(rpc_error) = self.downcast_ref::<RpcError>() {
rpc_error.cloned()
} else {
- anyhow::anyhow!("{self}")
+ anyhow::anyhow!("{self:#}")
}
}
}
@@ -49,6 +49,7 @@ messages!(
(ChannelMessageUpdate, Foreground),
(CloseBuffer, Foreground),
(Commit, Background),
+ (RunGitHook, Background),
(CopyProjectEntry, Foreground),
(CreateBufferForPeer, Foreground),
(CreateImageForPeer, Foreground),
@@ -289,9 +290,12 @@ messages!(
(RemoveRepository, Foreground),
(UsersResponse, Foreground),
(GitReset, Background),
+ (GitDeleteBranch, Background),
(GitCheckoutFiles, Background),
(GitShow, Background),
(GitCommitDetails, Background),
+ (GitFileHistory, Background),
+ (GitFileHistoryResponse, Background),
(SetIndexText, Background),
(Push, Background),
(Fetch, Background),
@@ -301,9 +305,13 @@ messages!(
(RemoteMessageResponse, Background),
(AskPassRequest, Background),
(AskPassResponse, Background),
+ (GitCreateRemote, Background),
+ (GitRemoveRemote, Background),
(GitCreateBranch, Background),
(GitChangeBranch, Background),
(GitRenameBranch, Background),
+ (TrustWorktrees, Background),
+ (RestrictWorktrees, Background),
(CheckForPushedCommits, Background),
(CheckForPushedCommitsResponse, Background),
(GitDiff, Background),
@@ -331,6 +339,7 @@ messages!(
(GetAgentServerCommand, Background),
(AgentServerCommand, Background),
(ExternalAgentsUpdated, Background),
+ (ExternalExtensionAgentsUpdated, Background),
(ExternalAgentLoadingStatusUpdated, Background),
(NewExternalAgentVersionAvailable, Background),
(RemoteStarted, Background),
@@ -348,6 +357,7 @@ request_messages!(
(Call, Ack),
(CancelCall, Ack),
(Commit, Ack),
+ (RunGitHook, Ack),
(CopyProjectEntry, ProjectEntryResponse),
(CreateChannel, CreateChannelResponse),
(CreateProjectEntry, ProjectEntryResponse),
@@ -488,7 +498,9 @@ request_messages!(
(InstallExtension, Ack),
(RegisterBufferWithLanguageServers, Ack),
(GitShow, GitCommitDetails),
+ (GitFileHistory, GitFileHistoryResponse),
(GitReset, Ack),
+ (GitDeleteBranch, Ack),
(GitCheckoutFiles, Ack),
(SetIndexText, Ack),
(Push, RemoteMessageResponse),
@@ -496,6 +508,8 @@ request_messages!(
(GetRemotes, GetRemotesResponse),
(Pull, RemoteMessageResponse),
(AskPassRequest, AskPassResponse),
+ (GitCreateRemote, Ack),
+ (GitRemoveRemote, Ack),
(GitCreateBranch, Ack),
(GitChangeBranch, Ack),
(GitRenameBranch, Ack),
@@ -517,7 +531,9 @@ request_messages!(
(GetAgentServerCommand, AgentServerCommand),
(RemoteStarted, Ack),
(GitGetWorktrees, GitWorktreesResponse),
- (GitCreateWorktree, Ack)
+ (GitCreateWorktree, Ack),
+ (TrustWorktrees, Ack),
+ (RestrictWorktrees, Ack),
);
lsp_messages!(
@@ -546,6 +562,7 @@ entity_messages!(
BufferSaved,
CloseBuffer,
Commit,
+ RunGitHook,
GetColorPresentation,
CopyProjectEntry,
CreateBufferForPeer,
@@ -651,7 +668,9 @@ entity_messages!(
CancelLanguageServerWork,
RegisterBufferWithLanguageServers,
GitShow,
+ GitFileHistory,
GitReset,
+ GitDeleteBranch,
GitCheckoutFiles,
SetIndexText,
ToggleLspLogs,
@@ -665,6 +684,8 @@ entity_messages!(
GitChangeBranch,
GitRenameBranch,
GitCreateBranch,
+ GitCreateRemote,
+ GitRemoveRemote,
CheckForPushedCommits,
GitDiff,
GitInit,
@@ -681,10 +702,13 @@ entity_messages!(
GitClone,
GetAgentServerCommand,
ExternalAgentsUpdated,
+ ExternalExtensionAgentsUpdated,
ExternalAgentLoadingStatusUpdated,
NewExternalAgentVersionAvailable,
GitGetWorktrees,
- GitCreateWorktree
+ GitCreateWorktree,
+ TrustWorktrees,
+ RestrictWorktrees,
);
entity_messages!(
@@ -16,6 +16,7 @@ doctest = false
anyhow.workspace = true
askpass.workspace = true
auto_update.workspace = true
+db.workspace = true
editor.workspace = true
extension_host.workspace = true
file_finder.workspace = true
@@ -26,13 +27,16 @@ language.workspace = true
log.workspace = true
markdown.workspace = true
menu.workspace = true
+node_runtime.workspace = true
ordered-float.workspace = true
paths.workspace = true
picker.workspace = true
project.workspace = true
release_channel.workspace = true
remote.workspace = true
+semver.workspace = true
serde.workspace = true
+serde_json.workspace = true
settings.workspace = true
smol.workspace = true
task.workspace = true
@@ -41,6 +45,7 @@ theme.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
+worktree.workspace = true
zed_actions.workspace = true
indoc.workspace = true
@@ -0,0 +1,295 @@
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use gpui::AsyncWindowContext;
+use node_runtime::NodeRuntime;
+use serde::Deserialize;
+use settings::DevContainerConnection;
+use smol::fs;
+use workspace::Workspace;
+
+use crate::remote_connections::Connection;
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct DevContainerUp {
+ _outcome: String,
+ container_id: String,
+ _remote_user: String,
+ remote_workspace_folder: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct DevContainerConfiguration {
+ name: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct DevContainerConfigurationOutput {
+ configuration: DevContainerConfiguration,
+}
+
+#[cfg(not(target_os = "windows"))]
+fn dev_container_cli() -> String {
+ "devcontainer".to_string()
+}
+
+#[cfg(target_os = "windows")]
+fn dev_container_cli() -> String {
+ "devcontainer.cmd".to_string()
+}
+
+async fn check_for_docker() -> Result<(), DevContainerError> {
+ let mut command = util::command::new_smol_command("docker");
+ command.arg("--version");
+
+ match command.output().await {
+ Ok(_) => Ok(()),
+ Err(e) => {
+ log::error!("Unable to find docker in $PATH: {:?}", e);
+ Err(DevContainerError::DockerNotAvailable)
+ }
+ }
+}
+
+async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result<PathBuf, DevContainerError> {
+ let mut command = util::command::new_smol_command(&dev_container_cli());
+ command.arg("--version");
+
+ if let Err(e) = command.output().await {
+ log::error!(
+ "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
+ e
+ );
+
+ let datadir_cli_path = paths::devcontainer_dir()
+ .join("node_modules")
+ .join(".bin")
+ .join(&dev_container_cli());
+
+ let mut command =
+ util::command::new_smol_command(&datadir_cli_path.as_os_str().display().to_string());
+ command.arg("--version");
+
+ if let Err(e) = command.output().await {
+ log::error!(
+ "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
+ e
+ );
+ } else {
+ log::info!("Found devcontainer CLI in Data dir");
+ return Ok(datadir_cli_path.clone());
+ }
+
+ if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
+ log::error!("Unable to create devcontainer directory. Error: {:?}", e);
+ return Err(DevContainerError::DevContainerCliNotAvailable);
+ }
+
+ if let Err(e) = node_runtime
+ .npm_install_packages(
+ &paths::devcontainer_dir(),
+ &[("@devcontainers/cli", "latest")],
+ )
+ .await
+ {
+ log::error!(
+ "Unable to install devcontainer CLI to data directory. Error: {:?}",
+ e
+ );
+ return Err(DevContainerError::DevContainerCliNotAvailable);
+ };
+
+ let mut command = util::command::new_smol_command(&datadir_cli_path.display().to_string());
+ command.arg("--version");
+ if let Err(e) = command.output().await {
+ log::error!(
+ "Unable to find devcontainer cli after NPM install. Error: {:?}",
+ e
+ );
+ Err(DevContainerError::DevContainerCliNotAvailable)
+ } else {
+ Ok(datadir_cli_path)
+ }
+ } else {
+ log::info!("Found devcontainer cli on $PATH, using it");
+ Ok(PathBuf::from(&dev_container_cli()))
+ }
+}
+
+async fn devcontainer_up(
+ path_to_cli: &PathBuf,
+ path: Arc<Path>,
+) -> Result<DevContainerUp, DevContainerError> {
+ let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
+ command.arg("up");
+ command.arg("--workspace-folder");
+ command.arg(path.display().to_string());
+
+ match command.output().await {
+ Ok(output) => {
+ if output.status.success() {
+ let raw = String::from_utf8_lossy(&output.stdout);
+ serde_json::from_str::<DevContainerUp>(&raw).map_err(|e| {
+ log::error!(
+ "Unable to parse response from 'devcontainer up' command, error: {:?}",
+ e
+ );
+ DevContainerError::DevContainerParseFailed
+ })
+ } else {
+ log::error!(
+ "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ Err(DevContainerError::DevContainerUpFailed)
+ }
+ }
+ Err(e) => {
+ log::error!("Error running devcontainer up: {:?}", e);
+ Err(DevContainerError::DevContainerUpFailed)
+ }
+ }
+}
+
+async fn devcontainer_read_configuration(
+ path_to_cli: &PathBuf,
+ path: Arc<Path>,
+) -> Result<DevContainerConfigurationOutput, DevContainerError> {
+ let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
+ command.arg("read-configuration");
+ command.arg("--workspace-folder");
+ command.arg(path.display().to_string());
+ match command.output().await {
+ Ok(output) => {
+ if output.status.success() {
+ let raw = String::from_utf8_lossy(&output.stdout);
+ serde_json::from_str::<DevContainerConfigurationOutput>(&raw).map_err(|e| {
+ log::error!(
+ "Unable to parse response from 'devcontainer read-configuration' command, error: {:?}",
+ e
+ );
+ DevContainerError::DevContainerParseFailed
+ })
+ } else {
+ log::error!(
+ "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ Err(DevContainerError::DevContainerUpFailed)
+ }
+ }
+ Err(e) => {
+ log::error!("Error running devcontainer read-configuration: {:?}", e);
+ Err(DevContainerError::DevContainerUpFailed)
+ }
+ }
+}
+
+// Name the project with two fallbacks
+async fn get_project_name(
+ path_to_cli: &PathBuf,
+ path: Arc<Path>,
+ remote_workspace_folder: String,
+ container_id: String,
+) -> Result<String, DevContainerError> {
+ if let Ok(dev_container_configuration) =
+ devcontainer_read_configuration(path_to_cli, path).await
+ && let Some(name) = dev_container_configuration.configuration.name
+ {
+ // Ideally, name the project after the name defined in devcontainer.json
+ Ok(name)
+ } else {
+ // Otherwise, name the project after the remote workspace folder name
+ Ok(Path::new(&remote_workspace_folder)
+ .file_name()
+ .and_then(|name| name.to_str())
+ .map(|string| string.into())
+ // Finally, name the project after the container ID as a last resort
+ .unwrap_or_else(|| container_id.clone()))
+ }
+}
+
+fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
+ let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
+ return None;
+ };
+
+ match workspace.update(cx, |workspace, _, cx| {
+ workspace.project().read(cx).active_project_directory(cx)
+ }) {
+ Ok(dir) => dir,
+ Err(e) => {
+ log::error!("Error getting project directory from workspace: {:?}", e);
+ None
+ }
+ }
+}
+
+pub(crate) async fn start_dev_container(
+ cx: &mut AsyncWindowContext,
+ node_runtime: NodeRuntime,
+) -> Result<(Connection, String), DevContainerError> {
+ check_for_docker().await?;
+
+ let path_to_devcontainer_cli = ensure_devcontainer_cli(node_runtime).await?;
+
+ let Some(directory) = project_directory(cx) else {
+ return Err(DevContainerError::DevContainerNotFound);
+ };
+
+ if let Ok(DevContainerUp {
+ container_id,
+ remote_workspace_folder,
+ ..
+ }) = devcontainer_up(&path_to_devcontainer_cli, directory.clone()).await
+ {
+ let project_name = get_project_name(
+ &path_to_devcontainer_cli,
+ directory,
+ remote_workspace_folder.clone(),
+ container_id.clone(),
+ )
+ .await?;
+
+ let connection = Connection::DevContainer(DevContainerConnection {
+ name: project_name.into(),
+ container_id: container_id.into(),
+ });
+
+ Ok((connection, remote_workspace_folder))
+ } else {
+ Err(DevContainerError::DevContainerUpFailed)
+ }
+}
+
+#[derive(Debug)]
+pub(crate) enum DevContainerError {
+ DockerNotAvailable,
+ DevContainerCliNotAvailable,
+ DevContainerUpFailed,
+ DevContainerNotFound,
+ DevContainerParseFailed,
+}
+
+#[cfg(test)]
+mod test {
+
+ use crate::dev_container::DevContainerUp;
+
+ #[test]
+ fn should_parse_from_devcontainer_json() {
+ let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
+ let up: DevContainerUp = serde_json::from_str(json).unwrap();
+ assert_eq!(up._outcome, "success");
+ assert_eq!(
+ up.container_id,
+ "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
+ );
+ assert_eq!(up._remote_user, "vscode");
+ assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
+ }
+}
@@ -0,0 +1,106 @@
+use db::kvp::KEY_VALUE_STORE;
+use gpui::{SharedString, Window};
+use project::{Project, WorktreeId};
+use std::sync::LazyLock;
+use ui::prelude::*;
+use util::rel_path::RelPath;
+use workspace::Workspace;
+use workspace::notifications::NotificationId;
+use workspace::notifications::simple_message_notification::MessageNotification;
+use worktree::UpdatedEntriesSet;
+
+const DEV_CONTAINER_SUGGEST_KEY: &str = "dev_container_suggest_dismissed";
+
+fn devcontainer_path() -> &'static RelPath {
+ static PATH: LazyLock<&'static RelPath> =
+ LazyLock::new(|| RelPath::unix(".devcontainer").expect("valid path"));
+ *PATH
+}
+
+fn project_devcontainer_key(project_path: &str) -> String {
+ format!("{}_{}", DEV_CONTAINER_SUGGEST_KEY, project_path)
+}
+
+pub fn suggest_on_worktree_updated(
+ worktree_id: WorktreeId,
+ updated_entries: &UpdatedEntriesSet,
+ project: &gpui::Entity<Project>,
+ window: &mut Window,
+ cx: &mut Context<Workspace>,
+) {
+ let devcontainer_updated = updated_entries
+ .iter()
+ .any(|(path, _, _)| path.as_ref() == devcontainer_path());
+
+ if !devcontainer_updated {
+ return;
+ }
+
+ let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
+ return;
+ };
+
+ let worktree = worktree.read(cx);
+
+ if !worktree.is_local() {
+ return;
+ }
+
+ let has_devcontainer = worktree
+ .entry_for_path(devcontainer_path())
+ .is_some_and(|entry| entry.is_dir());
+
+ if !has_devcontainer {
+ return;
+ }
+
+ let abs_path = worktree.abs_path();
+ let project_path = abs_path.to_string_lossy().to_string();
+ let key_for_dismiss = project_devcontainer_key(&project_path);
+
+ let already_dismissed = KEY_VALUE_STORE
+ .read_kvp(&key_for_dismiss)
+ .ok()
+ .flatten()
+ .is_some();
+
+ if already_dismissed {
+ return;
+ }
+
+ cx.on_next_frame(window, move |workspace, _window, cx| {
+ struct DevContainerSuggestionNotification;
+
+ let notification_id = NotificationId::composite::<DevContainerSuggestionNotification>(
+ SharedString::from(project_path.clone()),
+ );
+
+ workspace.show_notification(notification_id, cx, |cx| {
+ cx.new(move |cx| {
+ MessageNotification::new(
+ "This project contains a Dev Container configuration file. Would you like to re-open it in a container?",
+ cx,
+ )
+ .primary_message("Yes, Open in Container")
+ .primary_icon(IconName::Check)
+ .primary_icon_color(Color::Success)
+ .primary_on_click({
+ move |window, cx| {
+ window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx);
+ }
+ })
+ .secondary_message("Don't Show Again")
+ .secondary_icon(IconName::Close)
+ .secondary_icon_color(Color::Error)
+ .secondary_on_click({
+ move |_window, cx| {
+ let key = key_for_dismiss.clone();
+ db::write_and_log(cx, move || {
+ KEY_VALUE_STORE.write_kvp(key, "dismissed".to_string())
+ });
+ }
+ })
+ })
+ });
+ });
+}
@@ -1,8 +1,12 @@
+mod dev_container;
+mod dev_container_suggest;
pub mod disconnected_overlay;
mod remote_connections;
mod remote_servers;
mod ssh_config;
+use std::path::PathBuf;
+
#[cfg(target_os = "windows")]
mod wsl_picker;
@@ -31,7 +35,7 @@ use workspace::{
WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr,
with_active_or_new_workspace,
};
-use zed_actions::{OpenRecent, OpenRemote};
+use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
pub fn init(cx: &mut App) {
#[cfg(target_os = "windows")]
@@ -132,7 +136,8 @@ pub fn init(cx: &mut App) {
let create_new_window = open_recent.create_new_window;
with_active_or_new_workspace(cx, move |workspace, window, cx| {
let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
- RecentProjects::open(workspace, create_new_window, window, cx);
+ let focus_handle = workspace.focus_handle(cx);
+ RecentProjects::open(workspace, create_new_window, window, focus_handle, cx);
return;
};
@@ -160,6 +165,95 @@ pub fn init(cx: &mut App) {
});
cx.observe_new(DisconnectedOverlay::register).detach();
+
+ cx.on_action(|_: &OpenDevContainer, cx| {
+ with_active_or_new_workspace(cx, move |workspace, window, cx| {
+ let app_state = workspace.app_state().clone();
+ let replace_window = window.window_handle().downcast::<Workspace>();
+
+ cx.spawn_in(window, async move |_, mut cx| {
+ let (connection, starting_dir) = match dev_container::start_dev_container(
+ &mut cx,
+ app_state.node_runtime.clone(),
+ )
+ .await
+ {
+ Ok((c, s)) => (c, s),
+ Err(e) => {
+ log::error!("Failed to start Dev Container: {:?}", e);
+ cx.prompt(
+ gpui::PromptLevel::Critical,
+ "Failed to start Dev Container",
+ Some(&format!("{:?}", e)),
+ &["Ok"],
+ )
+ .await
+ .ok();
+ return;
+ }
+ };
+
+ let result = open_remote_project(
+ connection.into(),
+ vec![starting_dir].into_iter().map(PathBuf::from).collect(),
+ app_state,
+ OpenOptions {
+ replace_window,
+ ..OpenOptions::default()
+ },
+ &mut cx,
+ )
+ .await;
+
+ if let Err(e) = result {
+ log::error!("Failed to connect: {e:#}");
+ cx.prompt(
+ gpui::PromptLevel::Critical,
+ "Failed to connect",
+ Some(&e.to_string()),
+ &["Ok"],
+ )
+ .await
+ .ok();
+ }
+ })
+ .detach();
+
+ let fs = workspace.project().read(cx).fs().clone();
+ let handle = cx.entity().downgrade();
+ workspace.toggle_modal(window, cx, |window, cx| {
+ RemoteServerProjects::new_dev_container(fs, window, handle, cx)
+ });
+ });
+ });
+
+ // Subscribe to worktree additions to suggest opening the project in a dev container
+ cx.observe_new(
+ |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
+ let Some(window) = window else {
+ return;
+ };
+ cx.subscribe_in(
+ workspace.project(),
+ window,
+ move |_, project, event, window, cx| {
+ if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
+ event
+ {
+ dev_container_suggest::suggest_on_worktree_updated(
+ *worktree_id,
+ updated_entries,
+ project,
+ window,
+ cx,
+ );
+ }
+ },
+ )
+ .detach();
+ },
+ )
+ .detach();
}
#[cfg(target_os = "windows")]
@@ -246,11 +340,12 @@ impl RecentProjects {
workspace: &mut Workspace,
create_new_window: bool,
window: &mut Window,
+ focus_handle: FocusHandle,
cx: &mut Context<Workspace>,
) {
let weak = cx.entity().downgrade();
workspace.toggle_modal(window, cx, |window, cx| {
- let delegate = RecentProjectsDelegate::new(weak, create_new_window, true);
+ let delegate = RecentProjectsDelegate::new(weak, create_new_window, true, focus_handle);
Self::new(delegate, 34., window, cx)
})
@@ -289,10 +384,16 @@ pub struct RecentProjectsDelegate {
// Flag to reset index when there is a new query vs not reset index when user delete an item
reset_selected_match_index: bool,
has_any_non_local_projects: bool,
+ focus_handle: FocusHandle,
}
impl RecentProjectsDelegate {
- fn new(workspace: WeakEntity<Workspace>, create_new_window: bool, render_paths: bool) -> Self {
+ fn new(
+ workspace: WeakEntity<Workspace>,
+ create_new_window: bool,
+ render_paths: bool,
+ focus_handle: FocusHandle,
+ ) -> Self {
Self {
workspace,
workspaces: Vec::new(),
@@ -302,6 +403,7 @@ impl RecentProjectsDelegate {
render_paths,
reset_selected_match_index: true,
has_any_non_local_projects: false,
+ focus_handle,
}
}
@@ -532,8 +634,8 @@ impl PickerDelegate for RecentProjectsDelegate {
.unzip();
let prefix = match &location {
- SerializedWorkspaceLocation::Remote(RemoteConnectionOptions::Wsl(wsl)) => {
- Some(SharedString::from(&wsl.distro_name))
+ SerializedWorkspaceLocation::Remote(options) => {
+ Some(SharedString::from(options.display_name()))
}
_ => None,
};
@@ -544,6 +646,43 @@ impl PickerDelegate for RecentProjectsDelegate {
paths,
};
+ let focus_handle = self.focus_handle.clone();
+
+ let secondary_actions = h_flex()
+ .gap_px()
+ .child(
+ IconButton::new("open_new_window", IconName::ArrowUpRight)
+ .icon_size(IconSize::XSmall)
+ .tooltip({
+ move |_, cx| {
+ Tooltip::for_action_in(
+ "Open Project in New Window",
+ &menu::SecondaryConfirm,
+ &focus_handle,
+ cx,
+ )
+ }
+ })
+ .on_click(cx.listener(move |this, _event, window, cx| {
+ cx.stop_propagation();
+ window.prevent_default();
+ this.delegate.set_selected_index(ix, window, cx);
+ this.delegate.confirm(true, window, cx);
+ })),
+ )
+ .child(
+ IconButton::new("delete", IconName::Close)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Delete from Recent Projects"))
+ .on_click(cx.listener(move |this, _event, window, cx| {
+ cx.stop_propagation();
+ window.prevent_default();
+
+ this.delegate.delete_recent_project(ix, window, cx)
+ })),
+ )
+ .into_any_element();
+
Some(
ListItem::new(ix)
.toggle_state(selected)
@@ -551,8 +690,9 @@ impl PickerDelegate for RecentProjectsDelegate {
.spacing(ListItemSpacing::Sparse)
.child(
h_flex()
- .flex_grow()
+ .id("projecy_info_container")
.gap_3()
+ .flex_grow()
.when(self.has_any_non_local_projects, |this| {
this.child(match location {
SerializedWorkspaceLocation::Local => Icon::new(IconName::Screen)
@@ -562,6 +702,7 @@ impl PickerDelegate for RecentProjectsDelegate {
Icon::new(match options {
RemoteConnectionOptions::Ssh { .. } => IconName::Server,
RemoteConnectionOptions::Wsl { .. } => IconName::Linux,
+ RemoteConnectionOptions::Docker(_) => IconName::Box,
})
.color(Color::Muted)
.into_any_element()
@@ -574,35 +715,21 @@ impl PickerDelegate for RecentProjectsDelegate {
highlighted.paths.clear();
}
highlighted.render(window, cx)
+ })
+ .tooltip(move |_, cx| {
+ let tooltip_highlighted_location = highlighted_match.clone();
+ cx.new(|_| MatchTooltip {
+ highlighted_location: tooltip_highlighted_location,
+ })
+ .into()
}),
)
.map(|el| {
- let delete_button = div()
- .child(
- IconButton::new("delete", IconName::Close)
- .icon_size(IconSize::Small)
- .on_click(cx.listener(move |this, _event, window, cx| {
- cx.stop_propagation();
- window.prevent_default();
-
- this.delegate.delete_recent_project(ix, window, cx)
- }))
- .tooltip(Tooltip::text("Delete from Recent Projects...")),
- )
- .into_any_element();
-
if self.selected_index() == ix {
- el.end_slot::<AnyElement>(delete_button)
+ el.end_slot(secondary_actions)
} else {
- el.end_hover_slot::<AnyElement>(delete_button)
+ el.end_hover_slot(secondary_actions)
}
- })
- .tooltip(move |_, cx| {
- let tooltip_highlighted_location = highlighted_match.clone();
- cx.new(|_| MatchTooltip {
- highlighted_location: tooltip_highlighted_location,
- })
- .into()
}),
)
}
@@ -11,23 +11,24 @@ use extension_host::ExtensionStore;
use futures::channel::oneshot;
use gpui::{
AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
- ParentElement as _, PromptLevel, Render, SemanticVersion, SharedString, Task,
- TextStyleRefinement, WeakEntity,
+ ParentElement as _, PromptLevel, Render, SharedString, Task, TextStyleRefinement, WeakEntity,
};
use language::{CursorShape, Point};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
+use project::trusted_worktrees;
use release_channel::ReleaseChannel;
use remote::{
- ConnectionIdentifier, RemoteClient, RemoteConnection, RemoteConnectionOptions, RemotePlatform,
- SshConnectionOptions,
+ ConnectionIdentifier, DockerConnectionOptions, RemoteClient, RemoteConnection,
+ RemoteConnectionOptions, RemotePlatform, SshConnectionOptions,
};
+use semver::Version;
pub use settings::SshConnection;
-use settings::{ExtendingVec, RegisterSetting, Settings, WslConnection};
+use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
use theme::ThemeSettings;
use ui::{
- ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement,
- IntoElement, Label, LabelCommon, Styled, Window, prelude::*,
+ ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
+ LabelCommon, ListItem, Styled, Window, prelude::*,
};
use util::paths::PathWithPosition;
use workspace::{AppState, ModalView, Workspace};
@@ -51,7 +52,7 @@ impl SshSettings {
pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
for conn in self.ssh_connections() {
- if conn.host == options.host
+ if conn.host == options.host.to_string()
&& conn.username == options.username
&& conn.port == options.port
{
@@ -71,7 +72,7 @@ impl SshSettings {
username: Option<String>,
) -> SshConnectionOptions {
let mut options = SshConnectionOptions {
- host,
+ host: host.into(),
port,
username,
..Default::default()
@@ -85,6 +86,7 @@ impl SshSettings {
pub enum Connection {
Ssh(SshConnection),
Wsl(WslConnection),
+ DevContainer(DevContainerConnection),
}
impl From<Connection> for RemoteConnectionOptions {
@@ -92,6 +94,13 @@ impl From<Connection> for RemoteConnectionOptions {
match val {
Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
+ Connection::DevContainer(conn) => {
+ RemoteConnectionOptions::Docker(DockerConnectionOptions {
+ name: conn.name.to_string(),
+ container_id: conn.container_id.to_string(),
+ upload_binary_over_docker_exec: false,
+ })
+ }
}
}
}
@@ -123,6 +132,7 @@ pub struct RemoteConnectionPrompt {
connection_string: SharedString,
nickname: Option<SharedString>,
is_wsl: bool,
+ is_devcontainer: bool,
status_message: Option<SharedString>,
prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
cancellation: Option<oneshot::Sender<()>>,
@@ -148,6 +158,7 @@ impl RemoteConnectionPrompt {
connection_string: String,
nickname: Option<String>,
is_wsl: bool,
+ is_devcontainer: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -155,6 +166,7 @@ impl RemoteConnectionPrompt {
connection_string: connection_string.into(),
nickname: nickname.map(|nickname| nickname.into()),
is_wsl,
+ is_devcontainer,
editor: cx.new(|cx| Editor::single_line(window, cx)),
status_message: None,
cancellation: None,
@@ -197,7 +209,7 @@ impl RemoteConnectionPrompt {
let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
self.prompt = Some((markdown, tx));
self.status_message.take();
- window.focus(&self.editor.focus_handle(cx));
+ window.focus(&self.editor.focus_handle(cx), cx);
cx.notify();
}
@@ -244,17 +256,16 @@ impl Render for RemoteConnectionPrompt {
v_flex()
.key_context("PasswordPrompt")
- .py_2()
- .px_3()
+ .p_2()
.size_full()
.text_buffer(cx)
.when_some(self.status_message.clone(), |el, status_message| {
el.child(
h_flex()
- .gap_1()
+ .gap_2()
.child(
Icon::new(IconName::ArrowCircle)
- .size(IconSize::Medium)
+ .color(Color::Muted)
.with_rotate_animation(2),
)
.child(
@@ -287,15 +298,28 @@ impl RemoteConnectionModal {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
- let (connection_string, nickname, is_wsl) = match connection_options {
- RemoteConnectionOptions::Ssh(options) => {
- (options.connection_string(), options.nickname.clone(), false)
+ let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
+ RemoteConnectionOptions::Ssh(options) => (
+ options.connection_string(),
+ options.nickname.clone(),
+ false,
+ false,
+ ),
+ RemoteConnectionOptions::Wsl(options) => {
+ (options.distro_name.clone(), None, true, false)
}
- RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None, true),
+ RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
};
Self {
prompt: cx.new(|cx| {
- RemoteConnectionPrompt::new(connection_string, nickname, is_wsl, window, cx)
+ RemoteConnectionPrompt::new(
+ connection_string,
+ nickname,
+ is_wsl,
+ is_devcontainer,
+ window,
+ cx,
+ )
}),
finished: false,
paths,
@@ -328,6 +352,7 @@ pub(crate) struct SshConnectionHeader {
pub(crate) paths: Vec<PathBuf>,
pub(crate) nickname: Option<SharedString>,
pub(crate) is_wsl: bool,
+ pub(crate) is_devcontainer: bool,
}
impl RenderOnce for SshConnectionHeader {
@@ -343,9 +368,12 @@ impl RenderOnce for SshConnectionHeader {
(self.connection_string, None)
};
- let icon = match self.is_wsl {
- true => IconName::Linux,
- false => IconName::Server,
+ let icon = if self.is_wsl {
+ IconName::Linux
+ } else if self.is_devcontainer {
+ IconName::Box
+ } else {
+ IconName::Server
};
h_flex()
@@ -388,6 +416,7 @@ impl Render for RemoteConnectionModal {
let nickname = self.prompt.read(cx).nickname.clone();
let connection_string = self.prompt.read(cx).connection_string.clone();
let is_wsl = self.prompt.read(cx).is_wsl;
+ let is_devcontainer = self.prompt.read(cx).is_devcontainer;
let theme = cx.theme().clone();
let body_color = theme.colors().editor_background;
@@ -407,18 +436,34 @@ impl Render for RemoteConnectionModal {
connection_string,
nickname,
is_wsl,
+ is_devcontainer,
}
.render(window, cx),
)
.child(
div()
.w_full()
- .rounded_b_lg()
.bg(body_color)
- .border_t_1()
+ .border_y_1()
.border_color(theme.colors().border_variant)
.child(self.prompt.clone()),
)
+ .child(
+ div().w_full().py_1().child(
+ ListItem::new("li-devcontainer-go-back")
+ .inset(true)
+ .spacing(ui::ListItemSpacing::Sparse)
+ .start_slot(Icon::new(IconName::Close).color(Color::Muted))
+ .child(Label::new("Cancel"))
+ .end_slot(
+ KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx)
+ .size(rems_from_px(12.)),
+ )
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.dismiss(&menu::Cancel, window, cx);
+ })),
+ ),
+ )
}
}
@@ -480,16 +525,16 @@ impl remote::RemoteClientDelegate for RemoteClientDelegate {
&self,
platform: RemotePlatform,
release_channel: ReleaseChannel,
- version: Option<SemanticVersion>,
+ version: Option<Version>,
cx: &mut AsyncApp,
) -> Task<anyhow::Result<PathBuf>> {
let this = self.clone();
cx.spawn(async move |cx| {
AutoUpdater::download_remote_server_release(
release_channel,
- version,
- platform.os,
- platform.arch,
+ version.clone(),
+ platform.os.as_str(),
+ platform.arch.as_str(),
move |status, cx| this.set_status(Some(status), cx),
cx,
)
@@ -498,6 +543,7 @@ impl remote::RemoteClientDelegate for RemoteClientDelegate {
format!(
"Downloading remote server binary (version: {}, os: {}, arch: {})",
version
+ .as_ref()
.map(|v| format!("{}", v))
.unwrap_or("unknown".to_string()),
platform.os,
@@ -511,15 +557,15 @@ impl remote::RemoteClientDelegate for RemoteClientDelegate {
&self,
platform: RemotePlatform,
release_channel: ReleaseChannel,
- version: Option<SemanticVersion>,
+ version: Option<Version>,
cx: &mut AsyncApp,
) -> Task<Result<Option<String>>> {
cx.spawn(async move |cx| {
AutoUpdater::get_remote_server_release_url(
release_channel,
version,
- platform.os,
- platform.arch,
+ platform.os.as_str(),
+ platform.arch.as_str(),
cx,
)
.await
@@ -601,6 +647,7 @@ pub async fn open_remote_project(
app_state.languages.clone(),
app_state.fs.clone(),
None,
+ false,
cx,
);
cx.new(|cx| {
@@ -670,6 +717,9 @@ pub async fn open_remote_project(
match connection_options {
RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
+ RemoteConnectionOptions::Docker(_) => {
+ "Failed to connect to Dev Container"
+ }
},
Some(&format!("{e:#}")),
&["Retry", "Cancel"],
@@ -726,6 +776,9 @@ pub async fn open_remote_project(
match connection_options {
RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
+ RemoteConnectionOptions::Docker(_) => {
+ "Failed to connect to Dev Container"
+ }
},
Some(&format!("{e:#}")),
&["Retry", "Cancel"],
@@ -737,11 +790,20 @@ pub async fn open_remote_project(
continue;
}
- if created_new_window {
- window
- .update(cx, |_, window, _| window.remove_window())
- .ok();
- }
+ window
+ .update(cx, |workspace, window, cx| {
+ if created_new_window {
+ window.remove_window();
+ }
+ trusted_worktrees::track_worktree_trust(
+ workspace.project().read(cx).worktree_store(),
+ None,
+ None,
+ None,
+ cx,
+ );
+ })
+ .ok();
}
Ok(items) => {
@@ -1,4 +1,5 @@
use crate::{
+ dev_container::start_dev_container,
remote_connections::{
Connection, RemoteConnectionModal, RemoteConnectionPrompt, SshConnection,
SshConnectionHeader, SshSettings, connect, determine_paths_with_positions,
@@ -24,7 +25,7 @@ use remote::{
remote_client::ConnectionIdentifier,
};
use settings::{
- RemoteSettingsContent, Settings as _, SettingsStore, SshProject, update_settings_file,
+ RemoteProject, RemoteSettingsContent, Settings as _, SettingsStore, update_settings_file,
watch_config_file,
};
use smol::stream::StreamExt as _;
@@ -39,12 +40,13 @@ use std::{
},
};
use ui::{
- IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry,
- Section, Tooltip, WithScrollbar, prelude::*,
+ CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal,
+ ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, prelude::*,
};
use util::{
ResultExt,
paths::{PathStyle, RemotePathBuf},
+ rel_path::RelPath,
};
use workspace::{
ModalView, OpenOptions, Toast, Workspace,
@@ -74,7 +76,7 @@ impl CreateRemoteServer {
fn new(window: &mut Window, cx: &mut App) -> Self {
let address_editor = cx.new(|cx| Editor::single_line(window, cx));
address_editor.update(cx, |this, cx| {
- this.focus_handle(cx).focus(window);
+ this.focus_handle(cx).focus(window, cx);
});
Self {
address_editor,
@@ -85,6 +87,39 @@ impl CreateRemoteServer {
}
}
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+enum DevContainerCreationProgress {
+ Initial,
+ Creating,
+ Error(String),
+}
+
+#[derive(Clone)]
+struct CreateRemoteDevContainer {
+ // 3 Navigable Options
+ // - Create from devcontainer.json
+ // - Edit devcontainer.json
+ // - Go back
+ entries: [NavigableEntry; 3],
+ progress: DevContainerCreationProgress,
+}
+
+impl CreateRemoteDevContainer {
+ fn new(window: &mut Window, cx: &mut Context<RemoteServerProjects>) -> Self {
+ let entries = std::array::from_fn(|_| NavigableEntry::focusable(cx));
+ entries[0].focus_handle.focus(window, cx);
+ Self {
+ entries,
+ progress: DevContainerCreationProgress::Initial,
+ }
+ }
+
+ fn progress(&mut self, progress: DevContainerCreationProgress) -> Self {
+ self.progress = progress;
+ self.clone()
+ }
+}
+
#[cfg(target_os = "windows")]
struct AddWslDistro {
picker: Entity<Picker<crate::wsl_picker::WslPickerDelegate>>,
@@ -164,7 +199,7 @@ impl EditNicknameState {
this.set_text(starting_text, window, cx);
}
});
- this.editor.focus_handle(cx).focus(window);
+ this.editor.focus_handle(cx).focus(window, cx);
this
}
}
@@ -182,14 +217,13 @@ impl ProjectPicker {
connection: RemoteConnectionOptions,
project: Entity<Project>,
home_dir: RemotePathBuf,
- path_style: PathStyle,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut Context<RemoteServerProjects>,
) -> Entity<Self> {
let (tx, rx) = oneshot::channel();
let lister = project::DirectoryLister::Project(project.clone());
- let delegate = file_finder::OpenPathDelegate::new(tx, lister, false, path_style);
+ let delegate = file_finder::OpenPathDelegate::new(tx, lister, false, cx);
let picker = cx.new(|cx| {
let picker = Picker::uniform_list(delegate, window, cx)
@@ -207,6 +241,11 @@ impl ProjectPicker {
RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl {
distro_name: connection.distro_name.clone().into(),
},
+ RemoteConnectionOptions::Docker(_) => ProjectPickerData::Ssh {
+ // Not implemented as a project picker at this time
+ connection_string: "".into(),
+ nickname: None,
+ },
};
let _path_task = cx
.spawn_in(window, {
@@ -259,7 +298,7 @@ impl ProjectPicker {
.as_mut()
.and_then(|connections| connections.get_mut(index.0))
{
- server.projects.insert(SshProject { paths });
+ server.projects.insert(RemoteProject { paths });
};
}
ServerIndex::Wsl(index) => {
@@ -269,7 +308,7 @@ impl ProjectPicker {
.as_mut()
.and_then(|connections| connections.get_mut(index.0))
{
- server.projects.insert(SshProject { paths });
+ server.projects.insert(RemoteProject { paths });
};
}
}
@@ -349,6 +388,7 @@ impl gpui::Render for ProjectPicker {
paths: Default::default(),
nickname: nickname.clone(),
is_wsl: false,
+ is_devcontainer: false,
}
.render(window, cx),
ProjectPickerData::Wsl { distro_name } => SshConnectionHeader {
@@ -356,6 +396,7 @@ impl gpui::Render for ProjectPicker {
paths: Default::default(),
nickname: None,
is_wsl: true,
+ is_devcontainer: false,
}
.render(window, cx),
})
@@ -406,7 +447,7 @@ impl From<WslServerIndex> for ServerIndex {
enum RemoteEntry {
Project {
open_folder: NavigableEntry,
- projects: Vec<(NavigableEntry, SshProject)>,
+ projects: Vec<(NavigableEntry, RemoteProject)>,
configure: NavigableEntry,
connection: Connection,
index: ServerIndex,
@@ -440,6 +481,7 @@ impl RemoteEntry {
struct DefaultState {
scroll_handle: ScrollHandle,
add_new_server: NavigableEntry,
+ add_new_devcontainer: NavigableEntry,
add_new_wsl: NavigableEntry,
servers: Vec<RemoteEntry>,
}
@@ -448,6 +490,7 @@ impl DefaultState {
fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
let handle = ScrollHandle::new();
let add_new_server = NavigableEntry::new(&handle, cx);
+ let add_new_devcontainer = NavigableEntry::new(&handle, cx);
let add_new_wsl = NavigableEntry::new(&handle, cx);
let ssh_settings = SshSettings::get_global(cx);
@@ -517,6 +560,7 @@ impl DefaultState {
Self {
scroll_handle: handle,
add_new_server,
+ add_new_devcontainer,
add_new_wsl,
servers,
}
@@ -552,6 +596,7 @@ enum Mode {
EditNickname(EditNicknameState),
ProjectPicker(Entity<ProjectPicker>),
CreateRemoteServer(CreateRemoteServer),
+ CreateRemoteDevContainer(CreateRemoteDevContainer),
#[cfg(target_os = "windows")]
AddWslDistro(AddWslDistro),
}
@@ -598,6 +643,27 @@ impl RemoteServerProjects {
)
}
+ /// Creates a new RemoteServerProjects modal that opens directly in dev container creation mode.
+ /// Used when suggesting dev container connection from toast notification.
+ pub fn new_dev_container(
+ fs: Arc<dyn Fs>,
+ window: &mut Window,
+ workspace: WeakEntity<Workspace>,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ Self::new_inner(
+ Mode::CreateRemoteDevContainer(
+ CreateRemoteDevContainer::new(window, cx)
+ .progress(DevContainerCreationProgress::Creating),
+ ),
+ false,
+ fs,
+ window,
+ workspace,
+ cx,
+ )
+ }
+
fn new_inner(
mode: Mode,
create_new_window: bool,
@@ -652,7 +718,6 @@ impl RemoteServerProjects {
connection_options: remote::RemoteConnectionOptions,
project: Entity<Project>,
home_dir: RemotePathBuf,
- path_style: PathStyle,
window: &mut Window,
cx: &mut Context<Self>,
workspace: WeakEntity<Workspace>,
@@ -665,7 +730,6 @@ impl RemoteServerProjects {
connection_options,
project,
home_dir,
- path_style,
workspace,
window,
cx,
@@ -703,6 +767,7 @@ impl RemoteServerProjects {
connection_options.connection_string(),
connection_options.nickname.clone(),
false,
+ false,
window,
cx,
)
@@ -727,7 +792,7 @@ impl RemoteServerProjects {
this.retained_connections.push(client);
this.add_ssh_server(connection_options, cx);
this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
- this.focus_handle(cx).focus(window);
+ this.focus_handle(cx).focus(window, cx);
cx.notify()
})
.log_err(),
@@ -778,6 +843,7 @@ impl RemoteServerProjects {
connection_options.distro_name.clone(),
None,
true,
+ false,
window,
cx,
)
@@ -809,7 +875,7 @@ impl RemoteServerProjects {
crate::add_wsl_distro(fs, &connection_options, cx);
this.mode = Mode::default_mode(&BTreeSet::new(), cx);
- this.focus_handle(cx).focus(window);
+ this.focus_handle(cx).focus(window, cx);
cx.notify();
}),
_ => this.update(cx, |this, cx| {
@@ -858,7 +924,16 @@ impl RemoteServerProjects {
return;
}
});
- self.focus_handle(cx).focus(window);
+ self.focus_handle(cx).focus(window, cx);
+ cx.notify();
+ }
+
+ fn view_in_progress_dev_container(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ self.mode = Mode::CreateRemoteDevContainer(
+ CreateRemoteDevContainer::new(window, cx)
+ .progress(DevContainerCreationProgress::Creating),
+ );
+ self.focus_handle(cx).focus(window, cx);
cx.notify();
}
@@ -925,6 +1000,7 @@ impl RemoteServerProjects {
app_state.user_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
+ true,
cx,
),
)
@@ -952,7 +1028,6 @@ impl RemoteServerProjects {
connection_options,
project,
home_dir,
- path_style,
window,
cx,
weak,
@@ -981,6 +1056,7 @@ impl RemoteServerProjects {
self.create_ssh_server(state.address_editor.clone(), window, cx);
}
+ Mode::CreateRemoteDevContainer(_) => {}
Mode::EditNickname(state) => {
let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
let index = state.index;
@@ -992,7 +1068,7 @@ impl RemoteServerProjects {
}
});
self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
- self.focus_handle.focus(window);
+ self.focus_handle.focus(window, cx);
}
#[cfg(target_os = "windows")]
Mode::AddWslDistro(state) => {
@@ -1018,20 +1094,20 @@ impl RemoteServerProjects {
}
_ => {
self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
- self.focus_handle(cx).focus(window);
+ self.focus_handle(cx).focus(window, cx);
cx.notify();
}
}
}
- fn render_ssh_connection(
+ fn render_remote_connection(
&mut self,
ix: usize,
- ssh_server: RemoteEntry,
+ remote_server: RemoteEntry,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
- let connection = ssh_server.connection().into_owned();
+ let connection = remote_server.connection().into_owned();
let (main_label, aux_label, is_wsl) = match &connection {
Connection::Ssh(connection) => {
@@ -1045,6 +1121,9 @@ impl RemoteServerProjects {
Connection::Wsl(wsl_connection_options) => {
(wsl_connection_options.distro_name.clone(), None, true)
}
+ Connection::DevContainer(dev_container_options) => {
+ (dev_container_options.name.clone(), None, false)
+ }
};
v_flex()
.w_full()
@@ -1082,7 +1161,7 @@ impl RemoteServerProjects {
}),
),
)
- .child(match &ssh_server {
+ .child(match &remote_server {
RemoteEntry::Project {
open_folder,
projects,
@@ -1094,9 +1173,9 @@ impl RemoteServerProjects {
List::new()
.empty_message("No projects.")
.children(projects.iter().enumerate().map(|(pix, p)| {
- v_flex().gap_0p5().child(self.render_ssh_project(
+ v_flex().gap_0p5().child(self.render_remote_project(
index,
- ssh_server.clone(),
+ remote_server.clone(),
pix,
p,
window,
@@ -1222,12 +1301,12 @@ impl RemoteServerProjects {
})
}
- fn render_ssh_project(
+ fn render_remote_project(
&mut self,
server_ix: ServerIndex,
server: RemoteEntry,
ix: usize,
- (navigation, project): &(NavigableEntry, SshProject),
+ (navigation, project): &(NavigableEntry, RemoteProject),
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
@@ -1372,7 +1451,7 @@ impl RemoteServerProjects {
fn delete_remote_project(
&mut self,
server: ServerIndex,
- project: &SshProject,
+ project: &RemoteProject,
cx: &mut Context<Self>,
) {
match server {
@@ -1388,7 +1467,7 @@ impl RemoteServerProjects {
fn delete_ssh_project(
&mut self,
server: SshServerIndex,
- project: &SshProject,
+ project: &RemoteProject,
cx: &mut Context<Self>,
) {
let project = project.clone();
@@ -1406,7 +1485,7 @@ impl RemoteServerProjects {
fn delete_wsl_project(
&mut self,
server: WslServerIndex,
- project: &SshProject,
+ project: &RemoteProject,
cx: &mut Context<Self>,
) {
let project = project.clone();
@@ -1439,7 +1518,7 @@ impl RemoteServerProjects {
.ssh_connections
.get_or_insert(Default::default())
.push(SshConnection {
- host: SharedString::from(connection_options.host),
+ host: SharedString::from(connection_options.host.to_string()),
username: connection_options.username,
port: connection_options.port,
projects: BTreeSet::new(),
@@ -1447,8 +1526,345 @@ impl RemoteServerProjects {
args: connection_options.args.unwrap_or_default(),
upload_binary_over_ssh: None,
port_forwards: connection_options.port_forwards,
+ connection_timeout: connection_options.connection_timeout,
+ })
+ });
+ }
+
+ fn edit_in_dev_container_json(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let Some(workspace) = self.workspace.upgrade() else {
+ cx.emit(DismissEvent);
+ cx.notify();
+ return;
+ };
+
+ workspace.update(cx, |workspace, cx| {
+ let project = workspace.project().clone();
+
+ let worktree = project
+ .read(cx)
+ .visible_worktrees(cx)
+ .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
+
+ if let Some(worktree) = worktree {
+ let tree_id = worktree.read(cx).id();
+ let devcontainer_path = RelPath::unix(".devcontainer/devcontainer.json").unwrap();
+ cx.spawn_in(window, async move |workspace, cx| {
+ workspace
+ .update_in(cx, |workspace, window, cx| {
+ workspace.open_path(
+ (tree_id, devcontainer_path),
+ None,
+ true,
+ window,
+ cx,
+ )
+ })?
+ .await
})
+ .detach();
+ } else {
+ return;
+ }
});
+ cx.emit(DismissEvent);
+ cx.notify();
+ }
+
+ fn open_dev_container(&self, window: &mut Window, cx: &mut Context<Self>) {
+ let Some(app_state) = self
+ .workspace
+ .read_with(cx, |workspace, _| workspace.app_state().clone())
+ .log_err()
+ else {
+ return;
+ };
+
+ let replace_window = window.window_handle().downcast::<Workspace>();
+
+ cx.spawn_in(window, async move |entity, cx| {
+ let (connection, starting_dir) =
+ match start_dev_container(cx, app_state.node_runtime.clone()).await {
+ Ok((c, s)) => (c, s),
+ Err(e) => {
+ log::error!("Failed to start dev container: {:?}", e);
+ entity
+ .update_in(cx, |remote_server_projects, window, cx| {
+ remote_server_projects.mode = Mode::CreateRemoteDevContainer(
+ CreateRemoteDevContainer::new(window, cx).progress(
+ DevContainerCreationProgress::Error(format!("{:?}", e)),
+ ),
+ );
+ })
+ .log_err();
+ return;
+ }
+ };
+ entity
+ .update(cx, |_, cx| {
+ cx.emit(DismissEvent);
+ })
+ .log_err();
+
+ let result = open_remote_project(
+ connection.into(),
+ vec![starting_dir].into_iter().map(PathBuf::from).collect(),
+ app_state,
+ OpenOptions {
+ replace_window,
+ ..OpenOptions::default()
+ },
+ cx,
+ )
+ .await;
+ if let Err(e) = result {
+ log::error!("Failed to connect: {e:#}");
+ cx.prompt(
+ gpui::PromptLevel::Critical,
+ "Failed to connect",
+ Some(&e.to_string()),
+ &["Ok"],
+ )
+ .await
+ .ok();
+ }
+ })
+ .detach();
+ }
+
+ fn render_create_dev_container(
+ &self,
+ state: &CreateRemoteDevContainer,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> impl IntoElement {
+ match &state.progress {
+ DevContainerCreationProgress::Error(message) => {
+ self.focus_handle(cx).focus(window, cx);
+ return div()
+ .track_focus(&self.focus_handle(cx))
+ .size_full()
+ .child(
+ v_flex()
+ .py_1()
+ .child(
+ ListItem::new("Error")
+ .inset(true)
+ .selectable(false)
+ .spacing(ui::ListItemSpacing::Sparse)
+ .start_slot(Icon::new(IconName::XCircle).color(Color::Error))
+ .child(Label::new("Error Creating Dev Container:"))
+ .child(Label::new(message).buffer_font(cx)),
+ )
+ .child(ListSeparator)
+ .child(
+ div()
+ .id("devcontainer-go-back")
+ .track_focus(&state.entries[0].focus_handle)
+ .on_action(cx.listener(
+ |this, _: &menu::Confirm, window, cx| {
+ this.mode =
+ Mode::default_mode(&this.ssh_config_servers, cx);
+ cx.focus_self(window);
+ cx.notify();
+ },
+ ))
+ .child(
+ ListItem::new("li-devcontainer-go-back")
+ .toggle_state(
+ state.entries[0]
+ .focus_handle
+ .contains_focused(window, cx),
+ )
+ .inset(true)
+ .spacing(ui::ListItemSpacing::Sparse)
+ .start_slot(
+ Icon::new(IconName::ArrowLeft).color(Color::Muted),
+ )
+ .child(Label::new("Go Back"))
+ .end_slot(
+ KeyBinding::for_action_in(
+ &menu::Cancel,
+ &self.focus_handle,
+ cx,
+ )
+ .size(rems_from_px(12.)),
+ )
+ .on_click(cx.listener(|this, _, window, cx| {
+ let state =
+ CreateRemoteDevContainer::new(window, cx);
+ this.mode = Mode::CreateRemoteDevContainer(state);
+
+ cx.notify();
+ })),
+ ),
+ ),
+ )
+ .into_any_element();
+ }
+ _ => {}
+ };
+
+ let mut view = Navigable::new(
+ div()
+ .track_focus(&self.focus_handle(cx))
+ .size_full()
+ .child(
+ v_flex()
+ .pb_1()
+ .child(
+ ModalHeader::new()
+ .child(Headline::new("Dev Containers").size(HeadlineSize::XSmall)),
+ )
+ .child(ListSeparator)
+ .child(
+ div()
+ .id("confirm-create-from-devcontainer-json")
+ .track_focus(&state.entries[0].focus_handle)
+ .on_action(cx.listener({
+ move |this, _: &menu::Confirm, window, cx| {
+ this.open_dev_container(window, cx);
+ this.view_in_progress_dev_container(window, cx);
+ }
+ }))
+ .map(|this| {
+ if state.progress == DevContainerCreationProgress::Creating {
+ this.child(
+ ListItem::new("creating")
+ .inset(true)
+ .spacing(ui::ListItemSpacing::Sparse)
+ .disabled(true)
+ .start_slot(
+ Icon::new(IconName::ArrowCircle)
+ .color(Color::Muted)
+ .with_rotate_animation(2),
+ )
+ .child(
+ h_flex()
+ .opacity(0.6)
+ .gap_1()
+ .child(Label::new("Creating From"))
+ .child(
+ Label::new("devcontainer.json")
+ .buffer_font(cx),
+ )
+ .child(LoadingLabel::new("")),
+ ),
+ )
+ } else {
+ this.child(
+ ListItem::new(
+ "li-confirm-create-from-devcontainer-json",
+ )
+ .toggle_state(
+ state.entries[0]
+ .focus_handle
+ .contains_focused(window, cx),
+ )
+ .inset(true)
+ .spacing(ui::ListItemSpacing::Sparse)
+ .start_slot(
+ Icon::new(IconName::Plus).color(Color::Muted),
+ )
+ .child(
+ h_flex()
+ .gap_1()
+ .child(Label::new("Open or Create New From"))
+ .child(
+ Label::new("devcontainer.json")
+ .buffer_font(cx),
+ ),
+ )
+ .on_click(
+ cx.listener({
+ move |this, _, window, cx| {
+ this.open_dev_container(window, cx);
+ this.view_in_progress_dev_container(
+ window, cx,
+ );
+ cx.notify();
+ }
+ }),
+ ),
+ )
+ }
+ }),
+ )
+ .child(
+ div()
+ .id("edit-devcontainer-json")
+ .track_focus(&state.entries[1].focus_handle)
+ .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
+ this.edit_in_dev_container_json(window, cx);
+ }))
+ .child(
+ ListItem::new("li-edit-devcontainer-json")
+ .toggle_state(
+ state.entries[1]
+ .focus_handle
+ .contains_focused(window, cx),
+ )
+ .inset(true)
+ .spacing(ui::ListItemSpacing::Sparse)
+ .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
+ .child(
+ h_flex().gap_1().child(Label::new("Edit")).child(
+ Label::new("devcontainer.json").buffer_font(cx),
+ ),
+ )
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.edit_in_dev_container_json(window, cx);
+ })),
+ ),
+ )
+ .child(ListSeparator)
+ .child(
+ div()
+ .id("devcontainer-go-back")
+ .track_focus(&state.entries[2].focus_handle)
+ .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
+ this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
+ cx.focus_self(window);
+ cx.notify();
+ }))
+ .child(
+ ListItem::new("li-devcontainer-go-back")
+ .toggle_state(
+ state.entries[2]
+ .focus_handle
+ .contains_focused(window, cx),
+ )
+ .inset(true)
+ .spacing(ui::ListItemSpacing::Sparse)
+ .start_slot(
+ Icon::new(IconName::ArrowLeft).color(Color::Muted),
+ )
+ .child(Label::new("Go Back"))
+ .end_slot(
+ KeyBinding::for_action_in(
+ &menu::Cancel,
+ &self.focus_handle,
+ cx,
+ )
+ .size(rems_from_px(12.)),
+ )
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.mode =
+ Mode::default_mode(&this.ssh_config_servers, cx);
+ cx.focus_self(window);
+ cx.notify()
+ })),
+ ),
+ ),
+ )
+ .into_any_element(),
+ );
+
+ view = view.entry(state.entries[0].clone());
+ view = view.entry(state.entries[1].clone());
+ view = view.entry(state.entries[2].clone());
+
+ view.render(window, cx).into_any_element()
}
fn render_create_remote_server(
@@ -1536,7 +1952,7 @@ impl RemoteServerProjects {
let connection_prompt = state.connection_prompt.clone();
state.picker.update(cx, |picker, cx| {
- picker.focus_handle(cx).focus(window);
+ picker.focus_handle(cx).focus(window, cx);
});
v_flex()
@@ -1567,10 +1983,11 @@ impl RemoteServerProjects {
.size_full()
.child(match &options {
ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader {
- connection_string: connection.host.clone().into(),
+ connection_string: connection.host.to_string().into(),
paths: Default::default(),
nickname: connection.nickname.clone().map(|s| s.into()),
is_wsl: false,
+ is_devcontainer: false,
}
.render(window, cx)
.into_any_element(),
@@ -1579,6 +1996,7 @@ impl RemoteServerProjects {
paths: Default::default(),
nickname: None,
is_wsl: true,
+ is_devcontainer: false,
}
.render(window, cx)
.into_any_element(),
@@ -1730,7 +2148,7 @@ impl RemoteServerProjects {
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
- let connection_string = SharedString::new(connection.host.clone());
+ let connection_string = SharedString::new(connection.host.to_string());
v_flex()
.child({
@@ -1917,6 +2335,7 @@ impl RemoteServerProjects {
paths: Default::default(),
nickname,
is_wsl: false,
+ is_devcontainer: false,
}
.render(window, cx),
)
@@ -1998,7 +2417,7 @@ impl RemoteServerProjects {
.track_focus(&state.add_new_server.focus_handle)
.anchor_scroll(state.add_new_server.scroll_anchor.clone())
.child(
- ListItem::new("register-remove-server-button")
+ ListItem::new("register-remote-server-button")
.toggle_state(
state
.add_new_server
@@ -2008,7 +2427,7 @@ impl RemoteServerProjects {
.inset(true)
.spacing(ui::ListItemSpacing::Sparse)
.start_slot(Icon::new(IconName::Plus).color(Color::Muted))
- .child(Label::new("Connect New Server"))
+ .child(Label::new("Connect SSH Server"))
.on_click(cx.listener(|this, _, window, cx| {
let state = CreateRemoteServer::new(window, cx);
this.mode = Mode::CreateRemoteServer(state);
@@ -2023,6 +2442,36 @@ impl RemoteServerProjects {
cx.notify();
}));
+ let connect_dev_container_button = div()
+ .id("connect-new-dev-container")
+ .track_focus(&state.add_new_devcontainer.focus_handle)
+ .anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone())
+ .child(
+ ListItem::new("register-dev-container-button")
+ .toggle_state(
+ state
+ .add_new_devcontainer
+ .focus_handle
+ .contains_focused(window, cx),
+ )
+ .inset(true)
+ .spacing(ui::ListItemSpacing::Sparse)
+ .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
+ .child(Label::new("Connect Dev Container"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ let state = CreateRemoteDevContainer::new(window, cx);
+ this.mode = Mode::CreateRemoteDevContainer(state);
+
+ cx.notify();
+ })),
+ )
+ .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
+ let state = CreateRemoteDevContainer::new(window, cx);
+ this.mode = Mode::CreateRemoteDevContainer(state);
+
+ cx.notify();
+ }));
+
#[cfg(target_os = "windows")]
let wsl_connect_button = div()
.id("wsl-connect-new-server")
@@ -2049,13 +2498,30 @@ impl RemoteServerProjects {
cx.notify();
}));
+ let has_open_project = self
+ .workspace
+ .upgrade()
+ .map(|workspace| {
+ workspace
+ .read(cx)
+ .project()
+ .read(cx)
+ .visible_worktrees(cx)
+ .next()
+ .is_some()
+ })
+ .unwrap_or(false);
+
let modal_section = v_flex()
.track_focus(&self.focus_handle(cx))
.id("ssh-server-list")
.overflow_y_scroll()
.track_scroll(&state.scroll_handle)
.size_full()
- .child(connect_button);
+ .child(connect_button)
+ .when(has_open_project, |this| {
+ this.child(connect_dev_container_button)
+ });
#[cfg(target_os = "windows")]
let modal_section = modal_section.child(wsl_connect_button);
@@ -2067,17 +2533,20 @@ impl RemoteServerProjects {
.child(
List::new()
.empty_message(
- v_flex()
+ h_flex()
+ .size_full()
+ .p_2()
+ .justify_center()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
.child(
- div().px_3().child(
- Label::new("No remote servers registered yet.")
- .color(Color::Muted),
- ),
+ Label::new("No remote servers registered yet.")
+ .color(Color::Muted),
)
.into_any_element(),
)
.children(state.servers.iter().enumerate().map(|(ix, connection)| {
- self.render_ssh_connection(ix, connection.clone(), window, cx)
+ self.render_remote_connection(ix, connection.clone(), window, cx)
.into_any_element()
})),
)
@@ -2085,6 +2554,10 @@ impl RemoteServerProjects {
)
.entry(state.add_new_server.clone());
+ if has_open_project {
+ modal_section = modal_section.entry(state.add_new_devcontainer.clone());
+ }
+
if cfg!(target_os = "windows") {
modal_section = modal_section.entry(state.add_new_wsl.clone());
}
@@ -2160,7 +2633,7 @@ impl RemoteServerProjects {
)
.size_full(),
)
- .vertical_scrollbar_for(state.scroll_handle, window, cx),
+ .vertical_scrollbar_for(&state.scroll_handle, window, cx),
),
)
.into_any_element()
@@ -2186,7 +2659,7 @@ impl RemoteServerProjects {
self.add_ssh_server(
SshConnectionOptions {
- host: ssh_config_host.to_string(),
+ host: ssh_config_host.to_string().into(),
..SshConnectionOptions::default()
},
cx,
@@ -2279,7 +2752,7 @@ impl Render for RemoteServerProjects {
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(Self::confirm))
.capture_any_mouse_down(cx.listener(|this, _, window, cx| {
- this.focus_handle(cx).focus(window);
+ this.focus_handle(cx).focus(window, cx);
}))
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
if matches!(this.mode, Mode::Default(_)) {
@@ -2297,6 +2770,9 @@ impl Render for RemoteServerProjects {
Mode::CreateRemoteServer(state) => self
.render_create_remote_server(state, window, cx)
.into_any_element(),
+ Mode::CreateRemoteDevContainer(state) => self
+ .render_create_dev_container(state, window, cx)
+ .into_any_element(),
Mode::EditNickname(state) => self
.render_edit_nickname(state, window, cx)
.into_any_element(),
@@ -528,7 +528,12 @@ fn get_wrapper_type(field: &Field, ty: &Type) -> syn::Type {
} else {
panic!("Expected struct type for a refineable field");
};
- let refinement_struct_name = format_ident!("{}Refinement", struct_name);
+
+ let refinement_struct_name = if struct_name.to_string().ends_with("Refinement") {
+ format_ident!("{}", struct_name)
+ } else {
+ format_ident!("{}Refinement", struct_name)
+ };
let generics = if let Type::Path(tp) = ty {
&tp.path.segments.last().unwrap().arguments
} else {
@@ -13,7 +13,7 @@ pub use derive_refineable::Refineable;
/// wrapped appropriately:
///
/// - **Refineable fields** (marked with `#[refineable]`): Become the corresponding refinement type
-/// (e.g., `Bar` becomes `BarRefinement`)
+/// (e.g., `Bar` becomes `BarRefinement`, or `BarRefinement` remains `BarRefinement`)
/// - **Optional fields** (`Option<T>`): Remain as `Option<T>`
/// - **Regular fields**: Become `Option<T>`
///
@@ -10,3 +10,4 @@ workspace = true
[dependencies]
gpui.workspace = true
+semver.workspace = true
@@ -4,7 +4,8 @@
use std::{env, str::FromStr, sync::LazyLock};
-use gpui::{App, Global, SemanticVersion};
+use gpui::{App, Global};
+use semver::Version;
/// stable | dev | nightly | preview
pub static RELEASE_CHANNEL_NAME: LazyLock<String> = LazyLock::new(|| {
@@ -70,7 +71,7 @@ impl AppCommitSha {
}
}
-struct GlobalAppVersion(SemanticVersion);
+struct GlobalAppVersion(Version);
impl Global for GlobalAppVersion {}
@@ -79,20 +80,40 @@ pub struct AppVersion;
impl AppVersion {
/// Load the app version from env.
- pub fn load(pkg_version: &str) -> SemanticVersion {
- if let Ok(from_env) = env::var("ZED_APP_VERSION") {
+ pub fn load(
+ pkg_version: &str,
+ build_id: Option<&str>,
+ commit_sha: Option<AppCommitSha>,
+ ) -> Version {
+ let mut version: Version = if let Ok(from_env) = env::var("ZED_APP_VERSION") {
from_env.parse().expect("invalid ZED_APP_VERSION")
} else {
pkg_version.parse().expect("invalid version in Cargo.toml")
+ };
+ let mut pre = String::from(RELEASE_CHANNEL.dev_name());
+
+ if let Some(build_id) = build_id {
+ pre.push('.');
+ pre.push_str(&build_id);
+ }
+
+ if let Some(sha) = commit_sha {
+ pre.push('.');
+ pre.push_str(&sha.0);
}
+ if let Ok(build) = semver::BuildMetadata::new(&pre) {
+ version.build = build;
+ }
+
+ version
}
/// Returns the global version number.
- pub fn global(cx: &App) -> SemanticVersion {
+ pub fn global(cx: &App) -> Version {
if cx.has_global::<GlobalAppVersion>() {
- cx.global::<GlobalAppVersion>().0
+ cx.global::<GlobalAppVersion>().0.clone()
} else {
- SemanticVersion::default()
+ Version::new(0, 0, 0)
}
}
}
@@ -121,13 +142,13 @@ struct GlobalReleaseChannel(ReleaseChannel);
impl Global for GlobalReleaseChannel {}
/// Initializes the release channel.
-pub fn init(app_version: SemanticVersion, cx: &mut App) {
+pub fn init(app_version: Version, cx: &mut App) {
cx.set_global(GlobalAppVersion(app_version));
cx.set_global(GlobalReleaseChannel(*RELEASE_CHANNEL))
}
/// Initializes the release channel for tests that rely on fake release channel.
-pub fn init_test(app_version: SemanticVersion, release_channel: ReleaseChannel, cx: &mut App) {
+pub fn init_test(app_version: Version, release_channel: ReleaseChannel, cx: &mut App) {
cx.set_global(GlobalAppVersion(app_version));
cx.set_global(GlobalReleaseChannel(release_channel))
}
@@ -32,6 +32,7 @@ prost.workspace = true
release_channel.workspace = true
rpc = { workspace = true, features = ["gpui"] }
schemars.workspace = true
+semver.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
@@ -42,7 +43,6 @@ urlencoding.workspace = true
util.workspace = true
which.workspace = true
-
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
fs = { workspace = true, features = ["test-support"] }
@@ -7,8 +7,10 @@ mod transport;
#[cfg(target_os = "windows")]
pub use remote_client::OpenWslPath;
pub use remote_client::{
- ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent,
- RemoteConnection, RemoteConnectionOptions, RemotePlatform, connect,
+ ConnectionIdentifier, ConnectionState, RemoteArch, RemoteClient, RemoteClientDelegate,
+ RemoteClientEvent, RemoteConnection, RemoteConnectionOptions, RemoteOs, RemotePlatform,
+ connect,
};
+pub use transport::docker::DockerConnectionOptions;
pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption};
pub use transport::wsl::WslConnectionOptions;
@@ -3,6 +3,7 @@ use crate::{
protocol::MessageId,
proxy::ProxyLaunchError,
transport::{
+ docker::{DockerConnectionOptions, DockerExecConnection},
ssh::SshRemoteConnection,
wsl::{WslConnectionOptions, WslRemoteConnection},
},
@@ -22,7 +23,7 @@ use futures::{
};
use gpui::{
App, AppContext as _, AsyncApp, BackgroundExecutor, BorrowAppContext, Context, Entity,
- EventEmitter, FutureExt, Global, SemanticVersion, Task, WeakEntity,
+ EventEmitter, FutureExt, Global, Task, WeakEntity,
};
use parking_lot::Mutex;
@@ -31,6 +32,7 @@ use rpc::{
AnyProtoClient, ErrorExt, ProtoClient, ProtoMessageHandlerSet, RpcError,
proto::{self, Envelope, EnvelopedMessage, PeerId, RequestMessage, build_typed_envelope},
};
+use semver::Version;
use std::{
collections::VecDeque,
fmt,
@@ -47,10 +49,58 @@ use util::{
paths::{PathStyle, RemotePathBuf},
};
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum RemoteOs {
+ Linux,
+ MacOs,
+ Windows,
+}
+
+impl RemoteOs {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ RemoteOs::Linux => "linux",
+ RemoteOs::MacOs => "macos",
+ RemoteOs::Windows => "windows",
+ }
+ }
+
+ pub fn is_windows(&self) -> bool {
+ matches!(self, RemoteOs::Windows)
+ }
+}
+
+impl std::fmt::Display for RemoteOs {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum RemoteArch {
+ X86_64,
+ Aarch64,
+}
+
+impl RemoteArch {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ RemoteArch::X86_64 => "x86_64",
+ RemoteArch::Aarch64 => "aarch64",
+ }
+ }
+}
+
+impl std::fmt::Display for RemoteArch {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
#[derive(Copy, Clone, Debug)]
pub struct RemotePlatform {
- pub os: &'static str,
- pub arch: &'static str,
+ pub os: RemoteOs,
+ pub arch: RemoteArch,
}
#[derive(Clone, Debug)]
@@ -71,14 +121,14 @@ pub trait RemoteClientDelegate: Send + Sync {
&self,
platform: RemotePlatform,
release_channel: ReleaseChannel,
- version: Option<SemanticVersion>,
+ version: Option<Version>,
cx: &mut AsyncApp,
) -> Task<Result<Option<String>>>;
fn download_server_binary_locally(
&self,
platform: RemotePlatform,
release_channel: ReleaseChannel,
- version: Option<SemanticVersion>,
+ version: Option<Version>,
cx: &mut AsyncApp,
) -> Task<Result<PathBuf>>;
fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp);
@@ -87,7 +137,8 @@ pub trait RemoteClientDelegate: Send + Sync {
const MAX_MISSED_HEARTBEATS: usize = 5;
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5);
-const INITIAL_CONNECTION_TIMEOUT: Duration = Duration::from_secs(60);
+const INITIAL_CONNECTION_TIMEOUT: Duration =
+ Duration::from_secs(if cfg!(debug_assertions) { 5 } else { 60 });
const MAX_RECONNECT_ATTEMPTS: usize = 3;
@@ -327,8 +378,15 @@ impl RemoteClient {
let (incoming_tx, incoming_rx) = mpsc::unbounded::<Envelope>();
let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1);
- let client =
- cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?;
+ let client = cx.update(|cx| {
+ ChannelClient::new(
+ incoming_rx,
+ outgoing_tx,
+ cx,
+ "client",
+ remote_connection.has_wsl_interop(),
+ )
+ })?;
let path_style = remote_connection.path_style();
let this = cx.new(|_| Self {
@@ -419,8 +477,9 @@ impl RemoteClient {
outgoing_tx: mpsc::UnboundedSender<Envelope>,
cx: &App,
name: &'static str,
+ has_wsl_interop: bool,
) -> AnyProtoClient {
- ChannelClient::new(incoming_rx, outgoing_tx, cx, name).into()
+ ChannelClient::new(incoming_rx, outgoing_tx, cx, name, has_wsl_interop).into()
}
pub fn shutdown_processes<T: RequestMessage>(
@@ -911,17 +970,19 @@ impl RemoteClient {
client_cx: &mut gpui::TestAppContext,
server_cx: &mut gpui::TestAppContext,
) -> (RemoteConnectionOptions, AnyProtoClient) {
+ use crate::transport::ssh::SshConnectionHost;
+
let port = client_cx
.update(|cx| cx.default_global::<ConnectionPool>().connections.len() as u16 + 1);
let opts = RemoteConnectionOptions::Ssh(SshConnectionOptions {
- host: "<fake>".to_string(),
+ host: SshConnectionHost::from("<fake>".to_string()),
port: Some(port),
..Default::default()
});
let (outgoing_tx, _) = mpsc::unbounded::<Envelope>();
let (_, incoming_rx) = mpsc::unbounded::<Envelope>();
- let server_client =
- server_cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server"));
+ let server_client = server_cx
+ .update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server", false));
let connection: Arc<dyn RemoteConnection> = Arc::new(fake::FakeRemoteConnection {
connection_options: opts.clone(),
server_cx: fake::SendableCx::new(server_cx),
@@ -1033,6 +1094,11 @@ impl ConnectionPool {
.await
.map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>)
}
+ RemoteConnectionOptions::Docker(opts) => {
+ DockerExecConnection::new(opts, delegate, cx)
+ .await
+ .map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>)
+ }
};
cx.update_global(|pool: &mut Self, _| {
@@ -1068,13 +1134,15 @@ impl ConnectionPool {
pub enum RemoteConnectionOptions {
Ssh(SshConnectionOptions),
Wsl(WslConnectionOptions),
+ Docker(DockerConnectionOptions),
}
impl RemoteConnectionOptions {
pub fn display_name(&self) -> String {
match self {
- RemoteConnectionOptions::Ssh(opts) => opts.host.clone(),
+ RemoteConnectionOptions::Ssh(opts) => opts.host.to_string(),
RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(),
+ RemoteConnectionOptions::Docker(opts) => opts.name.clone(),
}
}
}
@@ -1139,6 +1207,7 @@ pub trait RemoteConnection: Send + Sync {
fn path_style(&self) -> PathStyle;
fn shell(&self) -> String;
fn default_system_shell(&self) -> String;
+ fn has_wsl_interop(&self) -> bool;
#[cfg(any(test, feature = "test-support"))]
fn simulate_disconnect(&self, _: &AsyncApp) {}
@@ -1187,6 +1256,7 @@ struct ChannelClient {
name: &'static str,
task: Mutex<Task<Result<()>>>,
remote_started: Signal<()>,
+ has_wsl_interop: bool,
}
impl ChannelClient {
@@ -1195,6 +1265,7 @@ impl ChannelClient {
outgoing_tx: mpsc::UnboundedSender<Envelope>,
cx: &App,
name: &'static str,
+ has_wsl_interop: bool,
) -> Arc<Self> {
Arc::new_cyclic(|this| Self {
outgoing_tx: Mutex::new(outgoing_tx),
@@ -1210,6 +1281,7 @@ impl ChannelClient {
&cx.to_async(),
)),
remote_started: Signal::new(cx),
+ has_wsl_interop,
})
}
@@ -1488,6 +1560,10 @@ impl ProtoClient for ChannelClient {
fn is_via_collab(&self) -> bool {
false
}
+
+ fn has_wsl_interop(&self) -> bool {
+ self.has_wsl_interop
+ }
}
#[cfg(any(test, feature = "test-support"))]
@@ -1506,9 +1582,10 @@ mod fake {
},
select_biased,
};
- use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task, TestAppContext};
+ use gpui::{App, AppContext as _, AsyncApp, Task, TestAppContext};
use release_channel::ReleaseChannel;
use rpc::proto::Envelope;
+ use semver::Version;
use std::{path::PathBuf, sync::Arc};
use util::paths::{PathStyle, RemotePathBuf};
@@ -1650,6 +1727,10 @@ mod fake {
fn default_system_shell(&self) -> String {
"sh".to_owned()
}
+
+ fn has_wsl_interop(&self) -> bool {
+ false
+ }
}
pub(super) struct Delegate;
@@ -1663,7 +1744,7 @@ mod fake {
&self,
_: RemotePlatform,
_: ReleaseChannel,
- _: Option<SemanticVersion>,
+ _: Option<Version>,
_: &mut AsyncApp,
) -> Task<Result<PathBuf>> {
unreachable!()
@@ -1673,7 +1754,7 @@ mod fake {
&self,
_platform: RemotePlatform,
_release_channel: ReleaseChannel,
- _version: Option<SemanticVersion>,
+ _version: Option<Version>,
_cx: &mut AsyncApp,
) -> Task<Result<Option<String>>> {
unreachable!()
@@ -1,4 +1,5 @@
use crate::{
+ RemoteArch, RemoteOs, RemotePlatform,
json_log::LogRecord,
protocol::{MESSAGE_LEN_SIZE, message_len_from_buffer, read_message_with_len, write_message},
};
@@ -11,19 +12,68 @@ use gpui::{AppContext as _, AsyncApp, Task};
use rpc::proto::Envelope;
use smol::process::Child;
+pub mod docker;
pub mod ssh;
pub mod wsl;
+/// Parses the output of `uname -sm` to determine the remote platform.
+/// Takes the last line to skip possible shell initialization output.
+fn parse_platform(output: &str) -> Result<RemotePlatform> {
+ let output = output.trim();
+ let uname = output.rsplit_once('\n').map_or(output, |(_, last)| last);
+ let Some((os, arch)) = uname.split_once(" ") else {
+ anyhow::bail!("unknown uname: {uname:?}")
+ };
+
+ let os = match os {
+ "Darwin" => RemoteOs::MacOs,
+ "Linux" => RemoteOs::Linux,
+ _ => anyhow::bail!(
+ "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
+ ),
+ };
+
+ // exclude armv5,6,7 as they are 32-bit.
+ let arch = if arch.starts_with("armv8")
+ || arch.starts_with("armv9")
+ || arch.starts_with("arm64")
+ || arch.starts_with("aarch64")
+ {
+ RemoteArch::Aarch64
+ } else if arch.starts_with("x86") {
+ RemoteArch::X86_64
+ } else {
+ anyhow::bail!(
+ "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
+ )
+ };
+
+ Ok(RemotePlatform { os, arch })
+}
+
+/// Parses the output of `echo $SHELL` to determine the remote shell.
+/// Takes the last line to skip possible shell initialization output.
+fn parse_shell(output: &str, fallback_shell: &str) -> String {
+ let output = output.trim();
+ let shell = output.rsplit_once('\n').map_or(output, |(_, last)| last);
+ if shell.is_empty() {
+ log::error!("$SHELL is not set, falling back to {fallback_shell}");
+ fallback_shell.to_owned()
+ } else {
+ shell.to_owned()
+ }
+}
+
fn handle_rpc_messages_over_child_process_stdio(
- mut ssh_proxy_process: Child,
+ mut remote_proxy_process: Child,
incoming_tx: UnboundedSender<Envelope>,
mut outgoing_rx: UnboundedReceiver<Envelope>,
mut connection_activity_tx: Sender<()>,
cx: &AsyncApp,
) -> Task<Result<i32>> {
- let mut child_stderr = ssh_proxy_process.stderr.take().unwrap();
- let mut child_stdout = ssh_proxy_process.stdout.take().unwrap();
- let mut child_stdin = ssh_proxy_process.stdin.take().unwrap();
+ let mut child_stderr = remote_proxy_process.stderr.take().unwrap();
+ let mut child_stdout = remote_proxy_process.stdout.take().unwrap();
+ let mut child_stdin = remote_proxy_process.stdin.take().unwrap();
let mut stdin_buffer = Vec::new();
let mut stdout_buffer = Vec::new();
@@ -107,7 +157,7 @@ fn handle_rpc_messages_over_child_process_stdio(
result.context("stderr")
}
};
- let status = ssh_proxy_process.status().await?.code().unwrap_or(1);
+ let status = remote_proxy_process.status().await?.code().unwrap_or(1);
match result {
Ok(_) => Ok(status),
Err(error) => Err(error),
@@ -124,17 +174,14 @@ async fn build_remote_server_from_source(
use smol::process::{Command, Stdio};
use std::env::VarError;
use std::path::Path;
+ use util::command::new_smol_command;
// By default, we make building remote server from source opt-out and we do not force artifact compression
// for quicker builds.
let build_remote_server =
std::env::var("ZED_BUILD_REMOTE_SERVER").unwrap_or("nocompress".into());
- if build_remote_server == "false"
- || build_remote_server == "no"
- || build_remote_server == "off"
- || build_remote_server == "0"
- {
+ if let "false" | "no" | "off" | "0" = &*build_remote_server {
return Ok(None);
}
@@ -146,7 +193,8 @@ async fn build_remote_server_from_source(
.await?;
anyhow::ensure!(
output.status.success(),
- "Failed to run command: {command:?}"
+ "Failed to run command: {command:?}: output: {}",
+ String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
@@ -156,14 +204,15 @@ async fn build_remote_server_from_source(
"{}-{}",
platform.arch,
match platform.os {
- "linux" =>
+ RemoteOs::Linux =>
if use_musl {
"unknown-linux-musl"
} else {
"unknown-linux-gnu"
},
- "macos" => "apple-darwin",
- _ => anyhow::bail!("can't cross compile for: {:?}", platform),
+ RemoteOs::MacOs => "apple-darwin",
+ RemoteOs::Windows if cfg!(windows) => "pc-windows-msvc",
+ RemoteOs::Windows => "pc-windows-gnu",
}
);
let mut rust_flags = match std::env::var("RUSTFLAGS") {
@@ -174,7 +223,7 @@ async fn build_remote_server_from_source(
String::new()
}
};
- if platform.os == "linux" && use_musl {
+ if platform.os == RemoteOs::Linux && use_musl {
rust_flags.push_str(" -C target-feature=+crt-static");
if let Ok(path) = std::env::var("ZED_ZSTD_MUSL_LIB") {
@@ -185,11 +234,13 @@ async fn build_remote_server_from_source(
rust_flags.push_str(" -C link-arg=-fuse-ld=mold");
}
- if platform.arch == std::env::consts::ARCH && platform.os == std::env::consts::OS {
+ if platform.arch.as_str() == std::env::consts::ARCH
+ && platform.os.as_str() == std::env::consts::OS
+ {
delegate.set_status(Some("Building remote server binary from source"), cx);
log::info!("building remote server binary from source");
run_cmd(
- Command::new("cargo")
+ new_smol_command("cargo")
.current_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/../.."))
.args([
"build",
@@ -219,12 +270,18 @@ async fn build_remote_server_from_source(
.context("rustup not found on $PATH, install rustup (see https://rustup.rs/)")?;
delegate.set_status(Some("Adding rustup target for cross-compilation"), cx);
log::info!("adding rustup target");
- run_cmd(Command::new(rustup).args(["target", "add"]).arg(&triple)).await?;
+ run_cmd(
+ new_smol_command(rustup)
+ .args(["target", "add"])
+ .arg(&triple),
+ )
+ .await?;
if which("cargo-zigbuild", cx).await?.is_none() {
delegate.set_status(Some("Installing cargo-zigbuild for cross-compilation"), cx);
log::info!("installing cargo-zigbuild");
- run_cmd(Command::new("cargo").args(["install", "--locked", "cargo-zigbuild"])).await?;
+ run_cmd(new_smol_command("cargo").args(["install", "--locked", "cargo-zigbuild"]))
+ .await?;
}
delegate.set_status(
@@ -235,7 +292,7 @@ async fn build_remote_server_from_source(
);
log::info!("building remote binary from source for {triple} with Zig");
run_cmd(
- Command::new("cargo")
+ new_smol_command("cargo")
.args([
"zigbuild",
"--package",
@@ -255,19 +312,21 @@ async fn build_remote_server_from_source(
.join("remote_server")
.join(&triple)
.join("debug")
- .join("remote_server");
+ .join("remote_server")
+ .with_extension(if platform.os.is_windows() { "exe" } else { "" });
let path = if !build_remote_server.contains("nocompress") {
delegate.set_status(Some("Compressing binary"), cx);
#[cfg(not(target_os = "windows"))]
{
- run_cmd(Command::new("gzip").args(["-f", &bin_path.to_string_lossy()])).await?;
+ run_cmd(new_smol_command("gzip").args(["-f", &bin_path.to_string_lossy()])).await?;
}
#[cfg(target_os = "windows")]
{
// On Windows, we use 7z to compress the binary
+
let seven_zip = which("7z.exe",cx)
.await?
.context("7z.exe not found on $PATH, install it (e.g. with `winget install -e --id 7zip.7zip`) or, if you don't want this behaviour, set $env:ZED_BUILD_REMOTE_SERVER=\"nocompress\"")?;
@@ -275,7 +334,7 @@ async fn build_remote_server_from_source(
if smol::fs::metadata(&gz_path).await.is_ok() {
smol::fs::remove_file(&gz_path).await?;
}
- run_cmd(Command::new(seven_zip).args([
+ run_cmd(new_smol_command(seven_zip).args([
"a",
"-tgzip",
&gz_path,
@@ -312,3 +371,72 @@ async fn which(
)),
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_platform() {
+ let result = parse_platform("Linux x86_64\n").unwrap();
+ assert_eq!(result.os, RemoteOs::Linux);
+ assert_eq!(result.arch, RemoteArch::X86_64);
+
+ let result = parse_platform("Darwin arm64\n").unwrap();
+ assert_eq!(result.os, RemoteOs::MacOs);
+ assert_eq!(result.arch, RemoteArch::Aarch64);
+
+ let result = parse_platform("Linux x86_64").unwrap();
+ assert_eq!(result.os, RemoteOs::Linux);
+ assert_eq!(result.arch, RemoteArch::X86_64);
+
+ let result = parse_platform("some shell init output\nLinux aarch64\n").unwrap();
+ assert_eq!(result.os, RemoteOs::Linux);
+ assert_eq!(result.arch, RemoteArch::Aarch64);
+
+ let result = parse_platform("some shell init output\nLinux aarch64").unwrap();
+ assert_eq!(result.os, RemoteOs::Linux);
+ assert_eq!(result.arch, RemoteArch::Aarch64);
+
+ assert_eq!(
+ parse_platform("Linux armv8l\n").unwrap().arch,
+ RemoteArch::Aarch64
+ );
+ assert_eq!(
+ parse_platform("Linux aarch64\n").unwrap().arch,
+ RemoteArch::Aarch64
+ );
+ assert_eq!(
+ parse_platform("Linux x86_64\n").unwrap().arch,
+ RemoteArch::X86_64
+ );
+
+ let result = parse_platform(
+ r#"Linux x86_64 - What you're referring to as Linux, is in fact, GNU/Linux...\n"#,
+ )
+ .unwrap();
+ assert_eq!(result.os, RemoteOs::Linux);
+ assert_eq!(result.arch, RemoteArch::X86_64);
+
+ assert!(parse_platform("Windows x86_64\n").is_err());
+ assert!(parse_platform("Linux armv7l\n").is_err());
+ }
+
+ #[test]
+ fn test_parse_shell() {
+ assert_eq!(parse_shell("/bin/bash\n", "sh"), "/bin/bash");
+ assert_eq!(parse_shell("/bin/zsh\n", "sh"), "/bin/zsh");
+
+ assert_eq!(parse_shell("/bin/bash", "sh"), "/bin/bash");
+ assert_eq!(
+ parse_shell("some shell init output\n/bin/bash\n", "sh"),
+ "/bin/bash"
+ );
+ assert_eq!(
+ parse_shell("some shell init output\n/bin/bash", "sh"),
+ "/bin/bash"
+ );
+ assert_eq!(parse_shell("", "sh"), "sh");
+ assert_eq!(parse_shell("\n", "sh"), "sh");
+ }
+}
@@ -0,0 +1,757 @@
+use anyhow::Context;
+use anyhow::Result;
+use anyhow::anyhow;
+use async_trait::async_trait;
+use collections::HashMap;
+use parking_lot::Mutex;
+use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
+use semver::Version as SemanticVersion;
+use std::time::Instant;
+use std::{
+ path::{Path, PathBuf},
+ process::Stdio,
+ sync::Arc,
+};
+use util::ResultExt;
+use util::shell::ShellKind;
+use util::{
+ paths::{PathStyle, RemotePathBuf},
+ rel_path::RelPath,
+};
+
+use futures::channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
+use gpui::{App, AppContext, AsyncApp, Task};
+use rpc::proto::Envelope;
+
+use crate::{
+ RemoteArch, RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemoteOs,
+ RemotePlatform, remote_client::CommandTemplate,
+};
+
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
+pub struct DockerConnectionOptions {
+ pub name: String,
+ pub container_id: String,
+ pub upload_binary_over_docker_exec: bool,
+}
+
+pub(crate) struct DockerExecConnection {
+ proxy_process: Mutex<Option<u32>>,
+ remote_dir_for_server: String,
+ remote_binary_relpath: Option<Arc<RelPath>>,
+ connection_options: DockerConnectionOptions,
+ remote_platform: Option<RemotePlatform>,
+ path_style: Option<PathStyle>,
+ shell: Option<String>,
+}
+
+impl DockerExecConnection {
+ pub async fn new(
+ connection_options: DockerConnectionOptions,
+ delegate: Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Result<Self> {
+ let mut this = Self {
+ proxy_process: Mutex::new(None),
+ remote_dir_for_server: "/".to_string(),
+ remote_binary_relpath: None,
+ connection_options,
+ remote_platform: None,
+ path_style: None,
+ shell: None,
+ };
+ let (release_channel, version, commit) = cx.update(|cx| {
+ (
+ ReleaseChannel::global(cx),
+ AppVersion::global(cx),
+ AppCommitSha::try_global(cx),
+ )
+ })?;
+ let remote_platform = this.check_remote_platform().await?;
+
+ this.path_style = match remote_platform.os {
+ RemoteOs::Windows => Some(PathStyle::Windows),
+ _ => Some(PathStyle::Posix),
+ };
+
+ this.remote_platform = Some(remote_platform);
+
+ this.shell = Some(this.discover_shell().await);
+
+ this.remote_dir_for_server = this.docker_user_home_dir().await?.trim().to_string();
+
+ this.remote_binary_relpath = Some(
+ this.ensure_server_binary(
+ &delegate,
+ release_channel,
+ version,
+ &this.remote_dir_for_server,
+ commit,
+ cx,
+ )
+ .await?,
+ );
+
+ Ok(this)
+ }
+
+ async fn discover_shell(&self) -> String {
+ let default_shell = "sh";
+ match self
+ .run_docker_exec("sh", None, &Default::default(), &["-c", "echo $SHELL"])
+ .await
+ {
+ Ok(shell) => match shell.trim() {
+ "" => {
+ log::error!("$SHELL is not set, falling back to {default_shell}");
+ default_shell.to_owned()
+ }
+ shell => shell.to_owned(),
+ },
+ Err(e) => {
+ log::error!("Failed to get shell: {e}");
+ default_shell.to_owned()
+ }
+ }
+ }
+
+ async fn check_remote_platform(&self) -> Result<RemotePlatform> {
+ let uname = self
+ .run_docker_exec("uname", None, &Default::default(), &["-sm"])
+ .await?;
+ let Some((os, arch)) = uname.split_once(" ") else {
+ anyhow::bail!("unknown uname: {uname:?}")
+ };
+
+ let os = match os.trim() {
+ "Darwin" => RemoteOs::MacOs,
+ "Linux" => RemoteOs::Linux,
+ _ => anyhow::bail!(
+ "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
+ ),
+ };
+ // exclude armv5,6,7 as they are 32-bit.
+ let arch = if arch.starts_with("armv8")
+ || arch.starts_with("armv9")
+ || arch.starts_with("arm64")
+ || arch.starts_with("aarch64")
+ {
+ RemoteArch::Aarch64
+ } else if arch.starts_with("x86") {
+ RemoteArch::X86_64
+ } else {
+ anyhow::bail!(
+ "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
+ )
+ };
+
+ Ok(RemotePlatform { os, arch })
+ }
+
+ async fn ensure_server_binary(
+ &self,
+ delegate: &Arc<dyn RemoteClientDelegate>,
+ release_channel: ReleaseChannel,
+ version: SemanticVersion,
+ remote_dir_for_server: &str,
+ commit: Option<AppCommitSha>,
+ cx: &mut AsyncApp,
+ ) -> Result<Arc<RelPath>> {
+ let remote_platform = if self.remote_platform.is_some() {
+ self.remote_platform.unwrap()
+ } else {
+ anyhow::bail!("No remote platform defined; cannot proceed.")
+ };
+
+ let version_str = match release_channel {
+ ReleaseChannel::Nightly => {
+ let commit = commit.map(|s| s.full()).unwrap_or_default();
+ format!("{}-{}", version, commit)
+ }
+ ReleaseChannel::Dev => "build".to_string(),
+ _ => version.to_string(),
+ };
+ let binary_name = format!(
+ "zed-remote-server-{}-{}",
+ release_channel.dev_name(),
+ version_str
+ );
+ let dst_path =
+ paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap());
+
+ #[cfg(debug_assertions)]
+ if let Some(remote_server_path) =
+ super::build_remote_server_from_source(&remote_platform, delegate.as_ref(), cx).await?
+ {
+ let tmp_path = paths::remote_server_dir_relative().join(
+ RelPath::unix(&format!(
+ "download-{}-{}",
+ std::process::id(),
+ remote_server_path.file_name().unwrap().to_string_lossy()
+ ))
+ .unwrap(),
+ );
+ self.upload_local_server_binary(
+ &remote_server_path,
+ &tmp_path,
+ &remote_dir_for_server,
+ delegate,
+ cx,
+ )
+ .await?;
+ self.extract_server_binary(&dst_path, &tmp_path, &remote_dir_for_server, delegate, cx)
+ .await?;
+ return Ok(dst_path);
+ }
+
+ if self
+ .run_docker_exec(
+ &dst_path.display(self.path_style()),
+ Some(&remote_dir_for_server),
+ &Default::default(),
+ &["version"],
+ )
+ .await
+ .is_ok()
+ {
+ return Ok(dst_path);
+ }
+
+ let wanted_version = cx.update(|cx| match release_channel {
+ ReleaseChannel::Nightly => Ok(None),
+ ReleaseChannel::Dev => {
+ anyhow::bail!(
+ "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
+ dst_path
+ )
+ }
+ _ => Ok(Some(AppVersion::global(cx))),
+ })??;
+
+ let tmp_path_gz = paths::remote_server_dir_relative().join(
+ RelPath::unix(&format!(
+ "{}-download-{}.gz",
+ binary_name,
+ std::process::id()
+ ))
+ .unwrap(),
+ );
+ if !self.connection_options.upload_binary_over_docker_exec
+ && let Some(url) = delegate
+ .get_download_url(remote_platform, release_channel, wanted_version.clone(), cx)
+ .await?
+ {
+ match self
+ .download_binary_on_server(&url, &tmp_path_gz, &remote_dir_for_server, delegate, cx)
+ .await
+ {
+ Ok(_) => {
+ self.extract_server_binary(
+ &dst_path,
+ &tmp_path_gz,
+ &remote_dir_for_server,
+ delegate,
+ cx,
+ )
+ .await
+ .context("extracting server binary")?;
+ return Ok(dst_path);
+ }
+ Err(e) => {
+ log::error!(
+ "Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}",
+ )
+ }
+ }
+ }
+
+ let src_path = delegate
+ .download_server_binary_locally(remote_platform, release_channel, wanted_version, cx)
+ .await
+ .context("downloading server binary locally")?;
+ self.upload_local_server_binary(
+ &src_path,
+ &tmp_path_gz,
+ &remote_dir_for_server,
+ delegate,
+ cx,
+ )
+ .await
+ .context("uploading server binary")?;
+ self.extract_server_binary(
+ &dst_path,
+ &tmp_path_gz,
+ &remote_dir_for_server,
+ delegate,
+ cx,
+ )
+ .await
+ .context("extracting server binary")?;
+ Ok(dst_path)
+ }
+
+ async fn docker_user_home_dir(&self) -> Result<String> {
+ let inner_program = self.shell();
+ self.run_docker_exec(
+ &inner_program,
+ None,
+ &Default::default(),
+ &["-c", "echo $HOME"],
+ )
+ .await
+ }
+
+ async fn extract_server_binary(
+ &self,
+ dst_path: &RelPath,
+ tmp_path: &RelPath,
+ remote_dir_for_server: &str,
+ delegate: &Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Result<()> {
+ delegate.set_status(Some("Extracting remote development server"), cx);
+ let server_mode = 0o755;
+
+ let shell_kind = ShellKind::Posix;
+ let orig_tmp_path = tmp_path.display(self.path_style());
+ let server_mode = format!("{:o}", server_mode);
+ let server_mode = shell_kind
+ .try_quote(&server_mode)
+ .context("shell quoting")?;
+ let dst_path = dst_path.display(self.path_style());
+ let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
+ let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
+ let orig_tmp_path = shell_kind
+ .try_quote(&orig_tmp_path)
+ .context("shell quoting")?;
+ let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?;
+ format!(
+ "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
+ )
+ } else {
+ let orig_tmp_path = shell_kind
+ .try_quote(&orig_tmp_path)
+ .context("shell quoting")?;
+ format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",)
+ };
+ let args = shell_kind.args_for_shell(false, script.to_string());
+ self.run_docker_exec(
+ "sh",
+ Some(&remote_dir_for_server),
+ &Default::default(),
+ &args,
+ )
+ .await
+ .log_err();
+ Ok(())
+ }
+
+ async fn upload_local_server_binary(
+ &self,
+ src_path: &Path,
+ tmp_path_gz: &RelPath,
+ remote_dir_for_server: &str,
+ delegate: &Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Result<()> {
+ if let Some(parent) = tmp_path_gz.parent() {
+ self.run_docker_exec(
+ "mkdir",
+ Some(remote_dir_for_server),
+ &Default::default(),
+ &["-p", parent.display(self.path_style()).as_ref()],
+ )
+ .await?;
+ }
+
+ let src_stat = smol::fs::metadata(&src_path).await?;
+ let size = src_stat.len();
+
+ let t0 = Instant::now();
+ delegate.set_status(Some("Uploading remote development server"), cx);
+ log::info!(
+ "uploading remote development server to {:?} ({}kb)",
+ tmp_path_gz,
+ size / 1024
+ );
+ self.upload_file(src_path, tmp_path_gz, remote_dir_for_server)
+ .await
+ .context("failed to upload server binary")?;
+ log::info!("uploaded remote development server in {:?}", t0.elapsed());
+ Ok(())
+ }
+
+ async fn upload_file(
+ &self,
+ src_path: &Path,
+ dest_path: &RelPath,
+ remote_dir_for_server: &str,
+ ) -> Result<()> {
+ log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
+
+ let src_path_display = src_path.display().to_string();
+ let dest_path_str = dest_path.display(self.path_style());
+
+ let mut command = util::command::new_smol_command("docker");
+ command.arg("cp");
+ command.arg("-a");
+ command.arg(&src_path_display);
+ command.arg(format!(
+ "{}:{}/{}",
+ &self.connection_options.container_id, remote_dir_for_server, dest_path_str
+ ));
+
+ let output = command.output().await?;
+
+ if output.status.success() {
+ return Ok(());
+ }
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ log::debug!(
+ "failed to upload file via docker cp {src_path_display} -> {dest_path_str}: {stderr}",
+ );
+ anyhow::bail!(
+ "failed to upload file via docker cp {} -> {}: {}",
+ src_path_display,
+ dest_path_str,
+ stderr,
+ );
+ }
+
+ async fn run_docker_command(
+ &self,
+ subcommand: &str,
+ args: &[impl AsRef<str>],
+ ) -> Result<String> {
+ let mut command = util::command::new_smol_command("docker");
+ command.arg(subcommand);
+ for arg in args {
+ command.arg(arg.as_ref());
+ }
+ let output = command.output().await?;
+ anyhow::ensure!(
+ output.status.success(),
+ "failed to run command {command:?}: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ }
+
+ async fn run_docker_exec(
+ &self,
+ inner_program: &str,
+ working_directory: Option<&str>,
+ env: &HashMap<String, String>,
+ program_args: &[impl AsRef<str>],
+ ) -> Result<String> {
+ let mut args = match working_directory {
+ Some(dir) => vec!["-w".to_string(), dir.to_string()],
+ None => vec![],
+ };
+
+ for (k, v) in env.iter() {
+ args.push("-e".to_string());
+ let env_declaration = format!("{}={}", k, v);
+ args.push(env_declaration);
+ }
+
+ args.push(self.connection_options.container_id.clone());
+ args.push(inner_program.to_string());
+
+ for arg in program_args {
+ args.push(arg.as_ref().to_owned());
+ }
+ self.run_docker_command("exec", args.as_ref()).await
+ }
+
+ async fn download_binary_on_server(
+ &self,
+ url: &str,
+ tmp_path_gz: &RelPath,
+ remote_dir_for_server: &str,
+ delegate: &Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Result<()> {
+ if let Some(parent) = tmp_path_gz.parent() {
+ self.run_docker_exec(
+ "mkdir",
+ Some(remote_dir_for_server),
+ &Default::default(),
+ &["-p", parent.display(self.path_style()).as_ref()],
+ )
+ .await?;
+ }
+
+ delegate.set_status(Some("Downloading remote development server on host"), cx);
+
+ match self
+ .run_docker_exec(
+ "curl",
+ Some(remote_dir_for_server),
+ &Default::default(),
+ &[
+ "-f",
+ "-L",
+ url,
+ "-o",
+ &tmp_path_gz.display(self.path_style()),
+ ],
+ )
+ .await
+ {
+ Ok(_) => {}
+ Err(e) => {
+ if self
+ .run_docker_exec("which", None, &Default::default(), &["curl"])
+ .await
+ .is_ok()
+ {
+ return Err(e);
+ }
+
+ log::info!("curl is not available, trying wget");
+ match self
+ .run_docker_exec(
+ "wget",
+ Some(remote_dir_for_server),
+ &Default::default(),
+ &[url, "-O", &tmp_path_gz.display(self.path_style())],
+ )
+ .await
+ {
+ Ok(_) => {}
+ Err(e) => {
+ if self
+ .run_docker_exec("which", None, &Default::default(), &["wget"])
+ .await
+ .is_ok()
+ {
+ return Err(e);
+ } else {
+ anyhow::bail!("Neither curl nor wget is available");
+ }
+ }
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn kill_inner(&self) -> Result<()> {
+ if let Some(pid) = self.proxy_process.lock().take() {
+ if let Ok(_) = util::command::new_smol_command("kill")
+ .arg(pid.to_string())
+ .spawn()
+ {
+ Ok(())
+ } else {
+ Err(anyhow::anyhow!("Failed to kill process"))
+ }
+ } else {
+ Ok(())
+ }
+ }
+}
+
+#[async_trait(?Send)]
+impl RemoteConnection for DockerExecConnection {
+ fn has_wsl_interop(&self) -> bool {
+ false
+ }
+ fn start_proxy(
+ &self,
+ unique_identifier: String,
+ reconnect: bool,
+ incoming_tx: UnboundedSender<Envelope>,
+ outgoing_rx: UnboundedReceiver<Envelope>,
+ connection_activity_tx: Sender<()>,
+ delegate: Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<i32>> {
+ // We'll try connecting anew every time we open a devcontainer, so proactively try to kill any old connections.
+ if !self.has_been_killed() {
+ if let Err(e) = self.kill_inner() {
+ return Task::ready(Err(e));
+ };
+ }
+
+ delegate.set_status(Some("Starting proxy"), cx);
+
+ let Some(remote_binary_relpath) = self.remote_binary_relpath.clone() else {
+ return Task::ready(Err(anyhow!("Remote binary path not set")));
+ };
+
+ let mut docker_args = vec![
+ "exec".to_string(),
+ "-w".to_string(),
+ self.remote_dir_for_server.clone(),
+ "-i".to_string(),
+ self.connection_options.container_id.to_string(),
+ ];
+ for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
+ if let Some(value) = std::env::var(env_var).ok() {
+ docker_args.push("-e".to_string());
+ docker_args.push(format!("{}='{}'", env_var, value));
+ }
+ }
+ let val = remote_binary_relpath
+ .display(self.path_style())
+ .into_owned();
+ docker_args.push(val);
+ docker_args.push("proxy".to_string());
+ docker_args.push("--identifier".to_string());
+ docker_args.push(unique_identifier);
+ if reconnect {
+ docker_args.push("--reconnect".to_string());
+ }
+ let mut command = util::command::new_smol_command("docker");
+ command
+ .kill_on_drop(true)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .args(docker_args);
+
+ let Ok(child) = command.spawn() else {
+ return Task::ready(Err(anyhow::anyhow!(
+ "Failed to start remote server process"
+ )));
+ };
+
+ let mut proxy_process = self.proxy_process.lock();
+ *proxy_process = Some(child.id());
+
+ super::handle_rpc_messages_over_child_process_stdio(
+ child,
+ incoming_tx,
+ outgoing_rx,
+ connection_activity_tx,
+ cx,
+ )
+ }
+
+ fn upload_directory(
+ &self,
+ src_path: PathBuf,
+ dest_path: RemotePathBuf,
+ cx: &App,
+ ) -> Task<Result<()>> {
+ let dest_path_str = dest_path.to_string();
+ let src_path_display = src_path.display().to_string();
+
+ let mut command = util::command::new_smol_command("docker");
+ command.arg("cp");
+ command.arg("-a"); // Archive mode is required to assign the file ownership to the default docker exec user
+ command.arg(src_path_display);
+ command.arg(format!(
+ "{}:{}",
+ self.connection_options.container_id, dest_path_str
+ ));
+
+ cx.background_spawn(async move {
+ let output = command.output().await?;
+
+ if output.status.success() {
+ Ok(())
+ } else {
+ Err(anyhow::anyhow!("Failed to upload directory"))
+ }
+ })
+ }
+
+ async fn kill(&self) -> Result<()> {
+ self.kill_inner()
+ }
+
+ fn has_been_killed(&self) -> bool {
+ self.proxy_process.lock().is_none()
+ }
+
+ fn build_command(
+ &self,
+ program: Option<String>,
+ args: &[String],
+ env: &HashMap<String, String>,
+ working_dir: Option<String>,
+ _port_forward: Option<(u16, String, u16)>,
+ ) -> Result<CommandTemplate> {
+ let mut parsed_working_dir = None;
+
+ let path_style = self.path_style();
+
+ if let Some(working_dir) = working_dir {
+ let working_dir = RemotePathBuf::new(working_dir, path_style).to_string();
+
+ const TILDE_PREFIX: &'static str = "~/";
+ if working_dir.starts_with(TILDE_PREFIX) {
+ let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
+ parsed_working_dir = Some(format!("$HOME/{working_dir}"));
+ } else {
+ parsed_working_dir = Some(working_dir);
+ }
+ }
+
+ let mut inner_program = Vec::new();
+
+ if let Some(program) = program {
+ inner_program.push(program);
+ for arg in args {
+ inner_program.push(arg.clone());
+ }
+ } else {
+ inner_program.push(self.shell());
+ inner_program.push("-l".to_string());
+ };
+
+ let mut docker_args = vec!["exec".to_string()];
+
+ if let Some(parsed_working_dir) = parsed_working_dir {
+ docker_args.push("-w".to_string());
+ docker_args.push(parsed_working_dir);
+ }
+
+ for (k, v) in env.iter() {
+ docker_args.push("-e".to_string());
+ docker_args.push(format!("{}={}", k, v));
+ }
+
+ docker_args.push("-it".to_string());
+ docker_args.push(self.connection_options.container_id.to_string());
+
+ docker_args.append(&mut inner_program);
+
+ Ok(CommandTemplate {
+ program: "docker".to_string(),
+ args: docker_args,
+ // Docker-exec pipes in environment via the "-e" argument
+ env: Default::default(),
+ })
+ }
+
+ fn build_forward_ports_command(
+ &self,
+ _forwards: Vec<(u16, String, u16)>,
+ ) -> Result<CommandTemplate> {
+ Err(anyhow::anyhow!("Not currently supported for docker_exec"))
+ }
+
+ fn connection_options(&self) -> RemoteConnectionOptions {
+ RemoteConnectionOptions::Docker(self.connection_options.clone())
+ }
+
+ fn path_style(&self) -> PathStyle {
+ self.path_style.unwrap_or(PathStyle::Posix)
+ }
+
+ fn shell(&self) -> String {
+ match &self.shell {
+ Some(shell) => shell.clone(),
+ None => self.default_system_shell(),
+ }
+ }
+
+ fn default_system_shell(&self) -> String {
+ String::from("/bin/sh")
+ }
+}
@@ -1,6 +1,7 @@
use crate::{
- RemoteClientDelegate, RemotePlatform,
+ RemoteArch, RemoteClientDelegate, RemoteOs, RemotePlatform,
remote_client::{CommandTemplate, RemoteConnection, RemoteConnectionOptions},
+ transport::{parse_platform, parse_shell},
};
use anyhow::{Context as _, Result, anyhow};
use async_trait::async_trait;
@@ -10,17 +11,19 @@ use futures::{
channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender},
select_biased,
};
-use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task};
+use gpui::{App, AppContext as _, AsyncApp, Task};
use parking_lot::Mutex;
use paths::remote_server_dir_relative;
-use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
+use release_channel::{AppVersion, ReleaseChannel};
use rpc::proto::Envelope;
+use semver::Version;
pub use settings::SshPortForwardOption;
use smol::{
fs,
process::{self, Child, Stdio},
};
use std::{
+ net::IpAddr,
path::{Path, PathBuf},
sync::Arc,
time::Instant,
@@ -29,7 +32,8 @@ use tempfile::TempDir;
use util::{
paths::{PathStyle, RemotePathBuf},
rel_path::RelPath,
- shell::ShellKind,
+ shell::{Shell, ShellKind},
+ shell_builder::ShellBuilder,
};
pub(crate) struct SshRemoteConnection {
@@ -44,14 +48,64 @@ pub(crate) struct SshRemoteConnection {
_temp_dir: TempDir,
}
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum SshConnectionHost {
+ IpAddr(IpAddr),
+ Hostname(String),
+}
+
+impl SshConnectionHost {
+ pub fn to_bracketed_string(&self) -> String {
+ match self {
+ Self::IpAddr(IpAddr::V4(ip)) => ip.to_string(),
+ Self::IpAddr(IpAddr::V6(ip)) => format!("[{}]", ip),
+ Self::Hostname(hostname) => hostname.clone(),
+ }
+ }
+
+ pub fn to_string(&self) -> String {
+ match self {
+ Self::IpAddr(ip) => ip.to_string(),
+ Self::Hostname(hostname) => hostname.clone(),
+ }
+ }
+}
+
+impl From<&str> for SshConnectionHost {
+ fn from(value: &str) -> Self {
+ if let Ok(address) = value.parse() {
+ Self::IpAddr(address)
+ } else {
+ Self::Hostname(value.to_string())
+ }
+ }
+}
+
+impl From<String> for SshConnectionHost {
+ fn from(value: String) -> Self {
+ if let Ok(address) = value.parse() {
+ Self::IpAddr(address)
+ } else {
+ Self::Hostname(value)
+ }
+ }
+}
+
+impl Default for SshConnectionHost {
+ fn default() -> Self {
+ Self::Hostname(Default::default())
+ }
+}
+
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
pub struct SshConnectionOptions {
- pub host: String,
+ pub host: SshConnectionHost,
pub username: Option<String>,
pub port: Option<u16>,
pub password: Option<String>,
pub args: Option<Vec<String>>,
pub port_forwards: Option<Vec<SshPortForwardOption>>,
+ pub connection_timeout: Option<u16>,
pub nickname: Option<String>,
pub upload_binary_over_ssh: bool,
@@ -60,7 +114,7 @@ pub struct SshConnectionOptions {
impl From<settings::SshConnection> for SshConnectionOptions {
fn from(val: settings::SshConnection) -> Self {
SshConnectionOptions {
- host: val.host.into(),
+ host: val.host.to_string().into(),
username: val.username,
port: val.port,
password: None,
@@ -68,6 +122,7 @@ impl From<settings::SshConnection> for SshConnectionOptions {
nickname: val.nickname,
upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(),
port_forwards: val.port_forwards,
+ connection_timeout: val.connection_timeout,
}
}
}
@@ -91,7 +146,7 @@ impl MasterProcess {
askpass_script_path: &std::ffi::OsStr,
additional_args: Vec<String>,
socket_path: &std::path::Path,
- url: &str,
+ destination: &str,
) -> Result<Self> {
let args = [
"-N",
@@ -115,7 +170,7 @@ impl MasterProcess {
master_process.arg(format!("ControlPath={}", socket_path.display()));
- let process = master_process.arg(&url).spawn()?;
+ let process = master_process.arg(&destination).spawn()?;
Ok(MasterProcess { process })
}
@@ -138,7 +193,7 @@ impl MasterProcess {
pub fn new(
askpass_script_path: &std::ffi::OsStr,
additional_args: Vec<String>,
- url: &str,
+ destination: &str,
) -> Result<Self> {
// On Windows, `ControlMaster` and `ControlPath` are not supported:
// https://github.com/PowerShell/Win32-OpenSSH/issues/405
@@ -160,7 +215,7 @@ impl MasterProcess {
.env("SSH_ASKPASS_REQUIRE", "force")
.env("SSH_ASKPASS", askpass_script_path)
.args(additional_args)
- .arg(url)
+ .arg(destination)
.args(args);
let process = master_process.spawn()?;
@@ -304,7 +359,7 @@ impl RemoteConnection for SshRemoteConnection {
let mut child = sftp_command.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
use futures::AsyncWriteExt;
- let sftp_batch = format!("put -r {src_path_display} {dest_path_str}\n");
+ let sftp_batch = format!("put -r \"{src_path_display}\" \"{dest_path_str}\"\n");
stdin.write_all(sftp_batch.as_bytes()).await?;
stdin.flush().await?;
}
@@ -347,30 +402,50 @@ impl RemoteConnection for SshRemoteConnection {
delegate: Arc<dyn RemoteClientDelegate>,
cx: &mut AsyncApp,
) -> Task<Result<i32>> {
+ const VARS: [&str; 3] = ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"];
delegate.set_status(Some("Starting proxy"), cx);
let Some(remote_binary_path) = self.remote_binary_path.clone() else {
return Task::ready(Err(anyhow!("Remote binary path not set")));
};
- let mut proxy_args = vec![];
- for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
- if let Some(value) = std::env::var(env_var).ok() {
- proxy_args.push(format!("{}='{}'", env_var, value));
+ let mut ssh_command = if self.ssh_platform.os.is_windows() {
+ // TODO: Set the `VARS` environment variables, we do not have `env` on windows
+ // so this needs a different approach
+ let mut proxy_args = vec![];
+ proxy_args.push("proxy".to_owned());
+ proxy_args.push("--identifier".to_owned());
+ proxy_args.push(unique_identifier);
+
+ if reconnect {
+ proxy_args.push("--reconnect".to_owned());
}
- }
- proxy_args.push(remote_binary_path.display(self.path_style()).into_owned());
- proxy_args.push("proxy".to_owned());
- proxy_args.push("--identifier".to_owned());
- proxy_args.push(unique_identifier);
+ self.socket.ssh_command(
+ self.ssh_shell_kind,
+ &remote_binary_path.display(self.path_style()),
+ &proxy_args,
+ false,
+ )
+ } else {
+ let mut proxy_args = vec![];
+ for env_var in VARS {
+ if let Some(value) = std::env::var(env_var).ok() {
+ proxy_args.push(format!("{}='{}'", env_var, value));
+ }
+ }
+ proxy_args.push(remote_binary_path.display(self.path_style()).into_owned());
+ proxy_args.push("proxy".to_owned());
+ proxy_args.push("--identifier".to_owned());
+ proxy_args.push(unique_identifier);
- if reconnect {
- proxy_args.push("--reconnect".to_owned());
- }
+ if reconnect {
+ proxy_args.push("--reconnect".to_owned());
+ }
+ self.socket
+ .ssh_command(self.ssh_shell_kind, "env", &proxy_args, false)
+ };
- let ssh_proxy_process = match self
- .socket
- .ssh_command(self.ssh_shell_kind, "env", &proxy_args, false)
+ let ssh_proxy_process = match ssh_command
// IMPORTANT: we kill this process when we drop the task that uses it.
.kill_on_drop(true)
.spawn()
@@ -393,6 +468,10 @@ impl RemoteConnection for SshRemoteConnection {
fn path_style(&self) -> PathStyle {
self.ssh_path_style
}
+
+ fn has_wsl_interop(&self) -> bool {
+ false
+ }
}
impl SshRemoteConnection {
@@ -403,7 +482,7 @@ impl SshRemoteConnection {
) -> Result<Self> {
use askpass::AskPassResult;
- let url = connection_options.ssh_url();
+ let destination = connection_options.ssh_destination();
let temp_dir = tempfile::Builder::new()
.prefix("zed-ssh-session")
@@ -428,14 +507,14 @@ impl SshRemoteConnection {
let mut master_process = MasterProcess::new(
askpass.script_path().as_ref(),
connection_options.additional_args(),
- &url,
+ &destination,
)?;
#[cfg(not(target_os = "windows"))]
let mut master_process = MasterProcess::new(
askpass.script_path().as_ref(),
connection_options.additional_args(),
&socket_path,
- &url,
+ &destination,
)?;
let result = select_biased! {
@@ -486,20 +565,20 @@ impl SshRemoteConnection {
.await?;
drop(askpass);
- let ssh_shell = socket.shell().await;
- let ssh_platform = socket.platform(ShellKind::new(&ssh_shell, false)).await?;
- let ssh_path_style = match ssh_platform.os {
- "windows" => PathStyle::Windows,
- _ => PathStyle::Posix,
+ let is_windows = socket.probe_is_windows().await;
+ log::info!("Remote is windows: {}", is_windows);
+
+ let ssh_shell = socket.shell(is_windows).await;
+ log::info!("Remote shell discovered: {}", ssh_shell);
+
+ let ssh_shell_kind = ShellKind::new(&ssh_shell, is_windows);
+ let ssh_platform = socket.platform(ssh_shell_kind, is_windows).await?;
+ log::info!("Remote platform discovered: {:?}", ssh_platform);
+
+ let (ssh_path_style, ssh_default_system_shell) = match ssh_platform.os {
+ RemoteOs::Windows => (PathStyle::Windows, ssh_shell.clone()),
+ _ => (PathStyle::Posix, String::from("/bin/sh")),
};
- let ssh_default_system_shell = String::from("/bin/sh");
- let ssh_shell_kind = ShellKind::new(
- &ssh_shell,
- match ssh_platform.os {
- "windows" => true,
- _ => false,
- },
- );
let mut this = Self {
socket,
@@ -513,15 +592,10 @@ impl SshRemoteConnection {
ssh_default_system_shell,
};
- let (release_channel, version, commit) = cx.update(|cx| {
- (
- ReleaseChannel::global(cx),
- AppVersion::global(cx),
- AppCommitSha::try_global(cx),
- )
- })?;
+ let (release_channel, version) =
+ cx.update(|cx| (ReleaseChannel::global(cx), AppVersion::global(cx)))?;
this.remote_binary_path = Some(
- this.ensure_server_binary(&delegate, release_channel, version, commit, cx)
+ this.ensure_server_binary(&delegate, release_channel, version, cx)
.await?,
);
@@ -532,22 +606,22 @@ impl SshRemoteConnection {
&self,
delegate: &Arc<dyn RemoteClientDelegate>,
release_channel: ReleaseChannel,
- version: SemanticVersion,
- commit: Option<AppCommitSha>,
+ version: Version,
cx: &mut AsyncApp,
) -> Result<Arc<RelPath>> {
let version_str = match release_channel {
- ReleaseChannel::Nightly => {
- let commit = commit.map(|s| s.full()).unwrap_or_default();
- format!("{}-{}", version, commit)
- }
ReleaseChannel::Dev => "build".to_string(),
_ => version.to_string(),
};
let binary_name = format!(
- "zed-remote-server-{}-{}",
+ "zed-remote-server-{}-{}{}",
release_channel.dev_name(),
- version_str
+ version_str,
+ if self.ssh_platform.os.is_windows() {
+ ".exe"
+ } else {
+ ""
+ }
);
let dst_path =
paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap());
@@ -607,7 +681,12 @@ impl SshRemoteConnection {
);
if !self.socket.connection_options.upload_binary_over_ssh
&& let Some(url) = delegate
- .get_download_url(self.ssh_platform, release_channel, wanted_version, cx)
+ .get_download_url(
+ self.ssh_platform,
+ release_channel,
+ wanted_version.clone(),
+ cx,
+ )
.await?
{
match self
@@ -622,14 +701,19 @@ impl SshRemoteConnection {
}
Err(e) => {
log::error!(
- "Failed to download binary on server, attempting to upload server: {e:#}",
+ "Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}",
)
}
}
}
let src_path = delegate
- .download_server_binary_locally(self.ssh_platform, release_channel, wanted_version, cx)
+ .download_server_binary_locally(
+ self.ssh_platform,
+ release_channel,
+ wanted_version.clone(),
+ cx,
+ )
.await
.context("downloading server binary locally")?;
self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx)
@@ -649,18 +733,30 @@ impl SshRemoteConnection {
cx: &mut AsyncApp,
) -> Result<()> {
if let Some(parent) = tmp_path_gz.parent() {
- self.socket
+ let res = self
+ .socket
.run_command(
self.ssh_shell_kind,
"mkdir",
&["-p", parent.display(self.path_style()).as_ref()],
true,
)
- .await?;
+ .await;
+ if !self.ssh_platform.os.is_windows() {
+ // mkdir fails on windows if the path already exists ...
+ res?;
+ }
}
delegate.set_status(Some("Downloading remote development server on host"), cx);
+ let connection_timeout = self
+ .socket
+ .connection_options
+ .connection_timeout
+ .unwrap_or(10)
+ .to_string();
+
match self
.socket
.run_command(
@@ -669,6 +765,8 @@ impl SshRemoteConnection {
&[
"-f",
"-L",
+ "--connect-timeout",
+ &connection_timeout,
url,
"-o",
&tmp_path_gz.display(self.path_style()),
@@ -688,12 +786,21 @@ impl SshRemoteConnection {
return Err(e);
}
+ log::info!("curl is not available, trying wget");
match self
.socket
.run_command(
self.ssh_shell_kind,
"wget",
- &[url, "-O", &tmp_path_gz.display(self.path_style())],
+ &[
+ "--connect-timeout",
+ &connection_timeout,
+ "--tries",
+ "1",
+ url,
+ "-O",
+ &tmp_path_gz.display(self.path_style()),
+ ],
true,
)
.await
@@ -726,17 +833,24 @@ impl SshRemoteConnection {
cx: &mut AsyncApp,
) -> Result<()> {
if let Some(parent) = tmp_path_gz.parent() {
- self.socket
+ let res = self
+ .socket
.run_command(
self.ssh_shell_kind,
"mkdir",
&["-p", parent.display(self.path_style()).as_ref()],
true,
)
- .await?;
+ .await;
+ if !self.ssh_platform.os.is_windows() {
+ // mkdir fails on windows if the path already exists ...
+ res?;
+ }
}
- let src_stat = fs::metadata(&src_path).await?;
+ let src_stat = fs::metadata(&src_path)
+ .await
+ .with_context(|| format!("failed to get metadata for {:?}", src_path))?;
let size = src_stat.len();
let t0 = Instant::now();
@@ -787,7 +901,7 @@ impl SshRemoteConnection {
};
let args = shell_kind.args_for_shell(false, script.to_string());
self.socket
- .run_command(shell_kind, "sh", &args, true)
+ .run_command(self.ssh_shell_kind, "sh", &args, true)
.await?;
Ok(())
}
@@ -811,7 +925,7 @@ impl SshRemoteConnection {
}
command.arg(src_path).arg(format!(
"{}:{}",
- self.socket.connection_options.scp_url(),
+ self.socket.connection_options.scp_destination(),
dest_path_str
));
command
@@ -827,7 +941,7 @@ impl SshRemoteConnection {
.unwrap_or_default(),
);
command.arg("-b").arg("-");
- command.arg(self.socket.connection_options.scp_url());
+ command.arg(self.socket.connection_options.scp_destination());
command.stdin(Stdio::piped());
command
}
@@ -957,7 +1071,7 @@ impl SshSocket {
let separator = shell_kind.sequential_commands_separator();
let to_run = format!("cd{separator} {to_run}");
self.ssh_options(&mut command, true)
- .arg(self.connection_options.ssh_url());
+ .arg(self.connection_options.ssh_destination());
if !allow_pseudo_tty {
command.arg("-T");
}
@@ -973,13 +1087,12 @@ impl SshSocket {
args: &[impl AsRef<str>],
allow_pseudo_tty: bool,
) -> Result<String> {
- let output = self
- .ssh_command(shell_kind, program, args, allow_pseudo_tty)
- .output()
- .await?;
+ let mut command = self.ssh_command(shell_kind, program, args, allow_pseudo_tty);
+ let output = command.output().await?;
+ log::debug!("{:?}: {:?}", command, output);
anyhow::ensure!(
output.status.success(),
- "failed to run command: {}",
+ "failed to run command {command:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
Ok(String::from_utf8_lossy(&output.stdout).to_string())
@@ -1036,7 +1149,7 @@ impl SshSocket {
"ControlMaster=no".to_string(),
"-o".to_string(),
format!("ControlPath={}", self.socket_path.display()),
- self.connection_options.ssh_url(),
+ self.connection_options.ssh_destination(),
]);
arguments
}
@@ -1044,60 +1157,94 @@ impl SshSocket {
#[cfg(target_os = "windows")]
fn ssh_args(&self) -> Vec<String> {
let mut arguments = self.connection_options.additional_args();
- arguments.push(self.connection_options.ssh_url());
+ arguments.push(self.connection_options.ssh_destination());
arguments
}
- async fn platform(&self, shell: ShellKind) -> Result<RemotePlatform> {
- let uname = self.run_command(shell, "uname", &["-sm"], false).await?;
- let Some((os, arch)) = uname.split_once(" ") else {
- anyhow::bail!("unknown uname: {uname:?}")
- };
-
- let os = match os.trim() {
- "Darwin" => "macos",
- "Linux" => "linux",
- _ => anyhow::bail!(
- "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
- ),
- };
- // exclude armv5,6,7 as they are 32-bit.
- let arch = if arch.starts_with("armv8")
- || arch.starts_with("armv9")
- || arch.starts_with("arm64")
- || arch.starts_with("aarch64")
- {
- "aarch64"
- } else if arch.starts_with("x86") {
- "x86_64"
+ async fn platform(&self, shell: ShellKind, is_windows: bool) -> Result<RemotePlatform> {
+ if is_windows {
+ self.platform_windows(shell).await
} else {
- anyhow::bail!(
- "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
+ self.platform_posix(shell).await
+ }
+ }
+
+ async fn platform_posix(&self, shell: ShellKind) -> Result<RemotePlatform> {
+ let output = self
+ .run_command(shell, "uname", &["-sm"], false)
+ .await
+ .context("Failed to run 'uname -sm' to determine platform")?;
+ parse_platform(&output)
+ }
+
+ async fn platform_windows(&self, shell: ShellKind) -> Result<RemotePlatform> {
+ let output = self
+ .run_command(
+ shell,
+ "cmd",
+ &["/c", "echo", "%PROCESSOR_ARCHITECTURE%"],
+ false,
)
- };
+ .await
+ .context(
+ "Failed to run 'echo %PROCESSOR_ARCHITECTURE%' to determine Windows architecture",
+ )?;
+
+ Ok(RemotePlatform {
+ os: RemoteOs::Windows,
+ arch: match output.trim() {
+ "AMD64" => RemoteArch::X86_64,
+ "ARM64" => RemoteArch::Aarch64,
+ arch => anyhow::bail!(
+ "Prebuilt remote servers are not yet available for windows-{arch}. See https://zed.dev/docs/remote-development"
+ ),
+ },
+ })
+ }
- Ok(RemotePlatform { os, arch })
+ /// Probes whether the remote host is running Windows.
+ ///
+ /// This is done by attempting to run a simple Windows-specific command.
+ /// If it succeeds and returns Windows-like output, we assume it's Windows.
+ async fn probe_is_windows(&self) -> bool {
+ match self
+ .run_command(ShellKind::PowerShell, "cmd", &["/c", "ver"], false)
+ .await
+ {
+ // Windows 'ver' command outputs something like "Microsoft Windows [Version 10.0.19045.5011]"
+ Ok(output) => output.trim().contains("indows"),
+ Err(_) => false,
+ }
+ }
+
+ async fn shell(&self, is_windows: bool) -> String {
+ if is_windows {
+ self.shell_windows().await
+ } else {
+ self.shell_posix().await
+ }
}
- async fn shell(&self) -> String {
- let default_shell = "sh";
+ async fn shell_posix(&self) -> String {
+ const DEFAULT_SHELL: &str = "sh";
match self
.run_command(ShellKind::Posix, "sh", &["-c", "echo $SHELL"], false)
.await
{
- Ok(shell) => match shell.trim() {
- "" => {
- log::error!("$SHELL is not set, falling back to {default_shell}");
- default_shell.to_owned()
- }
- shell => shell.to_owned(),
- },
+ Ok(output) => parse_shell(&output, DEFAULT_SHELL),
Err(e) => {
- log::error!("Failed to get shell: {e}");
- default_shell.to_owned()
+ log::error!("Failed to detect remote shell: {e}");
+ DEFAULT_SHELL.to_owned()
}
}
}
+
+ async fn shell_windows(&self) -> String {
+ // powershell is always the default, and cannot really be removed from the system
+ // so we can rely on that fact and reasonably assume that we will be running in a
+ // powershell environment
+ "powershell.exe".to_owned()
+ }
}
fn parse_port_number(port_str: &str) -> Result<u16> {
@@ -1213,10 +1360,24 @@ impl SshConnectionOptions {
input = rest;
username = Some(u.to_string());
}
- if let Some((rest, p)) = input.split_once(':') {
+
+ // Handle port parsing, accounting for IPv6 addresses
+ // IPv6 addresses can be: 2001:db8::1 or [2001:db8::1]:22
+ if input.starts_with('[') {
+ if let Some((rest, p)) = input.rsplit_once("]:") {
+ input = rest.strip_prefix('[').unwrap_or(rest);
+ port = p.parse().ok();
+ } else if input.ends_with(']') {
+ input = input.strip_prefix('[').unwrap_or(input);
+ input = input.strip_suffix(']').unwrap_or(input);
+ }
+ } else if let Some((rest, p)) = input.rsplit_once(':')
+ && !rest.contains(":")
+ {
input = rest;
- port = p.parse().ok()
+ port = p.parse().ok();
}
+
hostname = Some(input.to_string())
}
@@ -1230,7 +1391,7 @@ impl SshConnectionOptions {
};
Ok(Self {
- host: hostname,
+ host: hostname.into(),
username,
port,
port_forwards,
@@ -1238,22 +1399,20 @@ impl SshConnectionOptions {
password: None,
nickname: None,
upload_binary_over_ssh: false,
+ connection_timeout: None,
})
}
- pub fn ssh_url(&self) -> String {
- let mut result = String::from("ssh://");
+ pub fn ssh_destination(&self) -> String {
+ let mut result = String::default();
if let Some(username) = &self.username {
// Username might be: username1@username2@ip2
let username = urlencoding::encode(username);
result.push_str(&username);
result.push('@');
}
- result.push_str(&self.host);
- if let Some(port) = self.port {
- result.push(':');
- result.push_str(&port.to_string());
- }
+
+ result.push_str(&self.host.to_string());
result
}
@@ -1264,6 +1423,15 @@ impl SshConnectionOptions {
pub fn additional_args(&self) -> Vec<String> {
let mut args = self.additional_args_for_scp();
+ if let Some(timeout) = self.connection_timeout {
+ args.extend(["-o".to_string(), format!("ConnectTimeout={}", timeout)]);
+ }
+
+ if let Some(port) = self.port {
+ args.push("-p".to_string());
+ args.push(port.to_string());
+ }
+
if let Some(forwards) = &self.port_forwards {
args.extend(forwards.iter().map(|pf| {
let local_host = match &pf.local_host {
@@ -1285,22 +1453,23 @@ impl SshConnectionOptions {
args
}
- fn scp_url(&self) -> String {
+ fn scp_destination(&self) -> String {
if let Some(username) = &self.username {
- format!("{}@{}", username, self.host)
+ format!("{}@{}", username, self.host.to_bracketed_string())
} else {
- self.host.clone()
+ self.host.to_string()
}
}
pub fn connection_string(&self) -> String {
- let host = if let Some(username) = &self.username {
- format!("{}@{}", username, self.host)
+ let host = if let Some(port) = &self.port {
+ format!("{}:{}", self.host.to_bracketed_string(), port)
} else {
- self.host.clone()
+ self.host.to_string()
};
- if let Some(port) = &self.port {
- format!("{}:{}", host, port)
+
+ if let Some(username) = &self.username {
+ format!("{}@{}", username, host)
} else {
host
}
@@ -1326,7 +1495,7 @@ fn build_command(
let working_dir = RemotePathBuf::new(working_dir, ssh_path_style).to_string();
// shlex will wrap the command in single quotes (''), disabling ~ expansion,
- // replace with with something that works
+ // replace with something that works
const TILDE_PREFIX: &'static str = "~/";
if working_dir.starts_with(TILDE_PREFIX) {
let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
@@ -1375,6 +1544,8 @@ fn build_command(
} else {
write!(exec, "{ssh_shell} -l")?;
};
+ let (command, command_args) = ShellBuilder::new(&Shell::Program(ssh_shell.to_owned()), false)
+ .build(Some(exec.clone()), &[]);
let mut args = Vec::new();
args.extend(ssh_args);
@@ -1385,7 +1556,9 @@ fn build_command(
}
args.push("-t".into());
- args.push(exec);
+ args.push(command);
+ args.extend(command_args);
+
Ok(CommandTemplate {
program: "ssh".into(),
args,
@@ -1424,6 +1597,9 @@ mod tests {
"-p",
"2222",
"-t",
+ "/bin/fish",
+ "-i",
+ "-c",
"cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2"
]
);
@@ -1456,6 +1632,9 @@ mod tests {
"-L",
"1:foo:2",
"-t",
+ "/bin/fish",
+ "-i",
+ "-c",
"cd && exec env INPUT_VA=val /bin/fish -l"
]
);
@@ -1496,12 +1675,48 @@ mod tests {
"-p".to_string(),
"2222".to_string(),
"-o".to_string(),
- "StrictHostKeyChecking=no".to_string()
+ "StrictHostKeyChecking=no".to_string(),
]
);
- assert!(
- scp_args.iter().all(|arg| !arg.starts_with("-L")),
- "scp args should not contain port forward flags: {scp_args:?}"
- );
+ }
+
+ #[test]
+ fn test_host_parsing() -> Result<()> {
+ let opts = SshConnectionOptions::parse_command_line("user@2001:db8::1")?;
+ assert_eq!(opts.host, "2001:db8::1".into());
+ assert_eq!(opts.username, Some("user".to_string()));
+ assert_eq!(opts.port, None);
+
+ let opts = SshConnectionOptions::parse_command_line("user@[2001:db8::1]:2222")?;
+ assert_eq!(opts.host, "2001:db8::1".into());
+ assert_eq!(opts.username, Some("user".to_string()));
+ assert_eq!(opts.port, Some(2222));
+
+ let opts = SshConnectionOptions::parse_command_line("user@[2001:db8::1]")?;
+ assert_eq!(opts.host, "2001:db8::1".into());
+ assert_eq!(opts.username, Some("user".to_string()));
+ assert_eq!(opts.port, None);
+
+ let opts = SshConnectionOptions::parse_command_line("2001:db8::1")?;
+ assert_eq!(opts.host, "2001:db8::1".into());
+ assert_eq!(opts.username, None);
+ assert_eq!(opts.port, None);
+
+ let opts = SshConnectionOptions::parse_command_line("[2001:db8::1]:2222")?;
+ assert_eq!(opts.host, "2001:db8::1".into());
+ assert_eq!(opts.username, None);
+ assert_eq!(opts.port, Some(2222));
+
+ let opts = SshConnectionOptions::parse_command_line("user@example.com:2222")?;
+ assert_eq!(opts.host, "example.com".into());
+ assert_eq!(opts.username, Some("user".to_string()));
+ assert_eq!(opts.port, Some(2222));
+
+ let opts = SshConnectionOptions::parse_command_line("user@192.168.1.1:2222")?;
+ assert_eq!(opts.host, "192.168.1.1".into());
+ assert_eq!(opts.username, Some("user".to_string()));
+ assert_eq!(opts.port, Some(2222));
+
+ Ok(())
}
}
@@ -1,14 +1,16 @@
use crate::{
- RemoteClientDelegate, RemotePlatform,
+ RemoteArch, RemoteClientDelegate, RemoteOs, RemotePlatform,
remote_client::{CommandTemplate, RemoteConnection, RemoteConnectionOptions},
+ transport::{parse_platform, parse_shell},
};
use anyhow::{Context, Result, anyhow, bail};
use async_trait::async_trait;
use collections::HashMap;
use futures::channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
-use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task};
-use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
+use gpui::{App, AppContext as _, AsyncApp, Task};
+use release_channel::{AppVersion, ReleaseChannel};
use rpc::proto::Envelope;
+use semver::Version;
use smol::{fs, process};
use std::{
ffi::OsStr,
@@ -21,7 +23,8 @@ use std::{
use util::{
paths::{PathStyle, RemotePathBuf},
rel_path::RelPath,
- shell::ShellKind,
+ shell::{Shell, ShellKind},
+ shell_builder::ShellBuilder,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, schemars::JsonSchema)]
@@ -46,8 +49,8 @@ pub(crate) struct WslRemoteConnection {
shell: String,
shell_kind: ShellKind,
default_system_shell: String,
+ has_wsl_interop: bool,
connection_options: WslConnectionOptions,
- can_exec: bool,
}
impl WslRemoteConnection {
@@ -61,97 +64,102 @@ impl WslRemoteConnection {
connection_options.distro_name,
connection_options.user
);
- let (release_channel, version, commit) = cx.update(|cx| {
- (
- ReleaseChannel::global(cx),
- AppVersion::global(cx),
- AppCommitSha::try_global(cx),
- )
- })?;
+ let (release_channel, version) =
+ cx.update(|cx| (ReleaseChannel::global(cx), AppVersion::global(cx)))?;
let mut this = Self {
connection_options,
remote_binary_path: None,
- platform: RemotePlatform { os: "", arch: "" },
+ platform: RemotePlatform {
+ os: RemoteOs::Linux,
+ arch: RemoteArch::X86_64,
+ },
shell: String::new(),
shell_kind: ShellKind::Posix,
default_system_shell: String::from("/bin/sh"),
- can_exec: true,
+ has_wsl_interop: false,
};
delegate.set_status(Some("Detecting WSL environment"), cx);
- this.shell = this.detect_shell().await?;
+ this.shell = this
+ .detect_shell()
+ .await
+ .context("failed detecting shell")?;
+ log::info!("Remote shell discovered: {}", this.shell);
this.shell_kind = ShellKind::new(&this.shell, false);
- this.can_exec = this.detect_can_exec().await?;
- this.platform = this.detect_platform().await?;
+ this.has_wsl_interop = this.detect_has_wsl_interop().await.unwrap_or_default();
+ log::info!(
+ "Remote has wsl interop {}",
+ if this.has_wsl_interop {
+ "enabled"
+ } else {
+ "disabled"
+ }
+ );
+ this.platform = this
+ .detect_platform()
+ .await
+ .context("failed detecting platform")?;
+ log::info!("Remote platform discovered: {:?}", this.platform);
this.remote_binary_path = Some(
- this.ensure_server_binary(&delegate, release_channel, version, commit, cx)
- .await?,
+ this.ensure_server_binary(&delegate, release_channel, version, cx)
+ .await
+ .context("failed ensuring server binary")?,
);
log::debug!("Detected WSL environment: {this:#?}");
Ok(this)
}
- async fn detect_can_exec(&self) -> Result<bool> {
- let options = &self.connection_options;
- let program = self.shell_kind.prepend_command_prefix("uname");
- let args = &["-m"];
- let output = wsl_command_impl(options, &program, args, true)
- .output()
- .await;
-
- if !output.is_ok_and(|output| output.status.success()) {
- run_wsl_command_impl(options, &program, args, false).await?;
- Ok(false)
- } else {
- Ok(true)
- }
- }
async fn detect_platform(&self) -> Result<RemotePlatform> {
let program = self.shell_kind.prepend_command_prefix("uname");
- let arch_str = self.run_wsl_command(&program, &["-m"]).await?;
- let arch_str = arch_str.trim().to_string();
- let arch = match arch_str.as_str() {
- "x86_64" => "x86_64",
- "aarch64" | "arm64" => "aarch64",
- _ => "x86_64",
- };
- Ok(RemotePlatform { os: "linux", arch })
+ let output = self.run_wsl_command_with_output(&program, &["-sm"]).await?;
+ parse_platform(&output)
}
async fn detect_shell(&self) -> Result<String> {
+ const DEFAULT_SHELL: &str = "sh";
+ match self
+ .run_wsl_command_with_output("sh", &["-c", "echo $SHELL"])
+ .await
+ {
+ Ok(output) => Ok(parse_shell(&output, DEFAULT_SHELL)),
+ Err(e) => {
+ log::error!("Failed to detect remote shell: {e}");
+ Ok(DEFAULT_SHELL.to_owned())
+ }
+ }
+ }
+
+ async fn detect_has_wsl_interop(&self) -> Result<bool> {
Ok(self
- .run_wsl_command("sh", &["-c", "echo $SHELL"])
+ .run_wsl_command_with_output("cat", &["/proc/sys/fs/binfmt_misc/WSLInterop"])
.await
- .ok()
- .unwrap_or_else(|| "/bin/sh".to_string()))
+ .inspect_err(|err| log::error!("Failed to detect wsl interop: {err}"))?
+ .contains("enabled"))
}
async fn windows_path_to_wsl_path(&self, source: &Path) -> Result<String> {
- windows_path_to_wsl_path_impl(&self.connection_options, source, self.can_exec).await
+ windows_path_to_wsl_path_impl(&self.connection_options, source).await
}
- fn wsl_command(&self, program: &str, args: &[impl AsRef<OsStr>]) -> process::Command {
- wsl_command_impl(&self.connection_options, program, args, self.can_exec)
+ async fn run_wsl_command_with_output(&self, program: &str, args: &[&str]) -> Result<String> {
+ run_wsl_command_with_output_impl(&self.connection_options, program, args).await
}
- async fn run_wsl_command(&self, program: &str, args: &[&str]) -> Result<String> {
- run_wsl_command_impl(&self.connection_options, program, args, self.can_exec).await
+ async fn run_wsl_command(&self, program: &str, args: &[&str]) -> Result<()> {
+ run_wsl_command_impl(&self.connection_options, program, args, false)
+ .await
+ .map(|_| ())
}
async fn ensure_server_binary(
&self,
delegate: &Arc<dyn RemoteClientDelegate>,
release_channel: ReleaseChannel,
- version: SemanticVersion,
- commit: Option<AppCommitSha>,
+ version: Version,
cx: &mut AsyncApp,
) -> Result<Arc<RelPath>> {
let version_str = match release_channel {
- ReleaseChannel::Nightly => {
- let commit = commit.map(|s| s.full()).unwrap_or_default();
- format!("{}-{}", version, commit)
- }
ReleaseChannel::Dev => "build".to_string(),
_ => version.to_string(),
};
@@ -167,7 +175,8 @@ impl WslRemoteConnection {
if let Some(parent) = dst_path.parent() {
let parent = parent.display(PathStyle::Posix);
- self.run_wsl_command("mkdir", &["-p", &parent])
+ let mkdir = self.shell_kind.prepend_command_prefix("mkdir");
+ self.run_wsl_command(&mkdir, &["-p", &parent])
.await
.map_err(|e| anyhow!("Failed to create directory: {}", e))?;
}
@@ -233,13 +242,16 @@ impl WslRemoteConnection {
if let Some(parent) = dst_path.parent() {
let parent = parent.display(PathStyle::Posix);
- self.run_wsl_command("mkdir", &["-p", &parent])
+ let mkdir = self.shell_kind.prepend_command_prefix("mkdir");
+ self.run_wsl_command(&mkdir, &["-p", &parent])
.await
- .map_err(|e| anyhow!("Failed to create directory when uploading file: {}", e))?;
+ .context("Failed to create directory when uploading file")?;
}
let t0 = Instant::now();
- let src_stat = fs::metadata(&src_path).await?;
+ let src_stat = fs::metadata(&src_path)
+ .await
+ .with_context(|| format!("source path does not exist: {}", src_path.display()))?;
let size = src_stat.len();
log::info!(
"uploading remote server to WSL {:?} ({}kb)",
@@ -248,8 +260,9 @@ impl WslRemoteConnection {
);
let src_path_in_wsl = self.windows_path_to_wsl_path(src_path).await?;
+ let cp = self.shell_kind.prepend_command_prefix("cp");
self.run_wsl_command(
- "cp",
+ &cp,
&["-f", &src_path_in_wsl, &dst_path.display(PathStyle::Posix)],
)
.await
@@ -327,6 +340,7 @@ impl RemoteConnection for WslRemoteConnection {
proxy_args.push(format!("{}={}", env_var, value));
}
}
+
proxy_args.push(remote_binary_path.display(PathStyle::Posix).into_owned());
proxy_args.push("proxy".to_owned());
proxy_args.push("--identifier".to_owned());
@@ -335,16 +349,17 @@ impl RemoteConnection for WslRemoteConnection {
if reconnect {
proxy_args.push("--reconnect".to_owned());
}
- let proxy_process = match self
- .wsl_command("env", &proxy_args)
- .kill_on_drop(true)
- .spawn()
- {
- Ok(process) => process,
- Err(error) => {
- return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error)));
- }
- };
+
+ let proxy_process =
+ match wsl_command_impl(&self.connection_options, "env", &proxy_args, false)
+ .kill_on_drop(true)
+ .spawn()
+ {
+ Ok(process) => process,
+ Err(error) => {
+ return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error)));
+ }
+ };
super::handle_rpc_messages_over_child_process_stdio(
proxy_process,
@@ -363,15 +378,14 @@ impl RemoteConnection for WslRemoteConnection {
) -> Task<Result<()>> {
cx.background_spawn({
let options = self.connection_options.clone();
- let can_exec = self.can_exec;
async move {
- let wsl_src = windows_path_to_wsl_path_impl(&options, &src_path, can_exec).await?;
+ let wsl_src = windows_path_to_wsl_path_impl(&options, &src_path).await?;
run_wsl_command_impl(
&options,
"cp",
&["-r", &wsl_src, &dest_path.to_string()],
- can_exec,
+ true,
)
.await
.map_err(|e| {
@@ -443,8 +457,10 @@ impl RemoteConnection for WslRemoteConnection {
} else {
write!(&mut exec, "{} -l", self.shell)?;
}
+ let (command, args) =
+ ShellBuilder::new(&Shell::Program(self.shell.clone()), false).build(Some(exec), &[]);
- let wsl_args = if let Some(user) = &self.connection_options.user {
+ let mut wsl_args = if let Some(user) = &self.connection_options.user {
vec![
"--distribution".to_string(),
self.connection_options.distro_name.clone(),
@@ -453,9 +469,7 @@ impl RemoteConnection for WslRemoteConnection {
"--cd".to_string(),
working_dir,
"--".to_string(),
- self.shell.clone(),
- "-c".to_string(),
- exec,
+ command,
]
} else {
vec![
@@ -464,11 +478,10 @@ impl RemoteConnection for WslRemoteConnection {
"--cd".to_string(),
working_dir,
"--".to_string(),
- self.shell.clone(),
- "-c".to_string(),
- exec,
+ command,
]
};
+ wsl_args.extend(args);
Ok(CommandTemplate {
program: "wsl.exe".to_string(),
@@ -499,25 +512,44 @@ impl RemoteConnection for WslRemoteConnection {
fn default_system_shell(&self) -> String {
self.default_system_shell.clone()
}
+
+ fn has_wsl_interop(&self) -> bool {
+ self.has_wsl_interop
+ }
}
/// `wslpath` is a executable available in WSL, it's a linux binary.
/// So it doesn't support Windows style paths.
async fn sanitize_path(path: &Path) -> Result<String> {
- let path = smol::fs::canonicalize(path).await?;
+ let path = smol::fs::canonicalize(path)
+ .await
+ .with_context(|| format!("Failed to canonicalize path {}", path.display()))?;
let path_str = path.to_string_lossy();
let sanitized = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
Ok(sanitized.replace('\\', "/"))
}
+async fn run_wsl_command_with_output_impl(
+ options: &WslConnectionOptions,
+ program: &str,
+ args: &[&str],
+) -> Result<String> {
+ match run_wsl_command_impl(options, program, args, true).await {
+ Ok(res) => Ok(res),
+ Err(exec_err) => match run_wsl_command_impl(options, program, args, false).await {
+ Ok(res) => Ok(res),
+ Err(e) => Err(e.context(exec_err)),
+ },
+ }
+}
+
async fn windows_path_to_wsl_path_impl(
options: &WslConnectionOptions,
source: &Path,
- exec: bool,
) -> Result<String> {
let source = sanitize_path(source).await?;
- run_wsl_command_impl(options, "wslpath", &["-u", &source], exec).await
+ run_wsl_command_with_output_impl(options, "wslpath", &["-u", &source]).await
}
async fn run_wsl_command_impl(
@@ -526,14 +558,16 @@ async fn run_wsl_command_impl(
args: &[&str],
exec: bool,
) -> Result<String> {
- let output = wsl_command_impl(options, program, args, exec)
+ let mut command = wsl_command_impl(options, program, args, exec);
+ let output = command
.output()
- .await?;
+ .await
+ .with_context(|| format!("Failed to run command '{:?}'", command))?;
if !output.status.success() {
return Err(anyhow!(
- "Command '{}' failed: {}",
- program,
+ "Command '{:?}' failed: {}",
+ command,
String::from_utf8_lossy(&output.stderr).trim()
));
}
@@ -26,6 +26,7 @@ anyhow.workspace = true
askpass.workspace = true
clap.workspace = true
client.workspace = true
+collections.workspace = true
dap_adapters.workspace = true
debug_adapter_extension.workspace = true
env_logger.workspace = true
@@ -55,6 +56,7 @@ remote.workspace = true
reqwest_client.workspace = true
rpc.workspace = true
rust-embed = { workspace = true, optional = true, features = ["debug-embed"] }
+semver.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
@@ -77,10 +79,9 @@ minidumper.workspace = true
[dev-dependencies]
action_log.workspace = true
-agent.workspace = true
+agent = { workspace = true, features = ["test-support"] }
client = { workspace = true, features = ["test-support"] }
clock = { workspace = true, features = ["test-support"] }
-collections.workspace = true
dap = { workspace = true, features = ["test-support"] }
editor = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
@@ -92,8 +93,10 @@ node_runtime = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
remote = { workspace = true, features = ["test-support"] }
+theme = { workspace = true, features = ["test-support"] }
language_model = { workspace = true, features = ["test-support"] }
lsp = { workspace = true, features = ["test-support"] }
+prompt_store.workspace = true
unindent.workspace = true
serde_json.workspace = true
zlog.workspace = true
@@ -28,4 +28,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}");
+ }
}
@@ -1,4 +1,5 @@
use anyhow::{Context as _, Result, anyhow};
+use collections::HashSet;
use language::File;
use lsp::LanguageServerId;
@@ -17,10 +18,11 @@ use project::{
debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore},
git_store::GitStore,
image_store::ImageId,
- lsp_store::log_store::{self, GlobalLogStore, LanguageServerKind},
+ lsp_store::log_store::{self, GlobalLogStore, LanguageServerKind, LogKind},
project_settings::SettingsObserver,
search::SearchQuery,
task_store::TaskStore,
+ trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
worktree_store::WorktreeStore,
};
use rpc::{
@@ -86,6 +88,7 @@ impl HeadlessProject {
languages,
extension_host_proxy: proxy,
}: HeadlessAppState,
+ init_worktree_trust: bool,
cx: &mut Context<Self>,
) -> Self {
debug_adapter_extension::init(proxy.clone(), cx);
@@ -97,6 +100,16 @@ impl HeadlessProject {
store
});
+ if init_worktree_trust {
+ project::trusted_worktrees::track_worktree_trust(
+ worktree_store.clone(),
+ None::<RemoteHostLocation>,
+ Some((session.clone(), REMOTE_SERVER_PROJECT_ID)),
+ None,
+ cx,
+ );
+ }
+
let environment =
cx.new(|cx| ProjectEnvironment::new(None, worktree_store.downgrade(), None, true, cx));
let manifest_tree = ManifestTree::new(worktree_store.clone(), cx);
@@ -264,6 +277,8 @@ impl HeadlessProject {
session.add_entity_request_handler(Self::handle_get_directory_environment);
session.add_entity_message_handler(Self::handle_toggle_lsp_logs);
session.add_entity_request_handler(Self::handle_open_image_by_path);
+ session.add_entity_request_handler(Self::handle_trust_worktrees);
+ session.add_entity_request_handler(Self::handle_restrict_worktrees);
session.add_entity_request_handler(BufferStore::handle_update_buffer);
session.add_entity_message_handler(BufferStore::handle_close_buffer);
@@ -449,6 +464,7 @@ impl HeadlessProject {
message.payload.visible,
this.fs.clone(),
this.next_entry_id.clone(),
+ true,
&mut cx,
)
})?
@@ -594,6 +610,50 @@ impl HeadlessProject {
})
}
+ pub async fn handle_trust_worktrees(
+ _: Entity<Self>,
+ envelope: TypedEnvelope<proto::TrustWorktrees>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::Ack> {
+ let trusted_worktrees = cx
+ .update(|cx| TrustedWorktrees::try_get_global(cx))?
+ .context("missing trusted worktrees")?;
+ trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
+ trusted_worktrees.trust(
+ envelope
+ .payload
+ .trusted_paths
+ .into_iter()
+ .filter_map(PathTrust::from_proto)
+ .collect(),
+ None,
+ cx,
+ );
+ })?;
+ Ok(proto::Ack {})
+ }
+
+ pub async fn handle_restrict_worktrees(
+ _: Entity<Self>,
+ envelope: TypedEnvelope<proto::RestrictWorktrees>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::Ack> {
+ let trusted_worktrees = cx
+ .update(|cx| TrustedWorktrees::try_get_global(cx))?
+ .context("missing trusted worktrees")?;
+ trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
+ let restricted_paths = envelope
+ .payload
+ .worktree_ids
+ .into_iter()
+ .map(WorktreeId::from_proto)
+ .map(PathTrust::Worktree)
+ .collect::<HashSet<_>>();
+ trusted_worktrees.restrict(restricted_paths, None, cx);
+ })?;
+ Ok(proto::Ack {})
+ }
+
pub async fn handle_open_new_buffer(
this: Entity<Self>,
_message: TypedEnvelope<proto::OpenNewBuffer>,
@@ -623,26 +683,28 @@ impl HeadlessProject {
async fn handle_toggle_lsp_logs(
_: Entity<Self>,
envelope: TypedEnvelope<proto::ToggleLspLogs>,
- mut cx: AsyncApp,
+ cx: AsyncApp,
) -> Result<()> {
let server_id = LanguageServerId::from_proto(envelope.payload.server_id);
- let lsp_logs = cx
- .update(|cx| {
- cx.try_global::<GlobalLogStore>()
- .map(|lsp_logs| lsp_logs.0.clone())
- })?
- .context("lsp logs store is missing")?;
-
- lsp_logs.update(&mut cx, |lsp_logs, _| {
- // RPC logs are very noisy and we need to toggle it on the headless server too.
- // The rest of the logs for the ssh project are very important to have toggled always,
- // to e.g. send language server error logs to the client before anything is toggled.
- if envelope.payload.enabled {
- lsp_logs.enable_rpc_trace_for_language_server(server_id);
- } else {
- lsp_logs.disable_rpc_trace_for_language_server(server_id);
- }
- })?;
+ cx.update(|cx| {
+ let log_store = cx
+ .try_global::<GlobalLogStore>()
+ .map(|global_log_store| global_log_store.0.clone())
+ .context("lsp logs store is missing")?;
+ let toggled_log_kind =
+ match proto::toggle_lsp_logs::LogType::from_i32(envelope.payload.log_type)
+ .context("invalid log type")?
+ {
+ proto::toggle_lsp_logs::LogType::Log => LogKind::Logs,
+ proto::toggle_lsp_logs::LogType::Trace => LogKind::Trace,
+ proto::toggle_lsp_logs::LogType::Rpc => LogKind::Rpc,
+ };
+ log_store.update(cx, |log_store, _| {
+ log_store.toggle_lsp_logs(server_id, envelope.payload.enabled, toggled_log_kind);
+ });
+ anyhow::Ok(())
+ })??;
+
Ok(())
}
@@ -710,9 +772,15 @@ impl HeadlessProject {
PathStyle::local(),
)?;
let results = this.update(&mut cx, |this, cx| {
- this.buffer_store.update(cx, |buffer_store, cx| {
- buffer_store.find_search_candidates(&query, message.limit as _, this.fs.clone(), cx)
- })
+ project::Search::local(
+ this.fs.clone(),
+ this.buffer_store.clone(),
+ this.worktree_store.clone(),
+ message.limit as _,
+ cx,
+ )
+ .into_handle(query, cx)
+ .matching_buffers(cx)
})?;
let mut response = proto::FindSearchCandidatesResponse {
@@ -2,15 +2,16 @@
/// The tests in this file assume that server_cx is running on Windows too.
/// We neead to find a way to test Windows-Non-Windows interactions.
use crate::headless_project::HeadlessProject;
-use agent::{AgentTool, ReadFileTool, ReadFileToolInput, ToolCallEventStream};
+use agent::{AgentTool, ReadFileTool, ReadFileToolInput, Templates, Thread, ToolCallEventStream};
use client::{Client, UserStore};
use clock::FakeSystemClock;
use collections::{HashMap, HashSet};
-use language_model::LanguageModelToolResultContent;
+use language_model::{LanguageModelToolResultContent, fake_provider::FakeLanguageModel};
+use prompt_store::ProjectContext;
use extension::ExtensionHostProxy;
use fs::{FakeFs, Fs};
-use gpui::{AppContext as _, Entity, SemanticVersion, SharedString, TestAppContext};
+use gpui::{AppContext as _, Entity, SharedString, TestAppContext};
use http_client::{BlockedHttpClient, FakeHttpClient};
use language::{
Buffer, FakeLspAdapter, LanguageConfig, LanguageMatcher, LanguageRegistry, LineEnding,
@@ -397,12 +398,17 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
json!({
"settings.json": r#"
{
- "languages": {"Rust":{"language_servers":["rust-analyzer"]}},
+ "languages": {"Rust":{"language_servers":["rust-analyzer", "fake-analyzer"]}},
"lsp": {
"rust-analyzer": {
"binary": {
"path": "~/.cargo/bin/rust-analyzer"
}
+ },
+ "fake-analyzer": {
+ "binary": {
+ "path": "~/.cargo/bin/rust-analyzer"
+ }
}
}
}"#
@@ -430,11 +436,23 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
},
..FakeLspAdapter::default()
},
+ );
+ project.languages().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ name: "fake-analyzer",
+ capabilities: lsp::ServerCapabilities {
+ completion_provider: Some(lsp::CompletionOptions::default()),
+ rename_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ },
+ ..FakeLspAdapter::default()
+ },
)
});
let mut fake_lsp = server_cx.update(|cx| {
- headless.read(cx).languages.register_fake_language_server(
+ headless.read(cx).languages.register_fake_lsp_server(
LanguageServerName("rust-analyzer".into()),
lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions::default()),
@@ -445,6 +463,30 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
)
});
+ let mut fake_second_lsp = server_cx.update(|cx| {
+ headless.read(cx).languages.register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ name: "fake-analyzer",
+ capabilities: lsp::ServerCapabilities {
+ completion_provider: Some(lsp::CompletionOptions::default()),
+ rename_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ },
+ ..FakeLspAdapter::default()
+ },
+ );
+ headless.read(cx).languages.register_fake_lsp_server(
+ LanguageServerName("fake-analyzer".into()),
+ lsp::ServerCapabilities {
+ completion_provider: Some(lsp::CompletionOptions::default()),
+ rename_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ },
+ None,
+ )
+ });
+
cx.run_until_parked();
let worktree_id = project
@@ -468,12 +510,13 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
cx.run_until_parked();
let fake_lsp = fake_lsp.next().await.unwrap();
+ let fake_second_lsp = fake_second_lsp.next().await.unwrap();
cx.read(|cx| {
let file = buffer.read(cx).file();
assert_eq!(
language_settings(Some("Rust".into()), file, cx).language_servers,
- ["rust-analyzer".to_string()]
+ ["rust-analyzer".to_string(), "fake-analyzer".to_string()]
)
});
@@ -496,7 +539,7 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
server_cx.read(|cx| {
let lsp_store = headless.read(cx).lsp_store.read(cx);
- assert_eq!(lsp_store.as_local().unwrap().language_servers.len(), 1);
+ assert_eq!(lsp_store.as_local().unwrap().language_servers.len(), 2);
});
fake_lsp.set_request_handler::<lsp::request::Completion, _, _>(|_, _| async move {
@@ -506,6 +549,13 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
}])))
});
+ fake_second_lsp.set_request_handler::<lsp::request::Completion, _, _>(|_, _| async move {
+ Ok(Some(CompletionResponse::Array(vec![lsp::CompletionItem {
+ label: "beep".to_string(),
+ ..Default::default()
+ }])))
+ });
+
let result = project
.update(cx, |project, cx| {
project.completions(
@@ -527,7 +577,7 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
.flat_map(|response| response.completions)
.map(|c| c.label.text)
.collect::<Vec<_>>(),
- vec!["boop".to_string()]
+ vec!["boop".to_string(), "beep".to_string()]
);
fake_lsp.set_request_handler::<lsp::request::Rename, _, _>(|_, _| async move {
@@ -619,7 +669,7 @@ async fn test_remote_cancel_language_server_work(
});
let mut fake_lsp = server_cx.update(|cx| {
- headless.read(cx).languages.register_fake_language_server(
+ headless.read(cx).languages.register_fake_lsp_server(
LanguageServerName("rust-analyzer".into()),
Default::default(),
None,
@@ -1449,6 +1499,14 @@ async fn test_remote_git_diffs_when_recv_update_repository_delay(
cx: &mut TestAppContext,
server_cx: &mut TestAppContext,
) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ theme::init(theme::LoadThemes::JustBase, cx);
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
+ editor::init(cx);
+ });
+
use editor::Editor;
use gpui::VisualContext;
let text_2 = "
@@ -1722,12 +1780,27 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu
let action_log = cx.new(|_| action_log::ActionLog::new(project.clone()));
+ // Create a minimal thread for the ReadFileTool
+ let context_server_registry =
+ cx.new(|cx| agent::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 input = ReadFileToolInput {
path: "project/b.txt".into(),
start_line: None,
end_line: None,
};
- let read_tool = Arc::new(ReadFileTool::new(project, action_log));
+ let read_tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
let (event_stream, _) = ToolCallEventStream::test();
let exists_result = cx.update(|cx| read_tool.clone().run(input, event_stream.clone(), cx));
@@ -1773,6 +1846,7 @@ async fn test_remote_external_agent_server(
&json!({
"agent_servers": {
"foo": {
+ "type": "custom",
"command": "foo-cli",
"args": ["--flag"],
"env": {
@@ -1836,10 +1910,10 @@ pub async fn init_test(
) -> (Entity<Project>, Entity<HeadlessProject>) {
let server_fs = server_fs.clone();
cx.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);
});
init_logger();
@@ -1859,6 +1933,7 @@ pub async fn init_test(
languages,
extension_host_proxy: proxy,
},
+ false,
cx,
)
});
@@ -1903,5 +1978,5 @@ fn build_project(ssh: Entity<RemoteClient>, cx: &mut TestAppContext) -> Entity<P
Project::init(&client, cx);
});
- cx.update(|cx| Project::remote(ssh, client, node, user_store, languages, fs, cx))
+ cx.update(|cx| Project::remote(ssh, client, node, user_store, languages, fs, false, cx))
}
@@ -71,10 +71,14 @@ pub fn run(command: Commands) -> anyhow::Result<()> {
println!("{}", env!("ZED_PKG_VERSION"))
}
ReleaseChannel::Nightly | ReleaseChannel::Dev => {
- println!(
- "{}",
- option_env!("ZED_COMMIT_SHA").unwrap_or(release_channel.dev_name())
- )
+ let commit_sha =
+ option_env!("ZED_COMMIT_SHA").unwrap_or(release_channel.dev_name());
+ let build_id = option_env!("ZED_BUILD_ID");
+ if let Some(build_id) = build_id {
+ println!("{}+{}", build_id, commit_sha)
+ } else {
+ println!("{commit_sha}");
+ }
}
};
Ok(())
@@ -2,6 +2,8 @@ use crate::HeadlessProject;
use crate::headless_project::HeadlessAppState;
use anyhow::{Context as _, Result, anyhow};
use client::ProxySettings;
+use collections::HashMap;
+use project::trusted_worktrees;
use util::ResultExt;
use extension::ExtensionHostProxy;
@@ -9,16 +11,17 @@ use fs::{Fs, RealFs};
use futures::channel::{mpsc, oneshot};
use futures::{AsyncRead, AsyncWrite, AsyncWriteExt, FutureExt, SinkExt, select, select_biased};
use git::GitHostingProviderRegistry;
-use gpui::{App, AppContext as _, Context, Entity, SemanticVersion, UpdateGlobal as _};
+use gpui::{App, AppContext as _, Context, Entity, UpdateGlobal as _};
use gpui_tokio::Tokio;
use http_client::{Url, read_proxy_from_env};
use language::LanguageRegistry;
use node_runtime::{NodeBinaryOptions, NodeRuntime};
use paths::logs_dir;
use project::project_settings::ProjectSettings;
+use util::command::new_smol_command;
use proto::CrashReport;
-use release_channel::{AppVersion, RELEASE_CHANNEL, ReleaseChannel};
+use release_channel::{AppCommitSha, AppVersion, RELEASE_CHANNEL, ReleaseChannel};
use remote::RemoteClient;
use remote::{
json_log::LogRecord,
@@ -47,10 +50,16 @@ use std::{
};
use thiserror::Error;
-pub static VERSION: LazyLock<&str> = LazyLock::new(|| match *RELEASE_CHANNEL {
- ReleaseChannel::Stable | ReleaseChannel::Preview => env!("ZED_PKG_VERSION"),
+pub static VERSION: LazyLock<String> = LazyLock::new(|| match *RELEASE_CHANNEL {
+ ReleaseChannel::Stable | ReleaseChannel::Preview => env!("ZED_PKG_VERSION").to_owned(),
ReleaseChannel::Nightly | ReleaseChannel::Dev => {
- option_env!("ZED_COMMIT_SHA").unwrap_or("missing-zed-commit-sha")
+ let commit_sha = option_env!("ZED_COMMIT_SHA").unwrap_or("missing-zed-commit-sha");
+ let build_identifier = option_env!("ZED_BUILD_ID");
+ if let Some(build_id) = build_identifier {
+ format!("{build_id}+{commit_sha}")
+ } else {
+ commit_sha.to_owned()
+ }
}
});
@@ -192,6 +201,7 @@ fn start_server(
listeners: ServerListeners,
log_rx: Receiver<Vec<u8>>,
cx: &mut App,
+ is_wsl_interop: bool,
) -> AnyProtoClient {
// This is the server idle timeout. If no connection comes in this timeout, the server will shut down.
const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10 * 60);
@@ -311,7 +321,7 @@ fn start_server(
})
.detach();
- RemoteClient::proto_client_from_channels(incoming_rx, outgoing_tx, cx, "server")
+ RemoteClient::proto_client_from_channels(incoming_rx, outgoing_tx, cx, "server", is_wsl_interop)
}
fn init_paths() -> anyhow::Result<()> {
@@ -321,6 +331,7 @@ fn init_paths() -> anyhow::Result<()> {
paths::languages_dir(),
paths::logs_dir(),
paths::temp_dir(),
+ paths::hang_traces_dir(),
paths::remote_extensions_dir(),
paths::remote_extensions_uploads_dir(),
]
@@ -371,7 +382,7 @@ pub fn execute_run(
let listeners = ServerListeners::new(stdin_socket, stdout_socket, stderr_socket)?;
rayon::ThreadPoolBuilder::new()
- .num_threads(4)
+ .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()
@@ -388,14 +399,27 @@ pub fn execute_run(
let git_hosting_provider_registry = Arc::new(GitHostingProviderRegistry::new());
app.run(move |cx| {
settings::init(cx);
- let app_version = AppVersion::load(env!("ZED_PKG_VERSION"));
+ 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, cx);
gpui_tokio::init(cx);
HeadlessProject::init(cx);
+ let is_wsl_interop = if cfg!(target_os = "linux") {
+ // See: https://learn.microsoft.com/en-us/windows/wsl/filesystems#disable-interoperability
+ matches!(std::fs::read_to_string("/proc/sys/fs/binfmt_misc/WSLInterop"), Ok(s) if s.contains("enabled"))
+ } else {
+ false
+ };
+
log::info!("gpui app started, initializing server");
- let session = start_server(listeners, log_rx, cx);
+ let session = start_server(listeners, log_rx, cx, is_wsl_interop);
+ trusted_worktrees::init(HashMap::default(), Some((session.clone(), REMOTE_SERVER_PROJECT_ID)), None, cx);
GitHostingProviderRegistry::set_global(git_hosting_provider_registry, cx);
git_hosting_providers::init(cx);
@@ -447,6 +471,7 @@ pub fn execute_run(
languages,
extension_host_proxy,
},
+ true,
cx,
)
});
@@ -655,7 +680,7 @@ pub(crate) fn execute_proxy(
async fn kill_running_server(pid: u32, paths: &ServerPaths) -> Result<(), ExecuteProxyError> {
log::info!("killing existing server with PID {}", pid);
- smol::process::Command::new("kill")
+ new_smol_command("kill")
.arg(pid.to_string())
.output()
.await
@@ -706,7 +731,7 @@ async fn spawn_server(paths: &ServerPaths) -> Result<(), SpawnServerError> {
}
let binary_name = std::env::current_exe().map_err(SpawnServerError::CurrentExe)?;
- let mut server_process = smol::process::Command::new(binary_name);
+ let mut server_process = new_smol_command(binary_name);
server_process
.arg("run")
.arg("--log-file")
@@ -771,7 +796,7 @@ async fn check_pid_file(path: &Path) -> Result<Option<u32>, CheckPidError> {
};
log::debug!("Checking if process with PID {} exists...", pid);
- match smol::process::Command::new("kill")
+ match new_smol_command("kill")
.arg("-0")
.arg(pid.to_string())
.output()
@@ -1000,9 +1025,9 @@ fn cleanup_old_binaries() -> Result<()> {
}
fn is_new_version(version: &str) -> bool {
- SemanticVersion::from_str(version)
+ semver::Version::from_str(version)
.ok()
- .zip(SemanticVersion::from_str(env!("ZED_PKG_VERSION")).ok())
+ .zip(semver::Version::from_str(env!("ZED_PKG_VERSION")).ok())
.is_some_and(|(version, current_version)| version >= current_version)
}
@@ -81,7 +81,7 @@ pub fn python_env_kernel_specifications(
worktree_id: WorktreeId,
cx: &mut App,
) -> impl Future<Output = Result<Vec<KernelSpecification>>> + use<> {
- let python_language = LanguageName::new("Python");
+ let python_language = LanguageName::new_static("Python");
let toolchains = project.read(cx).available_toolchains(
ProjectPath {
worktree_id,
@@ -213,6 +213,13 @@ impl From<&Kernel> for KernelStatus {
Kernel::RunningKernel(kernel) => match kernel.execution_state() {
ExecutionState::Idle => KernelStatus::Idle,
ExecutionState::Busy => KernelStatus::Busy,
+ ExecutionState::Unknown => KernelStatus::Error,
+ ExecutionState::Starting => KernelStatus::Starting,
+ ExecutionState::Restarting => KernelStatus::Restarting,
+ ExecutionState::Terminating => KernelStatus::ShuttingDown,
+ ExecutionState::AutoRestarting => KernelStatus::Restarting,
+ ExecutionState::Dead => KernelStatus::Error,
+ ExecutionState::Other(_) => KernelStatus::Error,
},
Kernel::StartingKernel(_) => KernelStatus::Starting,
Kernel::ErroredLaunch(_) => KernelStatus::Error,
@@ -756,7 +756,7 @@ impl Item for NotebookEditor {
}
// TODO
- fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
None
}
@@ -476,6 +476,13 @@ impl ExecutionView {
self.status = ExecutionStatus::Executing;
}
ExecutionState::Idle => self.status = ExecutionStatus::Finished,
+ ExecutionState::Unknown => self.status = ExecutionStatus::Unknown,
+ ExecutionState::Starting => self.status = ExecutionStatus::ConnectingToKernel,
+ ExecutionState::Restarting => self.status = ExecutionStatus::Restarting,
+ ExecutionState::Terminating => self.status = ExecutionStatus::ShuttingDown,
+ ExecutionState::AutoRestarting => self.status = ExecutionStatus::Restarting,
+ ExecutionState::Dead => self.status = ExecutionStatus::Shutdown,
+ ExecutionState::Other(_) => self.status = ExecutionStatus::Unknown,
}
cx.notify();
return;
@@ -12,7 +12,7 @@ mod session;
use std::{sync::Arc, time::Duration};
use async_dispatcher::{Dispatcher, Runnable, set_dispatcher};
-use gpui::{App, PlatformDispatcher};
+use gpui::{App, PlatformDispatcher, Priority, RunnableVariant};
use project::Fs;
pub use runtimelib::ExecutionState;
@@ -45,11 +45,13 @@ fn zed_dispatcher(cx: &mut App) -> impl Dispatcher {
// other crates in Zed.
impl Dispatcher for ZedDispatcher {
fn dispatch(&self, runnable: Runnable) {
- self.dispatcher.dispatch(runnable, None)
+ self.dispatcher
+ .dispatch(RunnableVariant::Compat(runnable), None, Priority::default());
}
fn dispatch_after(&self, duration: Duration, runnable: Runnable) {
- self.dispatcher.dispatch_after(duration, runnable);
+ self.dispatcher
+ .dispatch_after(duration, RunnableVariant::Compat(runnable));
}
}
@@ -4,7 +4,7 @@ use std::ops::Range;
use std::sync::Arc;
use anyhow::{Context as _, Result};
-use editor::Editor;
+use editor::{Editor, MultiBufferOffset};
use gpui::{App, Entity, WeakEntity, Window, prelude::*};
use language::{BufferSnapshot, Language, LanguageName, Point};
use project::{ProjectItem as _, WorktreeId};
@@ -478,7 +478,9 @@ fn get_language(editor: WeakEntity<Editor>, cx: &mut App) -> Option<Arc<Language
editor
.update(cx, |editor, cx| {
let display_snapshot = editor.display_snapshot(cx);
- let selection = editor.selections.newest::<usize>(&display_snapshot);
+ let selection = editor
+ .selections
+ .newest::<MultiBufferOffset>(&display_snapshot);
display_snapshot
.buffer_snapshot()
.language_at(selection.head())
@@ -673,6 +673,13 @@ impl Render for Session {
Kernel::RunningKernel(kernel) => match kernel.execution_state() {
ExecutionState::Idle => Color::Success,
ExecutionState::Busy => Color::Modified,
+ ExecutionState::Unknown => Color::Modified,
+ ExecutionState::Starting => Color::Modified,
+ ExecutionState::Restarting => Color::Modified,
+ ExecutionState::Terminating => Color::Disabled,
+ ExecutionState::AutoRestarting => Color::Modified,
+ ExecutionState::Dead => Color::Disabled,
+ ExecutionState::Other(_) => Color::Modified,
},
Kernel::StartingKernel(_) => Color::Modified,
Kernel::ErroredLaunch(_) => Color::Error,
@@ -1,6 +1,6 @@
use std::error::Error;
use std::sync::{LazyLock, OnceLock};
-use std::{any::type_name, borrow::Cow, mem, pin::Pin, task::Poll, time::Duration};
+use std::{borrow::Cow, mem, pin::Pin, task::Poll, time::Duration};
use anyhow::anyhow;
use bytes::{BufMut, Bytes, BytesMut};
@@ -80,20 +80,22 @@ impl ReqwestClient {
}
}
+pub fn runtime() -> &'static tokio::runtime::Runtime {
+ RUNTIME.get_or_init(|| {
+ tokio::runtime::Builder::new_multi_thread()
+ // Since we now have two executors, let's try to keep our footprint small
+ .worker_threads(1)
+ .enable_all()
+ .build()
+ .expect("Failed to initialize HTTP client")
+ })
+}
+
impl From<reqwest::Client> for ReqwestClient {
fn from(client: reqwest::Client) -> Self {
let handle = tokio::runtime::Handle::try_current().unwrap_or_else(|_| {
log::debug!("no tokio runtime found, creating one for Reqwest...");
- let runtime = RUNTIME.get_or_init(|| {
- tokio::runtime::Builder::new_multi_thread()
- // Since we now have two executors, let's try to keep our footprint small
- .worker_threads(1)
- .enable_all()
- .build()
- .expect("Failed to initialize HTTP client")
- });
-
- runtime.handle().clone()
+ runtime().handle().clone()
});
Self {
client,
@@ -215,10 +217,6 @@ impl http_client::HttpClient for ReqwestClient {
self.proxy.as_ref()
}
- fn type_name(&self) -> &'static str {
- type_name::<Self>()
- }
-
fn user_agent(&self) -> Option<&HeaderValue> {
self.user_agent.as_ref()
}
@@ -272,26 +270,6 @@ impl http_client::HttpClient for ReqwestClient {
}
.boxed()
}
-
- fn send_multipart_form<'a>(
- &'a self,
- url: &str,
- form: reqwest::multipart::Form,
- ) -> futures::future::BoxFuture<'a, anyhow::Result<http_client::Response<http_client::AsyncBody>>>
- {
- let response = self.client.post(url).multipart(form).send();
- self.handle
- .spawn(async move {
- let response = response.await?;
- let mut builder = http::response::Builder::new().status(response.status());
- for (k, v) in response.headers() {
- builder = builder.header(k, v)
- }
- Ok(builder.body(response.bytes().await?.into())?)
- })
- .map(|e| e?)
- .boxed()
- }
}
#[cfg(test)]
@@ -18,6 +18,8 @@ rayon.workspace = true
sum_tree.workspace = true
unicode-segmentation.workspace = true
util.workspace = true
+ztracing.workspace = true
+tracing.workspace = true
[dev-dependencies]
ctor.workspace = true
@@ -30,3 +32,6 @@ zlog.workspace = true
[[bench]]
name = "rope_benchmark"
harness = false
+
+[package.metadata.cargo-machete]
+ignored = ["tracing"]
@@ -238,6 +238,35 @@ fn rope_benchmarks(c: &mut Criterion) {
});
}
group.finish();
+
+ let mut group = c.benchmark_group("append many");
+ group.throughput(Throughput::Bytes(128 * 100_000));
+
+ group.bench_function("small to large", |b| {
+ b.iter(|| {
+ let mut rope = Rope::new();
+ let small = Rope::from("A".repeat(128));
+ for _ in 0..100_000 {
+ rope.append(small.clone());
+ }
+ assert_eq!(rope.len(), 128 * 100_000);
+ });
+ });
+
+ group.bench_function("large to small", |b| {
+ b.iter(|| {
+ let mut rope = Rope::new();
+ let small = Rope::from("A".repeat(128));
+ for _ in 0..100_000 {
+ let large = rope;
+ rope = small.clone();
+ rope.append(large);
+ }
+ assert_eq!(rope.len(), 128 * 100_000);
+ });
+ });
+
+ group.finish();
}
criterion_group!(benches, rope_benchmarks);
@@ -47,22 +47,59 @@ impl Chunk {
#[inline(always)]
pub fn new(text: &str) -> Self {
- let mut this = Chunk::default();
- this.push_str(text);
- this
+ let text = ArrayString::from(text).unwrap();
+
+ const CHUNK_SIZE: usize = 8;
+
+ let mut chars_bytes = [0; MAX_BASE / CHUNK_SIZE];
+ let mut newlines_bytes = [0; MAX_BASE / CHUNK_SIZE];
+ let mut tabs_bytes = [0; MAX_BASE / CHUNK_SIZE];
+ let mut chars_utf16_bytes = [0; MAX_BASE / CHUNK_SIZE];
+
+ let mut chunk_ix = 0;
+
+ let mut bytes = text.as_bytes();
+ while !bytes.is_empty() {
+ let (chunk, rest) = bytes.split_at(bytes.len().min(CHUNK_SIZE));
+ bytes = rest;
+
+ let mut chars = 0;
+ let mut newlines = 0;
+ let mut tabs = 0;
+ let mut chars_utf16 = 0;
+
+ for (ix, &b) in chunk.iter().enumerate() {
+ chars |= (util::is_utf8_char_boundary(b) as u8) << ix;
+ newlines |= ((b == b'\n') as u8) << ix;
+ tabs |= ((b == b'\t') as u8) << ix;
+ // b >= 240 when we are at the first byte of the 4 byte encoded
+ // utf-8 code point (U+010000 or greater) it means that it would
+ // be encoded as two 16-bit code units in utf-16
+ chars_utf16 |= ((b >= 240) as u8) << ix;
+ }
+
+ chars_bytes[chunk_ix] = chars;
+ newlines_bytes[chunk_ix] = newlines;
+ tabs_bytes[chunk_ix] = tabs;
+ chars_utf16_bytes[chunk_ix] = chars_utf16;
+
+ chunk_ix += 1;
+ }
+
+ let chars = Bitmap::from_le_bytes(chars_bytes);
+
+ Chunk {
+ text,
+ chars,
+ chars_utf16: (Bitmap::from_le_bytes(chars_utf16_bytes) << 1) | chars,
+ newlines: Bitmap::from_le_bytes(newlines_bytes),
+ tabs: Bitmap::from_le_bytes(tabs_bytes),
+ }
}
#[inline(always)]
pub fn push_str(&mut self, text: &str) {
- for (char_ix, c) in text.char_indices() {
- let ix = self.text.len() + char_ix;
- self.chars |= 1 << ix;
- self.chars_utf16 |= 1 << ix;
- self.chars_utf16 |= (c.len_utf16() as Bitmap) << ix;
- self.newlines |= ((c == '\n') as Bitmap) << ix;
- self.tabs |= ((c == '\t') as Bitmap) << ix;
- }
- self.text.push_str(text);
+ self.append(Chunk::new(text).as_slice());
}
#[inline(always)]
@@ -110,18 +147,12 @@ impl Chunk {
}
pub fn floor_char_boundary(&self, index: usize) -> usize {
- #[inline]
- pub(crate) const fn is_utf8_char_boundary(u8: u8) -> bool {
- // This is bit magic equivalent to: b < 128 || b >= 192
- (u8 as i8) >= -0x40
- }
-
if index >= self.text.len() {
self.text.len()
} else {
let mut i = index;
while i > 0 {
- if is_utf8_char_boundary(self.text.as_bytes()[i]) {
+ if util::is_utf8_char_boundary(self.text.as_bytes()[i]) {
break;
}
i -= 1;
@@ -133,39 +164,15 @@ impl Chunk {
#[track_caller]
#[inline(always)]
- pub fn assert_char_boundary(&self, offset: usize) {
+ pub fn assert_char_boundary<const PANIC: bool>(&self, offset: usize) -> bool {
if self.is_char_boundary(offset) {
- return;
+ return true;
}
- panic_char_boundary(self, offset);
-
- #[cold]
- #[inline(never)]
- #[track_caller]
- fn panic_char_boundary(chunk: &Chunk, offset: usize) {
- if offset > chunk.text.len() {
- panic!(
- "byte index {} is out of bounds of `{:?}` (length: {})",
- offset,
- chunk.text,
- chunk.text.len()
- );
- }
- // find the character
- let char_start = chunk.floor_char_boundary(offset);
- // `char_start` must be less than len and a char boundary
- let ch = chunk
- .text
- .get(char_start..)
- .unwrap()
- .chars()
- .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 PANIC {
+ panic_char_boundary(&self.text, offset);
+ } else {
+ log_err_char_boundary(&self.text, offset);
+ false
}
}
}
@@ -236,10 +243,7 @@ impl<'a> ChunkSlice<'a> {
}
#[inline(always)]
- pub fn slice(self, range: Range<usize>) -> Self {
- let mask = (1 as Bitmap)
- .unbounded_shl(range.end as u32)
- .wrapping_sub(1);
+ pub fn slice(self, mut range: Range<usize>) -> Self {
if range.start == MAX_BASE {
Self {
chars: 0,
@@ -249,8 +253,19 @@ impl<'a> ChunkSlice<'a> {
text: "",
}
} else {
- self.assert_char_boundary(range.start);
- self.assert_char_boundary(range.end);
+ if !self.assert_char_boundary::<false>(range.start) {
+ range.start = self.text.ceil_char_boundary(range.start);
+ }
+ if !self.assert_char_boundary::<false>(range.end) {
+ range.end = if range.end < range.start {
+ range.start
+ } else {
+ self.text.floor_char_boundary(range.end)
+ };
+ }
+ let mask = (1 as Bitmap)
+ .unbounded_shl(range.end as u32)
+ .wrapping_sub(1);
Self {
chars: (self.chars & mask) >> range.start,
chars_utf16: (self.chars_utf16 & mask) >> range.start,
@@ -387,61 +402,20 @@ impl<'a> ChunkSlice<'a> {
#[track_caller]
#[inline(always)]
- pub fn assert_char_boundary(&self, offset: usize) {
+ pub fn assert_char_boundary<const PANIC: bool>(&self, offset: usize) -> bool {
if self.is_char_boundary(offset) {
- return;
+ return true;
}
- panic_char_boundary(self, offset);
-
- #[cold]
- #[inline(never)]
- fn panic_char_boundary(chunk: &ChunkSlice, offset: usize) {
- if offset > chunk.text.len() {
- panic!(
- "byte index {} is out of bounds of `{:?}` (length: {})",
- offset,
- chunk.text,
- chunk.text.len()
- );
- }
- // find the character
- let char_start = chunk.floor_char_boundary(offset);
- // `char_start` must be less than len and a char boundary
- let ch = chunk
- .text
- .get(char_start..)
- .unwrap()
- .chars()
- .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 PANIC {
+ panic_char_boundary(self.text, offset);
+ } else {
+ log_err_char_boundary(self.text, offset);
+ false
}
}
pub fn floor_char_boundary(&self, index: usize) -> usize {
- #[inline]
- pub(crate) const fn is_utf8_char_boundary(u8: u8) -> bool {
- // This is bit magic equivalent to: b < 128 || b >= 192
- (u8 as i8) >= -0x40
- }
-
- if index >= self.text.len() {
- self.text.len()
- } else {
- let mut i = index;
- while i > 0 {
- if is_utf8_char_boundary(self.text.as_bytes()[i]) {
- break;
- }
- i -= 1;
- }
-
- i
- }
+ self.text.floor_char_boundary(index)
}
#[inline(always)]
@@ -720,6 +694,54 @@ fn nth_set_bit(v: u128, n: usize) -> usize {
}
}
+#[cold]
+#[inline(never)]
+#[track_caller]
+fn panic_char_boundary(text: &str, offset: usize) -> ! {
+ if offset > text.len() {
+ panic!(
+ "byte index {} is out of bounds of `{:?}` (length: {})",
+ offset,
+ text,
+ text.len()
+ );
+ }
+ // find the character
+ let char_start = text.floor_char_boundary(offset);
+ // `char_start` must be less than len and a char boundary
+ let ch = text.get(char_start..).unwrap().chars().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,
+ );
+}
+
+#[cold]
+#[inline(never)]
+#[track_caller]
+fn log_err_char_boundary(text: &str, offset: usize) {
+ if offset > text.len() {
+ log::error!(
+ "byte index {} is out of bounds of `{:?}` (length: {})",
+ offset,
+ text,
+ text.len()
+ );
+ }
+ // find the character
+ let char_start = text.floor_char_boundary(offset);
+ // `char_start` must be less than len and a char boundary
+ let ch = text.get(char_start..).unwrap().chars().next().unwrap();
+ let char_range = char_start..char_start + ch.len_utf8();
+ log::error!(
+ "byte index {} is not a char boundary; it is inside {:?} (bytes {:?})",
+ offset,
+ ch,
+ char_range,
+ );
+}
+
#[inline(always)]
fn nth_set_bit_u64(v: u64, mut n: u64) -> u64 {
let v = v.reverse_bits();
@@ -32,7 +32,6 @@ impl Sub for OffsetUtf16 {
type Output = OffsetUtf16;
fn sub(self, other: Self) -> Self::Output {
- debug_assert!(other <= self);
Self(self.0 - other.0)
}
}
@@ -1,15 +1,22 @@
use std::{
cmp::Ordering,
+ fmt::{self, Debug},
ops::{Add, AddAssign, Range, Sub},
};
/// A zero-indexed point in a text buffer consisting of a row and column.
-#[derive(Clone, Copy, Default, Eq, PartialEq, Debug, Hash)]
+#[derive(Clone, Copy, Default, Eq, PartialEq, Hash)]
pub struct Point {
pub row: u32,
pub column: u32,
}
+impl Debug for Point {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "Point({}:{})", self.row, self.column)
+ }
+}
+
impl Point {
pub const MAX: Self = Self {
row: u32::MAX,
@@ -12,6 +12,7 @@ use std::{
str,
};
use sum_tree::{Bias, Dimension, Dimensions, SumTree};
+use ztracing::instrument;
pub use chunk::{Chunk, ChunkSlice};
pub use offset_utf16::OffsetUtf16;
@@ -50,23 +51,31 @@ impl Rope {
#[track_caller]
#[inline(always)]
- pub fn assert_char_boundary(&self, offset: usize) {
+ pub fn assert_char_boundary<const PANIC: bool>(&self, offset: usize) -> bool {
if self.chunks.is_empty() && offset == 0 {
- return;
+ return true;
}
let (start, _, item) = self.chunks.find::<usize, _>((), &offset, Bias::Left);
match item {
Some(chunk) => {
let chunk_offset = offset - start;
- chunk.assert_char_boundary(chunk_offset);
+ chunk.assert_char_boundary::<PANIC>(chunk_offset)
}
- None => {
+ None if PANIC => {
panic!(
"byte index {} is out of bounds of rope (length: {})",
offset,
self.len()
);
}
+ None => {
+ log::error!(
+ "byte index {} is out of bounds of rope (length: {})",
+ offset,
+ self.len()
+ );
+ false
+ }
}
}
@@ -74,29 +83,9 @@ impl Rope {
if index >= self.len() {
self.len()
} else {
- #[inline]
- pub(crate) const fn is_utf8_char_boundary(u8: u8) -> bool {
- // This is bit magic equivalent to: b < 128 || b >= 192
- (u8 as i8) >= -0x40
- }
-
let (start, _, item) = self.chunks.find::<usize, _>((), &index, Bias::Left);
let chunk_offset = index - start;
- let lower_idx = item.map(|chunk| {
- let lower_bound = chunk_offset.saturating_sub(3);
- chunk
- .text
- .as_bytes()
- .get(lower_bound..=chunk_offset)
- .map(|it| {
- let new_idx = it
- .iter()
- .rposition(|&b| is_utf8_char_boundary(b))
- .unwrap_or(0);
- lower_bound + new_idx
- })
- .unwrap_or(chunk.text.len())
- });
+ let lower_idx = item.map(|chunk| chunk.text.floor_char_boundary(chunk_offset));
lower_idx.map_or_else(|| self.len(), |idx| start + idx)
}
}
@@ -105,22 +94,9 @@ impl Rope {
if index > self.len() {
self.len()
} else {
- #[inline]
- pub(crate) const fn is_utf8_char_boundary(u8: u8) -> bool {
- // This is bit magic equivalent to: b < 128 || b >= 192
- (u8 as i8) >= -0x40
- }
-
let (start, _, item) = self.chunks.find::<usize, _>((), &index, Bias::Left);
let chunk_offset = index - start;
- let upper_idx = item.map(|chunk| {
- let upper_bound = Ord::min(chunk_offset + 4, chunk.text.len());
- chunk.text.as_bytes()[chunk_offset..upper_bound]
- .iter()
- .position(|&b| is_utf8_char_boundary(b))
- .map_or(upper_bound, |pos| pos + chunk_offset)
- });
-
+ let upper_idx = item.map(|chunk| chunk.text.ceil_char_boundary(chunk_offset));
upper_idx.map_or_else(|| self.len(), |idx| start + idx)
}
}
@@ -251,7 +227,7 @@ impl Rope {
#[cfg(all(test, not(rust_analyzer)))]
const PARALLEL_THRESHOLD: usize = 4;
#[cfg(not(all(test, not(rust_analyzer))))]
- const PARALLEL_THRESHOLD: usize = 4 * (2 * sum_tree::TREE_BASE);
+ const PARALLEL_THRESHOLD: usize = 84 * (2 * sum_tree::TREE_BASE);
if new_chunks.len() >= PARALLEL_THRESHOLD {
self.chunks
@@ -453,6 +429,7 @@ impl Rope {
})
}
+ #[instrument(skip_all)]
pub fn point_to_offset(&self, point: Point) -> usize {
if point >= self.summary().lines {
return self.summary().len;
@@ -748,10 +725,8 @@ impl<'a> Chunks<'a> {
range.start
};
let chunk_offset = offset - chunks.start();
- if let Some(chunk) = chunks.item()
- && !chunk.text.is_char_boundary(chunk_offset)
- {
- panic!("byte index {} is not a char boundary", offset);
+ if let Some(chunk) = chunks.item() {
+ chunk.assert_char_boundary::<true>(chunk_offset);
}
Self {
chunks,
@@ -1567,39 +1542,63 @@ where
}
}
-impl<K, V> ops::Sub for DimensionPair<K, V>
+impl<R, R2, K, V> ops::Sub for DimensionPair<K, V>
where
- K: ops::Sub<K, Output = K>,
- V: ops::Sub<V, Output = V>,
+ K: ops::Sub<K, Output = R>,
+ V: ops::Sub<V, Output = R2>,
{
- type Output = Self;
+ type Output = DimensionPair<R, R2>;
fn sub(self, rhs: Self) -> Self::Output {
- Self {
+ DimensionPair {
key: self.key - rhs.key,
value: self.value.zip(rhs.value).map(|(a, b)| a - b),
}
}
}
+impl<R, R2, K, V> ops::AddAssign<DimensionPair<R, R2>> for DimensionPair<K, V>
+where
+ K: ops::AddAssign<R>,
+ V: ops::AddAssign<R2>,
+{
+ fn add_assign(&mut self, rhs: DimensionPair<R, R2>) {
+ self.key += rhs.key;
+ if let Some(value) = &mut self.value {
+ if let Some(other_value) = rhs.value {
+ *value += other_value;
+ } else {
+ self.value.take();
+ }
+ }
+ }
+}
+
+impl<D> std::ops::AddAssign<DimensionPair<Point, D>> for Point {
+ fn add_assign(&mut self, rhs: DimensionPair<Point, D>) {
+ *self += rhs.key;
+ }
+}
+
impl<K, V> cmp::Eq for DimensionPair<K, V> where K: cmp::Eq {}
-impl<'a, K, V> sum_tree::Dimension<'a, ChunkSummary> for DimensionPair<K, V>
+impl<'a, K, V, S> sum_tree::Dimension<'a, S> for DimensionPair<K, V>
where
- K: sum_tree::Dimension<'a, ChunkSummary>,
- V: sum_tree::Dimension<'a, ChunkSummary>,
+ S: sum_tree::Summary,
+ K: sum_tree::Dimension<'a, S>,
+ V: sum_tree::Dimension<'a, S>,
{
- fn zero(_cx: ()) -> Self {
+ fn zero(cx: S::Context<'_>) -> Self {
Self {
- key: K::zero(_cx),
- value: Some(V::zero(_cx)),
+ key: K::zero(cx),
+ value: Some(V::zero(cx)),
}
}
- fn add_summary(&mut self, summary: &'a ChunkSummary, _cx: ()) {
- self.key.add_summary(summary, _cx);
+ fn add_summary(&mut self, summary: &'a S, cx: S::Context<'_>) {
+ self.key.add_summary(summary, cx);
if let Some(value) = &mut self.value {
- value.add_summary(summary, _cx);
+ value.add_summary(summary, cx);
}
}
}
@@ -2186,79 +2185,43 @@ mod tests {
#[test]
fn test_floor_char_boundary() {
- // polyfill of str::floor_char_boundary
- fn floor_char_boundary(str: &str, index: usize) -> usize {
- if index >= str.len() {
- str.len()
- } else {
- let lower_bound = index.saturating_sub(3);
- let new_index = str.as_bytes()[lower_bound..=index]
- .iter()
- .rposition(|b| (*b as i8) >= -0x40);
-
- lower_bound + new_index.unwrap()
- }
- }
-
let fixture = "地";
let rope = Rope::from("地");
for b in 0..=fixture.len() {
- assert_eq!(
- rope.floor_char_boundary(b),
- floor_char_boundary(&fixture, b)
- );
+ assert_eq!(rope.floor_char_boundary(b), fixture.floor_char_boundary(b));
}
let fixture = "";
let rope = Rope::from("");
for b in 0..=fixture.len() {
- assert_eq!(
- rope.floor_char_boundary(b),
- floor_char_boundary(&fixture, b)
- );
+ assert_eq!(rope.floor_char_boundary(b), fixture.floor_char_boundary(b));
}
let fixture = "🔴🟠🟡🟢🔵🟣⚫️⚪️🟤\n🏳️⚧️🏁🏳️🌈🏴☠️⛳️📬📭🏴🏳️🚩";
let rope = Rope::from("🔴🟠🟡🟢🔵🟣⚫️⚪️🟤\n🏳️⚧️🏁🏳️🌈🏴☠️⛳️📬📭🏴🏳️🚩");
for b in 0..=fixture.len() {
- assert_eq!(
- rope.floor_char_boundary(b),
- floor_char_boundary(&fixture, b)
- );
+ assert_eq!(rope.floor_char_boundary(b), fixture.floor_char_boundary(b));
}
}
#[test]
fn test_ceil_char_boundary() {
- // polyfill of str::ceil_char_boundary
- fn ceil_char_boundary(str: &str, index: usize) -> usize {
- if index > str.len() {
- str.len()
- } else {
- let upper_bound = Ord::min(index + 4, str.len());
- str.as_bytes()[index..upper_bound]
- .iter()
- .position(|b| (*b as i8) >= -0x40)
- .map_or(upper_bound, |pos| pos + index)
- }
- }
-
let fixture = "地";
let rope = Rope::from("地");
for b in 0..=fixture.len() {
- assert_eq!(rope.ceil_char_boundary(b), ceil_char_boundary(&fixture, b));
+ assert_eq!(rope.ceil_char_boundary(b), fixture.ceil_char_boundary(b));
}
let fixture = "";
let rope = Rope::from("");
for b in 0..=fixture.len() {
- assert_eq!(rope.ceil_char_boundary(b), ceil_char_boundary(&fixture, b));
+ assert_eq!(rope.ceil_char_boundary(b), fixture.ceil_char_boundary(b));
}
let fixture = "🔴🟠🟡🟢🔵🟣⚫️⚪️🟤\n🏳️⚧️🏁🏳️🌈🏴☠️⛳️📬📭🏴🏳️🚩";
let rope = Rope::from("🔴🟠🟡🟢🔵🟣⚫️⚪️🟤\n🏳️⚧️🏁🏳️🌈🏴☠️⛳️📬📭🏴🏳️🚩");
for b in 0..=fixture.len() {
- assert_eq!(rope.ceil_char_boundary(b), ceil_char_boundary(&fixture, b));
+ assert_eq!(rope.ceil_char_boundary(b), fixture.ceil_char_boundary(b));
}
}
@@ -59,6 +59,7 @@ pub trait ProtoClient: Send + Sync {
fn message_handler_set(&self) -> &parking_lot::Mutex<ProtoMessageHandlerSet>;
fn is_via_collab(&self) -> bool;
+ fn has_wsl_interop(&self) -> bool;
}
#[derive(Default)]
@@ -510,6 +511,10 @@ impl AnyProtoClient {
},
);
}
+
+ pub fn has_wsl_interop(&self) -> bool {
+ self.0.client.has_wsl_interop()
+ }
}
fn to_any_envelope<T: EnvelopedMessage>(
@@ -21,11 +21,9 @@ use std::sync::atomic::AtomicBool;
use std::time::Duration;
use theme::ThemeSettings;
use title_bar::platform_title_bar::PlatformTitleBar;
-use ui::{
- Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Render, Tooltip, prelude::*,
-};
+use ui::{Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
use util::{ResultExt, TryFutureExt};
-use workspace::{Workspace, client_side_decorations};
+use workspace::{Workspace, WorkspaceSettings, client_side_decorations};
use zed_actions::assistant::InlineAssist;
use prompt_store::*;
@@ -44,15 +42,12 @@ actions!(
/// Duplicates the selected rule.
DuplicateRule,
/// Toggles whether the selected rule is a default rule.
- ToggleDefaultRule
+ ToggleDefaultRule,
+ /// Restores a built-in rule to its default content.
+ RestoreDefaultContent
]
);
-const BUILT_IN_TOOLTIP_TEXT: &str = concat!(
- "This rule supports special functionality.\n",
- "It's read-only, but you can remove it from your default rules."
-);
-
pub trait InlineAssistDelegate {
fn assist(
&self,
@@ -122,7 +117,10 @@ pub fn open_rules_library(
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,
+ },
};
cx.open_window(
WindowOptions {
@@ -267,23 +265,35 @@ impl PickerDelegate for RulePickerDelegate {
.background_spawn(async move {
let matches = search.await;
- let (default_rules, non_default_rules): (Vec<_>, Vec<_>) =
- matches.iter().partition(|rule| rule.default);
+ let (built_in_rules, user_rules): (Vec<_>, Vec<_>) =
+ matches.into_iter().partition(|rule| rule.id.is_built_in());
+ let (default_rules, other_rules): (Vec<_>, Vec<_>) =
+ user_rules.into_iter().partition(|rule| rule.default);
let mut filtered_entries = Vec::new();
+ if !built_in_rules.is_empty() {
+ filtered_entries.push(RulePickerEntry::Header("Built-in Rules".into()));
+
+ for rule in built_in_rules {
+ filtered_entries.push(RulePickerEntry::Rule(rule));
+ }
+
+ filtered_entries.push(RulePickerEntry::Separator);
+ }
+
if !default_rules.is_empty() {
filtered_entries.push(RulePickerEntry::Header("Default Rules".into()));
for rule in default_rules {
- filtered_entries.push(RulePickerEntry::Rule(rule.clone()));
+ filtered_entries.push(RulePickerEntry::Rule(rule));
}
filtered_entries.push(RulePickerEntry::Separator);
}
- for rule in non_default_rules {
- filtered_entries.push(RulePickerEntry::Rule(rule.clone()));
+ for rule in other_rules {
+ filtered_entries.push(RulePickerEntry::Rule(rule));
}
let selected_index = prev_prompt_id
@@ -338,21 +348,27 @@ impl PickerDelegate for RulePickerDelegate {
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
match self.filtered_entries.get(ix)? {
- RulePickerEntry::Header(title) => Some(
- ListSubHeader::new(title.clone())
- .end_slot(
- IconButton::new("info", IconName::Info)
- .style(ButtonStyle::Transparent)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .tooltip(Tooltip::text(
- "Default Rules are attached by default with every new thread.",
- ))
- .into_any_element(),
- )
- .inset(true)
- .into_any_element(),
- ),
+ RulePickerEntry::Header(title) => {
+ let tooltip_text = if title.as_ref() == "Built-in Rules" {
+ "Built-in rules are those included out of the box with Zed."
+ } else {
+ "Default Rules are attached by default with every new thread."
+ };
+
+ Some(
+ ListSubHeader::new(title.clone())
+ .end_slot(
+ IconButton::new("info", IconName::Info)
+ .style(ButtonStyle::Transparent)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .tooltip(Tooltip::text(tooltip_text))
+ .into_any_element(),
+ )
+ .inset(true)
+ .into_any_element(),
+ )
+ }
RulePickerEntry::Separator => Some(
h_flex()
.py_1()
@@ -373,7 +389,7 @@ impl PickerDelegate for RulePickerDelegate {
.truncate()
.mr_10(),
)
- .end_slot::<IconButton>(default.then(|| {
+ .end_slot::<IconButton>((default && !prompt_id.is_built_in()).then(|| {
IconButton::new("toggle-default-rule", IconName::Paperclip)
.toggle_state(true)
.icon_color(Color::Accent)
@@ -383,62 +399,52 @@ impl PickerDelegate for RulePickerDelegate {
cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
}))
}))
- .end_hover_slot(
- h_flex()
- .child(if prompt_id.is_built_in() {
- div()
- .id("built-in-rule")
- .child(Icon::new(IconName::FileLock).color(Color::Muted))
- .tooltip(move |_window, cx| {
- Tooltip::with_meta(
- "Built-in rule",
- None,
- BUILT_IN_TOOLTIP_TEXT,
- cx,
- )
- })
- .into_any()
- } else {
- IconButton::new("delete-rule", IconName::Trash)
- .icon_color(Color::Muted)
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("Delete Rule"))
- .on_click(cx.listener(move |_, _, _, cx| {
- cx.emit(RulePickerEvent::Deleted { prompt_id })
- }))
- .into_any_element()
- })
- .child(
- IconButton::new("toggle-default-rule", IconName::Plus)
- .selected_icon(IconName::Dash)
- .toggle_state(default)
- .icon_size(IconSize::Small)
- .icon_color(if default {
- Color::Accent
- } else {
- Color::Muted
- })
- .map(|this| {
- if default {
- this.tooltip(Tooltip::text(
- "Remove from Default Rules",
- ))
+ .when(!prompt_id.is_built_in(), |this| {
+ this.end_hover_slot(
+ h_flex()
+ .child(
+ IconButton::new("delete-rule", IconName::Trash)
+ .icon_color(Color::Muted)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Delete Rule"))
+ .on_click(cx.listener(move |_, _, _, cx| {
+ cx.emit(RulePickerEvent::Deleted { prompt_id })
+ })),
+ )
+ .child(
+ IconButton::new("toggle-default-rule", IconName::Plus)
+ .selected_icon(IconName::Dash)
+ .toggle_state(default)
+ .icon_size(IconSize::Small)
+ .icon_color(if default {
+ Color::Accent
} else {
- this.tooltip(move |_window, cx| {
- Tooltip::with_meta(
- "Add to Default Rules",
- None,
- "Always included in every thread.",
- cx,
- )
+ Color::Muted
+ })
+ .map(|this| {
+ if default {
+ this.tooltip(Tooltip::text(
+ "Remove from Default Rules",
+ ))
+ } else {
+ this.tooltip(move |_window, cx| {
+ Tooltip::with_meta(
+ "Add to Default Rules",
+ None,
+ "Always included in every thread.",
+ cx,
+ )
+ })
+ }
+ })
+ .on_click(cx.listener(move |_, _, _, cx| {
+ cx.emit(RulePickerEvent::ToggledDefault {
+ prompt_id,
})
- }
- })
- .on_click(cx.listener(move |_, _, _, cx| {
- cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
- })),
- ),
- )
+ })),
+ ),
+ )
+ })
.into_any_element(),
)
}
@@ -570,7 +576,7 @@ impl RulesLibrary {
pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
const SAVE_THROTTLE: Duration = Duration::from_millis(500);
- if prompt_id.is_built_in() {
+ if !prompt_id.can_edit() {
return;
}
@@ -658,6 +664,33 @@ impl RulesLibrary {
}
}
+ pub fn restore_default_content_for_active_rule(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some(active_rule_id) = self.active_rule_id {
+ self.restore_default_content(active_rule_id, window, cx);
+ }
+ }
+
+ pub fn restore_default_content(
+ &mut self,
+ prompt_id: PromptId,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(default_content) = prompt_id.default_content() else {
+ return;
+ };
+
+ if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
+ rule_editor.body_editor.update(cx, |editor, cx| {
+ editor.set_text(default_content, window, cx);
+ });
+ }
+ }
+
pub fn toggle_default_for_rule(
&mut self,
prompt_id: PromptId,
@@ -687,7 +720,7 @@ impl RulesLibrary {
if focus {
rule_editor
.body_editor
- .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
+ .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx));
}
self.set_active_rule(Some(prompt_id), window, cx);
} else if let Some(rule_metadata) = self.store.read(cx).metadata(prompt_id) {
@@ -718,7 +751,7 @@ impl RulesLibrary {
});
let mut editor = Editor::for_buffer(buffer, None, window, cx);
- if prompt_id.is_built_in() {
+ if !prompt_id.can_edit() {
editor.set_read_only(true);
editor.set_show_edit_predictions(Some(false), window, cx);
}
@@ -730,7 +763,7 @@ impl RulesLibrary {
editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
editor.set_completion_provider(Some(make_completion_provider()));
if focus {
- window.focus(&editor.focus_handle(cx));
+ window.focus(&editor.focus_handle(cx), cx);
}
editor
});
@@ -906,7 +939,7 @@ impl RulesLibrary {
if let Some(active_rule) = self.active_rule_id {
self.rule_editors[&active_rule]
.body_editor
- .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
+ .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx));
cx.stop_propagation();
}
}
@@ -965,7 +998,7 @@ impl RulesLibrary {
if let Some(rule_id) = self.active_rule_id
&& let Some(rule_editor) = self.rule_editors.get(&rule_id)
{
- window.focus(&rule_editor.body_editor.focus_handle(cx));
+ window.focus(&rule_editor.body_editor.focus_handle(cx), cx);
}
}
@@ -978,7 +1011,7 @@ impl RulesLibrary {
if let Some(rule_id) = self.active_rule_id
&& let Some(rule_editor) = self.rule_editors.get(&rule_id)
{
- window.focus(&rule_editor.title_editor.focus_handle(cx));
+ window.focus(&rule_editor.title_editor.focus_handle(cx), cx);
}
}
@@ -1069,6 +1102,7 @@ impl RulesLibrary {
role: Role::System,
content: vec![body.to_string().into()],
cache: false,
+ reasoning_details: None,
}],
tools: Vec::new(),
tool_choice: None,
@@ -1144,30 +1178,38 @@ impl RulesLibrary {
fn render_active_rule_editor(
&self,
editor: &Entity<Editor>,
+ read_only: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
let settings = ThemeSettings::get_global(cx);
+ let text_color = if read_only {
+ cx.theme().colors().text_muted
+ } else {
+ cx.theme().colors().text
+ };
div()
.w_full()
- .on_action(cx.listener(Self::move_down_from_title))
.pl_1()
.border_1()
.border_color(transparent_black())
.rounded_sm()
- .group_hover("active-editor-header", |this| {
- this.border_color(cx.theme().colors().border_variant)
+ .when(!read_only, |this| {
+ this.group_hover("active-editor-header", |this| {
+ this.border_color(cx.theme().colors().border_variant)
+ })
})
+ .on_action(cx.listener(Self::move_down_from_title))
.child(EditorElement::new(
&editor,
EditorStyle {
background: cx.theme().system().transparent,
local_player: cx.theme().players().local(),
text: TextStyle {
- color: cx.theme().colors().editor_foreground,
+ color: text_color,
font_family: settings.ui_font.family.clone(),
font_features: settings.ui_font.features.clone(),
- font_size: HeadlineSize::Large.rems().into(),
+ font_size: HeadlineSize::Medium.rems().into(),
font_weight: settings.ui_font.weight,
line_height: relative(settings.buffer_line_height.value()),
..Default::default()
@@ -1182,6 +1224,68 @@ impl RulesLibrary {
))
}
+ fn render_duplicate_rule_button(&self) -> impl IntoElement {
+ IconButton::new("duplicate-rule", IconName::BookCopy)
+ .tooltip(move |_window, cx| Tooltip::for_action("Duplicate Rule", &DuplicateRule, cx))
+ .on_click(|_, window, cx| {
+ window.dispatch_action(Box::new(DuplicateRule), cx);
+ })
+ }
+
+ fn render_built_in_rule_controls(&self) -> impl IntoElement {
+ h_flex()
+ .gap_1()
+ .child(self.render_duplicate_rule_button())
+ .child(
+ IconButton::new("restore-default", IconName::RotateCcw)
+ .tooltip(move |_window, cx| {
+ Tooltip::for_action(
+ "Restore to Default Content",
+ &RestoreDefaultContent,
+ cx,
+ )
+ })
+ .on_click(|_, window, cx| {
+ window.dispatch_action(Box::new(RestoreDefaultContent), cx);
+ }),
+ )
+ }
+
+ fn render_regular_rule_controls(&self, default: bool) -> impl IntoElement {
+ h_flex()
+ .gap_1()
+ .child(
+ IconButton::new("toggle-default-rule", IconName::Paperclip)
+ .toggle_state(default)
+ .when(default, |this| this.icon_color(Color::Accent))
+ .map(|this| {
+ if default {
+ this.tooltip(Tooltip::text("Remove from Default Rules"))
+ } else {
+ this.tooltip(move |_window, cx| {
+ Tooltip::with_meta(
+ "Add to Default Rules",
+ None,
+ "Always included in every thread.",
+ cx,
+ )
+ })
+ }
+ })
+ .on_click(|_, window, cx| {
+ window.dispatch_action(Box::new(ToggleDefaultRule), cx);
+ }),
+ )
+ .child(self.render_duplicate_rule_button())
+ .child(
+ IconButton::new("delete-rule", IconName::Trash)
+ .tooltip(move |_window, cx| Tooltip::for_action("Delete Rule", &DeleteRule, cx))
+ .on_click(|_, window, cx| {
+ window.dispatch_action(Box::new(DeleteRule), cx);
+ }),
+ )
+ }
+
fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
div()
.id("rule-editor")
@@ -1194,9 +1298,9 @@ impl RulesLibrary {
let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
let rule_editor = &self.rule_editors[&prompt_id];
let focus_handle = rule_editor.body_editor.focus_handle(cx);
- let model = LanguageModelRegistry::read_global(cx)
- .default_model()
- .map(|default| default.model);
+ let registry = LanguageModelRegistry::read_global(cx);
+ let model = registry.default_model().map(|default| default.model);
+ let built_in = prompt_id.is_built_in();
Some(
v_flex()
@@ -1204,20 +1308,21 @@ impl RulesLibrary {
.size_full()
.relative()
.overflow_hidden()
- .on_click(cx.listener(move |_, _, window, _| {
- window.focus(&focus_handle);
+ .on_click(cx.listener(move |_, _, window, cx| {
+ window.focus(&focus_handle, cx);
}))
.child(
h_flex()
.group("active-editor-header")
- .pt_2()
- .pl_1p5()
- .pr_2p5()
+ .h_12()
+ .px_2()
.gap_2()
.justify_between()
- .child(
- self.render_active_rule_editor(&rule_editor.title_editor, cx),
- )
+ .child(self.render_active_rule_editor(
+ &rule_editor.title_editor,
+ built_in,
+ cx,
+ ))
.child(
h_flex()
.h_full()
@@ -1254,89 +1359,15 @@ impl RulesLibrary {
.color(Color::Muted),
)
}))
- .child(if prompt_id.is_built_in() {
- div()
- .id("built-in-rule")
- .child(
- Icon::new(IconName::FileLock)
- .color(Color::Muted),
- )
- .tooltip(move |_window, cx| {
- Tooltip::with_meta(
- "Built-in rule",
- None,
- BUILT_IN_TOOLTIP_TEXT,
- cx,
- )
- })
- .into_any()
- } else {
- IconButton::new("delete-rule", IconName::Trash)
- .tooltip(move |_window, cx| {
- Tooltip::for_action(
- "Delete Rule",
- &DeleteRule,
- cx,
- )
- })
- .on_click(|_, window, cx| {
- window
- .dispatch_action(Box::new(DeleteRule), cx);
- })
- .into_any_element()
- })
- .child(
- IconButton::new("duplicate-rule", IconName::BookCopy)
- .tooltip(move |_window, cx| {
- Tooltip::for_action(
- "Duplicate Rule",
- &DuplicateRule,
- cx,
- )
- })
- .on_click(|_, window, cx| {
- window.dispatch_action(
- Box::new(DuplicateRule),
- cx,
- );
- }),
- )
- .child(
- IconButton::new(
- "toggle-default-rule",
- IconName::Paperclip,
- )
- .toggle_state(rule_metadata.default)
- .icon_color(if rule_metadata.default {
- Color::Accent
+ .map(|this| {
+ if built_in {
+ this.child(self.render_built_in_rule_controls())
} else {
- Color::Muted
- })
- .map(|this| {
- if rule_metadata.default {
- this.tooltip(Tooltip::text(
- "Remove from Default Rules",
- ))
- } else {
- this.tooltip(move |_window, cx| {
- Tooltip::with_meta(
- "Add to Default Rules",
- None,
- "Always included in every thread.",
- cx,
- )
- })
- }
- })
- .on_click(
- |_, window, cx| {
- window.dispatch_action(
- Box::new(ToggleDefaultRule),
- cx,
- );
- },
- ),
- ),
+ this.child(self.render_regular_rule_controls(
+ rule_metadata.default,
+ ))
+ }
+ }),
),
)
.child(
@@ -1381,6 +1412,9 @@ impl Render for RulesLibrary {
.on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
this.toggle_default_for_active_rule(window, cx)
}))
+ .on_action(cx.listener(|this, &RestoreDefaultContent, window, cx| {
+ this.restore_default_content_for_active_rule(window, cx)
+ }))
.size_full()
.overflow_hidden()
.font(ui_font)
@@ -15,4 +15,5 @@ env_logger.workspace = true
schemars = { workspace = true, features = ["indexmap2"] }
serde.workspace = true
serde_json.workspace = true
+settings.workspace = true
theme.workspace = true
@@ -1,6 +1,7 @@
use anyhow::Result;
use clap::{Parser, ValueEnum};
use schemars::schema_for;
+use settings::ProjectSettingsContent;
use theme::{IconThemeFamilyContent, ThemeFamilyContent};
#[derive(Parser, Debug)]
@@ -14,6 +15,7 @@ pub struct Args {
pub enum SchemaType {
Theme,
IconTheme,
+ Project,
}
fn main() -> Result<()> {
@@ -30,6 +32,10 @@ fn main() -> Result<()> {
let schema = schema_for!(IconThemeFamilyContent);
println!("{}", serde_json::to_string_pretty(&schema)?);
}
+ SchemaType::Project => {
+ let schema = schema_for!(ProjectSettingsContent);
+ println!("{}", serde_json::to_string_pretty(&schema)?);
+ }
}
Ok(())
@@ -42,6 +42,9 @@ util.workspace = true
util_macros.workspace = true
workspace.workspace = true
zed_actions.workspace = true
+itertools.workspace = true
+ztracing.workspace = true
+tracing.workspace = true
[dev-dependencies]
client = { workspace = true, features = ["test-support"] }
@@ -49,5 +52,10 @@ editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] }
lsp.workspace = true
+pretty_assertions.workspace = true
unindent.workspace = true
workspace = { workspace = true, features = ["test-support"] }
+
+[package.metadata.cargo-machete]
+ignored = ["tracing"]
+
@@ -7,12 +7,10 @@ use crate::{
search_bar::{ActionButtonState, input_base_styles, render_action_button, render_text_input},
};
use any_vec::AnyVec;
-use anyhow::Context as _;
use collections::HashMap;
use editor::{
- DisplayPoint, Editor, EditorSettings, VimFlavor,
+ DisplayPoint, Editor, EditorSettings, MultiBufferOffset,
actions::{Backtab, Tab},
- vim_flavor,
};
use futures::channel::oneshot;
use gpui::{
@@ -433,10 +431,8 @@ impl Render for BufferSearchBar {
}))
.when(replacement, |this| {
this.on_action(cx.listener(Self::toggle_replace))
- .when(in_replace, |this| {
- this.on_action(cx.listener(Self::replace_next))
- .on_action(cx.listener(Self::replace_all))
- })
+ .on_action(cx.listener(Self::replace_next))
+ .on_action(cx.listener(Self::replace_all))
})
.when(case, |this| {
this.on_action(cx.listener(Self::toggle_case_sensitive))
@@ -521,7 +517,7 @@ impl BufferSearchBar {
pub fn register(registrar: &mut impl SearchActionsRegistrar) {
registrar.register_handler(ForDeployed(|this, _: &FocusSearch, window, cx| {
- this.query_editor.focus_handle(cx).focus(window);
+ this.query_editor.focus_handle(cx).focus(window, cx);
this.select_query(window, cx);
}));
registrar.register_handler(ForDeployed(
@@ -637,15 +633,19 @@ impl BufferSearchBar {
.read(cx)
.as_singleton()
.expect("query editor should be backed by a singleton buffer");
+
query_buffer
.read(cx)
.set_language_registry(languages.clone());
cx.spawn(async move |buffer_search_bar, cx| {
+ use anyhow::Context as _;
+
let regex_language = languages
.language_for_name("regex")
.await
.context("loading regex language")?;
+
buffer_search_bar
.update(cx, |buffer_search_bar, cx| {
buffer_search_bar.regex_language = Some(regex_language);
@@ -709,7 +709,7 @@ impl BufferSearchBar {
active_editor.search_bar_visibility_changed(false, window, cx);
active_editor.toggle_filtered_search_ranges(None, window, cx);
let handle = active_editor.item_focus_handle(cx);
- self.focus(&handle, window);
+ self.focus(&handle, window, cx);
}
cx.emit(Event::UpdateLocation);
@@ -732,12 +732,14 @@ impl BufferSearchBar {
self.search_suggested(window, cx);
self.smartcase(window, cx);
self.sync_select_next_case_sensitivity(cx);
- self.replace_enabled = deploy.replace_enabled;
- self.selection_search_enabled = if deploy.selection_search_enabled {
- Some(FilteredSearchRange::Default)
- } else {
- None
- };
+ self.replace_enabled |= deploy.replace_enabled;
+ self.selection_search_enabled =
+ self.selection_search_enabled
+ .or(if deploy.selection_search_enabled {
+ Some(FilteredSearchRange::Default)
+ } else {
+ None
+ });
if deploy.focus {
let mut handle = self.query_editor.focus_handle(cx);
let mut select_query = true;
@@ -750,7 +752,7 @@ impl BufferSearchBar {
self.select_query(window, cx);
}
- window.focus(&handle);
+ window.focus(&handle, cx);
}
return true;
}
@@ -828,8 +830,7 @@ impl BufferSearchBar {
.searchable_items_with_matches
.get(&active_searchable_item.downgrade())
{
- let collapse = editor::vim_flavor(cx) == Some(VimFlavor::Vim);
- active_searchable_item.activate_match(match_ix, matches, collapse, window, cx)
+ active_searchable_item.activate_match(match_ix, matches, window, cx)
}
}
@@ -870,13 +871,17 @@ impl BufferSearchBar {
.buffer()
.update(cx, |replacement_buffer, cx| {
let len = replacement_buffer.len(cx);
- replacement_buffer.edit([(0..len, replacement.unwrap())], None, cx);
+ replacement_buffer.edit(
+ [(MultiBufferOffset(0)..len, replacement.unwrap())],
+ None,
+ cx,
+ );
});
});
}
pub fn focus_replace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- self.focus(&self.replacement_editor.focus_handle(cx), window);
+ self.focus(&self.replacement_editor.focus_handle(cx), window, cx);
cx.notify();
}
@@ -894,7 +899,7 @@ impl BufferSearchBar {
self.query_editor.update(cx, |query_editor, cx| {
query_editor.buffer().update(cx, |query_buffer, cx| {
let len = query_buffer.len(cx);
- query_buffer.edit([(0..len, query)], None, cx);
+ query_buffer.edit([(MultiBufferOffset(0)..len, query)], None, cx);
});
});
self.set_search_options(options, cx);
@@ -907,7 +912,7 @@ impl BufferSearchBar {
pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
if let Some(active_editor) = self.active_searchable_item.as_ref() {
let handle = active_editor.item_focus_handle(cx);
- window.focus(&handle);
+ window.focus(&handle, cx);
}
}
@@ -976,8 +981,7 @@ impl BufferSearchBar {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let collapse = vim_flavor(cx) == Some(VimFlavor::Vim);
- self.select_match(Direction::Next, 1, collapse, window, cx);
+ self.select_match(Direction::Next, 1, window, cx);
}
fn select_prev_match(
@@ -986,8 +990,7 @@ impl BufferSearchBar {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let collapse = vim_flavor(cx) == Some(VimFlavor::Vim);
- self.select_match(Direction::Prev, 1, collapse, window, cx);
+ self.select_match(Direction::Prev, 1, window, cx);
}
pub fn select_all_matches(
@@ -1012,7 +1015,6 @@ impl BufferSearchBar {
&mut self,
direction: Direction,
count: usize,
- collapse: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1034,8 +1036,8 @@ impl BufferSearchBar {
let new_match_index = searchable_item
.match_index_for_direction(matches, index, direction, count, window, cx);
- searchable_item.update_matches(matches, window, cx);
- searchable_item.activate_match(new_match_index, matches, collapse, window, cx);
+ searchable_item.update_matches(matches, Some(new_match_index), window, cx);
+ searchable_item.activate_match(new_match_index, matches, window, cx);
}
}
@@ -1048,9 +1050,8 @@ impl BufferSearchBar {
if matches.is_empty() {
return;
}
- searchable_item.update_matches(matches, window, cx);
- let collapse = vim_flavor(cx) == Some(VimFlavor::Vim);
- searchable_item.activate_match(0, matches, collapse, window, cx);
+ searchable_item.update_matches(matches, Some(0), window, cx);
+ searchable_item.activate_match(0, matches, window, cx);
}
}
@@ -1064,9 +1065,8 @@ impl BufferSearchBar {
return;
}
let new_match_index = matches.len() - 1;
- searchable_item.update_matches(matches, window, cx);
- let collapse = vim_flavor(cx) == Some(VimFlavor::Vim);
- searchable_item.activate_match(new_match_index, matches, collapse, window, cx);
+ searchable_item.update_matches(matches, Some(new_match_index), window, cx);
+ searchable_item.activate_match(new_match_index, matches, window, cx);
}
}
@@ -1305,7 +1305,12 @@ impl BufferSearchBar {
if matches.is_empty() {
active_searchable_item.clear_matches(window, cx);
} else {
- active_searchable_item.update_matches(matches, window, cx);
+ active_searchable_item.update_matches(
+ matches,
+ this.active_match_index,
+ window,
+ cx,
+ );
}
let _ = done_tx.send(());
}
@@ -1340,6 +1345,18 @@ impl BufferSearchBar {
});
if new_index != self.active_match_index {
self.active_match_index = new_index;
+ if !self.dismissed {
+ if let Some(searchable_item) = self.active_searchable_item.as_ref() {
+ if let Some(matches) = self
+ .searchable_items_with_matches
+ .get(&searchable_item.downgrade())
+ {
+ if !matches.is_empty() {
+ searchable_item.update_matches(matches, new_index, window, cx);
+ }
+ }
+ }
+ }
cx.notify();
}
}
@@ -1370,7 +1387,7 @@ impl BufferSearchBar {
Direction::Prev => (current_index - 1) % handles.len(),
};
let next_focus_handle = &handles[new_index];
- self.focus(next_focus_handle, window);
+ self.focus(next_focus_handle, window, cx);
cx.stop_propagation();
}
@@ -1417,9 +1434,9 @@ impl BufferSearchBar {
}
}
- fn focus(&self, handle: &gpui::FocusHandle, window: &mut Window) {
+ fn focus(&self, handle: &gpui::FocusHandle, window: &mut Window, cx: &mut App) {
window.invalidate_character_coordinates();
- window.focus(handle);
+ window.focus(handle, cx);
}
fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
@@ -1430,7 +1447,7 @@ impl BufferSearchBar {
} else {
self.query_editor.focus_handle(cx)
};
- self.focus(&handle, window);
+ self.focus(&handle, window, cx);
cx.notify();
}
}
@@ -2024,7 +2041,7 @@ mod tests {
.update(cx, |_, window, cx| {
search_bar.update(cx, |search_bar, cx| {
let handle = search_bar.query_editor.focus_handle(cx);
- window.focus(&handle);
+ window.focus(&handle, cx);
search_bar.activate_current_match(window, cx);
});
assert!(
@@ -2042,7 +2059,7 @@ mod tests {
search_bar.update(cx, |search_bar, cx| {
assert_eq!(search_bar.active_match_index, Some(0));
let handle = search_bar.query_editor.focus_handle(cx);
- window.focus(&handle);
+ window.focus(&handle, cx);
search_bar.select_all_matches(&SelectAllMatches, window, cx);
});
assert!(
@@ -2095,7 +2112,7 @@ mod tests {
"Match index should be updated to the next one"
);
let handle = search_bar.query_editor.focus_handle(cx);
- window.focus(&handle);
+ window.focus(&handle, cx);
search_bar.select_all_matches(&SelectAllMatches, window, cx);
});
})
@@ -2161,7 +2178,7 @@ mod tests {
.update(cx, |_, window, cx| {
search_bar.update(cx, |search_bar, cx| {
let handle = search_bar.query_editor.focus_handle(cx);
- window.focus(&handle);
+ window.focus(&handle, cx);
search_bar.search("abas_nonexistent_match", None, true, window, cx)
})
})
@@ -2552,6 +2569,52 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_replace_focus(cx: &mut TestAppContext) {
+ let (editor, search_bar, cx) = init_test(cx);
+
+ editor.update_in(cx, |editor, window, cx| {
+ editor.set_text("What a bad day!", window, cx)
+ });
+
+ search_bar
+ .update_in(cx, |search_bar, window, cx| {
+ search_bar.search("bad", None, true, window, cx)
+ })
+ .await
+ .unwrap();
+
+ // Calling `toggle_replace` in the search bar ensures that the "Replace
+ // *" buttons are rendered, so we can then simulate clicking the
+ // buttons.
+ search_bar.update_in(cx, |search_bar, window, cx| {
+ search_bar.toggle_replace(&ToggleReplace, window, cx)
+ });
+
+ search_bar.update_in(cx, |search_bar, window, cx| {
+ search_bar.replacement_editor.update(cx, |editor, cx| {
+ editor.set_text("great", window, cx);
+ });
+ });
+
+ // Focus on the editor instead of the search bar, as we want to ensure
+ // that pressing the "Replace Next Match" button will work, even if the
+ // search bar is not focused.
+ cx.focus(&editor);
+
+ // We'll not simulate clicking the "Replace Next Match " button, asserting that
+ // the replacement was done.
+ let button_bounds = cx
+ .debug_bounds("ICON-ReplaceNext")
+ .expect("'Replace Next Match' button should be visible");
+ cx.simulate_click(button_bounds.center(), gpui::Modifiers::none());
+
+ assert_eq!(
+ editor.read_with(cx, |editor, cx| editor.text(cx)),
+ "What a great day!"
+ );
+ }
+
struct ReplacementTestParams<'a> {
editor: &'a Entity<Editor>,
search_bar: &'a Entity<BufferSearchBar>,
@@ -9,20 +9,19 @@ use anyhow::Context as _;
use collections::HashMap;
use editor::{
Anchor, Editor, EditorEvent, EditorSettings, MAX_TAB_TITLE_LEN, MultiBuffer, PathKey,
- SelectionEffects, VimFlavor,
+ SelectionEffects,
actions::{Backtab, SelectAll, Tab},
items::active_match_index,
multibuffer_context_lines,
scroll::Autoscroll,
- vim_flavor,
};
use futures::{StreamExt, stream::FuturesOrdered};
use gpui::{
- Action, AnyElement, AnyView, App, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle,
- Focusable, Global, Hsla, InteractiveElement, IntoElement, KeyContext, ParentElement, Point,
- Render, SharedString, Styled, Subscription, Task, UpdateGlobal, WeakEntity, Window, actions,
- div,
+ Action, AnyElement, App, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
+ Global, Hsla, InteractiveElement, IntoElement, KeyContext, ParentElement, Point, Render,
+ SharedString, Styled, Subscription, Task, UpdateGlobal, WeakEntity, Window, actions, div,
};
+use itertools::Itertools;
use language::{Buffer, Language};
use menu::Confirm;
use project::{
@@ -379,6 +378,9 @@ impl ProjectSearch {
})
.ok()?;
while let Some(new_ranges) = new_ranges.next().await {
+ // `new_ranges.next().await` likely never gets hit while still pending so `async_task`
+ // will not reschedule, starving other front end tasks, insert a yield point for that here
+ smol::future::yield_now().await;
project_search
.update(cx, |project_search, cx| {
project_search.match_ranges.extend(new_ranges);
@@ -498,7 +500,7 @@ impl Item for ProjectSearchView {
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
- ) -> Option<AnyView> {
+ ) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
@@ -507,7 +509,7 @@ impl Item for ProjectSearchView {
None
}
}
- fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.results_editor.clone()))
}
@@ -952,9 +954,9 @@ impl ProjectSearchView {
cx.on_next_frame(window, |this, window, cx| {
if this.focus_handle.is_focused(window) {
if this.has_matches() {
- this.results_editor.focus_handle(cx).focus(window);
+ this.results_editor.focus_handle(cx).focus(window, cx);
} else {
- this.query_editor.focus_handle(cx).focus(window);
+ this.query_editor.focus_handle(cx).focus(window, cx);
}
}
});
@@ -1145,7 +1147,7 @@ impl ProjectSearchView {
};
search.update(cx, |search, cx| {
- search.replace_enabled = action.replace_enabled;
+ search.replace_enabled |= action.replace_enabled;
if let Some(query) = query {
search.set_query(&query, window, cx);
}
@@ -1431,8 +1433,7 @@ impl ProjectSearchView {
let range_to_select = match_ranges[new_index].clone();
self.results_editor.update(cx, |editor, cx| {
- let collapse = vim_flavor(cx) == Some(VimFlavor::Vim);
- let range_to_select = editor.range_for_match(&range_to_select, collapse);
+ let range_to_select = editor.range_for_match(&range_to_select);
let autoscroll = if EditorSettings::get_global(cx).search.center_on_match {
Autoscroll::center()
} else {
@@ -1443,6 +1444,7 @@ impl ProjectSearchView {
s.select_ranges([range_to_select])
});
});
+ self.highlight_matches(&match_ranges, Some(new_index), cx);
}
}
@@ -1451,7 +1453,7 @@ impl ProjectSearchView {
query_editor.select_all(&SelectAll, window, cx);
});
let editor_handle = self.query_editor.focus_handle(cx);
- window.focus(&editor_handle);
+ window.focus(&editor_handle, cx);
}
fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
@@ -1491,7 +1493,7 @@ impl ProjectSearchView {
});
});
let results_handle = self.results_editor.focus_handle(cx);
- window.focus(&results_handle);
+ window.focus(&results_handle, cx);
}
fn entity_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -1509,20 +1511,14 @@ impl ProjectSearchView {
let is_new_search = self.search_id != prev_search_id;
self.results_editor.update(cx, |editor, cx| {
if is_new_search {
- let collapse = vim_flavor(cx) == Some(VimFlavor::Vim);
let range_to_select = match_ranges
.first()
- .map(|range| editor.range_for_match(range, collapse));
+ .map(|range| editor.range_for_match(range));
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(range_to_select)
});
editor.scroll(Point::default(), Some(Axis::Vertical), window, cx);
}
- editor.highlight_background::<Self>(
- &match_ranges,
- |theme| theme.colors().search_match_background,
- cx,
- );
});
if is_new_search && self.query_editor.focus_handle(cx).is_focused(window) {
self.focus_results_editor(window, cx);
@@ -1535,18 +1531,42 @@ impl ProjectSearchView {
fn update_match_index(&mut self, cx: &mut Context<Self>) {
let results_editor = self.results_editor.read(cx);
+ let match_ranges = self.entity.read(cx).match_ranges.clone();
let new_index = active_match_index(
Direction::Next,
- &self.entity.read(cx).match_ranges,
+ &match_ranges,
&results_editor.selections.newest_anchor().head(),
&results_editor.buffer().read(cx).snapshot(cx),
);
+ self.highlight_matches(&match_ranges, new_index, cx);
if self.active_match_index != new_index {
self.active_match_index = new_index;
cx.notify();
}
}
+ #[ztracing::instrument(skip_all)]
+ fn highlight_matches(
+ &self,
+ match_ranges: &[Range<Anchor>],
+ active_index: Option<usize>,
+ cx: &mut Context<Self>,
+ ) {
+ self.results_editor.update(cx, |editor, cx| {
+ editor.highlight_background::<Self>(
+ match_ranges,
+ move |index, theme| {
+ if active_index == Some(*index) {
+ theme.colors().search_active_match_background
+ } else {
+ theme.colors().search_match_background
+ }
+ },
+ cx,
+ );
+ });
+ }
+
pub fn has_matches(&self) -> bool {
self.active_match_index.is_some()
}
@@ -1730,7 +1750,7 @@ impl ProjectSearchBar {
fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(search_view) = self.active_project_search.as_ref() {
search_view.update(cx, |search_view, cx| {
- search_view.query_editor.focus_handle(cx).focus(window);
+ search_view.query_editor.focus_handle(cx).focus(window, cx);
});
}
}
@@ -1763,7 +1783,7 @@ impl ProjectSearchBar {
Direction::Prev => (current_index - 1) % views.len(),
};
let next_focus_handle = &views[new_index];
- window.focus(next_focus_handle);
+ window.focus(next_focus_handle, cx);
cx.stop_propagation();
});
}
@@ -1812,7 +1832,7 @@ impl ProjectSearchBar {
} else {
this.query_editor.focus_handle(cx)
};
- window.focus(&editor_to_focus);
+ window.focus(&editor_to_focus, cx);
cx.notify();
});
}
@@ -2453,9 +2473,12 @@ pub mod tests {
use editor::{DisplayPoint, display_map::DisplayRow};
use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
use language::{FakeLspAdapter, rust_lang};
+ use pretty_assertions::assert_eq;
use project::FakeFs;
use serde_json::json;
- use settings::{InlayHintSettingsContent, SettingsStore};
+ use settings::{
+ InlayHintSettingsContent, SettingsStore, ThemeColorsContent, ThemeStyleContent,
+ };
use util::{path, paths::PathStyle, rel_path::rel_path};
use util_macros::perf;
use workspace::DeploySearch;
@@ -2463,8 +2486,105 @@ pub mod tests {
#[perf]
#[gpui::test]
async fn test_project_search(cx: &mut TestAppContext) {
+ fn dp(row: u32, col: u32) -> DisplayPoint {
+ DisplayPoint::new(DisplayRow(row), col)
+ }
+
+ fn assert_active_match_index(
+ search_view: &WindowHandle<ProjectSearchView>,
+ cx: &mut TestAppContext,
+ expected_index: usize,
+ ) {
+ search_view
+ .update(cx, |search_view, _window, _cx| {
+ assert_eq!(search_view.active_match_index, Some(expected_index));
+ })
+ .unwrap();
+ }
+
+ fn assert_selection_range(
+ search_view: &WindowHandle<ProjectSearchView>,
+ cx: &mut TestAppContext,
+ expected_range: Range<DisplayPoint>,
+ ) {
+ search_view
+ .update(cx, |search_view, _window, cx| {
+ assert_eq!(
+ search_view.results_editor.update(cx, |editor, cx| editor
+ .selections
+ .display_ranges(&editor.display_snapshot(cx))),
+ [expected_range]
+ );
+ })
+ .unwrap();
+ }
+
+ fn assert_highlights(
+ search_view: &WindowHandle<ProjectSearchView>,
+ cx: &mut TestAppContext,
+ expected_highlights: Vec<(Range<DisplayPoint>, &str)>,
+ ) {
+ search_view
+ .update(cx, |search_view, window, cx| {
+ let match_bg = cx.theme().colors().search_match_background;
+ let active_match_bg = cx.theme().colors().search_active_match_background;
+ let selection_bg = cx
+ .theme()
+ .colors()
+ .editor_document_highlight_bracket_background;
+
+ let highlights: Vec<_> = expected_highlights
+ .into_iter()
+ .map(|(range, color_type)| {
+ let color = match color_type {
+ "active" => active_match_bg,
+ "match" => match_bg,
+ "selection" => selection_bg,
+ _ => panic!("Unknown color type"),
+ };
+ (range, color)
+ })
+ .collect();
+
+ assert_eq!(
+ search_view.results_editor.update(cx, |editor, cx| editor
+ .all_text_background_highlights(window, cx)),
+ highlights.as_slice()
+ );
+ })
+ .unwrap();
+ }
+
+ fn select_match(
+ search_view: &WindowHandle<ProjectSearchView>,
+ cx: &mut TestAppContext,
+ direction: Direction,
+ ) {
+ search_view
+ .update(cx, |search_view, window, cx| {
+ search_view.select_match(direction, window, cx);
+ })
+ .unwrap();
+ }
+
init_test(cx);
+ // Override active search match color since the fallback theme uses the same color
+ // for normal search match and active one, which can make this test less robust.
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |settings, cx| {
+ settings.update_user_settings(cx, |settings| {
+ settings.theme.experimental_theme_overrides = Some(ThemeStyleContent {
+ colors: ThemeColorsContent {
+ search_active_match_background: Some("#ff0000ff".to_string()),
+ ..Default::default()
+ },
+ ..Default::default()
+ });
+ });
+ });
+ });
+
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/dir"),
@@ -2485,117 +2605,113 @@ pub mod tests {
});
perform_search(search_view, "TWO", cx);
- search_view.update(cx, |search_view, window, cx| {
- assert_eq!(
- search_view
- .results_editor
- .update(cx, |editor, cx| editor.display_text(cx)),
- "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
- );
- let match_background_color = cx.theme().colors().search_match_background;
- let selection_background_color = cx.theme().colors().editor_document_highlight_bracket_background;
- assert_eq!(
- search_view
- .results_editor
- .update(cx, |editor, cx| editor.all_text_background_highlights(window, cx)),
- &[
- (
- DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35),
- match_background_color
- ),
- (
- DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40),
- selection_background_color
- ),
- (
- DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40),
- match_background_color
- ),
- (
- DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9),
- selection_background_color
- ),
- (
- DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9),
- match_background_color
- ),
-
- ]
- );
- assert_eq!(search_view.active_match_index, Some(0));
- assert_eq!(
- search_view
- .results_editor
- .update(cx, |editor, cx| editor.selections.display_ranges(&editor.display_snapshot(cx))),
- [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
- );
-
- search_view.select_match(Direction::Next, window, cx);
- }).unwrap();
+ cx.run_until_parked();
search_view
- .update(cx, |search_view, window, cx| {
- assert_eq!(search_view.active_match_index, Some(1));
+ .update(cx, |search_view, _window, cx| {
assert_eq!(
- search_view.results_editor.update(cx, |editor, cx| editor
- .selections
- .display_ranges(&editor.display_snapshot(cx))),
- [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
+ search_view
+ .results_editor
+ .update(cx, |editor, cx| editor.display_text(cx)),
+ "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
);
- search_view.select_match(Direction::Next, window, cx);
})
.unwrap();
- search_view
- .update(cx, |search_view, window, cx| {
- assert_eq!(search_view.active_match_index, Some(2));
- assert_eq!(
- search_view.results_editor.update(cx, |editor, cx| editor
- .selections
- .display_ranges(&editor.display_snapshot(cx))),
- [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
- );
- search_view.select_match(Direction::Next, window, cx);
- })
- .unwrap();
+ assert_active_match_index(&search_view, cx, 0);
+ assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35));
+ assert_highlights(
+ &search_view,
+ cx,
+ vec![
+ (dp(2, 32)..dp(2, 35), "active"),
+ (dp(2, 37)..dp(2, 40), "selection"),
+ (dp(2, 37)..dp(2, 40), "match"),
+ (dp(5, 6)..dp(5, 9), "match"),
+ // TODO: we should be getting selection highlight here after project search
+ // but for some reason we are not getting it here
+ ],
+ );
+ select_match(&search_view, cx, Direction::Next);
+ cx.run_until_parked();
- search_view
- .update(cx, |search_view, window, cx| {
- assert_eq!(search_view.active_match_index, Some(0));
- assert_eq!(
- search_view.results_editor.update(cx, |editor, cx| editor
- .selections
- .display_ranges(&editor.display_snapshot(cx))),
- [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
- );
- search_view.select_match(Direction::Prev, window, cx);
- })
- .unwrap();
+ assert_active_match_index(&search_view, cx, 1);
+ assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40));
+ assert_highlights(
+ &search_view,
+ cx,
+ vec![
+ (dp(2, 32)..dp(2, 35), "selection"),
+ (dp(2, 32)..dp(2, 35), "match"),
+ (dp(2, 37)..dp(2, 40), "active"),
+ (dp(5, 6)..dp(5, 9), "selection"),
+ (dp(5, 6)..dp(5, 9), "match"),
+ ],
+ );
+ select_match(&search_view, cx, Direction::Next);
+ cx.run_until_parked();
- search_view
- .update(cx, |search_view, window, cx| {
- assert_eq!(search_view.active_match_index, Some(2));
- assert_eq!(
- search_view.results_editor.update(cx, |editor, cx| editor
- .selections
- .display_ranges(&editor.display_snapshot(cx))),
- [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
- );
- search_view.select_match(Direction::Prev, window, cx);
- })
- .unwrap();
+ assert_active_match_index(&search_view, cx, 2);
+ assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9));
+ assert_highlights(
+ &search_view,
+ cx,
+ vec![
+ (dp(2, 32)..dp(2, 35), "selection"),
+ (dp(2, 32)..dp(2, 35), "match"),
+ (dp(2, 37)..dp(2, 40), "selection"),
+ (dp(2, 37)..dp(2, 40), "match"),
+ (dp(5, 6)..dp(5, 9), "active"),
+ ],
+ );
+ select_match(&search_view, cx, Direction::Next);
+ cx.run_until_parked();
- search_view
- .update(cx, |search_view, _, cx| {
- assert_eq!(search_view.active_match_index, Some(1));
- assert_eq!(
- search_view.results_editor.update(cx, |editor, cx| editor
- .selections
- .display_ranges(&editor.display_snapshot(cx))),
- [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
- );
- })
- .unwrap();
+ assert_active_match_index(&search_view, cx, 0);
+ assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35));
+ assert_highlights(
+ &search_view,
+ cx,
+ vec![
+ (dp(2, 32)..dp(2, 35), "active"),
+ (dp(2, 37)..dp(2, 40), "selection"),
+ (dp(2, 37)..dp(2, 40), "match"),
+ (dp(5, 6)..dp(5, 9), "selection"),
+ (dp(5, 6)..dp(5, 9), "match"),
+ ],
+ );
+ select_match(&search_view, cx, Direction::Prev);
+ cx.run_until_parked();
+
+ assert_active_match_index(&search_view, cx, 2);
+ assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9));
+ assert_highlights(
+ &search_view,
+ cx,
+ vec![
+ (dp(2, 32)..dp(2, 35), "selection"),
+ (dp(2, 32)..dp(2, 35), "match"),
+ (dp(2, 37)..dp(2, 40), "selection"),
+ (dp(2, 37)..dp(2, 40), "match"),
+ (dp(5, 6)..dp(5, 9), "active"),
+ ],
+ );
+ select_match(&search_view, cx, Direction::Prev);
+ cx.run_until_parked();
+
+ assert_active_match_index(&search_view, cx, 1);
+ assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40));
+ assert_highlights(
+ &search_view,
+ cx,
+ vec![
+ (dp(2, 32)..dp(2, 35), "selection"),
+ (dp(2, 32)..dp(2, 35), "match"),
+ (dp(2, 37)..dp(2, 40), "active"),
+ (dp(5, 6)..dp(5, 9), "selection"),
+ (dp(5, 6)..dp(5, 9), "match"),
+ ],
+ );
}
#[perf]
@@ -4236,7 +4352,7 @@ pub mod tests {
let buffer_search_query = "search bar query";
buffer_search_bar
.update_in(&mut cx, |buffer_search_bar, window, cx| {
- buffer_search_bar.focus_handle(cx).focus(window);
+ buffer_search_bar.focus_handle(cx).focus(window, cx);
buffer_search_bar.search(buffer_search_query, None, true, window, cx)
})
.await
@@ -143,7 +143,7 @@ impl SearchOption {
let focus_handle = focus_handle.clone();
button.on_click(move |_: &ClickEvent, window, cx| {
if !focus_handle.is_focused(window) {
- window.focus(&focus_handle);
+ window.focus(&focus_handle, cx);
}
window.dispatch_action(action.boxed_clone(), cx);
})
@@ -27,9 +27,9 @@ pub(super) fn render_action_button(
let focus_handle = focus_handle.clone();
move |_, window, cx| {
if !focus_handle.is_focused(window) {
- window.focus(&focus_handle);
+ window.focus(&focus_handle, cx);
}
- window.dispatch_action(action.boxed_clone(), cx)
+ window.dispatch_action(action.boxed_clone(), cx);
}
})
.tooltip(move |_window, cx| Tooltip::for_action_in(tooltip, action, &focus_handle, cx))
@@ -1,17 +0,0 @@
-[package]
-name = "semantic_version"
-version = "0.1.0"
-edition.workspace = true
-publish = false
-license = "Apache-2.0"
-description = "A library for working with semantic versioning in gpui and Zed"
-
-[lints]
-workspace = true
-
-[lib]
-path = "src/semantic_version.rs"
-
-[dependencies]
-anyhow.workspace = true
-serde.workspace = true
@@ -1,99 +0,0 @@
-//! Constructs for working with [semantic versions](https://semver.org/).
-
-#![deny(missing_docs)]
-
-use std::{
- fmt::{self, Display},
- str::FromStr,
-};
-
-use anyhow::{Context as _, Result};
-use serde::{Deserialize, Serialize, de::Error};
-
-/// A [semantic version](https://semver.org/) number.
-#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
-pub struct SemanticVersion {
- major: usize,
- minor: usize,
- patch: usize,
-}
-
-impl SemanticVersion {
- /// Returns a new [`SemanticVersion`] from the given components.
- pub const fn new(major: usize, minor: usize, patch: usize) -> Self {
- Self {
- major,
- minor,
- patch,
- }
- }
-
- /// Returns the major version number.
- #[inline(always)]
- pub fn major(&self) -> usize {
- self.major
- }
-
- /// Returns the minor version number.
- #[inline(always)]
- pub fn minor(&self) -> usize {
- self.minor
- }
-
- /// Returns the patch version number.
- #[inline(always)]
- pub fn patch(&self) -> usize {
- self.patch
- }
-}
-
-impl FromStr for SemanticVersion {
- type Err = anyhow::Error;
-
- fn from_str(s: &str) -> Result<Self> {
- let mut components = s.trim().split('.');
- let major = components
- .next()
- .context("missing major version number")?
- .parse()?;
- let minor = components
- .next()
- .context("missing minor version number")?
- .parse()?;
- let patch = components
- .next()
- .context("missing patch version number")?
- .parse()?;
- Ok(Self {
- major,
- minor,
- patch,
- })
- }
-}
-
-impl Display for SemanticVersion {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
- }
-}
-
-impl Serialize for SemanticVersion {
- fn serialize<S>(&self, serializer: S) -> std::prelude::v1::Result<S::Ok, S::Error>
- where
- S: serde::Serializer,
- {
- serializer.serialize_str(&self.to_string())
- }
-}
-
-impl<'de> Deserialize<'de> for SemanticVersion {
- fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
- where
- D: serde::Deserializer<'de>,
- {
- let string = String::deserialize(deserializer)?;
- Self::from_str(&string)
- .map_err(|_| Error::custom(format!("Invalid version string \"{string}\"")))
- }
-}
@@ -3,7 +3,6 @@ use std::time::Duration;
use db::kvp::KEY_VALUE_STORE;
use gpui::{App, AppContext as _, Context, Subscription, Task, WindowId};
use util::ResultExt;
-use uuid::Uuid;
pub struct Session {
session_id: String,
@@ -15,11 +14,9 @@ const SESSION_ID_KEY: &str = "session_id";
const SESSION_WINDOW_STACK_KEY: &str = "session_window_stack";
impl Session {
- pub async fn new() -> Self {
+ pub async fn new(session_id: String) -> Self {
let old_session_id = KEY_VALUE_STORE.read_kvp(SESSION_ID_KEY).ok().flatten();
- let session_id = Uuid::new_v4().to_string();
-
KEY_VALUE_STORE
.write_kvp(SESSION_ID_KEY.to_string(), session_id.clone())
.await
@@ -43,10 +40,10 @@ impl Session {
}
}
- // #[cfg(any(test, feature = "test-support"))]
+ #[cfg(any(test, feature = "test-support"))]
pub fn test() -> Self {
Self {
- session_id: Uuid::new_v4().to_string(),
+ session_id: uuid::Uuid::new_v4().to_string(),
old_session_id: None,
old_window_ids: None,
}
@@ -34,7 +34,6 @@ serde.workspace = true
serde_json.workspace = true
serde_json_lenient.workspace = true
serde_repr.workspace = true
-serde_with.workspace = true
settings_json.workspace = true
settings_macros.workspace = true
smallvec.workspace = true
@@ -0,0 +1,112 @@
+use std::cell::RefCell;
+
+use serde::Deserialize;
+
+use crate::ParseStatus;
+
+thread_local! {
+ static ERRORS: RefCell<Option<Vec<anyhow::Error>>> = const { RefCell::new(None) };
+}
+
+pub(crate) fn parse_json<'de, T>(json: &'de str) -> (Option<T>, ParseStatus)
+where
+ T: Deserialize<'de>,
+{
+ ERRORS.with_borrow_mut(|errors| {
+ errors.replace(Vec::default());
+ });
+
+ let mut deserializer = serde_json_lenient::Deserializer::from_str(json);
+ let value = T::deserialize(&mut deserializer);
+ let value = match value {
+ Ok(value) => value,
+ Err(error) => {
+ return (
+ None,
+ ParseStatus::Failed {
+ error: error.to_string(),
+ },
+ );
+ }
+ };
+
+ if let Some(errors) = ERRORS.with_borrow_mut(|errors| errors.take().filter(|e| !e.is_empty())) {
+ let error = errors
+ .into_iter()
+ .map(|e| e.to_string())
+ .flat_map(|e| ["\n".to_owned(), e])
+ .skip(1)
+ .collect::<String>();
+ return (Some(value), ParseStatus::Failed { error });
+ }
+
+ (Some(value), ParseStatus::Success)
+}
+
+pub(crate) fn deserialize<'de, D, T>(deserializer: D) -> Result<T, D::Error>
+where
+ D: serde::Deserializer<'de>,
+ T: serde::Deserialize<'de> + FallibleOption,
+{
+ match T::deserialize(deserializer) {
+ Ok(value) => Ok(value),
+ Err(e) => ERRORS.with_borrow_mut(|errors| {
+ if let Some(errors) = errors {
+ errors.push(anyhow::anyhow!("{}", e));
+ Ok(Default::default())
+ } else {
+ Err(e)
+ }
+ }),
+ }
+}
+
+pub trait FallibleOption: Default {}
+impl<T> FallibleOption for Option<T> {}
+
+#[cfg(test)]
+mod tests {
+ use serde::Deserialize;
+ use settings_macros::with_fallible_options;
+
+ use crate::ParseStatus;
+
+ #[with_fallible_options]
+ #[derive(Deserialize, Debug, PartialEq)]
+ struct Foo {
+ foo: Option<String>,
+ bar: Option<usize>,
+ baz: Option<bool>,
+ }
+
+ #[test]
+ fn test_fallible() {
+ let input = r#"
+ {"foo": "bar",
+ "bar": "foo",
+ "baz": 3,
+ }
+ "#;
+
+ let (settings, result) = crate::fallible_options::parse_json::<Foo>(&input);
+ assert_eq!(
+ settings.unwrap(),
+ Foo {
+ foo: Some("bar".into()),
+ bar: None,
+ baz: None,
+ }
+ );
+
+ assert!(crate::parse_json_with_comments::<Foo>(&input).is_err());
+
+ let ParseStatus::Failed { error } = result else {
+ panic!("Expected parse to fail")
+ };
+
+ assert_eq!(
+ error,
+ "invalid type: string \"foo\", expected usize at line 3 column 24\ninvalid type: integer `3`, expected a boolean at line 4 column 20".to_string()
+ )
+ }
+}
@@ -15,6 +15,7 @@ use util::ResultExt as _;
use util::{
asset_str,
markdown::{MarkdownEscaped, MarkdownInlineCode, MarkdownString},
+ schemars::AllowTrailingCommas,
};
use crate::SettingsAssets;
@@ -302,19 +303,21 @@ impl KeymapFile {
if errors.is_empty() {
KeymapFileLoadResult::Success { key_bindings }
} else {
- let mut error_message = "Errors in user keymap file.\n".to_owned();
+ let mut error_message = "Errors in user keymap file.".to_owned();
+
for (context, section_errors) in errors {
if context.is_empty() {
- let _ = write!(error_message, "\n\nIn section without context predicate:");
+ let _ = write!(error_message, "\nIn section without context predicate:");
} else {
let _ = write!(
error_message,
- "\n\nIn section with {}:",
+ "\nIn section with {}:",
MarkdownInlineCode(&format!("context = \"{}\"", context))
);
}
let _ = write!(error_message, "{section_errors}");
}
+
KeymapFileLoadResult::SomeFailedToLoad {
key_bindings,
error_message: MarkdownString(error_message),
@@ -451,7 +454,9 @@ impl KeymapFile {
/// Creates a JSON schema generator, suitable for generating json schemas
/// for actions
pub fn action_schema_generator() -> schemars::SchemaGenerator {
- schemars::generate::SchemaSettings::draft2019_09().into_generator()
+ schemars::generate::SchemaSettings::draft2019_09()
+ .with_transform(AllowTrailingCommas)
+ .into_generator()
}
pub fn generate_json_schema_for_registered_actions(cx: &mut App) -> Value {
@@ -1,6 +1,6 @@
/// Trait for recursively merging settings structures.
///
-/// When Zed starts it loads settinsg from `default.json` to initialize
+/// When Zed starts it loads settings from `default.json` to initialize
/// everything. These may be further refined by loading the user's settings,
/// and any settings profiles; and then further refined by loading any
/// local project settings.
@@ -1,5 +1,6 @@
mod base_keymap_setting;
mod editable_setting_control;
+mod fallible_options;
mod keymap_file;
pub mod merge_from;
mod serde_helper;
@@ -33,7 +34,7 @@ pub use settings_file::*;
pub use settings_json::*;
pub use settings_store::{
InvalidSettingsError, LocalSettingsKind, MigrationStatus, ParseStatus, Settings, SettingsFile,
- SettingsJsonSchemaParams, SettingsKey, SettingsLocation, SettingsStore,
+ SettingsJsonSchemaParams, SettingsKey, SettingsLocation, SettingsParseResult, SettingsStore,
};
pub use vscode_import::{VsCodeSettings, VsCodeSettingsSource};
@@ -23,8 +23,7 @@ use gpui::{App, SharedString};
use release_channel::ReleaseChannel;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use serde_with::skip_serializing_none;
-use settings_macros::MergeFrom;
+use settings_macros::{MergeFrom, with_fallible_options};
use std::collections::BTreeSet;
use std::env;
use std::sync::Arc;
@@ -32,7 +31,7 @@ pub use util::serde::default_true;
use crate::{ActiveSettingsProfileName, merge_from};
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, PartialEq, Default, Clone, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct SettingsContent {
#[serde(flatten)]
@@ -159,6 +158,9 @@ pub struct SettingsContent {
/// Default: false
pub disable_ai: Option<SaturatingBool>,
+ /// Settings for the which-key popup.
+ pub which_key: Option<WhichKeySettingsContent>,
+
/// Settings related to Vim mode in Zed.
pub vim: Option<VimSettingsContent>,
}
@@ -169,7 +171,7 @@ impl SettingsContent {
}
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct UserSettingsContent {
#[serde(flatten)]
@@ -260,7 +262,7 @@ impl strum::VariantNames for BaseKeymapContent {
];
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)]
pub struct TitleBarSettingsContent {
/// Whether to show the branch icon beside branch switcher in the title bar.
@@ -287,6 +289,10 @@ pub struct TitleBarSettingsContent {
///
/// Default: true
pub show_sign_in: Option<bool>,
+ /// Whether to show the user menu button in the title bar.
+ ///
+ /// Default: true
+ pub show_user_menu: Option<bool>,
/// Whether to show the menus in the title bar.
///
/// Default: false
@@ -294,7 +300,7 @@ pub struct TitleBarSettingsContent {
}
/// Configuration of audio in Zed.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)]
pub struct AudioSettingsContent {
/// Opt into the new audio system.
@@ -338,7 +344,7 @@ pub struct AudioSettingsContent {
}
/// Control what info is collected by Zed.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Debug, MergeFrom)]
pub struct TelemetrySettingsContent {
/// Send debug info like crash reports.
@@ -360,7 +366,7 @@ impl Default for TelemetrySettingsContent {
}
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Clone, MergeFrom)]
pub struct DebuggerSettingsContent {
/// Determines the stepping granularity.
@@ -441,7 +447,7 @@ pub enum DockPosition {
}
/// Settings for slash commands.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema, MergeFrom, PartialEq, Eq)]
pub struct SlashCommandSettings {
/// Settings for the `/cargo-workspace` slash command.
@@ -449,7 +455,7 @@ pub struct SlashCommandSettings {
}
/// Settings for the `/cargo-workspace` slash command.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema, MergeFrom, PartialEq, Eq)]
pub struct CargoWorkspaceCommandSettings {
/// Whether `/cargo-workspace` is enabled.
@@ -457,7 +463,7 @@ pub struct CargoWorkspaceCommandSettings {
}
/// Configuration of voice calls in Zed.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)]
pub struct CallSettingsContent {
/// Whether the microphone should be muted when joining a channel or a call.
@@ -471,7 +477,7 @@ pub struct CallSettingsContent {
pub share_on_join: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)]
pub struct GitPanelSettingsContent {
/// Whether to show the panel button in the status bar.
@@ -512,6 +518,11 @@ pub struct GitPanelSettingsContent {
///
/// Default: false
pub collapse_untracked_diff: Option<bool>,
+
+ /// Whether to show entries with tree or flat view in the panel
+ ///
+ /// Default: false
+ pub tree_view: Option<bool>,
}
#[derive(
@@ -535,7 +546,7 @@ pub enum StatusStyle {
LabelColor,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(
Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq,
)]
@@ -543,7 +554,7 @@ pub struct ScrollbarSettings {
pub show: Option<ShowScrollbar>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)]
pub struct NotificationPanelSettingsContent {
/// Whether to show the panel button in the status bar.
@@ -561,7 +572,7 @@ pub struct NotificationPanelSettingsContent {
pub default_width: Option<f32>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)]
pub struct PanelSettingsContent {
/// Whether to show the panel button in the status bar.
@@ -579,7 +590,7 @@ pub struct PanelSettingsContent {
pub default_width: Option<f32>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)]
pub struct MessageEditorSettings {
/// Whether to automatically replace emoji shortcodes with emoji characters.
@@ -589,7 +600,7 @@ pub struct MessageEditorSettings {
pub auto_replace_emoji_shortcode: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)]
pub struct FileFinderSettingsContent {
/// Whether to show file icons in the file finder.
@@ -664,7 +675,7 @@ pub enum FileFinderWidthContent {
Full,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Debug, JsonSchema, MergeFrom)]
pub struct VimSettingsContent {
pub default_mode: Option<ModeContent>,
@@ -697,7 +708,7 @@ pub enum UseSystemClipboard {
}
/// The settings for cursor shape.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom)]
pub struct CursorShapeSettings {
/// Cursor shape for the normal mode.
@@ -719,7 +730,7 @@ pub struct CursorShapeSettings {
}
/// Settings specific to journaling
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct JournalSettingsContent {
/// The path of the directory where journal entries are stored.
@@ -740,7 +751,7 @@ pub enum HourFormat {
Hour24,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)]
pub struct OutlinePanelSettingsContent {
/// Whether to show the outline panel button in the status bar.
@@ -835,7 +846,7 @@ pub enum ShowIndentGuides {
Never,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(
Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default,
)]
@@ -853,7 +864,7 @@ pub enum LineIndicatorFormat {
}
/// The settings for the image viewer.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, Default, PartialEq)]
pub struct ImageViewerSettingsContent {
/// The unit to use for displaying image file sizes.
@@ -862,7 +873,7 @@ pub struct ImageViewerSettingsContent {
pub unit: Option<ImageFileSizeUnit>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(
Clone,
Copy,
@@ -885,15 +896,25 @@ pub enum ImageFileSizeUnit {
Decimal,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct RemoteSettingsContent {
pub ssh_connections: Option<Vec<SshConnection>>,
pub wsl_connections: Option<Vec<WslConnection>>,
+ pub dev_container_connections: Option<Vec<DevContainerConnection>>,
pub read_ssh_config: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
+#[derive(
+ Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash,
+)]
+pub struct DevContainerConnection {
+ pub name: SharedString,
+ pub container_id: SharedString,
+}
+
+#[with_fallible_options]
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct SshConnection {
pub host: SharedString,
@@ -902,7 +923,7 @@ pub struct SshConnection {
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
- pub projects: collections::BTreeSet<SshProject>,
+ pub projects: collections::BTreeSet<RemoteProject>,
/// Name to use for this server in UI.
pub nickname: Option<String>,
// By default Zed will download the binary to the host directly.
@@ -912,6 +933,9 @@ pub struct SshConnection {
pub upload_binary_over_ssh: Option<bool>,
pub port_forwards: Option<Vec<SshPortForwardOption>>,
+ /// Timeout in seconds for SSH connection and downloading the remote server binary.
+ /// Defaults to 10 seconds if not specified.
+ pub connection_timeout: Option<u16>,
}
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom, Debug)]
@@ -919,30 +943,28 @@ pub struct WslConnection {
pub distro_name: SharedString,
pub user: Option<String>,
#[serde(default)]
- pub projects: BTreeSet<SshProject>,
+ pub projects: BTreeSet<RemoteProject>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(
Clone, Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord, Deserialize, JsonSchema,
)]
-pub struct SshProject {
+pub struct RemoteProject {
pub paths: Vec<String>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema, MergeFrom)]
pub struct SshPortForwardOption {
- #[serde(skip_serializing_if = "Option::is_none")]
pub local_host: Option<String>,
pub local_port: u16,
- #[serde(skip_serializing_if = "Option::is_none")]
pub remote_host: Option<String>,
pub remote_port: u16,
}
/// Settings for configuring REPL display and behavior.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ReplSettingsContent {
/// Maximum number of lines to keep in REPL's scrollback buffer.
@@ -957,6 +979,19 @@ pub struct ReplSettingsContent {
pub max_columns: Option<usize>,
}
+/// Settings for configuring the which-key popup behaviour.
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub struct WhichKeySettingsContent {
+ /// Whether to show the which-key popup when holding down key combinations
+ ///
+ /// Default: false
+ pub enabled: Option<bool>,
+ /// Delay in milliseconds before showing the which-key popup.
+ ///
+ /// Default: 700
+ pub delay_ms: Option<u64>,
+}
+
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
/// An ExtendingVec in the settings can only accumulate new values.
///
@@ -1039,218 +1074,3 @@ impl std::fmt::Display for DelayMs {
write!(f, "{}ms", self.0)
}
}
-
-/// A wrapper type that distinguishes between an explicitly set value (including null) and an unset value.
-///
-/// This is useful for configuration where you need to differentiate between:
-/// - A field that is not present in the configuration file (`Maybe::Unset`)
-/// - A field that is explicitly set to `null` (`Maybe::Set(None)`)
-/// - A field that is explicitly set to a value (`Maybe::Set(Some(value))`)
-///
-/// # Examples
-///
-/// In JSON:
-/// - `{}` (field missing) deserializes to `Maybe::Unset`
-/// - `{"field": null}` deserializes to `Maybe::Set(None)`
-/// - `{"field": "value"}` deserializes to `Maybe::Set(Some("value"))`
-///
-/// WARN: This type should not be wrapped in an option inside of settings, otherwise the default `serde_json` behavior
-/// of treating `null` and missing as the `Option::None` will be used
-#[derive(Debug, Clone, PartialEq, Eq, strum::EnumDiscriminants, Default)]
-#[strum_discriminants(derive(strum::VariantArray, strum::VariantNames, strum::FromRepr))]
-pub enum Maybe<T> {
- /// An explicitly set value, which may be `None` (representing JSON `null`) or `Some(value)`.
- Set(Option<T>),
- /// A value that was not present in the configuration.
- #[default]
- Unset,
-}
-
-impl<T: Clone> merge_from::MergeFrom for Maybe<T> {
- fn merge_from(&mut self, other: &Self) {
- if self.is_unset() {
- *self = other.clone();
- }
- }
-}
-
-impl<T> From<Option<Option<T>>> for Maybe<T> {
- fn from(value: Option<Option<T>>) -> Self {
- match value {
- Some(value) => Maybe::Set(value),
- None => Maybe::Unset,
- }
- }
-}
-
-impl<T> Maybe<T> {
- pub fn is_set(&self) -> bool {
- matches!(self, Maybe::Set(_))
- }
-
- pub fn is_unset(&self) -> bool {
- matches!(self, Maybe::Unset)
- }
-
- pub fn into_inner(self) -> Option<T> {
- match self {
- Maybe::Set(value) => value,
- Maybe::Unset => None,
- }
- }
-
- pub fn as_ref(&self) -> Option<&Option<T>> {
- match self {
- Maybe::Set(value) => Some(value),
- Maybe::Unset => None,
- }
- }
-}
-
-impl<T: serde::Serialize> serde::Serialize for Maybe<T> {
- fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
- where
- S: serde::Serializer,
- {
- match self {
- Maybe::Set(value) => value.serialize(serializer),
- Maybe::Unset => serializer.serialize_none(),
- }
- }
-}
-
-impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Maybe<T> {
- fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
- where
- D: serde::Deserializer<'de>,
- {
- Option::<T>::deserialize(deserializer).map(Maybe::Set)
- }
-}
-
-impl<T: JsonSchema> JsonSchema for Maybe<T> {
- fn schema_name() -> std::borrow::Cow<'static, str> {
- format!("Nullable<{}>", T::schema_name()).into()
- }
-
- fn json_schema(generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
- let mut schema = generator.subschema_for::<Option<T>>();
- // Add description explaining that null is an explicit value
- let description = if let Some(existing_desc) =
- schema.get("description").and_then(|desc| desc.as_str())
- {
- format!(
- "{}. Note: `null` is treated as an explicit value, different from omitting the field entirely.",
- existing_desc
- )
- } else {
- "This field supports explicit `null` values. Omitting the field is different from setting it to `null`.".to_string()
- };
-
- schema.insert("description".to_string(), description.into());
-
- schema
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use serde_json;
-
- #[test]
- fn test_maybe() {
- #[derive(Debug, PartialEq, Serialize, Deserialize)]
- struct TestStruct {
- #[serde(default)]
- #[serde(skip_serializing_if = "Maybe::is_unset")]
- field: Maybe<String>,
- }
-
- #[derive(Debug, PartialEq, Serialize, Deserialize)]
- struct NumericTest {
- #[serde(default)]
- value: Maybe<i32>,
- }
-
- let json = "{}";
- let result: TestStruct = serde_json::from_str(json).unwrap();
- assert!(result.field.is_unset());
- assert_eq!(result.field, Maybe::Unset);
-
- let json = r#"{"field": null}"#;
- let result: TestStruct = serde_json::from_str(json).unwrap();
- assert!(result.field.is_set());
- assert_eq!(result.field, Maybe::Set(None));
-
- let json = r#"{"field": "hello"}"#;
- let result: TestStruct = serde_json::from_str(json).unwrap();
- assert!(result.field.is_set());
- assert_eq!(result.field, Maybe::Set(Some("hello".to_string())));
-
- let test = TestStruct {
- field: Maybe::Unset,
- };
- let json = serde_json::to_string(&test).unwrap();
- assert_eq!(json, "{}");
-
- let test = TestStruct {
- field: Maybe::Set(None),
- };
- let json = serde_json::to_string(&test).unwrap();
- assert_eq!(json, r#"{"field":null}"#);
-
- let test = TestStruct {
- field: Maybe::Set(Some("world".to_string())),
- };
- let json = serde_json::to_string(&test).unwrap();
- assert_eq!(json, r#"{"field":"world"}"#);
-
- let default_maybe: Maybe<i32> = Maybe::default();
- assert!(default_maybe.is_unset());
-
- let unset: Maybe<String> = Maybe::Unset;
- assert!(unset.is_unset());
- assert!(!unset.is_set());
-
- let set_none: Maybe<String> = Maybe::Set(None);
- assert!(set_none.is_set());
- assert!(!set_none.is_unset());
-
- let set_some: Maybe<String> = Maybe::Set(Some("value".to_string()));
- assert!(set_some.is_set());
- assert!(!set_some.is_unset());
-
- let original = TestStruct {
- field: Maybe::Set(Some("test".to_string())),
- };
- let json = serde_json::to_string(&original).unwrap();
- let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
- assert_eq!(original, deserialized);
-
- let json = r#"{"value": 42}"#;
- let result: NumericTest = serde_json::from_str(json).unwrap();
- assert_eq!(result.value, Maybe::Set(Some(42)));
-
- let json = r#"{"value": null}"#;
- let result: NumericTest = serde_json::from_str(json).unwrap();
- assert_eq!(result.value, Maybe::Set(None));
-
- let json = "{}";
- let result: NumericTest = serde_json::from_str(json).unwrap();
- assert_eq!(result.value, Maybe::Unset);
-
- // Test JsonSchema implementation
- use schemars::schema_for;
- let schema = schema_for!(Maybe<String>);
- let schema_json = serde_json::to_value(&schema).unwrap();
-
- // Verify the description mentions that null is an explicit value
- let description = schema_json["description"].as_str().unwrap();
- assert!(
- description.contains("null") && description.contains("explicit"),
- "Schema description should mention that null is an explicit value. Got: {}",
- description
- );
- }
-}
@@ -2,13 +2,12 @@ use collections::{HashMap, IndexMap};
use gpui::SharedString;
use schemars::{JsonSchema, json_schema};
use serde::{Deserialize, Serialize};
-use serde_with::skip_serializing_none;
-use settings_macros::MergeFrom;
+use settings_macros::{MergeFrom, with_fallible_options};
use std::{borrow::Cow, path::PathBuf, sync::Arc};
-use crate::DockPosition;
+use crate::{DockPosition, DockSide};
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)]
pub struct AgentSettingsContent {
/// Whether the Agent is enabled.
@@ -23,6 +22,10 @@ pub struct AgentSettingsContent {
///
/// Default: right
pub dock: Option<DockPosition>,
+ /// Where to dock the utility pane (the thread view pane).
+ ///
+ /// Default: left
+ pub agents_panel_dock: Option<DockSide>,
/// Default width in pixels when the agent panel is docked to the left or right.
///
/// Default: 640
@@ -35,9 +38,18 @@ pub struct AgentSettingsContent {
pub default_height: Option<f32>,
/// The default model to use when creating new chats and for other features when a specific model is not specified.
pub default_model: Option<LanguageModelSelection>,
+ /// Favorite models to show at the top of the model selector.
+ #[serde(default)]
+ pub favorite_models: Vec<LanguageModelSelection>,
/// Model to use for the inline assistant. Defaults to default_model when not specified.
pub inline_assistant_model: Option<LanguageModelSelection>,
- /// Model to use for generating git commit messages. Defaults to default_model when not specified.
+ /// Model to use for the inline assistant when streaming tools are enabled.
+ ///
+ /// Default: true
+ pub inline_assistant_use_streaming_tools: Option<bool>,
+ /// Model to use for generating git commit messages.
+ ///
+ /// Default: true
pub commit_message_model: Option<LanguageModelSelection>,
/// Model to use for generating thread summaries. Defaults to default_model when not specified.
pub thread_summary_model: Option<LanguageModelSelection>,
@@ -130,6 +142,9 @@ impl AgentSettingsContent {
model,
});
}
+ pub fn set_inline_assistant_use_streaming_tools(&mut self, use_tools: bool) {
+ self.inline_assistant_use_streaming_tools = Some(use_tools);
+ }
pub fn set_commit_message_model(&mut self, provider: String, model: String) {
self.commit_message_model = Some(LanguageModelSelection {
@@ -164,9 +179,19 @@ impl AgentSettingsContent {
pub fn set_profile(&mut self, profile_id: Arc<str>) {
self.default_profile = Some(profile_id);
}
+
+ pub fn add_favorite_model(&mut self, model: LanguageModelSelection) {
+ if !self.favorite_models.contains(&model) {
+ self.favorite_models.push(model);
+ }
+ }
+
+ pub fn remove_favorite_model(&mut self, model: &LanguageModelSelection) {
+ self.favorite_models.retain(|m| m != model);
+ }
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct AgentProfileContent {
pub name: Arc<str>,
@@ -180,7 +205,7 @@ pub struct AgentProfileContent {
pub default_model: Option<LanguageModelSelection>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ContextServerPresetContent {
pub tools: IndexMap<Arc<str>, bool>,
@@ -215,7 +240,7 @@ pub enum NotifyWhenAgentWaiting {
Never,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct LanguageModelSelection {
pub provider: LanguageModelProviderSetting,
@@ -231,7 +256,7 @@ pub enum CompletionMode {
Burn,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct LanguageModelParameters {
pub provider: Option<LanguageModelProviderSetting>,
@@ -290,7 +315,7 @@ impl From<&str> for LanguageModelProviderSetting {
}
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, PartialEq, Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug)]
pub struct AllAgentServersSettings {
pub gemini: Option<BuiltinAgentServerSettings>,
@@ -302,7 +327,7 @@ pub struct AllAgentServersSettings {
pub custom: HashMap<SharedString, CustomAgentServerSettings>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq)]
pub struct BuiltinAgentServerSettings {
/// Absolute path to a binary to be used when launching this agent.
@@ -332,20 +357,49 @@ pub struct BuiltinAgentServerSettings {
///
/// Default: None
pub default_mode: Option<String>,
-}
-
-#[skip_serializing_none]
-#[derive(Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq)]
-pub struct CustomAgentServerSettings {
- #[serde(rename = "command")]
- pub path: PathBuf,
- #[serde(default)]
- pub args: Vec<String>,
- pub env: Option<HashMap<String, String>>,
- /// The default mode to use for this agent.
+ /// The default model to use for this agent.
///
- /// Note: Not only all agents support modes.
+ /// This should be the model ID as reported by the agent.
///
/// Default: None
- pub default_mode: Option<String>,
+ pub default_model: Option<String>,
+}
+
+#[with_fallible_options]
+#[derive(Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum CustomAgentServerSettings {
+ Custom {
+ #[serde(rename = "command")]
+ path: PathBuf,
+ #[serde(default)]
+ args: Vec<String>,
+ env: Option<HashMap<String, String>>,
+ /// The default mode to use for this agent.
+ ///
+ /// Note: Not only all agents support modes.
+ ///
+ /// Default: None
+ default_mode: Option<String>,
+ /// The default model to use for this agent.
+ ///
+ /// This should be the model ID as reported by the agent.
+ ///
+ /// Default: None
+ default_model: Option<String>,
+ },
+ Extension {
+ /// The default mode to use for this agent.
+ ///
+ /// Note: Not only all agents support modes.
+ ///
+ /// Default: None
+ default_mode: Option<String>,
+ /// The default model to use for this agent.
+ ///
+ /// This should be the model ID as reported by the agent.
+ ///
+ /// Default: None
+ default_model: Option<String>,
+ },
}
@@ -4,14 +4,13 @@ use std::num;
use collections::HashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use serde_with::skip_serializing_none;
-use settings_macros::MergeFrom;
+use settings_macros::{MergeFrom, with_fallible_options};
use crate::{
DelayMs, DiagnosticSeverityContent, ShowScrollbar, serialize_f32_with_two_decimal_places,
};
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct EditorSettingsContent {
/// Whether the cursor blinks in the editor.
@@ -254,7 +253,7 @@ impl RelativeLineNumbers {
}
// Toolbar related settings
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
pub struct ToolbarContent {
/// Whether to display breadcrumbs in the editor toolbar.
@@ -281,7 +280,7 @@ pub struct ToolbarContent {
}
/// Scrollbar related settings
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Default)]
pub struct ScrollbarContent {
/// When to show the scrollbar in the editor.
@@ -317,7 +316,7 @@ pub struct ScrollbarContent {
}
/// Sticky scroll related settings
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct StickyScrollContent {
/// Whether sticky scroll is enabled.
@@ -327,7 +326,7 @@ pub struct StickyScrollContent {
}
/// Minimap related settings
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct MinimapContent {
/// When to show the minimap in the editor.
@@ -362,7 +361,7 @@ pub struct MinimapContent {
}
/// Forcefully enable or disable the scrollbar for each axis
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Default)]
pub struct ScrollbarAxesContent {
/// When false, forcefully disables the horizontal scrollbar. Otherwise, obey other settings.
@@ -377,7 +376,7 @@ pub struct ScrollbarAxesContent {
}
/// Gutter related settings
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
pub struct GutterContent {
/// Whether to show line numbers in the gutter.
@@ -754,7 +753,7 @@ pub enum SnippetSortOrder {
}
/// Default options for buffer and project search items.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
pub struct SearchSettingsContent {
/// Whether to show the project search button in the status bar.
@@ -771,7 +770,7 @@ pub struct SearchSettingsContent {
pub center_on_match: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom)]
#[serde(rename_all = "snake_case")]
pub struct JupyterContent {
@@ -787,7 +786,7 @@ pub struct JupyterContent {
}
/// Whether to allow drag and drop text selection in buffer.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
pub struct DragAndDropSelectionContent {
/// When true, enables drag and drop text selection in buffer.
@@ -3,10 +3,9 @@ use std::sync::Arc;
use collections::HashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use serde_with::skip_serializing_none;
-use settings_macros::MergeFrom;
+use settings_macros::{MergeFrom, with_fallible_options};
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ExtensionSettingsContent {
/// The extensions that should be automatically installed by Zed.
@@ -20,7 +19,6 @@ pub struct ExtensionSettingsContent {
#[serde(default)]
pub auto_update_extensions: HashMap<Arc<str>, bool>,
/// The capabilities granted to extensions.
- #[serde(default)]
pub granted_extension_capabilities: Option<Vec<ExtensionCapabilityContent>>,
}
@@ -3,21 +3,18 @@ use std::num::NonZeroU32;
use collections::{HashMap, HashSet};
use gpui::{Modifiers, SharedString};
use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use serde_with::skip_serializing_none;
-use settings_macros::MergeFrom;
+use serde::{Deserialize, Serialize, de::Error as _};
+use settings_macros::{MergeFrom, with_fallible_options};
use std::sync::Arc;
use crate::{ExtendingVec, merge_from};
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct AllLanguageSettingsContent {
/// The settings for enabling/disabling features.
- #[serde(default)]
pub features: Option<FeaturesContent>,
/// The edit prediction settings.
- #[serde(default)]
pub edit_predictions: Option<EditPredictionSettingsContent>,
/// The default language settings.
#[serde(flatten)]
@@ -59,18 +56,18 @@ impl merge_from::MergeFrom for AllLanguageSettingsContent {
}
/// The settings for enabling/disabling features.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
#[serde(rename_all = "snake_case")]
pub struct FeaturesContent {
/// Determines which edit prediction provider to use.
pub edit_prediction_provider: Option<EditPredictionProvider>,
+ /// Enables the experimental edit prediction context retrieval system.
+ pub experimental_edit_prediction_context_retrieval: Option<bool>,
}
/// The provider that supplies edit predictions.
-#[derive(
- Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom,
-)]
+#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, JsonSchema, MergeFrom)]
#[serde(rename_all = "snake_case")]
pub enum EditPredictionProvider {
None,
@@ -79,6 +76,64 @@ pub enum EditPredictionProvider {
Supermaven,
Zed,
Codestral,
+ Experimental(&'static str),
+}
+
+pub const EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME: &str = "sweep";
+pub const EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME: &str = "zeta2";
+pub const EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME: &str = "mercury";
+
+impl<'de> Deserialize<'de> for EditPredictionProvider {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ #[derive(Deserialize)]
+ #[serde(rename_all = "snake_case")]
+ pub enum Content {
+ None,
+ Copilot,
+ Supermaven,
+ Zed,
+ Codestral,
+ Experimental(String),
+ }
+
+ Ok(match Content::deserialize(deserializer)? {
+ Content::None => EditPredictionProvider::None,
+ Content::Copilot => EditPredictionProvider::Copilot,
+ Content::Supermaven => EditPredictionProvider::Supermaven,
+ Content::Zed => EditPredictionProvider::Zed,
+ Content::Codestral => EditPredictionProvider::Codestral,
+ Content::Experimental(name)
+ if name == EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME =>
+ {
+ EditPredictionProvider::Experimental(
+ EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
+ )
+ }
+ Content::Experimental(name)
+ if name == EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME =>
+ {
+ EditPredictionProvider::Experimental(
+ EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
+ )
+ }
+ Content::Experimental(name)
+ if name == EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME =>
+ {
+ EditPredictionProvider::Experimental(
+ EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
+ )
+ }
+ Content::Experimental(name) => {
+ return Err(D::Error::custom(format!(
+ "Unknown experimental edit prediction provider: {}",
+ name
+ )));
+ }
+ })
+ }
}
impl EditPredictionProvider {
@@ -88,13 +143,14 @@ impl EditPredictionProvider {
EditPredictionProvider::None
| EditPredictionProvider::Copilot
| EditPredictionProvider::Supermaven
- | EditPredictionProvider::Codestral => false,
+ | EditPredictionProvider::Codestral
+ | EditPredictionProvider::Experimental(_) => false,
}
}
}
/// The contents of the edit prediction settings.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct EditPredictionSettingsContent {
/// A list of globs representing files that edit predictions should be disabled for.
@@ -113,7 +169,7 @@ pub struct EditPredictionSettingsContent {
pub enabled_in_text_threads: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct CopilotSettingsContent {
/// HTTP/HTTPS proxy to use for Copilot.
@@ -130,22 +186,20 @@ pub struct CopilotSettingsContent {
pub enterprise_uri: Option<String>,
}
+#[with_fallible_options]
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct CodestralSettingsContent {
/// Model to use for completions.
///
/// Default: "codestral-latest"
- #[serde(default)]
pub model: Option<String>,
/// Maximum tokens to generate.
///
/// Default: 150
- #[serde(default)]
pub max_tokens: Option<u32>,
/// Api URL to use for completions.
///
/// Default: "https://codestral.mistral.ai"
- #[serde(default)]
pub api_url: Option<String>,
}
@@ -206,7 +260,7 @@ pub enum SoftWrap {
}
/// The settings for a particular language.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct LanguageSettingsContent {
/// How many columns a tab should occupy.
@@ -334,7 +388,7 @@ pub struct LanguageSettingsContent {
///
/// Default: true
pub use_on_type_format: Option<bool>,
- /// Which code actions to run on save after the formatter.
+ /// Which code actions to run on save before the formatter.
/// These are not run if formatting is off.
///
/// Default: {} (or {"source.organizeImports": true} for Go).
@@ -372,6 +426,17 @@ pub struct LanguageSettingsContent {
///
/// Default: []
pub debuggers: Option<Vec<String>>,
+ /// 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
+ pub word_diff_enabled: Option<bool>,
+ /// Whether to use tree-sitter bracket queries to detect and colorize the brackets in the editor.
+ ///
+ /// Default: false
+ pub colorize_brackets: Option<bool>,
}
/// Controls how whitespace should be displayedin the editor.
@@ -407,7 +472,7 @@ pub enum ShowWhitespaceSetting {
Trailing,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct WhitespaceMapContent {
pub space: Option<char>,
@@ -439,7 +504,7 @@ pub enum RewrapBehavior {
Anywhere,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct JsxTagAutoCloseSettingsContent {
/// Enables or disables auto-closing of JSX tags.
@@ -447,7 +512,7 @@ pub struct JsxTagAutoCloseSettingsContent {
}
/// The settings for inlay hints.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
pub struct InlayHintSettingsContent {
/// Global switch to toggle hints on and off.
@@ -529,7 +594,7 @@ impl InlayHintKind {
}
/// Controls how completions are processed for this language.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom, Default)]
#[serde(rename_all = "snake_case")]
pub struct CompletionSettingsContent {
@@ -614,7 +679,7 @@ pub enum WordsCompletionMode {
/// Allows to enable/disable formatting with Prettier
/// and configure default Prettier, used when no project-level Prettier installation is found.
/// Prettier formatting is disabled by default.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct PrettierSettingsContent {
/// Enables or disables formatting with Prettier for a given language.
@@ -768,7 +833,7 @@ struct LanguageServerSpecifierContent {
}
/// The settings for indent guides.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct IndentGuideSettingsContent {
/// Whether to display indent guides in the editor.
@@ -794,7 +859,7 @@ pub struct IndentGuideSettingsContent {
}
/// The task settings for a particular language.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, JsonSchema, MergeFrom)]
pub struct LanguageTaskSettingsContent {
/// Extra task variables to set for a particular language.
@@ -811,7 +876,7 @@ pub struct LanguageTaskSettingsContent {
}
/// Map from language name to settings.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct LanguageToSettingsMap(pub HashMap<SharedString, LanguageSettingsContent>);
@@ -867,6 +932,9 @@ pub enum IndentGuideBackgroundColoring {
#[cfg(test)]
mod test {
+
+ use crate::{ParseStatus, fallible_options};
+
use super::*;
#[test]
@@ -926,8 +994,8 @@ mod test {
#[test]
fn test_formatter_deserialization_invalid() {
let raw_auto = "{\"formatter\": {}}";
- let result: Result<LanguageSettingsContent, _> = serde_json::from_str(raw_auto);
- assert!(result.is_err());
+ let (_, result) = fallible_options::parse_json::<LanguageSettingsContent>(raw_auto);
+ assert!(matches!(result, ParseStatus::Failed { .. }));
}
#[test]
@@ -1,12 +1,11 @@
use collections::HashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use serde_with::skip_serializing_none;
-use settings_macros::MergeFrom;
+use settings_macros::{MergeFrom, with_fallible_options};
use std::sync::Arc;
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct AllLanguageModelSettingsContent {
pub anthropic: Option<AnthropicSettingsContent>,
@@ -25,14 +24,14 @@ pub struct AllLanguageModelSettingsContent {
pub zed_dot_dev: Option<ZedDotDevSettingsContent>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct AnthropicSettingsContent {
pub api_url: Option<String>,
pub available_models: Option<Vec<AnthropicAvailableModel>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct AnthropicAvailableModel {
/// The model's name in the Anthropic API. e.g. claude-3-5-sonnet-latest, claude-3-opus-20240229, etc
@@ -54,7 +53,7 @@ pub struct AnthropicAvailableModel {
pub mode: Option<ModelMode>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct AmazonBedrockSettingsContent {
pub available_models: Option<Vec<BedrockAvailableModel>>,
@@ -62,9 +61,10 @@ pub struct AmazonBedrockSettingsContent {
pub region: Option<String>,
pub profile: Option<String>,
pub authentication_method: Option<BedrockAuthMethodContent>,
+ pub allow_global: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct BedrockAvailableModel {
pub name: String,
@@ -83,19 +83,22 @@ pub enum BedrockAuthMethodContent {
NamedProfile,
#[serde(rename = "sso")]
SingleSignOn,
+ #[serde(rename = "api_key")]
+ ApiKey,
/// IMDSv2, PodIdentity, env vars, etc.
#[serde(rename = "default")]
Automatic,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct OllamaSettingsContent {
pub api_url: Option<String>,
+ pub auto_discover: Option<bool>,
pub available_models: Option<Vec<OllamaAvailableModel>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct OllamaAvailableModel {
/// The model name in the Ollama API (e.g. "llama3.2:latest")
@@ -136,14 +139,14 @@ impl Default for KeepAlive {
}
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct LmStudioSettingsContent {
pub api_url: Option<String>,
pub available_models: Option<Vec<LmStudioAvailableModel>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct LmStudioAvailableModel {
pub name: String,
@@ -153,14 +156,14 @@ pub struct LmStudioAvailableModel {
pub supports_images: bool,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct DeepseekSettingsContent {
pub api_url: Option<String>,
pub available_models: Option<Vec<DeepseekAvailableModel>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct DeepseekAvailableModel {
pub name: String,
@@ -169,14 +172,14 @@ pub struct DeepseekAvailableModel {
pub max_output_tokens: Option<u64>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct MistralSettingsContent {
pub api_url: Option<String>,
pub available_models: Option<Vec<MistralAvailableModel>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct MistralAvailableModel {
pub name: String,
@@ -189,14 +192,14 @@ pub struct MistralAvailableModel {
pub supports_thinking: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct OpenAiSettingsContent {
pub api_url: Option<String>,
pub available_models: Option<Vec<OpenAiAvailableModel>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct OpenAiAvailableModel {
pub name: String,
@@ -216,14 +219,14 @@ pub enum OpenAiReasoningEffort {
High,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct OpenAiCompatibleSettingsContent {
pub api_url: String,
pub available_models: Vec<OpenAiCompatibleAvailableModel>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct OpenAiCompatibleAvailableModel {
pub name: String,
@@ -235,7 +238,7 @@ pub struct OpenAiCompatibleAvailableModel {
pub capabilities: OpenAiCompatibleModelCapabilities,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct OpenAiCompatibleModelCapabilities {
pub tools: bool,
@@ -255,14 +258,14 @@ impl Default for OpenAiCompatibleModelCapabilities {
}
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct VercelSettingsContent {
pub api_url: Option<String>,
pub available_models: Option<Vec<VercelAvailableModel>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct VercelAvailableModel {
pub name: String,
@@ -272,14 +275,14 @@ pub struct VercelAvailableModel {
pub max_completion_tokens: Option<u64>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct GoogleSettingsContent {
pub api_url: Option<String>,
pub available_models: Option<Vec<GoogleAvailableModel>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct GoogleAvailableModel {
pub name: String,
@@ -288,14 +291,14 @@ pub struct GoogleAvailableModel {
pub mode: Option<ModelMode>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct XAiSettingsContent {
pub api_url: Option<String>,
pub available_models: Option<Vec<XaiAvailableModel>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct XaiAvailableModel {
pub name: String,
@@ -308,13 +311,13 @@ pub struct XaiAvailableModel {
pub parallel_tool_calls: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct ZedDotDevSettingsContent {
pub available_models: Option<Vec<ZedDotDevAvailableModel>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ZedDotDevAvailableModel {
/// The provider of the language model.
@@ -351,14 +354,14 @@ pub enum ZedDotDevAvailableProvider {
Google,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct OpenRouterSettingsContent {
pub api_url: Option<String>,
pub available_models: Option<Vec<OpenRouterAvailableModel>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct OpenRouterAvailableModel {
pub name: String,
@@ -372,7 +375,7 @@ pub struct OpenRouterAvailableModel {
pub provider: Option<OpenRouterProvider>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct OpenRouterProvider {
order: Option<Vec<String>>,
@@ -388,25 +391,20 @@ pub struct OpenRouterProvider {
sort: Option<String>,
}
-#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
#[serde(rename_all = "lowercase")]
pub enum DataCollection {
+ #[default]
Allow,
Disallow,
}
-impl Default for DataCollection {
- fn default() -> Self {
- Self::Allow
- }
-}
-
fn default_true() -> bool {
true
}
/// Configuration for caching language model messages.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct LanguageModelCacheConfiguration {
pub max_cache_anchors: usize,
@@ -3,16 +3,15 @@ use std::{path::PathBuf, sync::Arc};
use collections::{BTreeMap, HashMap};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use serde_with::skip_serializing_none;
-use settings_macros::MergeFrom;
+use settings_macros::{MergeFrom, with_fallible_options};
use util::serde::default_true;
use crate::{
- AllLanguageSettingsContent, DelayMs, ExtendingVec, Maybe, ProjectTerminalSettingsContent,
+ AllLanguageSettingsContent, DelayMs, ExtendingVec, ProjectTerminalSettingsContent,
SlashCommandSettings,
};
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ProjectSettingsContent {
#[serde(flatten)]
@@ -32,7 +31,6 @@ pub struct ProjectSettingsContent {
#[serde(default)]
pub lsp: HashMap<Arc<str>, LspSettings>,
- #[serde(default)]
pub terminal: Option<ProjectTerminalSettingsContent>,
/// Configuration for Debugger-related features
@@ -53,16 +51,14 @@ pub struct ProjectSettingsContent {
pub git_hosting_providers: Option<ExtendingVec<GitHostingProviderConfig>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct WorktreeSettingsContent {
/// The displayed name of this project. If not set or null, the root directory name
/// will be displayed.
///
/// Default: null
- #[serde(default)]
- #[serde(skip_serializing_if = "Maybe::is_unset")]
- pub project_name: Maybe<String>,
+ pub project_name: Option<String>,
/// Whether to prevent this project from being shared in public channels.
///
@@ -103,7 +99,7 @@ pub struct WorktreeSettingsContent {
pub hidden_files: Option<Vec<String>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash)]
#[serde(rename_all = "snake_case")]
pub struct LspSettings {
@@ -140,7 +136,7 @@ impl Default for LspSettings {
}
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(
Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash,
)]
@@ -151,7 +147,7 @@ pub struct BinarySettings {
pub ignore_system_version: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(
Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash,
)]
@@ -161,7 +157,7 @@ pub struct FetchSettings {
}
/// Common language server settings.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct GlobalLspSettingsContent {
/// Whether to show the LSP servers button in the status bar.
@@ -170,18 +166,16 @@ pub struct GlobalLspSettingsContent {
pub button: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)]
#[serde(rename_all = "snake_case")]
pub struct DapSettingsContent {
pub binary: Option<String>,
- #[serde(default)]
pub args: Option<Vec<String>>,
- #[serde(default)]
pub env: Option<HashMap<String, String>>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(
Default, Copy, Clone, PartialEq, Eq, Debug, Serialize, Deserialize, JsonSchema, MergeFrom,
)]
@@ -193,12 +187,18 @@ pub struct SessionSettingsContent {
///
/// Default: true
pub restore_unsaved_buffers: Option<bool>,
+ /// Whether or not to skip worktree trust checks.
+ /// When trusted, project settings are synchronized automatically,
+ /// language and MCP servers are downloaded and started automatically.
+ ///
+ /// Default: false
+ pub trust_all_worktrees: Option<bool>,
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom, Debug)]
-#[serde(tag = "source", rename_all = "snake_case")]
+#[serde(untagged, rename_all = "snake_case")]
pub enum ContextServerSettingsContent {
- Custom {
+ Stdio {
/// Whether the context server is enabled.
#[serde(default = "default_true")]
enabled: bool,
@@ -206,6 +206,16 @@ pub enum ContextServerSettingsContent {
#[serde(flatten)]
command: ContextServerCommand,
},
+ Http {
+ /// Whether the context server is enabled.
+ #[serde(default = "default_true")]
+ enabled: bool,
+ /// The URL of the remote context server.
+ url: String,
+ /// Optional headers to send.
+ #[serde(skip_serializing_if = "HashMap::is_empty", default)]
+ headers: HashMap<String, String>,
+ },
Extension {
/// Whether the context server is enabled.
#[serde(default = "default_true")]
@@ -217,24 +227,29 @@ pub enum ContextServerSettingsContent {
settings: serde_json::Value,
},
}
+
impl ContextServerSettingsContent {
pub fn set_enabled(&mut self, enabled: bool) {
match self {
- ContextServerSettingsContent::Custom {
+ ContextServerSettingsContent::Stdio {
enabled: custom_enabled,
- command: _,
+ ..
} => {
*custom_enabled = enabled;
}
ContextServerSettingsContent::Extension {
enabled: ext_enabled,
- settings: _,
+ ..
} => *ext_enabled = enabled,
+ ContextServerSettingsContent::Http {
+ enabled: remote_enabled,
+ ..
+ } => *remote_enabled = enabled,
}
}
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom)]
pub struct ContextServerCommand {
#[serde(rename = "command")]
@@ -270,7 +285,7 @@ impl std::fmt::Debug for ContextServerCommand {
}
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct GitSettings {
/// Whether or not to show the git gutter.
@@ -296,6 +311,10 @@ pub struct GitSettings {
///
/// Default: staged_hollow
pub hunk_style: Option<GitHunkStyleSetting>,
+ /// How file paths are displayed in the git gutter.
+ ///
+ /// Default: file_name_first
+ pub path_style: Option<GitPathStyle>,
}
#[derive(
@@ -320,7 +339,7 @@ pub enum GitGutterSetting {
Hide,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
#[serde(rename_all = "snake_case")]
pub struct InlineBlameSettings {
@@ -349,7 +368,7 @@ pub struct InlineBlameSettings {
pub show_commit_summary: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
#[serde(rename_all = "snake_case")]
pub struct BlameSettings {
@@ -359,7 +378,7 @@ pub struct BlameSettings {
pub show_avatar: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Copy, PartialEq, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
#[serde(rename_all = "snake_case")]
pub struct BranchPickerSettingsContent {
@@ -391,7 +410,30 @@ pub enum GitHunkStyleSetting {
UnstagedHollow,
}
-#[skip_serializing_none]
+#[with_fallible_options]
+#[derive(
+ Copy,
+ Clone,
+ Debug,
+ PartialEq,
+ Default,
+ Serialize,
+ Deserialize,
+ JsonSchema,
+ MergeFrom,
+ strum::VariantArray,
+ strum::VariantNames,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum GitPathStyle {
+ /// Show file name first, then path
+ #[default]
+ FileNameFirst,
+ /// Show full path first
+ FilePathFirst,
+}
+
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct DiagnosticsSettingsContent {
/// Whether to show the project diagnostics button in the status bar.
@@ -407,7 +449,7 @@ pub struct DiagnosticsSettingsContent {
pub inline: Option<InlineDiagnosticsSettingsContent>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(
Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq,
)]
@@ -423,7 +465,7 @@ pub struct LspPullDiagnosticsSettingsContent {
pub debounce_ms: Option<DelayMs>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(
Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Eq,
)]
@@ -452,7 +494,7 @@ pub struct InlineDiagnosticsSettingsContent {
pub max_severity: Option<DiagnosticSeverityContent>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct NodeBinarySettings {
/// The path to the Node binary.
@@ -471,6 +513,8 @@ pub enum DirenvSettings {
/// Load direnv configuration directly using `direnv export json`
#[default]
Direct,
+ /// Do not load direnv configuration
+ Disabled,
}
#[derive(
@@ -500,12 +544,12 @@ pub enum DiagnosticSeverityContent {
}
/// A custom Git hosting provider.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct GitHostingProviderConfig {
/// The type of the provider.
///
- /// Must be one of `github`, `gitlab`, or `bitbucket`.
+ /// Must be one of `github`, `gitlab`, `bitbucket`, `gitea`, `forgejo`, or `source_hut`.
pub provider: GitHostingProviderKind,
/// The base URL for the provider (e.g., "https://code.corp.big.com").
@@ -521,4 +565,7 @@ pub enum GitHostingProviderKind {
Github,
Gitlab,
Bitbucket,
+ Gitea,
+ Forgejo,
+ SourceHut,
}
@@ -1,11 +1,10 @@
use std::path::PathBuf;
use collections::HashMap;
-use gpui::{AbsoluteLength, FontFeatures, SharedString, px};
+use gpui::{AbsoluteLength, FontFeatures, FontWeight, SharedString, px};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use serde_with::skip_serializing_none;
-use settings_macros::MergeFrom;
+use settings_macros::{MergeFrom, with_fallible_options};
use crate::FontFamilyName;
@@ -30,9 +29,44 @@ pub struct ProjectTerminalSettingsContent {
///
/// Default: on
pub detect_venv: Option<VenvSettings>,
+ /// Regexes used to identify paths for hyperlink navigation.
+ ///
+ /// Default: [
+ /// // Python-style diagnostics
+ /// "File \"(?<path>[^\"]+)\", line (?<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",
+ /// "(?<quote>[\"'`])?",
+ /// "# `path` is the shortest sequence of any non-space character",
+ /// "(?<link>(?<path>[^ ]+?",
+ /// " # which may end with a line and optionally a column,",
+ /// " (?<line_column>:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:][0-9]+)?\\))?",
+ /// "))",
+ /// "# which must be followed by a matching quote",
+ /// "(?(<quote>)\\k<quote>)",
+ /// "# and optionally a single closing symbol",
+ /// "[)}\\]>]?",
+ /// "# if line/column matched, may be followed by a description",
+ /// "(?(<line_column>):[^ 0-9][^ ]*)?",
+ /// "# which may be followed by trailing punctuation",
+ /// "[.,:)}\\]>]*",
+ /// "# and always includes trailing whitespace or end of line",
+ /// "([ ]+|$)"
+ /// ]
+ /// ]
+ pub path_hyperlink_regexes: Option<Vec<PathHyperlinkRegex>>,
+ /// Timeout for hover and Cmd-click path hyperlink discovery in milliseconds.
+ ///
+ /// Default: 1
+ pub path_hyperlink_timeout_ms: Option<u64>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct TerminalSettingsContent {
#[serde(flatten)]
@@ -62,8 +96,7 @@ pub struct TerminalSettingsContent {
pub line_height: Option<TerminalLineHeight>,
pub font_features: Option<FontFeatures>,
/// Sets the terminal's font weight in CSS weight units 0-900.
- #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
- pub font_weight: Option<f32>,
+ pub font_weight: Option<FontWeight>,
/// Default cursor shape for the terminal.
/// Can be "bar", "block", "underline", or "hollow".
///
@@ -116,6 +149,10 @@ pub struct TerminalSettingsContent {
///
/// Default: 10_000
pub max_scroll_history_lines: Option<usize>,
+ /// The multiplier for scrolling with the mouse wheel.
+ ///
+ /// Default: 1.0
+ pub scroll_multiplier: Option<f32>,
/// Toolbar related settings
pub toolbar: Option<TerminalToolbarContent>,
/// Scrollbar-related settings
@@ -184,10 +221,11 @@ pub enum Shell {
#[strum_discriminants(derive(strum::VariantArray, strum::VariantNames, strum::FromRepr))]
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectory {
- /// Use the current file's project directory. Will Fallback to the
+ /// Use the current file's project directory. Fallback to the
/// first project directory strategy if unsuccessful.
CurrentProjectDirectory,
- /// Use the first project in this workspace's directory.
+ /// Use the first project in this workspace's directory. Fallback to using
+ /// this platform's home directory.
FirstProjectDirectory,
/// Always use this platform's home directory (if it can be found).
AlwaysHome,
@@ -197,7 +235,7 @@ pub enum WorkingDirectory {
Always { directory: String },
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(
Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default,
)]
@@ -335,7 +373,7 @@ pub enum AlternateScroll {
}
// Toolbar related settings
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
pub struct TerminalToolbarContent {
/// Whether to display the terminal title in breadcrumbs inside the terminal pane.
@@ -382,7 +420,7 @@ pub enum VenvSettings {
conda_manager: Option<CondaManager>,
},
}
-#[skip_serializing_none]
+#[with_fallible_options]
pub struct VenvSettingsContent<'a> {
pub activate_script: ActivateScript,
pub venv_name: &'a str,
@@ -409,6 +447,13 @@ impl VenvSettings {
}
}
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
+#[serde(untagged)]
+pub enum PathHyperlinkRegex {
+ SingleLine(String),
+ MultiLine(Vec<String>),
+}
+
#[derive(
Copy,
Clone,
@@ -4,90 +4,72 @@ use schemars::{JsonSchema, JsonSchema_repr};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use serde_repr::{Deserialize_repr, Serialize_repr};
-use settings_macros::MergeFrom;
+use settings_macros::{MergeFrom, with_fallible_options};
use std::{fmt::Display, sync::Arc};
-use serde_with::skip_serializing_none;
-
use crate::serialize_f32_with_two_decimal_places;
/// Settings for rendering text in UI and text buffers.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ThemeSettingsContent {
/// The default font size for text in the UI.
- #[serde(default)]
#[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
pub ui_font_size: Option<f32>,
/// The name of a font to use for rendering in the UI.
- #[serde(default)]
pub ui_font_family: Option<FontFamilyName>,
/// The font fallbacks to use for rendering in the UI.
- #[serde(default)]
#[schemars(default = "default_font_fallbacks")]
#[schemars(extend("uniqueItems" = true))]
pub ui_font_fallbacks: Option<Vec<FontFamilyName>>,
/// The OpenType features to enable for text in the UI.
- #[serde(default)]
#[schemars(default = "default_font_features")]
pub ui_font_features: Option<FontFeatures>,
/// The weight of the UI font in CSS units from 100 to 900.
- #[serde(default)]
#[schemars(default = "default_buffer_font_weight")]
pub ui_font_weight: Option<FontWeight>,
/// The name of a font to use for rendering in text buffers.
- #[serde(default)]
pub buffer_font_family: Option<FontFamilyName>,
/// The font fallbacks to use for rendering in text buffers.
- #[serde(default)]
#[schemars(extend("uniqueItems" = true))]
pub buffer_font_fallbacks: Option<Vec<FontFamilyName>>,
/// The default font size for rendering in text buffers.
- #[serde(default)]
#[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
pub buffer_font_size: Option<f32>,
/// The weight of the editor font in CSS units from 100 to 900.
- #[serde(default)]
#[schemars(default = "default_buffer_font_weight")]
pub buffer_font_weight: Option<FontWeight>,
/// The buffer's line height.
- #[serde(default)]
pub buffer_line_height: Option<BufferLineHeight>,
/// The OpenType features to enable for rendering in text buffers.
- #[serde(default)]
#[schemars(default = "default_font_features")]
pub buffer_font_features: Option<FontFeatures>,
/// The font size for agent responses in the agent panel. Falls back to the UI font size if unset.
- #[serde(default)]
#[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
pub agent_ui_font_size: Option<f32>,
/// The font size for user messages in the agent panel.
- #[serde(default)]
#[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
pub agent_buffer_font_size: Option<f32>,
/// The name of the Zed theme to use.
- #[serde(default)]
pub theme: Option<ThemeSelection>,
/// The name of the icon theme to use.
- #[serde(default)]
pub icon_theme: Option<IconThemeSelection>,
/// UNSTABLE: Expect many elements to be broken.
///
// Controls the density of the UI.
- #[serde(rename = "unstable.ui_density", default)]
+ #[serde(rename = "unstable.ui_density")]
pub ui_density: Option<UiDensity>,
/// How much to fade out unused code.
- #[serde(default)]
#[schemars(range(min = 0.0, max = 0.9))]
pub unnecessary_code_fade: Option<CodeFade>,
/// EXPERIMENTAL: Overrides for the current theme.
///
/// These values will override the ones on the current theme specified in `theme`.
- #[serde(rename = "experimental.theme_overrides", default)]
+ #[serde(rename = "experimental.theme_overrides")]
pub experimental_theme_overrides: Option<ThemeStyleContent>,
/// Overrides per theme
@@ -270,7 +252,7 @@ impl UiDensity {
}
/// Font family name.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
#[serde(transparent)]
pub struct FontFamilyName(pub Arc<str>);
@@ -345,11 +327,11 @@ where
}
/// The content of a serialized theme.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
#[serde(default)]
pub struct ThemeStyleContent {
- #[serde(default, rename = "background.appearance")]
+ #[serde(rename = "background.appearance")]
pub window_background_appearance: Option<WindowBackgroundContent>,
#[serde(default)]
@@ -370,7 +352,7 @@ pub struct ThemeStyleContent {
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
-pub struct AccentContent(pub Option<String>);
+pub struct AccentContent(pub Option<SharedString>);
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct PlayerColorContent {
@@ -380,18 +362,18 @@ pub struct PlayerColorContent {
}
/// Theme name.
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
#[serde(transparent)]
pub struct ThemeName(pub Arc<str>);
/// Icon Theme Name
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
#[serde(transparent)]
pub struct IconThemeName(pub Arc<str>);
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
#[serde(default)]
pub struct ThemeColorsContent {
@@ -588,6 +570,9 @@ pub struct ThemeColorsContent {
#[serde(rename = "search.match_background")]
pub search_match_background: Option<String>,
+ #[serde(rename = "search.active_match_background")]
+ pub search_active_match_background: Option<String>,
+
#[serde(rename = "panel.background")]
pub panel_background: Option<String>,
@@ -879,6 +864,14 @@ pub struct ThemeColorsContent {
#[serde(rename = "version_control.ignored")]
pub version_control_ignored: Option<String>,
+ /// Color for added words in word diffs.
+ #[serde(rename = "version_control.word_added")]
+ pub version_control_word_added: Option<String>,
+
+ /// Color for deleted words in word diffs.
+ #[serde(rename = "version_control.word_deleted")]
+ pub version_control_word_deleted: Option<String>,
+
/// Background color for row highlights of "ours" regions in merge conflicts.
#[serde(rename = "version_control.conflict_marker.ours")]
pub version_control_conflict_marker_ours: Option<String>,
@@ -925,19 +918,27 @@ pub struct ThemeColorsContent {
pub vim_mode_text: Option<String>,
}
-#[skip_serializing_none]
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
#[serde(default)]
pub struct HighlightStyleContent {
pub color: Option<String>,
- #[serde(deserialize_with = "treat_error_as_none")]
+ #[serde(
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "treat_error_as_none"
+ )]
pub background_color: Option<String>,
- #[serde(deserialize_with = "treat_error_as_none")]
+ #[serde(
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "treat_error_as_none"
+ )]
pub font_style: Option<FontStyleContent>,
- #[serde(deserialize_with = "treat_error_as_none")]
+ #[serde(
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "treat_error_as_none"
+ )]
pub font_weight: Option<FontWeightContent>,
}
@@ -959,7 +960,7 @@ where
Ok(T::deserialize(value).ok())
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
#[serde(default)]
pub struct StatusColorsContent {
@@ -3,15 +3,14 @@ use std::num::NonZeroUsize;
use collections::HashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use serde_with::skip_serializing_none;
-use settings_macros::MergeFrom;
+use settings_macros::{MergeFrom, with_fallible_options};
use crate::{
CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity,
ScrollbarSettingsContent, ShowIndentGuides, serialize_optional_f32_with_two_decimal_places,
};
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct WorkspaceSettingsContent {
/// Active pane styling settings.
@@ -43,7 +42,7 @@ pub struct WorkspaceSettingsContent {
/// Default: off
pub autosave: Option<AutosaveSetting>,
/// Controls previous session restoration in freshly launched Zed instance.
- /// Values: none, last_workspace, last_session
+ /// Values: empty_tab, last_workspace, last_session, launchpad
/// Default: last_session
pub restore_on_startup: Option<RestoreOnStartupBehavior>,
/// Whether to attempt to restore previous file's state when opening it again.
@@ -110,9 +109,12 @@ pub struct WorkspaceSettingsContent {
///
/// Default: true
pub zoomed_padding: Option<bool>,
+ /// What draws window decorations/titlebar, the client application (Zed) or display server
+ /// Default: client
+ pub window_decorations: Option<WindowDecorations>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ItemSettingsContent {
/// Whether to show the Git file status on a tab item.
@@ -142,7 +144,7 @@ pub struct ItemSettingsContent {
pub show_close_button: Option<ShowCloseButton>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct PreviewTabsSettingsContent {
/// Whether to show opened editors as preview tabs.
@@ -150,14 +152,31 @@ pub struct PreviewTabsSettingsContent {
///
/// Default: true
pub enabled: Option<bool>,
+ /// Whether to open tabs in preview mode when opened from the project panel with a single click.
+ ///
+ /// Default: true
+ pub enable_preview_from_project_panel: Option<bool>,
/// Whether to open tabs in preview mode when selected from the file finder.
///
/// Default: false
pub enable_preview_from_file_finder: Option<bool>,
- /// Whether a preview tab gets replaced when code navigation is used to navigate away from the tab.
+ /// Whether to open tabs in preview mode when opened from a multibuffer.
+ ///
+ /// Default: true
+ pub enable_preview_from_multibuffer: Option<bool>,
+ /// Whether to open tabs in preview mode when code navigation is used to open a multibuffer.
+ ///
+ /// Default: false
+ pub enable_preview_multibuffer_from_code_navigation: Option<bool>,
+ /// Whether to open tabs in preview mode when code navigation is used to open a single file.
+ ///
+ /// Default: true
+ pub enable_preview_file_from_code_navigation: Option<bool>,
+ /// 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.
///
/// Default: false
- pub enable_preview_from_code_navigation: Option<bool>,
+ pub enable_keep_preview_on_code_navigation: Option<bool>,
}
#[derive(
@@ -244,7 +263,7 @@ pub enum ActivateOnClose {
LeftNeighbour,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Copy, Clone, PartialEq, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
#[serde(rename_all = "snake_case")]
pub struct ActivePaneModifiers {
@@ -291,6 +310,28 @@ pub enum BottomDockLayout {
RightAligned,
}
+#[derive(
+ Copy,
+ Clone,
+ Default,
+ Debug,
+ Serialize,
+ Deserialize,
+ PartialEq,
+ JsonSchema,
+ MergeFrom,
+ strum::VariantArray,
+ strum::VariantNames,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum WindowDecorations {
+ /// Zed draws its own window decorations/titlebar (client-side decoration)
+ #[default]
+ Client,
+ /// Show system's window titlebar (server-side decoration; not supported by GNOME Wayland)
+ Server,
+}
+
#[derive(
Copy,
Clone,
@@ -341,16 +382,19 @@ impl CloseWindowWhenNoItems {
)]
#[serde(rename_all = "snake_case")]
pub enum RestoreOnStartupBehavior {
- /// Always start with an empty editor
- None,
+ /// Always start with an empty editor tab
+ #[serde(alias = "none")]
+ EmptyTab,
/// Restore the workspace that was closed last.
LastWorkspace,
/// Restore all workspaces that were open when quitting Zed.
#[default]
LastSession,
+ /// Show the launchpad with recent projects (no tabs).
+ Launchpad,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)]
pub struct TabBarSettingsContent {
/// Whether or not to show the tab bar in the editor.
@@ -367,13 +411,13 @@ pub struct TabBarSettingsContent {
pub show_tab_bar_buttons: Option<bool>,
}
-#[skip_serializing_none]
+#[with_fallible_options]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq, Eq)]
pub struct StatusBarSettingsContent {
/// Whether to show the status bar.
///
/// Default: true
- #[serde(rename = "experimental.show", default)]
+ #[serde(rename = "experimental.show")]
pub show: Option<bool>,
/// Whether to display the active language button in the status bar.
///
@@ -465,7 +509,7 @@ pub enum PaneSplitDirectionVertical {
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
-#[skip_serializing_none]
+#[with_fallible_options]
pub struct CenteredLayoutSettings {
/// The relative width of the left padding of the central pane from the
/// workspace when the centered layout is used.
@@ -510,7 +554,24 @@ impl OnLastWindowClosed {
}
}
-#[skip_serializing_none]
+#[with_fallible_options]
+#[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)]
+pub struct ProjectPanelAutoOpenSettings {
+ /// Whether to automatically open newly created files in the editor.
+ ///
+ /// Default: true
+ pub on_create: Option<bool>,
+ /// Whether to automatically open files after pasting or duplicating them.
+ ///
+ /// Default: true
+ pub on_paste: Option<bool>,
+ /// Whether to automatically open files dropped from external sources.
+ ///
+ /// Default: true
+ pub on_drop: Option<bool>,
+}
+
+#[with_fallible_options]
#[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)]
pub struct ProjectPanelSettingsContent {
/// Whether to show the project panel button in the status bar.
@@ -590,10 +651,12 @@ pub struct ProjectPanelSettingsContent {
///
/// Default: true
pub drag_and_drop: Option<bool>,
- /// Whether to automatically open files when pasting them in the project panel.
+ /// Settings for automatically opening files.
+ pub auto_open: Option<ProjectPanelAutoOpenSettings>,
+ /// How to order sibling entries in the project panel.
///
- /// Default: true
- pub open_file_on_paste: Option<bool>,
+ /// Default: directories_first
+ pub sort_mode: Option<ProjectPanelSortMode>,
}
#[derive(
@@ -619,7 +682,32 @@ pub enum ProjectPanelEntrySpacing {
Standard,
}
-#[skip_serializing_none]
+#[derive(
+ Copy,
+ Clone,
+ Debug,
+ Default,
+ Serialize,
+ Deserialize,
+ JsonSchema,
+ MergeFrom,
+ PartialEq,
+ Eq,
+ strum::VariantArray,
+ strum::VariantNames,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum ProjectPanelSortMode {
+ /// Show directories first, then files
+ #[default]
+ DirectoriesFirst,
+ /// Mix directories and files together
+ Mixed,
+ /// Show files first, then directories
+ FilesFirst,
+}
+
+#[with_fallible_options]
#[derive(
Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default,
)]
@@ -25,14 +25,14 @@ use std::{
use util::{
ResultExt as _,
rel_path::RelPath,
- schemars::{DefaultDenyUnknownFields, replace_subschema},
+ schemars::{AllowTrailingCommas, DefaultDenyUnknownFields, replace_subschema},
};
pub type EditorconfigProperties = ec4rs::Properties;
use crate::{
ActiveSettingsProfileName, FontFamilyName, IconThemeName, LanguageSettingsContent,
- LanguageToSettingsMap, ThemeName, VsCodeSettings, WorktreeId,
+ LanguageToSettingsMap, ThemeName, VsCodeSettings, WorktreeId, fallible_options,
merge_from::MergeFrom,
settings_content::{
ExtensionsSettingsContent, ProjectSettingsContent, SettingsContent, UserSettingsContent,
@@ -247,6 +247,7 @@ pub trait AnySettingValue: 'static + Send + Sync {
fn all_local_values(&self) -> Vec<(WorktreeId, Arc<RelPath>, &dyn Any)>;
fn set_global_value(&mut self, value: Box<dyn Any>);
fn set_local_value(&mut self, root_id: WorktreeId, path: Arc<RelPath>, value: Box<dyn Any>);
+ fn clear_local_values(&mut self, root_id: WorktreeId);
}
/// Parameters that are used when generating some JSON schemas at runtime.
@@ -666,44 +667,31 @@ impl SettingsStore {
file: SettingsFile,
) -> (Option<SettingsContentType>, SettingsParseResult) {
let mut migration_status = MigrationStatus::NotNeeded;
- let settings: SettingsContentType = if user_settings_content.is_empty() {
- parse_json_with_comments("{}").expect("Empty settings should always be valid")
+ let (settings, parse_status) = if user_settings_content.is_empty() {
+ fallible_options::parse_json("{}")
} else {
let migration_res = migrator::migrate_settings(user_settings_content);
- let content = match &migration_res {
- Ok(Some(content)) => content,
- Ok(None) => user_settings_content,
- Err(_) => user_settings_content,
- };
- let parse_result = parse_json_with_comments(content);
- migration_status = match migration_res {
+ migration_status = match &migration_res {
Ok(Some(_)) => MigrationStatus::Succeeded,
Ok(None) => MigrationStatus::NotNeeded,
Err(err) => MigrationStatus::Failed {
error: err.to_string(),
},
};
- match parse_result {
- Ok(settings) => settings,
- Err(err) => {
- let result = SettingsParseResult {
- parse_status: ParseStatus::Failed {
- error: err.to_string(),
- },
- migration_status,
- };
- self.file_errors.insert(file, result.clone());
- return (None, result);
- }
- }
+ let content = match &migration_res {
+ Ok(Some(content)) => content,
+ Ok(None) => user_settings_content,
+ Err(_) => user_settings_content,
+ };
+ fallible_options::parse_json(content)
};
let result = SettingsParseResult {
- parse_status: ParseStatus::Success,
+ parse_status,
migration_status,
};
self.file_errors.insert(file, result.clone());
- return (Some(settings), result);
+ return (settings, result);
}
pub fn error_for_file(&self, file: SettingsFile) -> Option<SettingsParseResult> {
@@ -984,6 +972,11 @@ impl SettingsStore {
pub fn clear_local_settings(&mut self, root_id: WorktreeId, cx: &mut App) -> Result<()> {
self.local_settings
.retain(|(worktree_id, _), _| worktree_id != &root_id);
+ self.raw_editorconfig_settings
+ .retain(|(worktree_id, _), _| worktree_id != &root_id);
+ for setting_value in self.setting_values.values_mut() {
+ setting_value.clear_local_values(root_id);
+ }
self.recompute_values(Some((root_id, RelPath::empty())), cx);
Ok(())
}
@@ -1023,6 +1016,7 @@ impl SettingsStore {
pub fn json_schema(&self, params: &SettingsJsonSchemaParams) -> Value {
let mut generator = schemars::generate::SchemaSettings::draft2019_09()
.with_transform(DefaultDenyUnknownFields)
+ .with_transform(AllowTrailingCommas)
.into_generator();
UserSettingsContent::json_schema(&mut generator);
@@ -1350,6 +1344,11 @@ impl<T: Settings> AnySettingValue for SettingValue<T> {
Err(ix) => self.local_values.insert(ix, (root_id, path, value)),
}
}
+
+ fn clear_local_values(&mut self, root_id: WorktreeId) {
+ self.local_values
+ .retain(|(worktree_id, _, _)| *worktree_id != root_id);
+ }
}
#[cfg(test)]
@@ -215,6 +215,7 @@ impl VsCodeSettings {
vim: None,
vim_mode: None,
workspace: self.workspace_settings_content(),
+ which_key: None,
}
}
@@ -450,6 +451,7 @@ impl VsCodeSettings {
prettier: None,
remove_trailing_whitespace_on_save: self.read_bool("editor.trimAutoWhitespace"),
show_completion_documentation: None,
+ colorize_brackets: self.read_bool("editor.bracketPairColorization.enabled"),
show_completions_on_input: self.read_bool("editor.suggestOnTriggerCharacters"),
show_edit_predictions: self.read_bool("editor.inlineSuggest.enabled"),
show_whitespaces: self.read_enum("editor.renderWhitespace", |s| {
@@ -489,6 +491,7 @@ impl VsCodeSettings {
.flat_map(|n| n.as_u64().map(|n| n as usize))
.collect()
}),
+ word_diff_enabled: None,
}
}
@@ -567,7 +570,7 @@ impl VsCodeSettings {
.filter_map(|(k, v)| {
Some((
k.clone().into(),
- ContextServerSettingsContent::Custom {
+ ContextServerSettingsContent::Stdio {
enabled: true,
command: serde_json::from_value::<VsCodeContextServerCommand>(v.clone())
.ok()
@@ -617,9 +620,13 @@ impl VsCodeSettings {
fn preview_tabs_settings_content(&self) -> Option<PreviewTabsSettingsContent> {
skip_default(PreviewTabsSettingsContent {
enabled: self.read_bool("workbench.editor.enablePreview"),
+ enable_preview_from_project_panel: None,
enable_preview_from_file_finder: self
.read_bool("workbench.editor.enablePreviewFromQuickOpen"),
- enable_preview_from_code_navigation: self
+ enable_preview_from_multibuffer: None,
+ enable_preview_multibuffer_from_code_navigation: None,
+ enable_preview_file_from_code_navigation: None,
+ enable_keep_preview_on_code_navigation: self
.read_bool("workbench.editor.enablePreviewFromCodeNavigation"),
})
}
@@ -664,13 +671,14 @@ impl VsCodeSettings {
hide_root: None,
indent_guides: None,
indent_size: None,
- open_file_on_paste: None,
scrollbar: None,
show_diagnostics: self
.read_bool("problems.decorations.enabled")
.and_then(|b| if b { Some(ShowDiagnostics::Off) } else { None }),
+ sort_mode: None,
starts_open: None,
sticky_scroll: None,
+ auto_open: None,
};
if let (Some(false), Some(false)) = (
@@ -737,6 +745,7 @@ impl VsCodeSettings {
option_as_meta: self.read_bool("terminal.integrated.macOptionIsMeta"),
project: self.project_terminal_settings_content(),
scrollbar: None,
+ scroll_multiplier: None,
toolbar: None,
})
}
@@ -753,7 +762,13 @@ impl VsCodeSettings {
let env = self
.read_value(&format!("terminal.integrated.env.{platform}"))
.and_then(|v| v.as_object())
- .map(|v| v.iter().map(|(k, v)| (k.clone(), v.to_string())).collect());
+ .map(|v| {
+ v.iter()
+ .map(|(k, v)| (k.clone(), v.to_string()))
+ // zed does not support substitutions, so this can break env vars
+ .filter(|(_, v)| !v.contains('$'))
+ .collect()
+ });
ProjectTerminalSettingsContent {
// TODO: handle arguments
@@ -763,6 +778,8 @@ impl VsCodeSettings {
working_directory: None,
env,
detect_venv: None,
+ path_hyperlink_regexes: None,
+ path_hyperlink_timeout_ms: None,
}
}
@@ -832,6 +849,7 @@ impl VsCodeSettings {
resize_all_panels_in_dock: None,
restore_on_file_reopen: self.read_bool("workbench.editor.restoreViewState"),
restore_on_startup: None,
+ window_decorations: None,
show_call_status_icon: None,
use_system_path_prompts: self.read_bool("files.simpleDialog.enable"),
use_system_prompts: None,
@@ -862,7 +880,7 @@ impl VsCodeSettings {
fn worktree_settings_content(&self) -> WorktreeSettingsContent {
WorktreeSettingsContent {
- project_name: crate::Maybe::Unset,
+ project_name: None,
prevent_sharing_in_public_channels: false,
file_scan_exclusions: self
.read_value("files.watcherExclude")
@@ -1,6 +1,9 @@
use proc_macro::TokenStream;
+
use quote::quote;
-use syn::{Data, DeriveInput, Fields, parse_macro_input};
+use syn::{
+ Data, DeriveInput, Field, Fields, ItemEnum, ItemStruct, Type, parse_macro_input, parse_quote,
+};
/// Derives the `MergeFrom` trait for a struct.
///
@@ -100,3 +103,50 @@ pub fn derive_register_setting(input: TokenStream) -> TokenStream {
}
.into()
}
+
+// Adds serde attributes to each field with type Option<T>:
+// #serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "settings::deserialize_fallible")
+#[proc_macro_attribute]
+pub fn with_fallible_options(_args: TokenStream, input: TokenStream) -> TokenStream {
+ fn apply_on_fields(fields: &mut Fields) {
+ match fields {
+ Fields::Unit => {}
+ Fields::Named(fields) => {
+ for field in &mut fields.named {
+ add_if_option(field)
+ }
+ }
+ Fields::Unnamed(fields) => {
+ for field in &mut fields.unnamed {
+ add_if_option(field)
+ }
+ }
+ }
+ }
+
+ fn add_if_option(field: &mut Field) {
+ match &field.ty {
+ Type::Path(syn::TypePath { qself: None, path })
+ if path.leading_colon.is_none()
+ && path.segments.len() == 1
+ && path.segments[0].ident == "Option" => {}
+ _ => return,
+ }
+ let attr = parse_quote!(
+ #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with="crate::fallible_options::deserialize")]
+ );
+ field.attrs.push(attr);
+ }
+
+ if let Ok(mut input) = syn::parse::<ItemStruct>(input.clone()) {
+ apply_on_fields(&mut input.fields);
+ quote!(#input).into()
+ } else if let Ok(mut input) = syn::parse::<ItemEnum>(input) {
+ for variant in &mut input.variants {
+ apply_on_fields(&mut variant.fields);
+ }
+ quote!(#input).into()
+ } else {
+ panic!("with_fallible_options can only be applied to struct or enum definitions.");
+ }
+}
@@ -18,6 +18,9 @@ test-support = []
[dependencies]
anyhow.workspace = true
bm25 = "2.3.2"
+copilot.workspace = true
+edit_prediction.workspace = true
+language_models.workspace = true
editor.workspace = true
feature_flags.workspace = true
fs.workspace = true
@@ -38,8 +41,8 @@ strum.workspace = true
telemetry.workspace = true
theme.workspace = true
title_bar.workspace = true
-ui.workspace = true
ui_input.workspace = true
+ui.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true
@@ -2,10 +2,12 @@ mod dropdown;
mod font_picker;
mod icon_theme_picker;
mod input_field;
+mod section_items;
mod theme_picker;
pub use dropdown::*;
pub use font_picker::font_picker;
pub use icon_theme_picker::icon_theme_picker;
pub use input_field::*;
+pub use section_items::*;
pub use theme_picker::theme_picker;
@@ -13,6 +13,7 @@ pub struct SettingsInputField {
tab_index: Option<isize>,
}
+// TODO: Update the `ui_input::InputField` to use `window.use_state` and `RenceOnce` and remove this component
impl SettingsInputField {
pub fn new() -> Self {
Self {
@@ -0,0 +1,56 @@
+use gpui::{IntoElement, ParentElement, Styled};
+use ui::{Divider, DividerColor, prelude::*};
+
+#[derive(IntoElement)]
+pub struct SettingsSectionHeader {
+ icon: Option<IconName>,
+ label: SharedString,
+ no_padding: bool,
+}
+
+impl SettingsSectionHeader {
+ pub fn new(label: impl Into<SharedString>) -> Self {
+ Self {
+ label: label.into(),
+ icon: None,
+ no_padding: false,
+ }
+ }
+
+ pub fn icon(mut self, icon: IconName) -> Self {
+ self.icon = Some(icon);
+ self
+ }
+
+ pub fn no_padding(mut self, no_padding: bool) -> Self {
+ self.no_padding = no_padding;
+ self
+ }
+}
+
+impl RenderOnce for SettingsSectionHeader {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let label = Label::new(self.label)
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .buffer_font(cx);
+
+ v_flex()
+ .w_full()
+ .when(!self.no_padding, |this| this.px_8())
+ .gap_1p5()
+ .map(|this| {
+ if self.icon.is_some() {
+ this.child(
+ h_flex()
+ .gap_1p5()
+ .child(Icon::new(self.icon.unwrap()).color(Color::Muted))
+ .child(label),
+ )
+ } else {
+ this.child(label)
+ }
+ })
+ .child(Divider::horizontal().color(DividerColor::BorderFaded))
+ }
+}
@@ -1,12 +1,12 @@
-use gpui::App;
+use gpui::{Action as _, App};
use settings::{LanguageSettingsContent, SettingsContent};
use std::sync::Arc;
use strum::IntoDiscriminant as _;
use ui::{IntoElement, SharedString};
use crate::{
- DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata, SettingsPage,
- SettingsPageItem, SubPageLink, USER, all_language_names, sub_page_stack,
+ ActionLink, DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata,
+ SettingsPage, SettingsPageItem, SubPageLink, USER, all_language_names, sub_page_stack,
};
const DEFAULT_STRING: String = String::new();
@@ -33,10 +33,10 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
SettingField {
json_path: Some("project_name"),
pick: |settings_content| {
- settings_content.project.worktree.project_name.as_ref()?.as_ref().or(DEFAULT_EMPTY_STRING)
+ settings_content.project.worktree.project_name.as_ref().or(DEFAULT_EMPTY_STRING)
},
write: |settings_content, value| {
- settings_content.project.worktree.project_name = settings::Maybe::Set(value.filter(|name| !name.is_empty()));
+ settings_content.project.worktree.project_name = value.filter(|name| !name.is_empty());
},
}
),
@@ -138,6 +138,28 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None,
files: USER,
}),
+ SettingsPageItem::SectionHeader("Security"),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Trust All Projects By Default",
+ description: "When opening Zed, avoid Restricted Mode by auto-trusting all projects, enabling use of all features without having to give permission to each new project.",
+ field: Box::new(SettingField {
+ json_path: Some("session.trust_all_projects"),
+ pick: |settings_content| {
+ settings_content
+ .session
+ .as_ref()
+ .and_then(|session| session.trust_all_worktrees.as_ref())
+ },
+ write: |settings_content, value| {
+ settings_content
+ .session
+ .get_or_insert_default()
+ .trust_all_worktrees = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
SettingsPageItem::SectionHeader("Workspace Restoration"),
SettingsPageItem::SettingItem(SettingItem {
title: "Restore Unsaved Buffers",
@@ -1054,6 +1076,25 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
SettingsPage {
title: "Keymap",
items: vec![
+ SettingsPageItem::SectionHeader("Keybindings"),
+ SettingsPageItem::ActionLink(ActionLink {
+ title: "Edit Keybindings".into(),
+ description: Some("Customize keybindings in the keymap editor.".into()),
+ button_text: "Open Keymap".into(),
+ on_click: Arc::new(|settings_window, window, cx| {
+ let Some(original_window) = settings_window.original_window else {
+ return;
+ };
+ original_window
+ .update(cx, |_workspace, original_window, cx| {
+ original_window
+ .dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx);
+ original_window.activate_window();
+ })
+ .ok();
+ window.remove_window();
+ }),
+ }),
SettingsPageItem::SectionHeader("Base Keymap"),
SettingsPageItem::SettingItem(SettingItem {
title: "Base Keymap",
@@ -1192,6 +1233,49 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
}
}).collect(),
}),
+ SettingsPageItem::SectionHeader("Which-key Menu"),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Show Which-key Menu",
+ description: "Display the which-key menu with matching bindings while a multi-stroke binding is pending.",
+ field: Box::new(SettingField {
+ json_path: Some("which_key.enabled"),
+ pick: |settings_content| {
+ settings_content
+ .which_key
+ .as_ref()
+ .and_then(|settings| settings.enabled.as_ref())
+ },
+ write: |settings_content, value| {
+ settings_content
+ .which_key
+ .get_or_insert_default()
+ .enabled = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Menu Delay",
+ description: "Delay in milliseconds before the which-key menu appears.",
+ field: Box::new(SettingField {
+ json_path: Some("which_key.delay_ms"),
+ pick: |settings_content| {
+ settings_content
+ .which_key
+ .as_ref()
+ .and_then(|settings| settings.delay_ms.as_ref())
+ },
+ write: |settings_content, value| {
+ settings_content
+ .which_key
+ .get_or_insert_default()
+ .delay_ms = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
SettingsPageItem::SectionHeader("Multibuffer"),
SettingsPageItem::SettingItem(SettingItem {
title: "Double Click In Multibuffer",
@@ -2330,8 +2414,12 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
// Note that `crates/json_schema_store` solves the same problem, there is probably a way to unify the two
items.push(SettingsPageItem::SectionHeader(LANGUAGES_SECTION_HEADER));
items.extend(all_language_names(cx).into_iter().map(|language_name| {
+ let link = format!("languages.{language_name}");
SettingsPageItem::SubPageLink(SubPageLink {
title: language_name,
+ description: None,
+ json_path: Some(link.leak()),
+ in_json: true,
files: USER | PROJECT,
render: Arc::new(|this, window, cx| {
this.render_sub_page_items(
@@ -2909,40 +2997,58 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
- title: "Show User Picture",
- description: "Show user picture in the titlebar.",
+ title: "Show Sign In",
+ description: "Show the sign in button in the titlebar.",
field: Box::new(SettingField {
- json_path: Some("title_bar.show_user_picture"),
+ json_path: Some("title_bar.show_sign_in"),
pick: |settings_content| {
+ settings_content.title_bar.as_ref()?.show_sign_in.as_ref()
+ },
+ write: |settings_content, value| {
settings_content
.title_bar
- .as_ref()?
- .show_user_picture
- .as_ref()
+ .get_or_insert_default()
+ .show_sign_in = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Show User Menu",
+ description: "Show the user menu button in the titlebar.",
+ field: Box::new(SettingField {
+ json_path: Some("title_bar.show_user_menu"),
+ pick: |settings_content| {
+ settings_content.title_bar.as_ref()?.show_user_menu.as_ref()
},
write: |settings_content, value| {
settings_content
.title_bar
.get_or_insert_default()
- .show_user_picture = value;
+ .show_user_menu = value;
},
}),
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
- title: "Show Sign In",
- description: "Show the sign in button in the titlebar.",
+ title: "Show User Picture",
+ description: "Show user picture in the titlebar.",
field: Box::new(SettingField {
- json_path: Some("title_bar.show_sign_in"),
+ json_path: Some("title_bar.show_user_picture"),
pick: |settings_content| {
- settings_content.title_bar.as_ref()?.show_sign_in.as_ref()
+ settings_content
+ .title_bar
+ .as_ref()?
+ .show_user_picture
+ .as_ref()
},
write: |settings_content, value| {
settings_content
.title_bar
.get_or_insert_default()
- .show_sign_in = value;
+ .show_user_picture = value;
},
}),
metadata: None,
@@ -3065,6 +3171,28 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None,
files: USER,
}),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Show Tab Bar Buttons",
+ description: "Show the tab bar buttons (New, Split Pane, Zoom).",
+ field: Box::new(SettingField {
+ json_path: Some("tab_bar.show_tab_bar_buttons"),
+ pick: |settings_content| {
+ settings_content
+ .tab_bar
+ .as_ref()?
+ .show_tab_bar_buttons
+ .as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content
+ .tab_bar
+ .get_or_insert_default()
+ .show_tab_bar_buttons = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
SettingsPageItem::SectionHeader("Tab Settings"),
SettingsPageItem::SettingItem(SettingItem {
title: "Activate On Close",
@@ -3123,7 +3251,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
SettingsPageItem::SectionHeader("Preview Tabs"),
SettingsPageItem::SettingItem(SettingItem {
title: "Preview Tabs Enabled",
- description: "Show opened editors as Preview tabs.",
+ description: "Show opened editors as preview tabs.",
field: Box::new(SettingField {
json_path: Some("preview_tabs.enabled"),
pick: |settings_content| {
@@ -3139,9 +3267,31 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None,
files: USER,
}),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Enable Preview From Project Panel",
+ description: "Whether to open tabs in preview mode when opened from the project panel with a single click.",
+ field: Box::new(SettingField {
+ json_path: Some("preview_tabs.enable_preview_from_project_panel"),
+ pick: |settings_content| {
+ settings_content
+ .preview_tabs
+ .as_ref()?
+ .enable_preview_from_project_panel
+ .as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content
+ .preview_tabs
+ .get_or_insert_default()
+ .enable_preview_from_project_panel = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
SettingsPageItem::SettingItem(SettingItem {
title: "Enable Preview From File Finder",
- description: "Whether to open tabs in Preview mode when selected from the file finder.",
+ description: "Whether to open tabs in preview mode when selected from the file finder.",
field: Box::new(SettingField {
json_path: Some("preview_tabs.enable_preview_from_file_finder"),
pick: |settings_content| {
@@ -3162,22 +3312,88 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
- title: "Enable Preview From Code Navigation",
- description: "Whether a preview tab gets replaced when code navigation is used to navigate away from the tab.",
+ title: "Enable Preview From Multibuffer",
+ description: "Whether to open tabs in preview mode when opened from a multibuffer.",
+ field: Box::new(SettingField {
+ json_path: Some("preview_tabs.enable_preview_from_multibuffer"),
+ pick: |settings_content| {
+ settings_content
+ .preview_tabs
+ .as_ref()?
+ .enable_preview_from_multibuffer
+ .as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content
+ .preview_tabs
+ .get_or_insert_default()
+ .enable_preview_from_multibuffer = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Enable Preview Multibuffer From Code Navigation",
+ description: "Whether to open tabs in preview mode when code navigation is used to open a multibuffer.",
field: Box::new(SettingField {
- json_path: Some("preview_tabs.enable_preview_from_code_navigation"),
+ json_path: Some("preview_tabs.enable_preview_multibuffer_from_code_navigation"),
pick: |settings_content| {
settings_content
.preview_tabs
.as_ref()?
- .enable_preview_from_code_navigation
+ .enable_preview_multibuffer_from_code_navigation
.as_ref()
},
write: |settings_content, value| {
settings_content
.preview_tabs
.get_or_insert_default()
- .enable_preview_from_code_navigation = value;
+ .enable_preview_multibuffer_from_code_navigation = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Enable Preview File From Code Navigation",
+ description: "Whether to open tabs in preview mode when code navigation is used to open a single file.",
+ field: Box::new(SettingField {
+ json_path: Some("preview_tabs.enable_preview_file_from_code_navigation"),
+ pick: |settings_content| {
+ settings_content
+ .preview_tabs
+ .as_ref()?
+ .enable_preview_file_from_code_navigation
+ .as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content
+ .preview_tabs
+ .get_or_insert_default()
+ .enable_preview_file_from_code_navigation = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Enable Keep Preview On Code Navigation",
+ description: "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.",
+ field: Box::new(SettingField {
+ json_path: Some("preview_tabs.enable_keep_preview_on_code_navigation"),
+ pick: |settings_content| {
+ settings_content
+ .preview_tabs
+ .as_ref()?
+ .enable_keep_preview_on_code_navigation
+ .as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content
+ .preview_tabs
+ .get_or_insert_default()
+ .enable_keep_preview_on_code_navigation = value;
},
}),
metadata: None,
@@ -3264,6 +3480,21 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None,
files: USER,
}),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Window Decorations",
+ description: "(Linux only) whether Zed or your compositor should draw window decorations.",
+ field: Box::new(SettingField {
+ json_path: Some("window_decorations"),
+ pick: |settings_content| {
+ settings_content.workspace.window_decorations.as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content.workspace.window_decorations = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
SettingsPageItem::SectionHeader("Pane Modifiers"),
SettingsPageItem::SettingItem(SettingItem {
title: "Inactive Opacity",
@@ -3776,24 +4007,66 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None,
files: USER,
}),
+ SettingsPageItem::SectionHeader("Auto Open Files"),
SettingsPageItem::SettingItem(SettingItem {
- title: "Open File on Paste",
- description: "Whether to automatically open files when pasting them in the project panel.",
+ title: "On Create",
+ description: "Whether to automatically open newly created files in the editor.",
field: Box::new(SettingField {
- json_path: Some("project_panel.open_file_on_paste"),
+ json_path: Some("project_panel.auto_open.on_create"),
pick: |settings_content| {
- settings_content
- .project_panel
- .as_ref()?
- .open_file_on_paste
- .as_ref()
+ settings_content.project_panel.as_ref()?.auto_open.as_ref()?.on_create.as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content.project_panel.get_or_insert_default().auto_open.get_or_insert_default().on_create = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "On Paste",
+ description: "Whether to automatically open files after pasting or duplicating them.",
+ field: Box::new(SettingField {
+ json_path: Some("project_panel.auto_open.on_paste"),
+ pick: |settings_content| {
+ settings_content.project_panel.as_ref()?.auto_open.as_ref()?.on_paste.as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content.project_panel.get_or_insert_default().auto_open.get_or_insert_default().on_paste = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "On Drop",
+ description: "Whether to automatically open files dropped from external sources.",
+ field: Box::new(SettingField {
+ json_path: Some("project_panel.auto_open.on_drop"),
+ pick: |settings_content| {
+ settings_content.project_panel.as_ref()?.auto_open.as_ref()?.on_drop.as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content.project_panel.get_or_insert_default().auto_open.get_or_insert_default().on_drop = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Sort Mode",
+ description: "Sort order for entries in the project panel.",
+ field: Box::new(SettingField {
+ pick: |settings_content| {
+ settings_content.project_panel.as_ref()?.sort_mode.as_ref()
},
write: |settings_content, value| {
settings_content
.project_panel
.get_or_insert_default()
- .open_file_on_paste = value;
+ .sort_mode = value;
},
+ json_path: Some("project_panel.sort_mode"),
}),
metadata: None,
files: USER,
@@ -4147,6 +4420,24 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None,
files: USER,
}),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Tree View",
+ description: "Enable to show entries in tree view list, disable to show in flat view list.",
+ field: Box::new(SettingField {
+ json_path: Some("git_panel.tree_view"),
+ pick: |settings_content| {
+ settings_content.git_panel.as_ref()?.tree_view.as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content
+ .git_panel
+ .get_or_insert_default()
+ .tree_view = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
SettingsPageItem::SettingItem(SettingItem {
title: "Scroll Bar",
description: "How and when the scrollbar should be displayed.",
@@ -4385,7 +4676,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
title: "Stepping Granularity",
description: "Determines the stepping granularity for debug operations.",
field: Box::new(SettingField {
- json_path: Some("agent.default_height"),
+ json_path: Some("debugger.stepping_granularity"),
pick: |settings_content| {
settings_content
.debugger
@@ -4520,6 +4811,11 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
.project
.shell
.get_or_insert_with(|| settings::Shell::default());
+ let default_shell = if cfg!(target_os = "windows") {
+ "powershell.exe"
+ } else {
+ "sh"
+ };
*settings_value = match value {
settings::ShellDiscriminants::System => {
settings::Shell::System
@@ -4528,7 +4824,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
let program = match settings_value {
settings::Shell::Program(p) => p.clone(),
settings::Shell::WithArguments { program, .. } => program.clone(),
- _ => String::from("sh"),
+ _ => String::from(default_shell),
};
settings::Shell::Program(program)
},
@@ -4538,7 +4834,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
settings::Shell::WithArguments { program, args, title_override } => {
(program.clone(), args.clone(), title_override.clone())
},
- _ => (String::from("sh"), vec![], None),
+ _ => (String::from(default_shell), vec![], None),
};
settings::Shell::WithArguments {
program,
@@ -5144,6 +5440,24 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None,
files: USER,
}),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Scroll Multiplier",
+ description: "The multiplier for scrolling in the terminal with the mouse wheel",
+ field: Box::new(SettingField {
+ json_path: Some("terminal.scroll_multiplier"),
+ pick: |settings_content| {
+ settings_content.terminal.as_ref()?.scroll_multiplier.as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content
+ .terminal
+ .get_or_insert_default()
+ .scroll_multiplier = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
SettingsPageItem::SectionHeader("Toolbar"),
SettingsPageItem::SettingItem(SettingItem {
title: "Breadcrumbs",
@@ -5434,6 +5748,19 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None,
files: USER,
}),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Path Style",
+ description: "Should the name or path be displayed first in the git view.",
+ field: Box::new(SettingField {
+ json_path: Some("git.path_style"),
+ pick: |settings_content| settings_content.git.as_ref()?.path_style.as_ref(),
+ write: |settings_content, value| {
+ settings_content.git.get_or_insert_default().path_style = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
],
},
SettingsPage {
@@ -5792,7 +6119,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
- title: "In Text Threads",
+ title: "Display In Text Threads",
description: "Whether edit predictions are enabled when editing text threads in the agent panel.",
field: Box::new(SettingField {
json_path: Some("edit_prediction.in_text_threads"),
@@ -5806,42 +6133,6 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None,
files: USER,
}),
- SettingsPageItem::SettingItem(SettingItem {
- title: "Copilot Provider",
- description: "Use GitHub Copilot as your edit prediction provider.",
- field: Box::new(
- SettingField {
- json_path: Some("edit_prediction.copilot_provider"),
- pick: |settings_content| {
- settings_content.project.all_languages.edit_predictions.as_ref()?.copilot.as_ref()
- },
- write: |settings_content, value| {
- settings_content.project.all_languages.edit_predictions.get_or_insert_default().copilot = value;
- },
- }
- .unimplemented(),
- ),
- metadata: None,
- files: USER | PROJECT,
- }),
- SettingsPageItem::SettingItem(SettingItem {
- title: "Codestral Provider",
- description: "Use Mistral's Codestral as your edit prediction provider.",
- field: Box::new(
- SettingField {
- json_path: Some("edit_prediction.codestral_provider"),
- pick: |settings_content| {
- settings_content.project.all_languages.edit_predictions.as_ref()?.codestral.as_ref()
- },
- write: |settings_content, value| {
- settings_content.project.all_languages.edit_predictions.get_or_insert_default().codestral = value;
- },
- }
- .unimplemented(),
- ),
- metadata: None,
- files: USER | PROJECT,
- }),
]
);
items
@@ -6411,7 +6702,7 @@ fn language_settings_data() -> Vec<SettingsPageItem> {
files: USER | PROJECT,
}),
SettingsPageItem::SettingItem(SettingItem {
- title: "Jsx Tag Auto Close",
+ title: "JSX Tag Auto Close",
description: "Whether to automatically close JSX tags.",
field: Box::new(SettingField {
json_path: Some("languages.$(language).jsx_tag_auto_close"),
@@ -6866,6 +7157,25 @@ fn language_settings_data() -> Vec<SettingsPageItem> {
files: USER | PROJECT,
}),
SettingsPageItem::SectionHeader("Miscellaneous"),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Word Diff Enabled",
+ description: "Whether to enable word diff highlighting in the editor. When enabled, changed words within modified lines are highlighted to show exactly what changed.",
+ field: Box::new(SettingField {
+ json_path: Some("languages.$(language).word_diff_enabled"),
+ pick: |settings_content| {
+ language_settings_field(settings_content, |language| {
+ language.word_diff_enabled.as_ref()
+ })
+ },
+ write: |settings_content, value| {
+ language_settings_field_mut(settings_content, value, |language, value| {
+ language.word_diff_enabled = value;
+ })
+ },
+ }),
+ metadata: None,
+ files: USER | PROJECT,
+ }),
SettingsPageItem::SettingItem(SettingItem {
title: "Debuggers",
description: "Preferred debuggers for this language.",
@@ -6918,6 +7228,25 @@ fn language_settings_data() -> Vec<SettingsPageItem> {
metadata: None,
files: USER | PROJECT,
}),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Colorize Brackets",
+ description: "Whether to colorize brackets in the editor.",
+ field: Box::new(SettingField {
+ json_path: Some("languages.$(language).colorize_brackets"),
+ pick: |settings_content| {
+ language_settings_field(settings_content, |language| {
+ language.colorize_brackets.as_ref()
+ })
+ },
+ write: |settings_content, value| {
+ language_settings_field_mut(settings_content, value, |language, value| {
+ language.colorize_brackets = value;
+ })
+ },
+ }),
+ metadata: None,
+ files: USER | PROJECT,
+ }),
]);
if current_language().is_none() {
@@ -7226,9 +7555,23 @@ fn non_editor_language_settings_data() -> Vec<SettingsPageItem> {
fn edit_prediction_language_settings_section() -> Vec<SettingsPageItem> {
vec![
SettingsPageItem::SectionHeader("Edit Predictions"),
+ SettingsPageItem::SubPageLink(SubPageLink {
+ title: "Configure Providers".into(),
+ json_path: Some("edit_predictions.providers"),
+ description: Some("Set up different edit prediction providers in complement to Zed's built-in Zeta model.".into()),
+ in_json: false,
+ files: USER,
+ render: Arc::new(|_, window, cx| {
+ let settings_window = cx.entity();
+ let page = window.use_state(cx, |_, _| {
+ crate::pages::EditPredictionSetupPage::new(settings_window)
+ });
+ page.into_any_element()
+ }),
+ }),
SettingsPageItem::SettingItem(SettingItem {
title: "Show Edit Predictions",
- description: "Controls whether edit predictions are shown immediately or manually by triggering `editor::showeditprediction` (false).",
+ description: "Controls whether edit predictions are shown immediately or manually.",
field: Box::new(SettingField {
json_path: Some("languages.$(language).show_edit_predictions"),
pick: |settings_content| {
@@ -7246,7 +7589,7 @@ fn edit_prediction_language_settings_section() -> Vec<SettingsPageItem> {
files: USER | PROJECT,
}),
SettingsPageItem::SettingItem(SettingItem {
- title: "Edit Predictions Disabled In",
+ title: "Disable in Language Scopes",
description: "Controls whether edit predictions are shown in the given language scopes.",
field: Box::new(
SettingField {
@@ -0,0 +1,2 @@
+mod edit_prediction_provider_setup;
+pub use edit_prediction_provider_setup::EditPredictionSetupPage;
@@ -0,0 +1,365 @@
+use edit_prediction::{
+ ApiKeyState, Zeta2FeatureFlag,
+ mercury::{MERCURY_CREDENTIALS_URL, mercury_api_token},
+ sweep_ai::{SWEEP_CREDENTIALS_URL, sweep_api_token},
+};
+use feature_flags::FeatureFlagAppExt as _;
+use gpui::{Entity, ScrollHandle, prelude::*};
+use language_models::provider::mistral::{CODESTRAL_API_URL, codestral_api_key};
+use ui::{ButtonLink, ConfiguredApiCard, WithScrollbar, prelude::*};
+
+use crate::{
+ SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER,
+ components::{SettingsInputField, SettingsSectionHeader},
+};
+
+pub struct EditPredictionSetupPage {
+ settings_window: Entity<SettingsWindow>,
+ scroll_handle: ScrollHandle,
+}
+
+impl EditPredictionSetupPage {
+ pub fn new(settings_window: Entity<SettingsWindow>) -> Self {
+ Self {
+ settings_window,
+ scroll_handle: ScrollHandle::new(),
+ }
+ }
+}
+
+impl Render for EditPredictionSetupPage {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let settings_window = self.settings_window.clone();
+
+ let providers = [
+ Some(render_github_copilot_provider(window, cx).into_any_element()),
+ cx.has_flag::<Zeta2FeatureFlag>().then(|| {
+ render_api_key_provider(
+ IconName::Inception,
+ "Mercury",
+ "https://platform.inceptionlabs.ai/dashboard/api-keys".into(),
+ mercury_api_token(cx),
+ |_cx| MERCURY_CREDENTIALS_URL,
+ None,
+ window,
+ cx,
+ )
+ .into_any_element()
+ }),
+ cx.has_flag::<Zeta2FeatureFlag>().then(|| {
+ render_api_key_provider(
+ IconName::SweepAi,
+ "Sweep",
+ "https://app.sweep.dev/".into(),
+ sweep_api_token(cx),
+ |_cx| SWEEP_CREDENTIALS_URL,
+ None,
+ window,
+ cx,
+ )
+ .into_any_element()
+ }),
+ Some(
+ render_api_key_provider(
+ IconName::AiMistral,
+ "Codestral",
+ "https://console.mistral.ai/codestral".into(),
+ codestral_api_key(cx),
+ |cx| language_models::MistralLanguageModelProvider::api_url(cx),
+ Some(settings_window.update(cx, |settings_window, cx| {
+ let codestral_settings = codestral_settings();
+ settings_window
+ .render_sub_page_items_section(
+ codestral_settings.iter().enumerate(),
+ None,
+ window,
+ cx,
+ )
+ .into_any_element()
+ })),
+ window,
+ cx,
+ )
+ .into_any_element(),
+ ),
+ ];
+
+ div()
+ .size_full()
+ .vertical_scrollbar_for(&self.scroll_handle, window, cx)
+ .child(
+ v_flex()
+ .id("ep-setup-page")
+ .min_w_0()
+ .size_full()
+ .px_8()
+ .pb_16()
+ .overflow_y_scroll()
+ .track_scroll(&self.scroll_handle)
+ .children(providers.into_iter().flatten()),
+ )
+ }
+}
+
+fn render_api_key_provider(
+ icon: IconName,
+ title: &'static str,
+ link: SharedString,
+ api_key_state: Entity<ApiKeyState>,
+ current_url: fn(&mut App) -> SharedString,
+ additional_fields: Option<AnyElement>,
+ window: &mut Window,
+ cx: &mut Context<EditPredictionSetupPage>,
+) -> impl IntoElement {
+ let weak_page = cx.weak_entity();
+ _ = window.use_keyed_state(title, cx, |_, cx| {
+ let task = api_key_state.update(cx, |key_state, cx| {
+ key_state.load_if_needed(current_url(cx), |state| state, cx)
+ });
+ cx.spawn(async move |_, cx| {
+ task.await.ok();
+ weak_page
+ .update(cx, |_, cx| {
+ cx.notify();
+ })
+ .ok();
+ })
+ });
+
+ let (has_key, env_var_name, is_from_env_var) = api_key_state.read_with(cx, |state, _| {
+ (
+ state.has_key(),
+ Some(state.env_var_name().clone()),
+ state.is_from_env_var(),
+ )
+ });
+
+ let write_key = move |api_key: Option<String>, cx: &mut App| {
+ api_key_state
+ .update(cx, |key_state, cx| {
+ let url = current_url(cx);
+ key_state.store(url, api_key, |key_state| key_state, cx)
+ })
+ .detach_and_log_err(cx);
+ };
+
+ let base_container = v_flex().id(title).min_w_0().pt_8().gap_1p5();
+ let header = SettingsSectionHeader::new(title)
+ .icon(icon)
+ .no_padding(true);
+ let button_link_label = format!("{} dashboard", title);
+ let description = h_flex()
+ .min_w_0()
+ .gap_0p5()
+ .child(
+ Label::new("Visit the")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(
+ ButtonLink::new(button_link_label, link)
+ .no_icon(true)
+ .label_size(LabelSize::Small)
+ .label_color(Color::Muted),
+ )
+ .child(
+ Label::new("to generate an API key.")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ );
+ let configured_card_label = if is_from_env_var {
+ "API Key Set in Environment Variable"
+ } else {
+ "API Key Configured"
+ };
+
+ let container = if has_key {
+ base_container.child(header).child(
+ ConfiguredApiCard::new(configured_card_label)
+ .button_label("Reset Key")
+ .button_tab_index(0)
+ .disabled(is_from_env_var)
+ .when_some(env_var_name, |this, env_var_name| {
+ this.when(is_from_env_var, |this| {
+ this.tooltip_label(format!(
+ "To reset your API key, unset the {} environment variable.",
+ env_var_name
+ ))
+ })
+ })
+ .on_click(move |_, _, cx| {
+ write_key(None, cx);
+ }),
+ )
+ } else {
+ base_container.child(header).child(
+ h_flex()
+ .pt_2p5()
+ .w_full()
+ .justify_between()
+ .child(
+ v_flex()
+ .w_full()
+ .max_w_1_2()
+ .child(Label::new("API Key"))
+ .child(description)
+ .when_some(env_var_name, |this, env_var_name| {
+ this.child({
+ let label = format!(
+ "Or set the {} env var and restart Zed.",
+ env_var_name.as_ref()
+ );
+ Label::new(label).size(LabelSize::Small).color(Color::Muted)
+ })
+ }),
+ )
+ .child(
+ SettingsInputField::new()
+ .tab_index(0)
+ .with_placeholder("xxxxxxxxxxxxxxxxxxxx")
+ .on_confirm(move |api_key, cx| {
+ write_key(api_key.filter(|key| !key.is_empty()), cx);
+ }),
+ ),
+ )
+ };
+
+ container.when_some(additional_fields, |this, additional_fields| {
+ this.child(
+ div()
+ .map(|this| if has_key { this.mt_1() } else { this.mt_4() })
+ .px_neg_8()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
+ .child(additional_fields),
+ )
+ })
+}
+
+fn codestral_settings() -> Box<[SettingsPageItem]> {
+ Box::new([
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "API URL",
+ description: "The API URL to use for Codestral.",
+ field: Box::new(SettingField {
+ pick: |settings| {
+ settings
+ .project
+ .all_languages
+ .edit_predictions
+ .as_ref()?
+ .codestral
+ .as_ref()?
+ .api_url
+ .as_ref()
+ },
+ write: |settings, value| {
+ settings
+ .project
+ .all_languages
+ .edit_predictions
+ .get_or_insert_default()
+ .codestral
+ .get_or_insert_default()
+ .api_url = value;
+ },
+ json_path: Some("edit_predictions.codestral.api_url"),
+ }),
+ metadata: Some(Box::new(SettingsFieldMetadata {
+ placeholder: Some(CODESTRAL_API_URL),
+ ..Default::default()
+ })),
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Max Tokens",
+ description: "The maximum number of tokens to generate.",
+ field: Box::new(SettingField {
+ pick: |settings| {
+ settings
+ .project
+ .all_languages
+ .edit_predictions
+ .as_ref()?
+ .codestral
+ .as_ref()?
+ .max_tokens
+ .as_ref()
+ },
+ write: |settings, value| {
+ settings
+ .project
+ .all_languages
+ .edit_predictions
+ .get_or_insert_default()
+ .codestral
+ .get_or_insert_default()
+ .max_tokens = value;
+ },
+ json_path: Some("edit_predictions.codestral.max_tokens"),
+ }),
+ metadata: None,
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Model",
+ description: "The Codestral model id to use.",
+ field: Box::new(SettingField {
+ pick: |settings| {
+ settings
+ .project
+ .all_languages
+ .edit_predictions
+ .as_ref()?
+ .codestral
+ .as_ref()?
+ .model
+ .as_ref()
+ },
+ write: |settings, value| {
+ settings
+ .project
+ .all_languages
+ .edit_predictions
+ .get_or_insert_default()
+ .codestral
+ .get_or_insert_default()
+ .model = value;
+ },
+ json_path: Some("edit_predictions.codestral.model"),
+ }),
+ metadata: Some(Box::new(SettingsFieldMetadata {
+ placeholder: Some("codestral-latest"),
+ ..Default::default()
+ })),
+ files: USER,
+ }),
+ ])
+}
+
+pub(crate) fn render_github_copilot_provider(
+ window: &mut Window,
+ cx: &mut App,
+) -> impl IntoElement {
+ let configuration_view = window.use_state(cx, |_, cx| {
+ copilot::ConfigurationView::new(
+ |cx| {
+ copilot::Copilot::global(cx)
+ .is_some_and(|copilot| copilot.read(cx).is_authenticated())
+ },
+ copilot::ConfigurationMode::EditPrediction,
+ cx,
+ )
+ });
+
+ v_flex()
+ .id("github-copilot")
+ .min_w_0()
+ .gap_1p5()
+ .child(
+ SettingsSectionHeader::new("GitHub Copilot")
+ .icon(IconName::Copilot)
+ .no_padding(true),
+ )
+ .child(configuration_view)
+}