Merge branch 'main' into queue-refactor

This commit is contained in:
Danilo Leal 2026-05-01 05:37:23 -03:00 committed by GitHub
commit f22eff062c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
253 changed files with 8034 additions and 3124 deletions

View file

@ -4,11 +4,11 @@ from Cloudflare.
- `open-source-website-assets` is used for `install.sh`
- `docs-proxy` is used for `https://zed.dev/docs`
On push to `main`, both of these (and the files they depend on) are uploaded to Cloudflare.
During docs deployments, both of these (and the files they depend on) are uploaded to Cloudflare.
### Deployment
These functions are deployed on push to main by the deploy_cloudflare.yml workflow. Worker Rules in Cloudflare intercept requests to zed.dev and proxy them to the appropriate workers.
These functions are deployed by the docs deployment workflows. Worker Rules in Cloudflare intercept requests to zed.dev and proxy them to the appropriate workers.
### Testing

View file

@ -1,7 +1,22 @@
export default {
async fetch(request, _env, _ctx) {
const url = new URL(request.url);
url.hostname = "docs-anw.pages.dev";
if (url.pathname === "/docs/nightly") {
url.hostname = "docs-nightly.pages.dev";
url.pathname = "/docs/";
} else if (url.pathname.startsWith("/docs/nightly/")) {
url.hostname = "docs-nightly.pages.dev";
url.pathname = url.pathname.replace("/docs/nightly/", "/docs/");
} else if (url.pathname === "/docs/preview") {
url.hostname = "docs-preview-5xd.pages.dev";
url.pathname = "/docs/";
} else if (url.pathname.startsWith("/docs/preview/")) {
url.hostname = "docs-preview-5xd.pages.dev";
url.pathname = url.pathname.replace("/docs/preview/", "/docs/");
} else {
url.hostname = "docs-anw.pages.dev";
}
let res = await fetch(url, request);

View file

@ -2,7 +2,7 @@
blank_issues_enabled: false
contact_links:
- name: Feature request
url: https://github.com/zed-industries/zed/discussions/new/choose
url: https://github.com/zed-industries/zed/discussions/new?category=feature-requests
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

View file

@ -1,46 +0,0 @@
name: "Build docs"
description: "Build the docs"
runs:
using: "composite"
steps:
- name: Setup mdBook
uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 # v2
with:
mdbook-version: "0.4.37"
- name: Cache dependencies
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
# cache-provider: "buildjet"
- name: Install Linux dependencies
shell: bash -euxo pipefail {0}
run: ./script/linux
- name: Download WASI SDK
shell: bash -euxo pipefail {0}
run: ./script/download-wasi-sdk
- name: Generate action metadata
shell: bash -euxo pipefail {0}
run: ./script/generate-action-metadata
- name: Check for broken links (in MD)
uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1
with:
args: --no-progress --exclude '^http' './docs/src/**/*'
fail: true
- name: Build book
shell: bash -euxo pipefail {0}
run: |
mkdir -p target/deploy
mdbook build ./docs --dest-dir=../target/deploy/docs/
- name: Check for broken links (in HTML)
uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1
with:
args: --no-progress --exclude '^http' 'target/deploy/docs/'
fail: true

View file

@ -1,6 +1,9 @@
# Generated from xtask::workflows::after_release
# Rebuild with `cargo xtask workflows`.
name: after_release
env:
TAG_NAME: ${{ github.event.release.tag_name || inputs.tag_name }}
IS_PRERELEASE: ${{ github.event.release.prerelease || inputs.prerelease }}
on:
release:
types:
@ -25,7 +28,7 @@ jobs:
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- name: after_release::rebuild_releases_page::refresh_cloud_releases
run: curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag=${{ github.event.release.tag_name || inputs.tag_name }}
run: curl -fX POST "https://cloud.zed.dev/releases/refresh?expect_tag=$TAG_NAME"
- name: steps::checkout_repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
@ -34,6 +37,18 @@ jobs:
run: ./script/redeploy-vercel
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
deploy_docs:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
permissions:
contents: read
uses: zed-industries/zed/.github/workflows/deploy_docs.yml@main
secrets:
DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
with:
channel: ${{ (github.event.release.prerelease || inputs.prerelease) && 'preview' || 'stable' }}
checkout_ref: ${{ github.event.release.tag_name || inputs.tag_name }}
post_to_discord:
needs:
- rebuild_releases_page
@ -43,7 +58,7 @@ jobs:
- id: get-release-url
name: after_release::post_to_discord::get_release_url
run: |
if [ "${{ github.event.release.prerelease || inputs.prerelease }}" == "true" ]; then
if [ "$IS_PRERELEASE" == "true" ]; then
URL="https://zed.dev/releases/preview"
else
URL="https://zed.dev/releases/stable"
@ -55,7 +70,7 @@ jobs:
uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757
with:
stringToTruncate: |
📣 Zed [${{ github.event.release.tag_name || inputs.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released!
📣 Zed [${{ env.TAG_NAME }}](<${{ steps.get-release-url.outputs.URL }}>) was just released!
${{ github.event.release.body || inputs.body }}
maxLength: 2000
@ -90,7 +105,7 @@ jobs:
- id: set-package-name
name: after_release::publish_winget::set_package_name
run: |
if ("${{ github.event.release.prerelease || inputs.prerelease }}" -eq "true") {
if ($env:IS_PRERELEASE -eq "true") {
$PACKAGE_NAME = "ZedIndustries.Zed.Preview"
} else {
$PACKAGE_NAME = "ZedIndustries.Zed"
@ -102,7 +117,7 @@ jobs:
uses: vedantmgoyal9/winget-releaser@19e706d4c9121098010096f9c495a70a7518b30f
with:
identifier: ${{ steps.set-package-name.outputs.PACKAGE_NAME }}
release-tag: ${{ github.event.release.tag_name || inputs.tag_name }}
release-tag: ${{ env.TAG_NAME }}
max-versions-to-keep: 5
token: ${{ secrets.WINGET_TOKEN }}
create_sentry_release:
@ -127,6 +142,7 @@ jobs:
- post_to_discord
- publish_winget
- create_sentry_release
- deploy_docs
if: failure()
runs-on: namespace-profile-2x4-ubuntu-2404
steps:

View file

@ -0,0 +1,70 @@
# Assign Contributor Issue — auto-assign labeled contributor issues
#
# When an issue has both a `.contrib/good *` label and an `area:` label,
# finds the least-busy contributor interested in that area (via Tally form
# responses), assigns the issue, updates the project board, and notifies
# the contributor on Slack.
#
# Errors and "no candidates" conditions are reported to the Slack activity
# channel.
name: Assign Contributor Issue
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
issue_number:
description: "Issue number to test against"
required: true
type: number
permissions:
contents: read
concurrency:
group: assign-contributor-${{ github.event.issue.number || inputs.issue_number }}
cancel-in-progress: true
jobs:
assign-contributor:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.repository == 'zed-industries/zed' &&
github.event.issue.state == 'open' &&
(startsWith(github.event.label.name, '.contrib/good ') || startsWith(github.event.label.name, 'area:')))
runs-on: namespace-profile-2x4-ubuntu-2404
timeout-minutes: 5
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}
owner: zed-industries
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
sparse-checkout: script/github-assign-contributor-issue.py
sparse-checkout-cone-mode: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Install dependencies
run: pip install requests
- name: Assign contributor
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
TALLY_API_KEY: ${{ secrets.TALLY_API_KEY }}
TALLY_FORM_ID: ${{ vars.TALLY_CONTRIBUTOR_FORM_ID }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_CONTRIBUTOR_BOT_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }}
run: python script/github-assign-contributor-issue.py "$ISSUE_NUMBER"

View file

@ -16,6 +16,9 @@ on:
jobs:
run_autofix:
runs-on: namespace-profile-16x32-ubuntu-2204
env:
CC: clang
CXX: clang++
steps:
- name: steps::checkout_repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
@ -50,13 +53,13 @@ jobs:
tool: cargo-machete@0.7.0
- name: autofix_pr::run_autofix::run_cargo_fix
if: ${{ inputs.run_clippy }}
run: cargo fix --workspace --release --all-targets --all-features --allow-dirty --allow-staged
run: cargo fix --workspace --allow-dirty --allow-staged
- name: autofix_pr::run_autofix::run_cargo_machete_fix
if: ${{ inputs.run_clippy }}
run: cargo machete --fix
- 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
run: cargo clippy --workspace --fix --allow-dirty --allow-staged
- name: autofix_pr::run_autofix::run_prettier_fix
run: ./script/prettier --write
- name: autofix_pr::run_autofix::run_cargo_fmt

View file

@ -50,16 +50,6 @@ jobs:
echo "version=$version"
echo "tag_suffix=$tag_suffix"
} >> "$GITHUB_OUTPUT"
- name: bump_patch_version::run_bump_patch_version::verify_prior_release_exists
run: |
status=$(curl -s -o /dev/null -w '%{http_code}' "https://cloud.zed.dev/releases/$CHANNEL/$VERSION/asset?asset=zed&os=macos&arch=aarch64")
if [[ "$status" != "200" ]]; then
echo "::error::version $VERSION has not been released on $CHANNEL yet (HTTP $status) — bump the patch version only after the current version is released"
exit 1
fi
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
VERSION: ${{ steps.channel.outputs.version }}
- name: steps::install_cargo_edit
uses: taiki-e/install-action@02cc5f8ca9f2301050c0c099055816a41ee05507
with:

View file

@ -1,64 +0,0 @@
name: Deploy Docs
on:
push:
branches:
- main
jobs:
deploy-docs:
name: Deploy Docs
if: github.repository_owner == 'zed-industries'
runs-on: namespace-profile-16x32-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
clean: false
- name: Set up default .cargo/config.toml
run: cp ./.cargo/collab-config.toml ./.cargo/config.toml
- name: Build docs
uses: ./.github/actions/build_docs
env:
CC: clang
CXX: clang++
DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }}
DOCS_CONSENT_IO_INSTANCE: ${{ secrets.DOCS_CONSENT_IO_INSTANCE }}
- name: Deploy Docs
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy target/deploy --project-name=docs
- name: Deploy Install
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: r2 object put -f script/install.sh zed-open-source-website-assets/install.sh
- name: Deploy Docs Workers
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy .cloudflare/docs-proxy/src/worker.js
- name: Deploy Install Workers
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy .cloudflare/docs-proxy/src/worker.js
- name: Preserve Wrangler logs
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
name: wrangler_logs
path: /home/runner/.config/.wrangler/logs/

153
.github/workflows/deploy_docs.yml vendored Normal file
View file

@ -0,0 +1,153 @@
# Generated from xtask::workflows::deploy_docs
# Rebuild with `cargo xtask workflows`.
name: deploy_docs
on:
workflow_call:
inputs:
channel:
description: channel
type: string
default: ''
checkout_ref:
description: checkout_ref
type: string
default: ''
secrets:
DOCS_AMPLITUDE_API_KEY:
description: DOCS_AMPLITUDE_API_KEY
required: true
CLOUDFLARE_API_TOKEN:
description: CLOUDFLARE_API_TOKEN
required: true
CLOUDFLARE_ACCOUNT_ID:
description: CLOUDFLARE_ACCOUNT_ID
required: true
workflow_dispatch:
inputs:
channel:
description: 'Docs channel to deploy: nightly, preview, or stable'
type: string
default: ''
checkout_ref:
description: Git ref to checkout and deploy. Defaults to event SHA when omitted.
type: string
default: ''
jobs:
deploy_docs:
if: github.repository_owner == 'zed-industries'
name: Build and Deploy Docs
runs-on: namespace-profile-16x32-ubuntu-2204
env:
DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }}
CC: clang
CXX: clang++
steps:
- id: resolve-channel
name: deploy_docs::resolve_channel_step
run: |
if [ -z "$CHANNEL" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
CHANNEL="nightly"
else
echo "::error::channel input is required when ref is not main."
exit 1
fi
fi
case "$CHANNEL" in
"nightly")
SITE_URL="/docs/nightly/"
PROJECT_NAME="docs-nightly"
;;
"preview")
SITE_URL="/docs/preview/"
PROJECT_NAME="docs-preview"
;;
"stable")
SITE_URL="/docs/"
PROJECT_NAME="docs"
;;
*)
echo "::error::Invalid docs channel '$CHANNEL'. Expected one of: nightly, preview, stable."
exit 1
;;
esac
{
echo "channel=$CHANNEL"
echo "site_url=$SITE_URL"
echo "project_name=$PROJECT_NAME"
} >> "$GITHUB_OUTPUT"
env:
CHANNEL: ${{ inputs.channel }}
- name: steps::checkout_repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
clean: false
ref: ${{ inputs.checkout_ref != '' && inputs.checkout_ref || github.sha }}
- name: steps::setup_cargo_config
run: |
mkdir -p ./../.cargo
cp ./.cargo/ci-config.toml ./../.cargo/config.toml
- name: steps::cache_rust_dependencies_namespace
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
with:
cache: rust
path: ~/.rustup
- name: steps::setup_linux
run: ./script/linux
- name: steps::download_wasi_sdk
run: ./script/download-wasi-sdk
- name: ./script/generate-action-metadata
run: ./script/generate-action-metadata
- name: deploy_docs::lychee_link_check
uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332
with:
args: --no-progress --exclude '^http' './docs/src/**/*'
fail: true
jobSummary: false
- name: deploy_docs::install_mdbook
uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08
with:
mdbook-version: 0.4.37
- name: deploy_docs::build_docs_book
run: |
mkdir -p target/deploy
mdbook build ./docs --dest-dir=../target/deploy/docs/
env:
DOCS_CHANNEL: ${{ steps.resolve-channel.outputs.channel }}
MDBOOK_BOOK__SITE_URL: ${{ steps.resolve-channel.outputs.site_url }}
- name: deploy_docs::lychee_link_check
uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332
with:
args: --no-progress --exclude '^http' 'target/deploy/docs'
fail: true
jobSummary: false
- name: deploy_docs::docs_deploy_steps::deploy_to_cf_pages
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy target/deploy --project-name=${{ steps.resolve-channel.outputs.project_name }} --branch main
- name: deploy_docs::docs_deploy_steps::upload_install_script
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: r2 object put -f script/install.sh zed-open-source-website-assets/install.sh
- name: deploy_docs::docs_deploy_steps::deploy_docs_worker
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy .cloudflare/docs-proxy/src/worker.js
- name: deploy_docs::docs_deploy_steps::upload_wrangler_logs
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: wrangler_logs
path: /home/runner/.config/.wrangler/logs/
timeout-minutes: 60
defaults:
run:
shell: bash -euxo pipefail {0}

View file

@ -0,0 +1,23 @@
# Generated from xtask::workflows::deploy_nightly_docs
# Rebuild with `cargo xtask workflows`.
name: deploy_nightly_docs
on:
push:
branches:
- main
jobs:
deploy_docs:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
permissions:
contents: read
uses: zed-industries/zed/.github/workflows/deploy_docs.yml@main
secrets:
DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
with:
channel: nightly
checkout_ref: ${{ github.sha }}
defaults:
run:
shell: bash -euxo pipefail {0}

View file

@ -718,7 +718,7 @@ jobs:
needs:
- validate_release_assets
- release_compliance_check
if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre')
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: generate-token
@ -727,10 +727,51 @@ jobs:
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
- name: steps::checkout_repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
clean: false
ref: ${{ github.ref }}
token: ${{ steps.generate-token.outputs.token }}
- id: auto-release-preview
name: release::auto_release_preview::auto_release_preview
run: |
tag="$GITHUB_REF_NAME"
release_published=false
if [[ ! "$tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)-pre$ ]]; then
echo "::error::expected preview release tag in the form vMAJOR.MINOR.PATCH-pre, got $tag"
exit 1
fi
major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
should_release=true
released_preview="$(script/get-released-version preview)"
if [[ -z "$released_preview" || "$released_preview" == "null" ]]; then
echo "::error::could not determine released preview version"
exit 1
fi
released_preview_major="$(echo "$released_preview" | cut -d. -f1)"
released_preview_minor="$(echo "$released_preview" | cut -d. -f2)"
if [[ "$released_preview_major" != "$major" || "$released_preview_minor" != "$minor" ]]; then
should_release=false
echo "Leaving $tag as a draft because it is the first preview release for v${major}.${minor}.x"
fi
if [[ "$should_release" == "true" ]]; then
gh release edit "$tag" --repo=zed-industries/zed --draft=false
release_published=true
fi
echo "release_published=$release_published" >> "$GITHUB_OUTPUT"
env:
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
outputs:
release_published: ${{ steps.auto-release-preview.outputs.release_published }}
push_release_update_notification:
needs:
- create_draft_release
@ -797,10 +838,10 @@ jobs:
echo ""
elif [ "$VALIDATE_RESULT" == "failure" ]; then
echo "❌ Release validation failed for $TAG: missing assets: $RUN_URL"
elif [ "$AUTO_RELEASE_RESULT" == "success" ]; then
echo "✅ Release $TAG was auto-released successfully: $RELEASE_URL"
elif [ "$AUTO_RELEASE_RESULT" == "failure" ]; then
echo "❌ Auto release failed for $TAG: $RUN_URL"
elif [ "$AUTO_RELEASE_RESULT" == "success" ] && [ "$AUTO_RELEASE_PUBLISHED" == "true" ]; then
echo "✅ Release $TAG was auto-released successfully: $RELEASE_URL"
else
echo "👀 Release $TAG sitting freshly baked in the oven and waiting to be published: $RELEASE_URL"
fi
@ -814,6 +855,7 @@ jobs:
VALIDATE_RESULT: ${{ needs.validate_release_assets.result }}
COMPLIANCE_RESULT: ${{ needs.release_compliance_check.result }}
AUTO_RELEASE_RESULT: ${{ needs.auto_release_preview.result }}
AUTO_RELEASE_PUBLISHED: ${{ needs.auto_release_preview.outputs.release_published }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
TAG: ${{ github.ref_name }}
RESULT_RUN_TESTS_MAC: ${{ needs.run_tests_mac.result }}

View file

@ -1,88 +0,0 @@
# Generated from xtask::workflows::retag_release
# Rebuild with `cargo xtask workflows`.
name: retag_release
on:
workflow_dispatch:
inputs:
branch:
description: Release branch to re-tag (e.g. v0.180.x)
required: true
type: string
jobs:
run_retag_release:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
runs-on: namespace-profile-16x32-ubuntu-2204
steps:
- id: generate-token
name: steps::authenticate_as_zippy
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
with:
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
- name: steps::checkout_repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
clean: false
ref: ${{ inputs.branch }}
token: ${{ steps.generate-token.outputs.token }}
- id: info
name: retag_release::run_retag_release::resolve_tag
run: |
if [[ ! "$BRANCH" =~ ^v[0-9]+\.[0-9]{1,3}\.x$ ]]; then
echo "::error::branch '$BRANCH' does not match the release branch pattern v[N].[N].x"
exit 1
fi
channel="$(cat crates/zed/RELEASE_CHANNEL)"
tag_suffix=""
case $channel in
stable)
;;
preview)
tag_suffix="-pre"
;;
*)
echo "::error::must be run on a stable or preview release branch"
exit 1
;;
esac
version=$(script/get-crate-version zed)
{
echo "channel=$channel"
echo "version=$version"
echo "tag_suffix=$tag_suffix"
echo "head_sha=$(git rev-parse HEAD)"
} >> "$GITHUB_OUTPUT"
env:
BRANCH: ${{ inputs.branch }}
- name: retag_release::run_retag_release::verify_no_existing_release
run: |
status=$(curl -s -o /dev/null -w '%{http_code}' "https://cloud.zed.dev/releases/$CHANNEL/$VERSION/asset?asset=zed&os=macos&arch=aarch64")
if [[ "$status" == "200" ]]; then
echo "::error::version $VERSION is already released on $CHANNEL — cannot re-tag a released version"
exit 1
fi
env:
CHANNEL: ${{ steps.info.outputs.channel }}
VERSION: ${{ steps.info.outputs.version }}
- name: steps::update_tag
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
with:
script: |
github.rest.git.updateRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: 'tags/v${{ steps.info.outputs.version }}${{ steps.info.outputs.tag_suffix }}',
sha: '${{ steps.info.outputs.head_sha }}',
force: true
})
github-token: ${{ steps.generate-token.outputs.token }}
concurrency:
group: ${{ github.workflow }}-${{ inputs.branch }}
cancel-in-progress: true
defaults:
run:
shell: bash -euxo pipefail {0}

View file

@ -637,8 +637,9 @@ jobs:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_docs == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-8x16-ubuntu-2204
runs-on: namespace-profile-16x32-ubuntu-2204
env:
DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }}
CC: clang
CXX: clang++
steps:
@ -655,27 +656,30 @@ jobs:
with:
cache: rust
path: ~/.rustup
- name: run_tests::check_docs::lychee_link_check
uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332
with:
args: --no-progress --exclude '^http' './docs/src/**/*'
fail: true
jobSummary: false
- name: steps::setup_linux
run: ./script/linux
- name: steps::download_wasi_sdk
run: ./script/download-wasi-sdk
- name: ./script/generate-action-metadata
run: ./script/generate-action-metadata
- name: run_tests::check_docs::install_mdbook
- name: deploy_docs::lychee_link_check
uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332
with:
args: --no-progress --exclude '^http' './docs/src/**/*'
fail: true
jobSummary: false
- name: deploy_docs::install_mdbook
uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08
with:
mdbook-version: 0.4.37
- name: run_tests::check_docs::build_docs
- name: deploy_docs::build_docs_book
run: |
mkdir -p target/deploy
mdbook build ./docs --dest-dir=../target/deploy/docs/
- name: run_tests::check_docs::lychee_link_check
env:
DOCS_CHANNEL: stable
MDBOOK_BOOK__SITE_URL: /docs/
- name: deploy_docs::lychee_link_check
uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332
with:
args: --no-progress --exclude '^http' 'target/deploy/docs'

51
Cargo.lock generated
View file

@ -5513,6 +5513,7 @@ dependencies = [
"tracing",
"tree-sitter-bash",
"tree-sitter-c",
"tree-sitter-go",
"tree-sitter-html",
"tree-sitter-md",
"tree-sitter-python",
@ -7674,7 +7675,6 @@ dependencies = [
"mach2 0.5.0",
"media",
"metal",
"naga 29.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
"num_cpus",
"objc",
"objc2",
@ -9659,7 +9659,6 @@ dependencies = [
"ui",
"ui_input",
"util",
"vercel",
"x_ai",
]
@ -10430,11 +10429,13 @@ version = "0.1.0"
dependencies = [
"anyhow",
"editor",
"fs",
"gpui",
"language",
"log",
"markdown",
"project",
"serde_json",
"settings",
"tempfile",
"theme",
@ -10938,31 +10939,6 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a"
[[package]]
name = "naga"
version = "29.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85b4372fed0bd362d646d01b6926df0e837859ccc522fed720c395e0460f29c8"
dependencies = [
"arrayvec",
"bit-set 0.9.1",
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases 0.2.1",
"codespan-reporting",
"half",
"hashbrown 0.16.1",
"hexf-parse",
"indexmap 2.11.4",
"libm",
"log",
"num-traits",
"once_cell",
"rustc-hash 1.1.0",
"thiserror 2.0.17",
"unicode-ident",
]
[[package]]
name = "naga"
version = "29.0.0"
@ -11895,6 +11871,7 @@ dependencies = [
"futures 0.3.32",
"google_ai",
"http_client",
"language_model_core",
"schemars 1.0.4",
"serde",
"serde_json",
@ -19468,16 +19445,6 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vercel"
version = "0.1.0"
dependencies = [
"anyhow",
"schemars 1.0.4",
"serde",
"strum 0.27.2",
]
[[package]]
name = "version-compare"
version = "0.2.0"
@ -20512,7 +20479,7 @@ dependencies = [
"hashbrown 0.16.1",
"js-sys",
"log",
"naga 29.0.0 (git+https://github.com/zed-industries/wgpu.git?branch=v29)",
"naga",
"parking_lot",
"portable-atomic",
"profiling",
@ -20542,7 +20509,7 @@ dependencies = [
"hashbrown 0.16.1",
"indexmap 2.11.4",
"log",
"naga 29.0.0 (git+https://github.com/zed-industries/wgpu.git?branch=v29)",
"naga",
"once_cell",
"parking_lot",
"portable-atomic",
@ -20607,7 +20574,7 @@ dependencies = [
"libc",
"libloading",
"log",
"naga 29.0.0 (git+https://github.com/zed-industries/wgpu.git?branch=v29)",
"naga",
"ndk-sys",
"objc2",
"objc2-core-foundation",
@ -20640,7 +20607,7 @@ name = "wgpu-naga-bridge"
version = "29.0.0"
source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575"
dependencies = [
"naga 29.0.0 (git+https://github.com/zed-industries/wgpu.git?branch=v29)",
"naga",
"wgpu-types",
]
@ -22383,7 +22350,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.235.0"
version = "1.2.0"
dependencies = [
"acp_thread",
"acp_tools",

View file

@ -213,7 +213,6 @@ members = [
"crates/ui_prompt",
"crates/util",
"crates/util_macros",
"crates/vercel",
"crates/vim",
"crates/vim_mode_setting",
"crates/watch",
@ -469,7 +468,6 @@ ui_macros = { path = "crates/ui_macros" }
ui_prompt = { path = "crates/ui_prompt" }
util = { path = "crates/util" }
util_macros = { path = "crates/util_macros" }
vercel = { path = "crates/vercel" }
vim = { path = "crates/vim" }
vim_mode_setting = { path = "crates/vim_mode_setting" }
which_key = { path = "crates/which_key" }
@ -621,7 +619,6 @@ markup5ever_rcdom = "0.3.0"
metal = "0.33"
minidumper = "0.9"
moka = { version = "0.12.10", features = ["sync"] }
naga = { version = "29.0", features = ["wgsl-in"] }
nanoid = "0.4"
nbformat = "1.2.0"
nix = "0.29"

View file

@ -1,16 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2639_570)">
<g clip-path="url(#clip1_2639_570)">
<path d="M9.85676 4H13.6675C15.2128 4 16.4654 5.25266 16.4654 6.7979V10.4322H14.9002V6.7979C14.9002 6.76067 14.8988 6.7237 14.8959 6.68706L11.0851 10.4316C11.098 10.432 11.1109 10.4322 11.1238 10.4322H14.9002V11.9105H11.1238C9.57856 11.9105 8.29152 10.6456 8.29152 9.10032V5.47569H9.85676V9.10032C9.85676 9.17012 9.86216 9.23908 9.87264 9.30672L13.7673 5.4798C13.7344 5.47708 13.7012 5.47569 13.6675 5.47569H9.85676V4Z" fill="black"/>
<path d="M6.00752 11.6382L0.5 5.47504H2.71573L5.94924 9.09348V5.47504H7.6014V11.0298C7.6014 11.8682 6.56616 12.2634 6.00752 11.6382Z" fill="black"/>
</g>
</g>
<defs>
<clipPath id="clip0_2639_570">
<rect width="16" height="16" fill="white" transform="translate(0.5)"/>
</clipPath>
<clipPath id="clip1_2639_570">
<rect width="16" height="8" fill="white" transform="translate(0.5 4)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1,015 B

View file

@ -1,7 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.4381 11.5973V4.40274C14.4381 3.7405 13.8616 3.20366 13.1505 3.20366H2.84956C2.13843 3.20366 1.56195 3.7405 1.56195 4.40274V11.5973C1.56195 12.2595 2.13843 12.7963 2.84956 12.7963H5.69262" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.71237 3.20366V5.75366" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.56195 5.75365H14.4381" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.13715 3.20366V5.75366" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.01288 13.0158H10.8129M10.8129 13.0158H12.6129M10.8129 13.0158V11.2158M10.8129 13.0158V14.8158" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 941 B

View file

@ -1071,6 +1071,8 @@
"ctrl-shift-enter": "git::Amend",
"alt-up": "git_panel::FocusChanges",
"alt-l": "git::GenerateCommitMessage",
"shift-escape": "git::ExpandCommitEditor",
"alt-shift-escape": "git::ToggleFillCommitEditor",
},
},
{

View file

@ -1102,6 +1102,7 @@
"shift-tab": "git_panel::FocusChanges",
"alt-up": "git_panel::FocusChanges",
"shift-escape": "git::ExpandCommitEditor",
"alt-shift-escape": "git::ToggleFillCommitEditor",
"alt-tab": "git::GenerateCommitMessage",
},
},

View file

@ -1069,6 +1069,8 @@
"ctrl-shift-enter": "git::Amend",
"alt-up": "git_panel::FocusChanges",
"alt-l": "git::GenerateCommitMessage",
"shift-escape": "git::ExpandCommitEditor",
"alt-shift-escape": "git::ToggleFillCommitEditor",
},
},
{
@ -1226,6 +1228,7 @@
"ctrl-delete": ["terminal::SendText", "\u001bd"],
"ctrl-n": "workspace::NewTerminal",
// Overrides for conflicting keybindings
"alt-f4": "workspace::CloseWindow",
"ctrl-b": ["terminal::SendKeystroke", "ctrl-b"],
"ctrl-c": ["terminal::SendKeystroke", "ctrl-c"],
"ctrl-e": ["terminal::SendKeystroke", "ctrl-e"],

View file

@ -1033,6 +1033,15 @@
"enter": "menu::Cancel",
},
},
{
"context": "GitGraph",
"bindings": {
"j": "vim::MenuSelectNext",
"k": "vim::MenuSelectPrevious",
"shift-g": "menu::SelectLast",
"g g": "menu::SelectFirst"
}
},
{
"context": "GitPanel && ChangesList && !GitBranchSelector",
"use_key_equivalents": true,

View file

@ -361,6 +361,8 @@
// 1. Vertically center the target in the viewport: `center` (default)
// 2. Scroll the minimum amount needed to make the target visible: `minimum`
// 3. Scroll so the target appears near the top of the viewport: `top`
// 4. Preserve the cursor's vertical position within the viewport, falling back to `center` when the cursor is
// offscreen: `preserve`
"go_to_definition_scroll_strategy": "center",
// Which level to use to filter out diagnostics displayed in the editor.
//
@ -2350,9 +2352,6 @@
"mistral": {
"api_url": "https://api.mistral.ai/v1",
},
"vercel": {
"api_url": "https://api.v0.dev/v1",
},
"vercel_ai_gateway": {
"api_url": "https://ai-gateway.vercel.sh/v1",
},

View file

@ -1 +0,0 @@
words = ["breakpoint"]

View file

@ -729,13 +729,7 @@ impl Render for ActivityIndicator {
}
})
.label_size(LabelSize::Small)
.when(content.icon.is_some(), |this| {
this.start_icon(
Icon::new(IconName::LoadCircle)
.color(Color::Muted)
.size(IconSize::Small),
)
})
.loading(content.icon.is_some())
.map(|button| {
if truncate_content {
button.tooltip(Tooltip::text(content.message))

View file

@ -261,7 +261,7 @@ impl DbThread {
tool_use_id: tool_result.tool_use_id,
tool_name: name.into(),
is_error: tool_result.is_error,
content: tool_result.content,
content: vec![tool_result.content],
output: tool_result.output,
},
);

View file

@ -1156,7 +1156,7 @@ fn tool_result(
tool_use_id: LanguageModelToolUseId::from(id.into()),
tool_name: name.into(),
is_error: false,
content: LanguageModelToolResultContent::Text(result.into()),
content: vec![LanguageModelToolResultContent::Text(result.into())],
output: None,
})
}

View file

@ -3,6 +3,15 @@ use shell_command_parser::{extract_commands, extract_terminal_command_prefix};
use std::path::{Path, PathBuf};
use url::Url;
/// Escapes a string for use in a regex pattern, but leaves dashes unescaped.
///
/// `regex::escape()` escapes dashes, but they are only special inside `[]`
/// character classes. Leaving them unescaped produces cleaner patterns
/// (e.g. `^git-lfs\s+pull` instead of `^git\-lfs\s+pull`).
fn escape_for_pattern(text: &str) -> String {
regex::escape(text).replace("\\-", "-")
}
/// Normalize path separators to forward slashes for consistent cross-platform patterns.
fn normalize_separators(path_str: &str) -> String {
path_str.replace('\\', "/")
@ -64,14 +73,14 @@ pub fn extract_terminal_pattern(command: &str) -> Option<String> {
match tokens.as_slice() {
[] => None,
[single] => Some(format!("^{}\\b", regex::escape(single))),
[single] => Some(format!("^{}\\b", escape_for_pattern(single))),
[rest @ .., last] => Some(format!(
"^{}\\s+{}(\\s|$)",
rest.iter()
.map(|token| regex::escape(token))
.map(|token| escape_for_pattern(token))
.collect::<Vec<_>>()
.join("\\s+"),
regex::escape(last)
escape_for_pattern(last)
)),
}
}
@ -116,7 +125,7 @@ pub fn extract_path_pattern(path: &str) -> Option<String> {
if parent_str.is_empty() || parent_str == "/" {
return None;
}
Some(format!("^{}/", regex::escape(&parent_str)))
Some(format!("^{}/", escape_for_pattern(&parent_str)))
}
pub fn extract_path_pattern_display(path: &str) -> Option<String> {
@ -156,7 +165,7 @@ pub fn extract_copy_move_pattern(input: &str) -> Option<String> {
if common_str.is_empty() || common_str == "/" {
return None;
}
Some(format!("^{}/", regex::escape(&common_str)))
Some(format!("^{}/", escape_for_pattern(&common_str)))
}
pub fn extract_copy_move_pattern_display(input: &str) -> Option<String> {
@ -172,7 +181,7 @@ pub fn extract_copy_move_pattern_display(input: &str) -> Option<String> {
pub fn extract_url_pattern(url: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
let domain = parsed.host_str()?;
Some(format!("^https?://{}", regex::escape(domain)))
Some(format!("^https?://{}", escape_for_pattern(domain)))
}
pub fn extract_url_pattern_display(url: &str) -> Option<String> {
@ -201,7 +210,7 @@ mod tests {
);
assert_eq!(
extract_terminal_pattern("git-lfs pull"),
Some("^git\\-lfs\\s+pull(\\s|$)".to_string())
Some("^git-lfs\\s+pull(\\s|$)".to_string())
);
assert_eq!(
extract_terminal_pattern("my_script arg"),
@ -244,7 +253,7 @@ mod tests {
);
assert_eq!(
extract_terminal_pattern("PAGER='less -R' git log"),
Some("^PAGER='less \\-R'\\s+git\\s+log(\\s|$)".to_string())
Some("^PAGER='less -R'\\s+git\\s+log(\\s|$)".to_string())
);
// Path-like commands are rejected
@ -396,6 +405,22 @@ mod tests {
);
}
#[test]
fn test_dashes_are_not_escaped() {
assert_eq!(
extract_terminal_pattern("git-lfs pull"),
Some("^git-lfs\\s+pull(\\s|$)".to_string())
);
assert_eq!(
extract_url_pattern("https://typescript-eslint.io/rules/no-unused-vars"),
Some("^https?://typescript-eslint\\.io".to_string())
);
assert_eq!(
extract_path_pattern("/my-project/sub-dir/file.rs"),
Some("^/my-project/sub-dir/".to_string())
);
}
#[test]
fn test_special_chars_are_escaped() {
assert_eq!(

View file

@ -210,11 +210,6 @@ async fn test_streaming_edit_json_parse_error_does_not_cause_unsaved_changes(
super::init_test(cx);
super::always_allow_tools(cx);
// Enable the streaming edit file tool feature flag.
cx.update(|cx| {
cx.update_flags(true, vec!["streaming-edit-file-tool".to_string()]);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
@ -387,10 +382,7 @@ async fn test_streaming_edit_json_parse_error_does_not_cause_unsaved_changes(
"Tool result should succeed, got: {:?}",
tool_result
);
let content_text = match &tool_result.content {
language_model::LanguageModelToolResultContent::Text(t) => t.to_string(),
other => panic!("Expected text content, got: {:?}", other),
};
let content_text = tool_result.text_contents();
assert!(
!content_text.contains("file has been modified since you last read it"),
"Did not expect a stale last-read error, got: {content_text}"

View file

@ -494,7 +494,9 @@ async fn test_system_prompt(cx: &mut TestAppContext) {
assert_eq!(pending_completion.messages[0].role, Role::System);
let system_message = &pending_completion.messages[0];
let system_prompt = system_message.content[0].to_str().unwrap();
let MessageContent::Text(system_prompt) = &system_message.content[0] else {
panic!("Expected text content");
};
assert!(
system_prompt.contains("test-shell"),
"unexpected system message: {:?}",
@ -530,7 +532,9 @@ async fn test_system_prompt_without_tools(cx: &mut TestAppContext) {
assert_eq!(pending_completion.messages[0].role, Role::System);
let system_message = &pending_completion.messages[0];
let system_prompt = system_message.content[0].to_str().unwrap();
let MessageContent::Text(system_prompt) = &system_message.content[0] else {
panic!("Expected text content");
};
assert!(
!system_prompt.contains("## Tool Use"),
"unexpected system message: {:?}",
@ -637,7 +641,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) {
tool_use_id: "tool_1".into(),
tool_name: EchoTool::NAME.into(),
is_error: false,
content: "test".into(),
content: vec!["test".into()],
output: Some("test".into()),
};
assert_eq!(
@ -866,14 +870,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
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(),
content: vec!["Allowed".into()],
output: Some("Allowed".into())
}),
language_model::MessageContent::ToolResult(LanguageModelToolResult {
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(),
content: vec!["Permission to run tool denied by user".into()],
output: Some("Permission to run tool denied by user".into())
})
]
@ -912,7 +916,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
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(),
content: vec!["Allowed".into()],
output: Some("Allowed".into())
}
)]
@ -940,7 +944,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
tool_use_id: "tool_id_4".into(),
tool_name: ToolRequiringPermission::NAME.into(),
is_error: false,
content: "Allowed".into(),
content: vec!["Allowed".into()],
output: Some("Allowed".into())
}
)]
@ -1453,6 +1457,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
"test_server",
vec![context_server::types::Tool {
name: "echo".into(),
title: None,
description: None,
input_schema: serde_json::to_value(EchoTool::input_schema(
LanguageModelToolSchemaFormat::JsonSchema,
@ -1562,14 +1567,14 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
tool_use_id: "tool_3".into(),
tool_name: "echo".into(),
is_error: false,
content: "native".into(),
content: vec!["native".into()],
output: Some("native".into()),
},),
MessageContent::ToolResult(LanguageModelToolResult {
tool_use_id: "tool_2".into(),
tool_name: "test_server_echo".into(),
is_error: false,
content: "mcp".into(),
content: vec!["mcp".into()],
output: Some("mcp".into()),
},),
]
@ -1578,6 +1583,127 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
events.collect::<Vec<_>>().await;
}
#[gpui::test]
async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) {
let ThreadTest {
model,
thread,
context_server_store,
fs,
..
} = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
fake_model.set_supports_images(true);
fs.insert_file(
paths::settings_file(),
json!({
"agent": {
"tool_permissions": { "default": "allow" },
"profiles": {
"test": {
"name": "Test Profile",
"enable_all_context_servers": true,
"tools": {}
},
}
}
})
.to_string()
.into_bytes(),
)
.await;
cx.run_until_parked();
thread.update(cx, |thread, cx| {
thread.set_profile(AgentProfileId("test".into()), cx)
});
let mut mcp_tool_calls = setup_context_server(
"screenshot_server",
vec![context_server::types::Tool {
name: "screenshot".into(),
title: None,
description: None,
input_schema: json!({"type": "object", "properties": {}}),
output_schema: None,
annotations: None,
}],
&context_server_store,
cx,
);
let events = thread.update(cx, |thread, cx| {
thread
.send(UserMessageId::new(), ["Take a screenshot"], cx)
.unwrap()
});
cx.run_until_parked();
let completion = fake_model.pending_completions().pop().unwrap();
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: "tool_1".into(),
name: "screenshot".into(),
raw_input: json!({}).to_string(),
input: json!({}),
is_input_complete: true,
thought_signature: None,
},
));
fake_model.end_last_completion_stream();
cx.run_until_parked();
let _ = completion;
let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
assert_eq!(tool_call_params.name, "screenshot");
tool_call_response
.send(context_server::types::CallToolResponse {
content: vec![
context_server::types::ToolResponseContent::Text {
text: "Some text".into(),
},
context_server::types::ToolResponseContent::Image {
data: "aGVsbG8=".into(),
mime_type: "image/png".into(),
},
context_server::types::ToolResponseContent::Text {
text: "Some more text".into(),
},
],
is_error: None,
meta: None,
structured_content: None,
})
.unwrap();
cx.run_until_parked();
// Verify the tool result round-trips back to the model as a multi-part Vec.
let completion = fake_model.pending_completions().pop().unwrap();
let tool_result = completion
.messages
.last()
.unwrap()
.content
.iter()
.find_map(|c| match c {
MessageContent::ToolResult(r) => Some(r.clone()),
_ => None,
})
.expect("expected a tool result");
assert_eq!(tool_result.tool_use_id, "tool_1".into());
assert_eq!(tool_result.content.len(), 2);
assert_eq!(
tool_result.content[0],
language_model::LanguageModelToolResultContent::Text(Arc::from("Some text"))
);
assert_eq!(
tool_result.content[1],
language_model::LanguageModelToolResultContent::Text(Arc::from("Some more text"))
);
fake_model.end_last_completion_stream();
events.collect::<Vec<_>>().await;
}
#[gpui::test]
async fn test_mcp_tool_result_displayed_when_server_disconnected(cx: &mut TestAppContext) {
let ThreadTest {
@ -1618,6 +1744,7 @@ async fn test_mcp_tool_result_displayed_when_server_disconnected(cx: &mut TestAp
"github_server",
vec![context_server::types::Tool {
name: "issue_read".into(),
title: None,
description: Some("Read a GitHub issue".into()),
input_schema: json!({
"type": "object",
@ -1812,6 +1939,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
vec![
context_server::types::Tool {
name: "echo".into(), // Conflicts with native EchoTool
title: None,
description: None,
input_schema: serde_json::to_value(EchoTool::input_schema(
LanguageModelToolSchemaFormat::JsonSchema,
@ -1822,6 +1950,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
},
context_server::types::Tool {
name: "unique_tool_1".into(),
title: None,
description: None,
input_schema: json!({"type": "object", "properties": {}}),
output_schema: None,
@ -1837,6 +1966,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
vec![
context_server::types::Tool {
name: "echo".into(), // Also conflicts with native EchoTool
title: None,
description: None,
input_schema: serde_json::to_value(EchoTool::input_schema(
LanguageModelToolSchemaFormat::JsonSchema,
@ -1847,6 +1977,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
},
context_server::types::Tool {
name: "unique_tool_2".into(),
title: None,
description: None,
input_schema: json!({"type": "object", "properties": {}}),
output_schema: None,
@ -1854,6 +1985,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
},
context_server::types::Tool {
name: "a".repeat(MAX_TOOL_NAME_LENGTH - 2),
title: None,
description: None,
input_schema: json!({"type": "object", "properties": {}}),
output_schema: None,
@ -1861,6 +1993,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
},
context_server::types::Tool {
name: "b".repeat(MAX_TOOL_NAME_LENGTH - 1),
title: None,
description: None,
input_schema: json!({"type": "object", "properties": {}}),
output_schema: None,
@ -1875,6 +2008,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
vec![
context_server::types::Tool {
name: "a".repeat(MAX_TOOL_NAME_LENGTH - 2),
title: None,
description: None,
input_schema: json!({"type": "object", "properties": {}}),
output_schema: None,
@ -1882,6 +2016,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
},
context_server::types::Tool {
name: "b".repeat(MAX_TOOL_NAME_LENGTH - 1),
title: None,
description: None,
input_schema: json!({"type": "object", "properties": {}}),
output_schema: None,
@ -1889,6 +2024,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
},
context_server::types::Tool {
name: "c".repeat(MAX_TOOL_NAME_LENGTH + 1),
title: None,
description: None,
input_schema: json!({"type": "object", "properties": {}}),
output_schema: None,
@ -1904,6 +2040,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
"Azure DevOps",
vec![context_server::types::Tool {
name: "echo".into(), // Also conflicts - will be disambiguated as azure_dev_ops_echo
title: None,
description: None,
input_schema: serde_json::to_value(EchoTool::input_schema(
LanguageModelToolSchemaFormat::JsonSchema,
@ -2106,10 +2243,7 @@ async fn test_terminal_tool_cancellation_captures_output(cx: &mut TestAppContext
.get(&tool_use.id)
.expect("expected tool result");
let result_text = match &tool_result.content {
language_model::LanguageModelToolResultContent::Text(text) => text.to_string(),
_ => panic!("expected text content in tool result"),
};
let result_text = tool_result.text_contents();
// "partial output" comes from FakeTerminalHandle's output field
assert!(
@ -2571,10 +2705,7 @@ async fn test_terminal_tool_stopped_via_terminal_card_button(cx: &mut TestAppCon
.get(&tool_use.id)
.expect("expected tool result");
let result_text = match &tool_result.content {
language_model::LanguageModelToolResultContent::Text(text) => text.to_string(),
_ => panic!("expected text content in tool result"),
};
let result_text = tool_result.text_contents();
assert!(
result_text.contains("The user stopped this command"),
@ -2666,10 +2797,7 @@ async fn test_terminal_tool_timeout_expires(cx: &mut TestAppContext) {
.get(&tool_use.id)
.expect("expected tool result");
let result_text = match &tool_result.content {
language_model::LanguageModelToolResultContent::Text(text) => text.to_string(),
_ => panic!("expected text content in tool result"),
};
let result_text = tool_result.text_contents();
assert!(
result_text.contains("timed out"),
@ -3290,7 +3418,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) {
tool_use_id: echo_tool_use.id.clone(),
tool_name: echo_tool_use.name,
is_error: false,
content: "test".into(),
content: vec!["test".into()],
output: Some("test".into())
})],
cache: false,
@ -3776,7 +3904,7 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) {
tool_use_id: tool_use_1.id.clone(),
tool_name: tool_use_1.name.clone(),
is_error: false,
content: "test".into(),
content: vec!["test".into()],
output: Some("test".into())
}
)],
@ -3936,8 +4064,10 @@ async fn test_streaming_tool_completes_when_llm_stream_ends_without_final_input(
tool_use_id: tool_use.id.clone(),
tool_name: tool_use.name,
is_error: true,
content: "Failed to receive tool input: tool input was not fully received"
.into(),
content: vec![
"Failed to receive tool input: tool input was not fully received"
.into(),
],
output: Some(
"Failed to receive tool input: tool input was not fully received"
.into()
@ -4044,10 +4174,7 @@ async fn test_streaming_tool_json_parse_error_is_forwarded_to_running_tool(
let result = tool_results[0];
assert!(result.is_error);
let content_text = match &result.content {
language_model::LanguageModelToolResultContent::Text(text) => text.to_string(),
other => panic!("Expected text content, got {:?}", other),
};
let content_text = result.text_contents();
assert!(
content_text.contains("Saw partial text 'partial' before invalid JSON"),
"Expected tool-enriched partial context, got: {content_text}"
@ -4292,7 +4419,9 @@ fn setup_context_server(
),
server_info: context_server::types::Implementation {
name: name.into(),
title: None,
version: "1.0.0".to_string(),
description: None,
},
capabilities: context_server::types::ServerCapabilities {
tools: Some(context_server::types::ToolsCapabilities {
@ -7069,7 +7198,7 @@ async fn test_streaming_tool_error_breaks_stream_loop_immediately(cx: &mut TestA
tool_use_id: tool_use.id.clone(),
tool_name: tool_use.name,
is_error: true,
content: "failed".into(),
content: vec!["failed".into()],
output: Some("failed".into()),
}
)],
@ -7180,14 +7309,14 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te
tool_use_id: second_tool_use.id.clone(),
tool_name: second_tool_use.name,
is_error: true,
content: "failed".into(),
content: vec!["failed".into()],
output: Some("failed".into()),
}),
language_model::MessageContent::ToolResult(LanguageModelToolResult {
tool_use_id: first_tool_use.id.clone(),
tool_name: first_tool_use.name,
is_error: false,
content: "hello world".into(),
content: vec!["hello world".into()],
output: Some("hello world".into()),
}),
],

View file

@ -8,9 +8,7 @@ use crate::{
};
use acp_thread::{MentionUri, UserMessageId};
use action_log::ActionLog;
use feature_flags::{
FeatureFlagAppExt as _, StreamingEditFileToolFeatureFlag, UpdatePlanToolFeatureFlag,
};
use feature_flags::{FeatureFlagAppExt as _, UpdatePlanToolFeatureFlag};
use agent_client_protocol::schema as acp;
use agent_settings::{
@ -518,12 +516,14 @@ impl AgentMessage {
markdown.push_str("**ERROR:**\n");
}
match &tool_result.content {
LanguageModelToolResultContent::Text(text) => {
writeln!(markdown, "{text}\n").ok();
}
LanguageModelToolResultContent::Image(_) => {
writeln!(markdown, "<image />\n").ok();
for part in &tool_result.content {
match part {
LanguageModelToolResultContent::Text(text) => {
writeln!(markdown, "{text}\n").ok();
}
LanguageModelToolResultContent::Image(_) => {
writeln!(markdown, "<image />\n").ok();
}
}
}
@ -588,8 +588,8 @@ impl AgentMessage {
let mut tool_result = tool_result.clone();
// Surprisingly, the API fails if we return an empty string here.
// It thinks we are sending a tool use without a tool result.
if tool_result.content.is_empty() {
tool_result.content = "<Tool returned an empty string>".into();
if tool_result.is_content_empty() {
tool_result.content = vec!["<Tool returned an empty string>".into()];
}
user_message
.content
@ -2332,7 +2332,7 @@ impl Thread {
let Some(tool) = tool else {
let content = format!("No tool named {} exists", tool_use.name);
return Some(Task::ready(LanguageModelToolResult {
content: LanguageModelToolResultContent::Text(Arc::from(content)),
content: vec![LanguageModelToolResultContent::Text(Arc::from(content))],
tool_use_id: tool_use.id,
tool_name: tool_use.name,
is_error: true,
@ -2418,13 +2418,40 @@ impl Thread {
cx.foreground_executor().spawn(async move {
let (is_error, output) = match tool_result.await {
Ok(mut output) => {
if let LanguageModelToolResultContent::Image(_) = &output.llm_output
&& !supports_images
{
output = AgentToolOutput::from_error(
"Attempted to read an image, but this model doesn't support it.",
);
(true, output)
let contains_image = output
.llm_output
.iter()
.any(|part| matches!(part, LanguageModelToolResultContent::Image(_)));
if contains_image && !supports_images {
// Replace each image part with an inline placeholder so
// any accompanying text is still presented to the model.
// If there's nothing else in the output, surface an error
// to match the pre-multi-part behavior for image-only
// tool results.
let placeholder = LanguageModelToolResultContent::Text(Arc::from(
"[Tool responded with an image, but this model doesn't support images]",
));
let has_non_image = output
.llm_output
.iter()
.any(|part| !matches!(part, LanguageModelToolResultContent::Image(_)));
if has_non_image {
output.llm_output = output
.llm_output
.into_iter()
.map(|part| match part {
LanguageModelToolResultContent::Image(_) => placeholder.clone(),
other => other,
})
.collect();
(false, output)
} else {
let output = anyhow::anyhow!(
"Attempted to read an image, but this model doesn't support it.",
)
.into();
(true, output)
}
} else {
(false, output)
}
@ -2472,7 +2499,7 @@ impl Thread {
let Some(tool) = tool else {
let content = format!("No tool named {} exists", tool_use.name);
return Some(Task::ready(LanguageModelToolResult {
content: LanguageModelToolResultContent::Text(Arc::from(content)),
content: vec![LanguageModelToolResultContent::Text(Arc::from(content))],
tool_use_id: tool_use.id,
tool_name: tool_use.name,
is_error: true,
@ -2743,7 +2770,9 @@ impl Thread {
tool_use_id: tool_use.id.clone(),
tool_name: tool_use.name.clone(),
is_error: true,
content: LanguageModelToolResultContent::Text(TOOL_CANCELED_MESSAGE.into()),
content: vec![LanguageModelToolResultContent::Text(
TOOL_CANCELED_MESSAGE.into(),
)],
output: None,
},
);
@ -2836,8 +2865,7 @@ impl Thread {
}
}
let use_streaming_edit_tool =
cx.has_flag::<StreamingEditFileToolFeatureFlag>() && model.supports_streaming_tools();
let use_streaming_edit_tool = model.supports_streaming_tools();
let mut tools = self
.tools
@ -3392,16 +3420,19 @@ where
pub struct Erased<T>(T);
pub struct AgentToolOutput {
pub llm_output: LanguageModelToolResultContent,
pub llm_output: Vec<LanguageModelToolResultContent>,
pub raw_output: serde_json::Value,
}
impl AgentToolOutput {
pub fn from_error(message: impl Into<String>) -> Self {
let message = message.into();
let llm_output = LanguageModelToolResultContent::Text(Arc::from(message.as_str()));
impl From<anyhow::Error> for AgentToolOutput {
fn from(error: anyhow::Error) -> Self {
let llm_output = vec![error.into()];
let raw_output = serde_json::to_value(&llm_output).unwrap_or_else(|e| {
log::error!("Failed to serialize tool output: {e}");
serde_json::Value::Null
});
Self {
raw_output: serde_json::Value::String(message),
raw_output,
llm_output,
}
}
@ -3480,12 +3511,13 @@ where
let task = self.0.clone().run(tool_input, event_stream, cx);
cx.spawn(async move |_cx| match task.await {
Ok(output) => {
let raw_output = serde_json::to_value(&output).map_err(|e| {
AgentToolOutput::from_error(format!("Failed to serialize tool output: {e}"))
})?;
let raw_output = serde_json::to_value(&output).unwrap_or_else(|e| {
log::error!("Failed to serialize tool output: {e}");
serde_json::Value::Null
});
Ok(AgentToolOutput {
llm_output: output.into(),
raw_output,
llm_output: vec![output.into()],
})
}
Err(error_output) => {
@ -3494,7 +3526,7 @@ where
serde_json::Value::Null
});
Err(AgentToolOutput {
llm_output: error_output.into(),
llm_output: vec![error_output.into()],
raw_output,
})
}
@ -4518,8 +4550,8 @@ mod tests {
assert_eq!(result.tool_use_id, tool_use_id);
assert_eq!(result.tool_name, tool_name);
assert!(matches!(
result.content,
LanguageModelToolResultContent::Text(_)
result.content.as_slice(),
[LanguageModelToolResultContent::Text(_)]
));
thread.update(cx, |thread, _cx| {

View file

@ -5,6 +5,7 @@ use collections::{BTreeMap, HashMap};
use context_server::{ContextServerId, client::NotificationSubscription};
use futures::FutureExt as _;
use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task};
use language_model::LanguageModelToolResultContent;
use project::context_server_store::{ContextServerStatus, ContextServerStore};
use std::sync::Arc;
use util::ResultExt;
@ -336,7 +337,7 @@ impl AnyAgentTool for ContextServerTool {
cx: &mut App,
) -> Task<Result<AgentToolOutput, AgentToolOutput>> {
let Some(server) = self.store.read(cx).get_running_server(&self.server_id) else {
return Task::ready(Err(AgentToolOutput::from_error("Context server not found")));
return Task::ready(Err(anyhow::anyhow!("Context server not found").into()));
};
let tool_name = self.tool.name.clone();
let tool_id = mcp_tool_id(&self.server_id.0, &self.tool.name);
@ -346,14 +347,17 @@ impl AnyAgentTool for ContextServerTool {
event_stream.authorize_third_party_tool(initial_title, tool_id, display_name, cx);
cx.spawn(async move |_cx| {
let input = input.recv().await.map_err(|e| {
AgentToolOutput::from_error(format!("Failed to receive tool input: {e}"))
})?;
let input = input
.recv()
.await
.map_err(|e| anyhow::anyhow!(format!("Failed to receive tool input: {e}")))?;
authorize.await.map_err(|e| AgentToolOutput::from_error(e.to_string()))?;
authorize
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let Some(protocol) = server.client() else {
return Err(AgentToolOutput::from_error("Context server not initialized"));
return Err(anyhow::anyhow!("Context server not initialized").into());
};
let arguments = if let serde_json::Value::Object(map) = input {
@ -377,23 +381,25 @@ impl AnyAgentTool for ContextServerTool {
);
let response = futures::select! {
response = request.fuse() => response.map_err(|e| AgentToolOutput::from_error(e.to_string()))?,
response = request.fuse() => response?,
_ = event_stream.cancelled_by_user().fuse() => {
return Err(AgentToolOutput::from_error("MCP tool cancelled by user"));
return Err(anyhow::anyhow!("MCP tool cancelled by user").into());
}
};
if response.is_error == Some(true) {
let error_message: String =
response.content.iter().filter_map(|c| c.text()).collect();
return Err(AgentToolOutput::from_error(error_message));
return Err(anyhow::anyhow!(error_message).into());
}
let mut result = String::new();
let mut llm_output = Vec::new();
let mut concatenated_text = String::new();
for content in response.content {
match content {
context_server::types::ToolResponseContent::Text { text } => {
result.push_str(&text);
concatenated_text.push_str(&text);
llm_output.push(LanguageModelToolResultContent::Text(text.into()));
}
context_server::types::ToolResponseContent::Image { .. } => {
log::warn!("Ignoring image content from tool response");
@ -404,11 +410,15 @@ impl AnyAgentTool for ContextServerTool {
context_server::types::ToolResponseContent::Resource { .. } => {
log::warn!("Ignoring resource content from tool response");
}
context_server::types::ToolResponseContent::ResourceLink { .. } => {
log::warn!("Ignoring resource link content from tool response");
}
}
}
let raw_output = serde_json::Value::String(concatenated_text);
Ok(AgentToolOutput {
raw_output: result.clone().into(),
llm_output: result.into(),
raw_output,
llm_output,
})
})
}

View file

@ -666,7 +666,7 @@ fn tool_result(
tool_use_id: LanguageModelToolUseId::from(id.into()),
tool_name: name.into(),
is_error: false,
content: LanguageModelToolResultContent::Text(result.into()),
content: vec![LanguageModelToolResultContent::Text(result.into())],
output: None,
})
}

View file

@ -13,8 +13,10 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput};
#[schemars(inline)]
pub enum Timezone {
/// Use UTC for the datetime.
#[serde(alias = "UTC", alias = "Utc")]
Utc,
/// Use local time for the datetime.
#[serde(alias = "LOCAL", alias = "Local")]
Local,
}

View file

@ -585,8 +585,7 @@ fn update_command_palette_filter(cx: &mut App) {
| EditPredictionProvider::Codestral
| EditPredictionProvider::Ollama
| EditPredictionProvider::OpenAiCompatibleApi
| EditPredictionProvider::Mercury
| EditPredictionProvider::Experimental(_) => {
| EditPredictionProvider::Mercury => {
filter.show_namespace("edit_prediction");
filter.hide_namespace("copilot");
filter.show_action_types(edit_prediction_actions.iter());

View file

@ -88,7 +88,7 @@ use crate::profile_selector::{ProfileProvider, ProfileSelector};
use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
use crate::ui::{AgentNotification, AgentNotificationEvent};
use crate::{
Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce,
Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AgentPanelEvent, AllowAlways, AllowOnce,
AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector,
CycleThinkingEffort, EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread,
OpenAddContextMenu, OpenAgentDiff, RejectAll, RejectOnce, RemoveFirstQueuedMessage,
@ -2426,23 +2426,61 @@ impl ConversationView {
self.notifications.push(screen_window);
// If the user manually refocuses the original window, dismiss the popup.
self.notification_subscriptions
.entry(screen_window)
.or_insert_with(Vec::new)
.push({
let pop_up_weak = pop_up.downgrade();
let dismiss_if_visible = {
let pop_up_weak = pop_up.downgrade();
move |this: &ConversationView,
window: &mut Window,
cx: &mut Context<ConversationView>| {
if this.agent_status_visible(window, cx)
&& let Some(pop_up) = pop_up_weak.upgrade()
{
pop_up.update(cx, |notification, cx| {
notification.dismiss(cx);
});
}
}
};
cx.observe_window_activation(window, move |this, window, cx| {
if this.agent_status_visible(window, cx)
&& let Some(pop_up) = pop_up_weak.upgrade()
{
pop_up.update(cx, |notification, cx| {
notification.dismiss(cx);
});
let subscriptions = self
.notification_subscriptions
.entry(screen_window)
.or_insert_with(Vec::new);
subscriptions.push({
let dismiss_if_visible = dismiss_if_visible.clone();
cx.observe_window_activation(window, move |this, window, cx| {
dismiss_if_visible(this, window, cx);
})
});
if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
let dismiss_if_visible = dismiss_if_visible.clone();
subscriptions.push(cx.observe_in(
&multi_workspace,
window,
move |this, _, window, cx| {
dismiss_if_visible(this, window, cx);
},
));
}
if let Some(panel) = self
.workspace
.upgrade()
.and_then(|workspace| workspace.read(cx).panel::<AgentPanel>(cx))
{
subscriptions.push(cx.subscribe_in(
&panel,
window,
move |this, _, event: &AgentPanelEvent, window, cx| match event {
AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ThreadFocused => {
dismiss_if_visible(this, window, cx);
}
})
});
AgentPanelEvent::RetainedThreadChanged
| AgentPanelEvent::ThreadInteracted { .. } => {}
},
));
}
}
}
@ -3657,6 +3695,94 @@ pub(crate) mod tests {
);
}
#[gpui::test]
async fn test_notification_dismissed_when_sidebar_opens(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
cx.update(|cx| {
cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
<dyn Fs>::set_global(fs.clone(), cx);
});
let project = Project::test(fs, [], cx).await;
let multi_workspace_handle =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace_handle
.read_with(cx, |mw, _cx| mw.workspace().clone())
.unwrap();
let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
let connection_store =
cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
let conversation_view = cx.update(|window, cx| {
cx.new(|cx| {
ConversationView::new(
Rc::new(StubAgentServer::default_response()),
connection_store,
Agent::Custom { id: "Test".into() },
None,
None,
None,
None,
None,
workspace.downgrade(),
project.clone(),
Some(thread_store),
None,
"agent_panel",
window,
cx,
)
})
});
cx.run_until_parked();
let message_editor = message_editor(&conversation_view, cx);
message_editor.update_in(cx, |editor, window, cx| {
editor.set_text("Hello", window, cx);
});
active_thread(&conversation_view, cx)
.update_in(cx, |view, window, cx| view.send(window, cx));
cx.run_until_parked();
assert_eq!(
cx.windows()
.iter()
.filter(|window| window.downcast::<AgentNotification>().is_some())
.count(),
1,
"Expected a notification while the thread is not visible"
);
multi_workspace_handle
.update(cx, |mw, _window, cx| {
mw.open_sidebar(cx);
})
.unwrap();
cx.run_until_parked();
assert_eq!(
cx.windows()
.iter()
.filter(|window| window.downcast::<AgentNotification>().is_some())
.count(),
0,
"Notification should auto-dismiss when the sidebar opens and makes the thread visible"
);
}
#[gpui::test]
async fn test_notification_when_workspace_is_background_in_multi_workspace(
cx: &mut TestAppContext,

View file

@ -2364,11 +2364,9 @@ impl ThreadView {
.id("edited_files_list")
.max_h_40()
.overflow_y_scroll()
.children(
sorted_buffers
.into_iter()
.enumerate()
.flat_map(|(index, (buffer, diff))| {
.child(
v_flex().children(sorted_buffers.into_iter().enumerate().flat_map(
|(index, (buffer, diff))| {
let file = buffer.read(cx).file()?;
let path = file.path();
let path_style = file.path_style(cx);
@ -2471,7 +2469,8 @@ impl ThreadView {
.child(buttons);
Some(element)
}),
},
)),
)
.into_any_element()
}
@ -2854,66 +2853,69 @@ impl ThreadView {
.id("plan_items_list")
.max_h_40()
.overflow_y_scroll()
.children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
let entry_bg = cx.theme().colors().editor_background;
let tooltip_text: SharedString = entry.content.read(cx).source().to_string().into();
.child(
v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
let entry_bg = cx.theme().colors().editor_background;
let tooltip_text: SharedString =
entry.content.read(cx).source().to_string().into();
Some(
h_flex()
.id(("plan_entry_row", index))
.py_1()
.px_2()
.gap_2()
.justify_between()
.relative()
.bg(entry_bg)
.when(index < plan.entries.len() - 1, |parent| {
parent.border_color(cx.theme().colors().border).border_b_1()
})
.overflow_hidden()
.child(
h_flex()
.id(("plan_entry", index))
.gap_1p5()
.min_w_0()
.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),
)),
)
.child(div().absolute().top_0().right_0().h_full().w_8().bg(
linear_gradient(
90.,
linear_color_stop(entry_bg, 1.),
linear_color_stop(entry_bg.opacity(0.), 0.),
),
))
.tooltip(Tooltip::text(tooltip_text)),
)
}))
Some(
h_flex()
.id(("plan_entry_row", index))
.py_1()
.px_2()
.gap_2()
.justify_between()
.relative()
.bg(entry_bg)
.when(index < plan.entries.len() - 1, |parent| {
parent.border_color(cx.theme().colors().border).border_b_1()
})
.overflow_hidden()
.child(
h_flex()
.id(("plan_entry", index))
.gap_1p5()
.min_w_0()
.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),
)),
)
.child(div().absolute().top_0().right_0().h_full().w_8().bg(
linear_gradient(
90.,
linear_color_stop(entry_bg, 1.),
linear_color_stop(entry_bg.opacity(0.), 0.),
),
))
.tooltip(Tooltip::text(tooltip_text)),
)
})),
)
.into_any_element()
}
@ -3279,10 +3281,9 @@ impl ThreadView {
.child(
v_flex()
.when_some(max_content_width, |this, max_w| this.flex_basis(max_w))
.when(max_content_width.is_none(), |this| this.w_full())
.when(fills_container, |this| this.h_full())
.flex_shrink()
.flex_grow_0()
.when(fills_container, |this| this.h_full())
.justify_between()
.gap_2()
.child(
@ -3343,6 +3344,7 @@ impl ThreadView {
)
.child(
h_flex()
.flex_wrap()
.gap_1()
.children(self.render_token_usage(cx))
.children(self.profile_selector.clone())

View file

@ -162,7 +162,7 @@ fn migrate_thread_metadata(cx: &mut App) -> Task<anyhow::Result<()>> {
.push(entry);
}
for entries in per_project.values_mut() {
entries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
entries.sort_by_key(|entry| std::cmp::Reverse(entry.updated_at));
for entry in entries.iter_mut().take(5) {
entry.archived = false;
}
@ -2321,7 +2321,7 @@ mod tests {
.filter(|m| *m.folder_paths() == project_a_paths)
.collect();
assert_eq!(project_a_entries.len(), 7);
project_a_entries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
project_a_entries.sort_by_key(|entry| std::cmp::Reverse(entry.updated_at));
for entry in &project_a_entries[..5] {
assert!(

View file

@ -160,7 +160,7 @@ pub fn build_root_plan(
// Only linked worktrees can be archived to disk via `git worktree remove`.
// Main worktrees must be left alone — git refuses to remove them.
let (linked_snapshot, repo) = linked_repo?;
let main_repo_path = linked_snapshot.original_repo_abs_path.to_path_buf();
let main_repo_path = linked_snapshot.main_worktree_abs_path()?.to_path_buf();
// Only archive worktrees that live inside the Zed-managed worktrees
// directory (configured via `git.worktree_directory`). Worktrees the

View file

@ -1052,9 +1052,7 @@ impl Render for ThreadsArchiveView {
.size_full(),
)
.custom_scrollbars(
Scrollbars::new(ScrollAxes::Vertical)
.tracked_scroll_handle(&self.list_state)
.width_sm(),
Scrollbars::new(ScrollAxes::Vertical).tracked_scroll_handle(&self.list_state),
window,
cx,
)

View file

@ -70,25 +70,38 @@ fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
input: tool_use.input,
cache_control: None,
}),
MessageContent::ToolResult(tool_result) => Some(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) => {
MessageContent::ToolResult(tool_result) => {
let content = match tool_result.content.as_slice() {
[LanguageModelToolResultContent::Text(text)] => {
ToolResultContent::Plain(text.to_string())
}
LanguageModelToolResultContent::Image(image) => {
ToolResultContent::Multipart(vec![ToolResultPart::Image {
source: ImageSource {
source_type: "base64".to_string(),
media_type: "image/png".to_string(),
data: image.source.to_string(),
},
}])
_ => {
let parts = tool_result
.content
.into_iter()
.map(|part| match part {
LanguageModelToolResultContent::Text(text) => ToolResultPart::Text {
text: text.to_string(),
},
LanguageModelToolResultContent::Image(image) => ToolResultPart::Image {
source: ImageSource {
source_type: "base64".to_string(),
media_type: "image/png".to_string(),
data: image.source.to_string(),
},
},
})
.collect();
ToolResultContent::Multipart(parts)
}
},
cache_control: None,
}),
};
Some(RequestContent::ToolResult {
tool_use_id: tool_result.tool_use_id.to_string(),
is_error: tool_result.is_error,
content,
cache_control: None,
})
}
}
}

View file

@ -1101,7 +1101,7 @@ mod windows {
use crate::{Detect, InstalledApp};
use std::io;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use std::process::{ExitStatus, Stdio};
fn check_single_instance() -> bool {
let mutex = unsafe {
@ -1144,6 +1144,9 @@ mod windows {
if let Some(dir) = user_data_dir {
cmd.arg("--user-data-dir").arg(dir);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd.spawn()?;
} else {
unsafe {

View file

@ -46,6 +46,9 @@ pub struct PredictEditsV3Response {
pub editable_range: Range<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_version: Option<String>,
/// Predicted cursor offset within `output`.
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor_offset: Option<usize>,
}
#[derive(Debug, Deserialize, Serialize)]

View file

@ -112,6 +112,8 @@ CREATE TABLE "project_repositories" (
"remote_upstream_url" VARCHAR,
"remote_origin_url" VARCHAR,
"linked_worktrees" VARCHAR,
"repository_dir_abs_path" VARCHAR,
"common_dir_abs_path" VARCHAR,
PRIMARY KEY (project_id, id)
);

View file

@ -308,7 +308,9 @@ CREATE TABLE public.project_repositories (
merge_message character varying,
remote_upstream_url character varying,
remote_origin_url character varying,
linked_worktrees text
linked_worktrees text,
repository_dir_abs_path character varying,
common_dir_abs_path character varying
);
CREATE TABLE public.project_repository_statuses (
@ -333,7 +335,7 @@ CREATE TABLE public.projects (
host_connection_id integer,
host_connection_server_id integer,
windows_paths boolean DEFAULT false,
features text NOT NULL DEFAULT ''
features text DEFAULT ''::text NOT NULL
);
CREATE SEQUENCE public.projects_id_seq

View file

@ -379,6 +379,8 @@ impl Database {
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()),
repository_dir_abs_path: ActiveValue::set(update.repository_dir_abs_path.clone()),
common_dir_abs_path: ActiveValue::set(update.common_dir_abs_path.clone()),
linked_worktrees: ActiveValue::Set(Some(
serde_json::to_string(&update.linked_worktrees).unwrap(),
)),
@ -396,6 +398,8 @@ impl Database {
project_repository::Column::CurrentMergeConflicts,
project_repository::Column::HeadCommitDetails,
project_repository::Column::MergeMessage,
project_repository::Column::RepositoryDirAbsPath,
project_repository::Column::CommonDirAbsPath,
project_repository::Column::LinkedWorktrees,
])
.to_owned(),
@ -893,7 +897,8 @@ impl Database {
stash_entries: Vec::new(),
remote_upstream_url: db_repository_entry.remote_upstream_url.clone(),
remote_origin_url: db_repository_entry.remote_origin_url.clone(),
original_repo_abs_path: Some(db_repository_entry.abs_path),
repository_dir_abs_path: db_repository_entry.repository_dir_abs_path,
common_dir_abs_path: db_repository_entry.common_dir_abs_path,
linked_worktrees: db_repository_entry
.linked_worktrees
.as_deref()

View file

@ -800,7 +800,8 @@ impl Database {
stash_entries: Vec::new(),
remote_upstream_url: db_repository.remote_upstream_url.clone(),
remote_origin_url: db_repository.remote_origin_url.clone(),
original_repo_abs_path: Some(db_repository.abs_path),
repository_dir_abs_path: db_repository.repository_dir_abs_path,
common_dir_abs_path: db_repository.common_dir_abs_path,
linked_worktrees: db_repository
.linked_worktrees
.as_deref()

View file

@ -24,6 +24,8 @@ pub struct Model {
pub head_commit_details: Option<String>,
pub remote_upstream_url: Option<String>,
pub remote_origin_url: Option<String>,
pub repository_dir_abs_path: Option<String>,
pub common_dir_abs_path: Option<String>,
// JSON array of linked worktree objects
pub linked_worktrees: Option<String>,
}

View file

@ -947,10 +947,6 @@ impl Server {
)?;
}
if should_auto_subscribe_to_channels(&zed_version) {
subscribe_user_to_channels(user.id, session).await?;
}
if let Some(incoming_call) =
self.app_state.db.incoming_call_for_user(user.id).await?
{
@ -2748,10 +2744,6 @@ async fn remove_contact(
Ok(())
}
fn should_auto_subscribe_to_channels(version: &ZedVersion) -> bool {
version.0.minor < 139
}
async fn subscribe_to_channels(
_: proto::SubscribeToChannels,
session: MessageContext,

View file

@ -36,12 +36,6 @@ impl ZedVersion {
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 >= Version::new(0, 199, 0) && self.0 < Version::new(0, 199, 2) {
return false;
}
true
}
}

View file

@ -657,6 +657,11 @@ async fn test_channel_buffer_changes(
deterministic.advance_clock(ACKNOWLEDGE_DEBOUNCE_INTERVAL);
server
.simulate_long_connection_interruption(client_b.peer_id().unwrap(), deterministic.clone());
// Re-subscribe to channels after reconnection (simulates collab panel re-rendering)
client_b.initialize_channel_store(cx_b);
deterministic.run_until_parked();
channel_store_b.read_with(cx_b, |channel_store, _| {
assert!(!channel_store.has_channel_buffer_changed(channel_id))
});

View file

@ -24,6 +24,11 @@ async fn test_core_channels(
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
// Subscribe to channels (simulates opening the collab panel)
client_a.initialize_channel_store(cx_a);
client_b.initialize_channel_store(cx_b);
executor.run_until_parked();
let channel_a_id = client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
@ -290,6 +295,11 @@ async fn test_core_channels(
server.allow_connections();
executor.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
// Re-subscribe to channels after reconnection (simulates collab panel re-rendering)
client_a.initialize_channel_store(cx_a);
executor.run_until_parked();
assert_channels(
client_a.channel_store(),
cx_a,

View file

@ -951,11 +951,19 @@ async fn test_linked_worktrees_sync(
executor.run_until_parked();
// Verify host now sees 2 linked worktrees (feature-branch and hotfix-branch).
let host_linked_after_removal = project_a.read_with(cx_a, |project, cx| {
let repos = project.repositories(cx);
let repo = repos.values().next().unwrap();
repo.read(cx).linked_worktrees().to_vec()
});
let (host_linked_after_removal, host_git_paths_after_removal) =
project_a.read_with(cx_a, |project, cx| {
let repos = project.repositories(cx);
let repo = repos.values().next().unwrap();
let repo = repo.read(cx);
(
repo.linked_worktrees().to_vec(),
(
repo.repository_dir_abs_path.to_path_buf(),
repo.common_dir_abs_path.to_path_buf(),
),
)
});
assert_eq!(
host_linked_after_removal.len(),
2,
@ -998,6 +1006,19 @@ async fn test_linked_worktrees_sync(
late_joiner_linked, host_linked_after_removal,
"late-joining client's linked_worktrees should match host's (DB roundtrip)"
);
let late_joiner_git_paths = project_c.read_with(cx_c, |project, cx| {
let repos = project.repositories(cx);
let repo = repos.values().next().unwrap();
let repo = repo.read(cx);
(
repo.repository_dir_abs_path.to_path_buf(),
repo.common_dir_abs_path.to_path_buf(),
)
});
assert_eq!(
late_joiner_git_paths, host_git_paths_after_removal,
"late-joining client's git directory paths should match host's (DB roundtrip)"
);
// Test reconnection: disconnect client B (guest) and reconnect.
// After rejoining, client B should get linked_worktrees back from the DB.
@ -1010,20 +1031,32 @@ async fn test_linked_worktrees_sync(
executor.run_until_parked();
// Verify client B still has the correct linked worktrees after reconnection.
let guest_linked_after_reconnect = project_b.read_with(cx_b, |project, cx| {
let repos = project.repositories(cx);
assert_eq!(
repos.len(),
1,
"guest should still have exactly 1 repository after reconnect"
);
let repo = repos.values().next().unwrap();
repo.read(cx).linked_worktrees().to_vec()
});
let (guest_linked_after_reconnect, guest_git_paths_after_reconnect) =
project_b.read_with(cx_b, |project, cx| {
let repos = project.repositories(cx);
assert_eq!(
repos.len(),
1,
"guest should still have exactly 1 repository after reconnect"
);
let repo = repos.values().next().unwrap();
let repo = repo.read(cx);
(
repo.linked_worktrees().to_vec(),
(
repo.repository_dir_abs_path.to_path_buf(),
repo.common_dir_abs_path.to_path_buf(),
),
)
});
assert_eq!(
guest_linked_after_reconnect, host_linked_after_removal,
"guest's linked_worktrees should survive guest disconnect/reconnect"
);
assert_eq!(
guest_git_paths_after_reconnect, host_git_paths_after_removal,
"guest's git directory paths should survive guest disconnect/reconnect"
);
}
#[gpui::test]

View file

@ -437,7 +437,12 @@ impl TestServer {
admin: (&TestClient, &mut TestAppContext),
members: &mut [(&TestClient, &mut TestAppContext)],
) -> ChannelId {
let (_, admin_cx) = admin;
let (admin_client, admin_cx) = admin;
// Subscribe to channels (simulates opening the collab panel)
admin_client.initialize_channel_store(admin_cx);
admin_cx.executor().run_until_parked();
let channel_id = admin_cx
.read(ChannelStore::global)
.update(admin_cx, |channel_store, cx| {
@ -447,6 +452,10 @@ impl TestServer {
.unwrap();
for (member_client, member_cx) in members {
// Subscribe member to channels (simulates opening the collab panel)
member_client.initialize_channel_store(member_cx);
member_cx.executor().run_until_parked();
admin_cx
.read(ChannelStore::global)
.update(admin_cx, |channel_store, cx| {
@ -665,6 +674,12 @@ impl TestClient {
.await;
}
/// Subscribe to channels. In production this happens when the user opens the collab panel.
pub fn initialize_channel_store(&self, cx: &mut TestAppContext) {
self.channel_store
.update(cx, |channel_store, _| channel_store.initialize());
}
pub fn local_projects(&self) -> impl Deref<Target = Vec<Entity<Project>>> + '_ {
Ref::map(self.state.borrow(), |state| &state.local_projects)
}

View file

@ -474,6 +474,13 @@ impl Client {
Ok(())
}
/// Notify the underlying transport of the negotiated MCP protocol version
/// so it can stamp subsequent requests (e.g. HTTP's `MCP-Protocol-Version`
/// header required from 2025-06-18 onward).
pub(crate) fn set_protocol_version(&self, version: &str) {
self.transport.set_protocol_version(version);
}
#[must_use]
pub fn on_notification(
&self,

View file

@ -138,7 +138,9 @@ impl ContextServer {
let protocol = crate::protocol::ModelContextProtocol::new(client);
let client_info = types::Implementation {
name: "Zed".to_string(),
title: None,
version: env!("CARGO_PKG_VERSION").to_string(),
description: None,
};
let initialized_protocol = protocol.initialize(client_info).await?;

View file

@ -103,6 +103,7 @@ impl McpServer {
let registered_tool = RegisteredTool {
tool: Tool {
name: T::NAME.into(),
title: None,
description,
input_schema: input_schema.into(),
output_schema: if TypeId::of::<T::Output>() == TypeId::of::<()>() {

View file

@ -27,6 +27,8 @@ impl ModelContextProtocol {
fn supported_protocols() -> Vec<types::ProtocolVersion> {
vec![
types::ProtocolVersion(types::LATEST_PROTOCOL_VERSION.to_string()),
types::ProtocolVersion(types::VERSION_2025_06_18.to_string()),
types::ProtocolVersion(types::VERSION_2025_03_26.to_string()),
types::ProtocolVersion(types::VERSION_2024_11_05.to_string()),
]
}
@ -59,6 +61,11 @@ impl ModelContextProtocol {
log::trace!("mcp server info {:?}", response.server_info);
// Per MCP 2025-06-18, HTTP transport must attach the negotiated version
// as `MCP-Protocol-Version` on every post-initialize request.
self.inner
.set_protocol_version(&response.protocol_version.0);
let initialized_protocol = InitializedContextServerProtocol {
inner: self.inner,
initialize: response,

View file

@ -27,7 +27,9 @@ fn create_initialize_response(server_name: String) -> InitializeResponse {
protocol_version: ProtocolVersion(crate::types::LATEST_PROTOCOL_VERSION.to_string()),
server_info: Implementation {
name: server_name,
title: None,
version: "1.0.0".to_string(),
description: None,
},
capabilities: ServerCapabilities::default(),
meta: None,

View file

@ -14,4 +14,9 @@ pub trait Transport: Send + Sync {
async fn send(&self, message: String) -> Result<()>;
fn receive(&self) -> Pin<Box<dyn Stream<Item = String> + Send>>;
fn receive_err(&self) -> Pin<Box<dyn Stream<Item = String> + Send>>;
/// Called after the MCP initialize handshake completes so transports that
/// need the negotiated version (currently only HTTP, which must attach an
/// `MCP-Protocol-Version` header from 2025-06-18 onward) can pick it up.
fn set_protocol_version(&self, _version: &str) {}
}

View file

@ -9,6 +9,7 @@ use std::{pin::Pin, sync::Arc};
use crate::oauth::{self, OAuthTokenProvider, WwwAuthenticate};
use crate::transport::Transport;
use crate::types;
/// Typed errors returned by the HTTP transport that callers can downcast from
/// `anyhow::Error` to handle specific failure modes.
@ -33,6 +34,7 @@ impl std::error::Error for TransportError {}
// Constants from MCP spec
const HEADER_SESSION_ID: &str = "Mcp-Session-Id";
const HEADER_PROTOCOL_VERSION: &str = "MCP-Protocol-Version";
const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream";
const JSON_MIME_TYPE: &str = "application/json";
@ -41,6 +43,11 @@ pub struct HttpTransport {
http_client: Arc<dyn HttpClient>,
endpoint: String,
session_id: Arc<SyncMutex<Option<String>>>,
/// Negotiated MCP protocol version, populated by `set_protocol_version`
/// after the initialize handshake. From 2025-06-18 onward the server
/// requires clients to echo this in the `MCP-Protocol-Version` header on
/// every subsequent request.
protocol_version: Arc<SyncMutex<Option<String>>>,
executor: BackgroundExecutor,
response_tx: async_channel::Sender<String>,
response_rx: async_channel::Receiver<String>,
@ -78,6 +85,7 @@ impl HttpTransport {
executor,
endpoint,
session_id: Arc::new(SyncMutex::new(None)),
protocol_version: Arc::new(SyncMutex::new(None)),
response_tx,
response_rx,
error_tx,
@ -114,6 +122,14 @@ impl HttpTransport {
request_builder = request_builder.header(HEADER_SESSION_ID, session_id.as_str());
}
// Echo the negotiated protocol version once initialization has
// completed. Required by servers speaking MCP 2025-06-18 or later.
if let Some(ref version) = *self.protocol_version.lock()
&& types::requires_protocol_version_header(version)
{
request_builder = request_builder.header(HEADER_PROTOCOL_VERSION, version.as_str());
}
Ok(request_builder.body(AsyncBody::from(message.to_vec()))?)
}
@ -315,6 +331,10 @@ impl Transport for HttpTransport {
fn receive_err(&self) -> Pin<Box<dyn Stream<Item = String> + Send>> {
Box::pin(self.error_rx.clone())
}
fn set_protocol_version(&self, version: &str) {
*self.protocol_version.lock() = Some(version.to_string());
}
}
impl Drop for HttpTransport {
@ -323,6 +343,7 @@ impl Drop for HttpTransport {
let http_client = self.http_client.clone();
let endpoint = self.endpoint.clone();
let session_id = self.session_id.lock().clone();
let protocol_version = self.protocol_version.lock().clone();
let headers = self.headers.clone();
let access_token = self.token_provider.as_ref().and_then(|p| p.access_token());
@ -345,6 +366,15 @@ impl Drop for HttpTransport {
request_builder.header("Authorization", format!("Bearer {}", token));
}
// Stamp the negotiated MCP protocol version on the DELETE
// too, matching what `build_request` does for POSTs.
if let Some(ref version) = protocol_version
&& types::requires_protocol_version_header(version)
{
request_builder =
request_builder.header(HEADER_PROTOCOL_VERSION, version.as_str());
}
let request = request_builder.body(AsyncBody::empty());
if let Ok(request) = request {

View file

@ -5,8 +5,16 @@ use url::Url;
use crate::client::RequestId;
pub const LATEST_PROTOCOL_VERSION: &str = "2025-03-26";
pub const VERSION_2024_11_05: &str = "2024-11-05";
pub const VERSION_2025_03_26: &str = "2025-03-26";
pub const VERSION_2025_06_18: &str = "2025-06-18";
pub const LATEST_PROTOCOL_VERSION: &str = "2025-11-25";
/// Protocol versions that include the streamable HTTP transport's
/// `MCP-Protocol-Version` header requirement on post-initialize requests.
pub fn requires_protocol_version_header(version: &str) -> bool {
matches!(version, VERSION_2025_06_18 | LATEST_PROTOCOL_VERSION)
}
pub mod requests {
use super::*;
@ -209,10 +217,21 @@ pub struct CompletionCompleteParams {
#[serde(rename = "ref")]
pub reference: CompletionReference,
pub argument: CompletionArgument,
/// Previously-resolved argument values so the server can provide
/// context-sensitive completions (added in MCP 2025-06-18).
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<CompletionContext>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompletionContext {
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<HashMap<String, String>>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CompletionReference {
@ -421,6 +440,9 @@ pub struct CompletionResult {
#[serde(rename_all = "camelCase")]
pub struct Prompt {
pub name: String,
/// Human-readable display name (added in MCP 2025-06-18).
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -431,6 +453,9 @@ pub struct Prompt {
#[serde(rename_all = "camelCase")]
pub struct PromptArgument {
pub name: String,
/// Human-readable display name (added in MCP 2025-06-18).
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -499,6 +524,9 @@ pub struct RootsCapabilities {
#[serde(rename_all = "camelCase")]
pub struct Tool {
pub name: String,
/// Human-readable display name (added in MCP 2025-06-18).
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: serde_json::Value,
@ -532,7 +560,13 @@ pub struct ToolAnnotations {
#[serde(rename_all = "camelCase")]
pub struct Implementation {
pub name: String,
/// Human-readable display name (added in MCP 2025-06-18).
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub version: String,
/// Human-readable description of the implementation (added in MCP 2025-11-25).
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@ -540,6 +574,9 @@ pub struct Implementation {
pub struct Resource {
pub uri: Url,
pub name: String,
/// Human-readable display name (added in MCP 2025-06-18).
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -577,6 +614,9 @@ pub struct BlobResourceContents {
pub struct ResourceTemplate {
pub uri_template: String,
pub name: String,
/// Human-readable display name (added in MCP 2025-06-18).
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -709,6 +749,19 @@ pub enum ToolResponseContent {
Audio { data: String, mime_type: String },
#[serde(rename = "resource")]
Resource { resource: ResourceContents },
/// Link to an MCP resource on the server, without inlining its contents.
/// Added in MCP 2025-06-18.
#[serde(rename = "resource_link", rename_all = "camelCase")]
ResourceLink {
uri: Url,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
mime_type: Option<String>,
},
}
impl ToolResponseContent {

View file

@ -18,7 +18,7 @@ use std::{
borrow::Borrow,
ffi::OsStr,
fmt::Debug,
net::Ipv4Addr,
net::IpAddr,
ops::Deref,
path::{Path, PathBuf},
sync::Arc,
@ -106,7 +106,7 @@ impl<'a> From<&'a str> for DebugAdapterName {
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TcpArguments {
pub host: Ipv4Addr,
pub host: IpAddr,
pub port: u16,
pub timeout: Option<u64>,
}

View file

@ -6,7 +6,7 @@ pub mod proto_conversions;
mod registry;
pub mod transport;
use std::net::Ipv4Addr;
use std::net::IpAddr;
pub use dap_types::*;
use debugger_settings::DebuggerSettings;
@ -26,7 +26,7 @@ use task::{DebugScenario, TcpArgumentsTemplate};
pub async fn configure_tcp_connection(
tcp_connection: TcpArgumentsTemplate,
) -> anyhow::Result<(Ipv4Addr, u16, Option<u64>)> {
) -> anyhow::Result<(IpAddr, u16, Option<u64>)> {
let host = tcp_connection.host();
let timeout = tcp_connection.timeout;

View file

@ -18,7 +18,7 @@ use smol::{
};
use std::{
collections::HashMap,
net::{Ipv4Addr, SocketAddrV4},
net::{IpAddr, SocketAddr},
process::Stdio,
sync::Arc,
time::Duration,
@ -472,7 +472,7 @@ impl TransportDelegate {
pub struct TcpTransport {
executor: BackgroundExecutor,
pub port: u16,
pub host: Ipv4Addr,
pub host: IpAddr,
pub timeout: u64,
process: Arc<Mutex<Option<Child>>>,
_stderr_task: Option<Task<()>>,
@ -489,8 +489,8 @@ impl TcpTransport {
}
}
pub async fn unused_port(host: Ipv4Addr) -> Result<u16> {
Ok(TcpListener::bind(SocketAddrV4::new(host, 0))
pub async fn unused_port(host: IpAddr) -> Result<u16> {
Ok(TcpListener::bind(SocketAddr::new(host, 0))
.await?
.local_addr()?
.port())
@ -598,7 +598,7 @@ impl Transport for TcpTransport {
> {
let executor = self.executor.clone();
let timeout = self.timeout;
let address = SocketAddrV4::new(self.host, self.port);
let address = SocketAddr::new(self.host, self.port);
let process = self.process.clone();
executor.clone().spawn(async move {
select! {

View file

@ -103,7 +103,7 @@ impl JsDebugAdapter {
if let Some(env) = configuration.get("env").cloned()
&& let Ok(env) = serde_json::from_value::<HashMap<String, String>>(env)
{
envs.extend(env.into_iter());
envs.extend(env);
}
configuration

View file

@ -14,7 +14,7 @@ use smol::fs::File;
use smol::io::AsyncReadExt;
use smol::lock::OnceCell;
use std::ffi::OsString;
use std::net::Ipv4Addr;
use std::net::IpAddr;
use std::str::FromStr;
use std::{
ffi::OsStr,
@ -42,7 +42,7 @@ impl PythonDebugAdapter {
const LANGUAGE_NAME: &'static str = "Python";
async fn generate_debugpy_arguments<'a>(
host: &'a Ipv4Addr,
host: &'a IpAddr,
port: u16,
launch_mode: DebugpyLaunchMode<'a>,
user_installed_path: Option<&'a Path>,
@ -380,7 +380,7 @@ impl PythonDebugAdapter {
}
if let Some(hostname) = config_host {
tcp_connection.host = Some(hostname.parse().context("hostname must be IPv4")?);
tcp_connection.host = Some(hostname.parse().context("invalid IP address")?);
}
tcp_connection.port = config_port;
DebugpyLaunchMode::AttachWithConnect { host: config_host }
@ -974,7 +974,7 @@ mod tests {
.contains("Cannot have two different ports")
);
let host = Ipv4Addr::new(127, 0, 0, 1);
let host = IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
let config_with_host_conflict = json!({
"request": "attach",
"connect": {
@ -1018,7 +1018,7 @@ mod tests {
#[gpui::test]
async fn test_attach_with_connect_mode_generates_correct_arguments() {
let host = Ipv4Addr::new(127, 0, 0, 1);
let host = IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
let port = 5678;
let args_without_host = PythonDebugAdapter::generate_debugpy_arguments(
@ -1071,7 +1071,7 @@ mod tests {
#[gpui::test]
async fn test_debugpy_install_path_cases() {
let host = Ipv4Addr::new(127, 0, 0, 1);
let host = IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
let port = 5678;
// Case 1: User-defined debugpy path (highest precedence)

View file

@ -328,7 +328,7 @@ async fn test_escape_code_processing(executor: BackgroundExecutor, cx: &mut Test
let text_highlights = editor.update(cx, |editor, cx| {
let mut text_highlights = editor.all_text_highlights(window, cx).into_iter().flat_map(|(_, ranges)| ranges).collect::<Vec<_>>();
text_highlights.sort_by(|a, b| a.start.cmp(&b.start));
text_highlights.sort_by_key(|hl| hl.start);
text_highlights
});
pretty_assertions::assert_eq!(

View file

@ -48,11 +48,11 @@ impl From<Role> for String {
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub enum Model {
#[serde(rename = "deepseek-chat")]
#[serde(rename = "deepseek-v4-flash")]
V4Flash,
#[serde(rename = "deepseek-v4-pro")]
#[default]
Chat,
#[serde(rename = "deepseek-reasoner")]
Reasoner,
V4Pro,
#[serde(rename = "custom")]
Custom {
name: String,
@ -65,29 +65,29 @@ pub enum Model {
impl Model {
pub fn default_fast() -> Self {
Model::Chat
Model::V4Flash
}
pub fn from_id(id: &str) -> Result<Self> {
match id {
"deepseek-chat" => Ok(Self::Chat),
"deepseek-reasoner" => Ok(Self::Reasoner),
"deepseek-v4-flash" => Ok(Self::V4Flash),
"deepseek-v4-pro" => Ok(Self::V4Pro),
_ => anyhow::bail!("invalid model id {id}"),
}
}
pub fn id(&self) -> &str {
match self {
Self::Chat => "deepseek-chat",
Self::Reasoner => "deepseek-reasoner",
Self::V4Flash => "deepseek-v4-flash",
Self::V4Pro => "deepseek-v4-pro",
Self::Custom { name, .. } => name,
}
}
pub fn display_name(&self) -> &str {
match self {
Self::Chat => "DeepSeek Chat",
Self::Reasoner => "DeepSeek Reasoner",
Self::V4Flash => "DeepSeek V4 Flash",
Self::V4Pro => "DeepSeek V4 Pro",
Self::Custom {
name, display_name, ..
} => display_name.as_ref().unwrap_or(name).as_str(),
@ -96,16 +96,14 @@ impl Model {
pub fn max_token_count(&self) -> u64 {
match self {
Self::Chat | Self::Reasoner => 128_000,
Self::V4Flash | Self::V4Pro => 1_000_000,
Self::Custom { max_tokens, .. } => *max_tokens,
}
}
pub fn max_output_tokens(&self) -> Option<u64> {
match self {
// Their API treats this max against the context window, which means we hit the limit a lot
// Using the default value of None in the API instead
Self::Chat | Self::Reasoner => None,
Self::V4Flash | Self::V4Pro => Some(384_000),
Self::Custom {
max_output_tokens, ..
} => *max_output_tokens,
@ -123,11 +121,35 @@ pub struct Request {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking: Option<Thinking>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_format: Option<ResponseFormat>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ToolDefinition>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Thinking {
#[serde(rename = "type")]
pub kind: ThinkingType,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ThinkingType {
Enabled,
Disabled,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ReasoningEffort {
High,
Max,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponseFormat {

View file

@ -118,12 +118,8 @@ fn run_neural_denoiser(
input_rx: mpsc::Receiver<[f32; BLOCK_SHIFT]>,
) {
let mut engine = Engine::new();
loop {
let Ok(sub_block) = input_rx.recv() else {
// tx must have dropped, stop thread
break;
};
// until tx is dropped
while let Ok(sub_block) = input_rx.recv() {
let denoised_sub_block = engine.feed(&sub_block);
if denoised_tx.send(denoised_sub_block).is_err() {
break;

View file

@ -542,23 +542,22 @@ impl BufferDiagnosticsEditor {
// display map for the new diagnostics. Update the `blocks`
// property before finishing, to ensure the blocks are removed
// on the next execution.
let editor_blocks =
anchor_ranges
.into_iter()
.zip(blocks.into_iter())
.map(|(anchor, block)| {
let editor = buffer_diagnostics_editor.editor.downgrade();
let editor_blocks = anchor_ranges
.into_iter()
.zip(blocks)
.map(|(anchor, block)| {
let editor = buffer_diagnostics_editor.editor.downgrade();
BlockProperties {
placement: BlockPlacement::Near(anchor.start),
height: Some(1),
style: BlockStyle::Flex,
render: Arc::new(move |block_context| {
block.render_block(editor.clone(), block_context)
}),
priority: 1,
}
});
BlockProperties {
placement: BlockPlacement::Near(anchor.start),
height: Some(1),
style: BlockStyle::Flex,
render: Arc::new(move |block_context| {
block.render_block(editor.clone(), block_context)
}),
priority: 1,
}
});
let block_ids = buffer_diagnostics_editor.editor.update(cx, |editor, cx| {
editor.display_map.update(cx, |display_map, cx| {

View file

@ -667,10 +667,8 @@ impl ProjectDiagnosticsEditor {
}
}
let editor_blocks = anchor_ranges
.into_iter()
.zip_eq(result_blocks.into_iter())
.filter_map(|(anchor, block)| {
let editor_blocks = anchor_ranges.into_iter().zip_eq(result_blocks).filter_map(
|(anchor, block)| {
let block = block?;
let editor = this.editor.downgrade();
Some(BlockProperties {
@ -680,7 +678,8 @@ impl ProjectDiagnosticsEditor {
render: Arc::new(move |bcx| block.render_block(editor.clone(), bcx)),
priority: 1,
})
});
},
);
let block_ids = this.editor.update(cx, |editor, cx| {
editor.display_map.update(cx, |display_map, cx| {

View file

@ -679,6 +679,12 @@ fn handle_postprocessing() -> Result<()> {
.to_string();
let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
let consent_io_instance = std::env::var("DOCS_CONSENT_IO_INSTANCE").unwrap_or_default();
let docs_channel = std::env::var("DOCS_CHANNEL").unwrap_or_else(|_| "stable".to_string());
let noindex = if docs_channel == "nightly" || docs_channel == "preview" {
"<meta name=\"robots\" content=\"noindex, nofollow\">"
} else {
""
};
output.insert("html".to_string(), zed_html);
mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?;
@ -749,6 +755,7 @@ fn handle_postprocessing() -> Result<()> {
let contents = contents.replace("#description#", meta_description);
let contents = contents.replace("#amplitude_key#", &amplitude_key);
let contents = contents.replace("#consent_io_instance#", &consent_io_instance);
let contents = contents.replace("#noindex#", noindex);
let contents = title_regex()
.replace(&contents, |_: &regex::Captures| {
format!("<title>{}</title>", meta_title)

View file

@ -725,7 +725,7 @@ fn compute_diff_between_snapshots_in_range(
Some((diff, new_start_point..new_end_point))
}
fn buffer_path_with_id_fallback(
pub(crate) fn buffer_path_with_id_fallback(
file: Option<&Arc<dyn File>>,
snapshot: &TextBufferSnapshot,
cx: &App,
@ -2109,8 +2109,7 @@ fn is_ep_store_provider(provider: EditPredictionProvider) -> bool {
EditPredictionProvider::Zed
| EditPredictionProvider::Mercury
| EditPredictionProvider::Ollama
| EditPredictionProvider::OpenAiCompatibleApi
| EditPredictionProvider::Experimental(_) => true,
| EditPredictionProvider::OpenAiCompatibleApi => true,
EditPredictionProvider::None
| EditPredictionProvider::Copilot
| EditPredictionProvider::Codestral => false,
@ -2145,9 +2144,7 @@ impl EditPredictionStore {
let (needs_acceptance_tracking, max_pending_predictions) =
match all_language_settings(None, cx).edit_predictions.provider {
EditPredictionProvider::Zed
| EditPredictionProvider::Mercury
| EditPredictionProvider::Experimental(_) => (true, 2),
EditPredictionProvider::Zed | EditPredictionProvider::Mercury => (true, 2),
EditPredictionProvider::Ollama => (false, 1),
EditPredictionProvider::OpenAiCompatibleApi => (false, 2),
EditPredictionProvider::None
@ -2517,7 +2514,7 @@ impl EditPredictionStore {
.collect()
});
candidates.sort_by(|a, b| b.1.cmp(&a.1));
candidates.sort_by_key(|c| std::cmp::Reverse(c.1));
for (path, _) in candidates {
let candidate_buffer = project

View file

@ -2356,6 +2356,7 @@ fn model_response(request: &PredictEditsV3Request, diff_to_apply: &str) -> Predi
request_id: Uuid::new_v4().to_string(),
editable_range,
output: new_excerpt,
cursor_offset: None,
model_version: None,
}
}
@ -2365,6 +2366,7 @@ fn empty_response() -> PredictEditsV3Response {
request_id: Uuid::new_v4().to_string(),
editable_range: 0..0,
output: String::new(),
cursor_offset: None,
model_version: None,
}
}
@ -2713,6 +2715,7 @@ async fn test_edit_prediction_no_spurious_trailing_newline(cx: &mut TestAppConte
output: "hello world\n".to_string(),
editable_range: 0..excerpt_length,
model_version: None,
cursor_offset: None,
};
respond_tx.send(response).unwrap();
@ -2771,9 +2774,10 @@ async fn test_v3_prediction_strips_cursor_marker_from_edit_text(cx: &mut TestApp
respond_tx
.send(PredictEditsV3Response {
request_id: Uuid::new_v4().to_string(),
output: "hello<|user_cursor|> world".to_string(),
output: "hello world".to_string(),
editable_range: 0..excerpt_length,
model_version: None,
cursor_offset: Some(5),
})
.unwrap();
@ -2878,6 +2882,7 @@ async fn make_test_ep_store(
editable_range: 0..req.input.cursor_excerpt.len(),
output: completion_response.lock().clone(),
model_version: None,
cursor_offset: None,
})
.unwrap()
.into(),
@ -3310,8 +3315,7 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) {
// Let the worker process the channel message before we start advancing.
cx.run_until_parked();
let mut region_a_edit_offset = 5;
for _ in 0..3 {
for region_a_edit_offset in (5..).take(3) {
// Edit inside region A (not at the boundary) so `last_edit_at` is
// updated before the worker's next wake.
buffer.update(cx, |buffer, cx| {
@ -3321,7 +3325,6 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) {
cx,
);
});
region_a_edit_offset += 1;
cx.run_until_parked();
cx.executor()
@ -3417,6 +3420,28 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) {
}
}
#[gpui::test]
fn test_buffer_path_with_id_fallback_for_untitled_buffers(cx: &mut TestAppContext) {
let buffer_1 = cx.new(|cx| Buffer::local("one", cx));
let buffer_2 = cx.new(|cx| Buffer::local("two", cx));
let snapshot_1 = buffer_1.read_with(cx, |buffer, _| buffer.text_snapshot());
let snapshot_2 = buffer_2.read_with(cx, |buffer, _| buffer.text_snapshot());
let path_1 = cx.read(|cx| buffer_path_with_id_fallback(None, &snapshot_1, cx));
let path_2 = cx.read(|cx| buffer_path_with_id_fallback(None, &snapshot_2, cx));
assert_eq!(
path_1.as_ref(),
Path::new(&format!("untitled-{}", snapshot_1.remote_id()))
);
assert_eq!(
path_2.as_ref(),
Path::new(&format!("untitled-{}", snapshot_2.remote_id()))
);
assert_ne!(path_1.as_ref(), path_2.as_ref());
}
#[gpui::test]
async fn test_data_collection_disabled_by_default(cx: &mut TestAppContext) {
let (ep_store, _channels) = init_test_with_fake_client(cx);

View file

@ -1,7 +1,7 @@
use crate::{
CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId,
EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, StoredEvent,
ZedUpdateRequiredError,
ZedUpdateRequiredError, buffer_path_with_id_fallback,
cursor_excerpt::{self, compute_cursor_excerpt, compute_syntax_ranges},
prediction::EditPredictionResult,
};
@ -25,8 +25,7 @@ use zeta_prompt::{ParsedOutput, ZetaPromptInput};
use std::{env, ops::Range, path::Path, sync::Arc};
use zeta_prompt::{
ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output,
parsed_output_from_editable_region, prompt_input_contains_special_tokens,
stop_tokens_for_format,
prompt_input_contains_special_tokens, stop_tokens_for_format,
zeta1::{self, EDITABLE_REGION_END_MARKER},
};
@ -70,10 +69,7 @@ pub fn request_prediction_with_zeta(
let preferred_experiment = store.preferred_experiment().map(|s| s.to_owned());
let open_ai_compatible_api_key = load_open_ai_compatible_api_key_if_needed(provider, cx);
let excerpt_path: Arc<Path> = snapshot
.file()
.map(|file| -> Arc<Path> { file.full_path(cx).into() })
.unwrap_or_else(|| Arc::from(Path::new("untitled")));
let excerpt_path = buffer_path_with_id_fallback(snapshot.file(), &snapshot.text, cx);
let repo_url = if can_collect_data {
let buffer_id = buffer.read(cx).remote_id();
@ -283,12 +279,12 @@ pub fn request_prediction_with_zeta(
.await?;
let request_id = EditPredictionId(response.request_id.into());
let output_text = Some(response.output).filter(|s| !s.is_empty());
let model_version = response.model_version;
let parsed_output = parsed_output_from_editable_region(
response.editable_range,
output_text.unwrap_or_default(),
);
let parsed_output = ParsedOutput {
new_editable_region: response.output,
range_in_excerpt: response.editable_range,
cursor_offset_in_new_editable_region: response.cursor_offset,
};
Some((request_id, Some(parsed_output), model_version, usage))
})

View file

@ -168,7 +168,7 @@ fn get_all_languages(extension_map: &HashMap<String, String>) -> Vec<(String, Ve
}
let mut result: Vec<_> = language_to_extensions.into_iter().collect();
result.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
result.sort_by_key(|res| res.0.to_lowercase());
for (_, extensions) in &mut result {
extensions.sort();
}
@ -380,7 +380,7 @@ pub fn run_filter_languages(
if let Some(top_n) = args.show_top_excluded {
if !excluded_extensions.is_empty() {
let mut sorted: Vec<_> = excluded_extensions.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1));
sorted.sort_by_key(|res| std::cmp::Reverse(res.1));
eprintln!("\nTop {} excluded extensions:", top_n.min(sorted.len()));
for (ext, count) in sorted.into_iter().take(top_n) {
eprintln!(" {:>6} .{}", count, ext);
@ -439,7 +439,7 @@ fn run_stats(input: &Path, extension_map: &HashMap<String, String>) -> Result<()
}
let mut sorted_counts: Vec<_> = language_counts.into_iter().collect();
sorted_counts.sort_by(|a, b| b.1.cmp(&a.1));
sorted_counts.sort_by_key(|res| std::cmp::Reverse(res.1));
println!("Language distribution ({} total examples):", total_count);
println!();
@ -452,7 +452,7 @@ fn run_stats(input: &Path, extension_map: &HashMap<String, String>) -> Result<()
println!();
println!("Unknown extensions:");
let mut sorted_unknown: Vec<_> = unknown_extensions.into_iter().collect();
sorted_unknown.sort_by(|a, b| b.1.cmp(&a.1));
sorted_unknown.sort_by_key(|res| std::cmp::Reverse(res.1));
for (ext, count) in sorted_unknown.iter().take(30) {
println!(" {:>6} .{}", count, ext);
}

View file

@ -47,7 +47,14 @@ pub async fn run_format_prompt(
let (editable_range, context_range) =
resolved_excerpt_ranges_for_format(prompt_inputs, zeta_format);
let prompt = TeacherPrompt::format_prompt(example, editable_range, context_range);
let include_diagnostics = matches!(zeta_format, ZetaFormat::V0420Diagnostics);
let prompt = TeacherPrompt::format_prompt(
example,
editable_range,
context_range,
include_diagnostics,
);
example.prompt = Some(ExamplePrompt {
input: prompt,
expected_output: None,
@ -64,8 +71,14 @@ pub async fn run_format_prompt(
let (editable_range, context_range) =
resolved_excerpt_ranges_for_format(prompt_inputs, zeta_format);
let prompt =
TeacherMultiRegionPrompt::format_prompt(example, editable_range, context_range);
let include_diagnostics = matches!(zeta_format, ZetaFormat::V0420Diagnostics);
let prompt = TeacherMultiRegionPrompt::format_prompt(
example,
editable_range,
context_range,
include_diagnostics,
);
example.prompt = Some(ExamplePrompt {
input: prompt,
expected_output: None,
@ -128,15 +141,20 @@ impl TeacherPrompt {
example: &Example,
editable_range: Range<usize>,
context_range: Range<usize>,
include_diagnostics: bool,
) -> String {
let edit_history = Self::format_edit_history(&example.spec.edit_history);
let context = Self::format_context(example);
let cursor_excerpt = Self::format_cursor_excerpt(example, editable_range, context_range);
let diagnostics = include_diagnostics
.then(|| Self::format_diagnostics(example))
.map(|diagnostics| format!("# 4. Diagnostics\n\n{diagnostics}"));
let prompt_template = crate::prompt_assets::get_prompt("teacher.md");
let prompt = prompt_template
.replace("{{context}}", &context)
.replace("{{edit_history}}", &edit_history)
.replace("{{diagnostics}}", diagnostics.as_deref().unwrap_or(""))
.replace("{{cursor_excerpt}}", &cursor_excerpt);
prompt
@ -294,6 +312,27 @@ impl TeacherPrompt {
let region = &text[start..end];
Ok(region.strip_suffix('\n').unwrap_or(region).to_string())
}
fn format_diagnostics(example: &Example) -> String {
example
.prompt_inputs
.as_ref()
.map(|prompt_inputs| {
prompt_inputs
.active_buffer_diagnostics
.iter()
.map(|diagnostic| {
format!(
"*{}*:\n```\n{}\n```\n",
&diagnostic.message, &diagnostic.snippet
)
})
.collect::<Vec<_>>()
.join("\n")
})
.filter(|m| !m.is_empty())
.unwrap_or("No Diagnostics".to_string())
}
}
pub struct TeacherMultiRegionPrompt;
@ -309,15 +348,20 @@ impl TeacherMultiRegionPrompt {
example: &Example,
editable_range: Range<usize>,
context_range: Range<usize>,
include_diagnostics: bool,
) -> String {
let edit_history = Self::format_edit_history(&example.spec.edit_history);
let context = Self::format_context(example);
let cursor_excerpt = Self::format_cursor_excerpt(example, editable_range, context_range);
let diagnostics = include_diagnostics
.then(|| TeacherPrompt::format_diagnostics(example))
.map(|diagnostics| format!("# 4. Diagnostics\n\n{diagnostics}"));
let prompt_template = crate::prompt_assets::get_prompt("teacher_multi_region.md");
let prompt = prompt_template
.replace("{{context}}", &context)
.replace("{{edit_history}}", &edit_history)
.replace("{{diagnostics}}", diagnostics.as_deref().unwrap_or(""))
.replace("{{cursor_excerpt}}", &cursor_excerpt);
prompt
@ -900,6 +944,7 @@ mod tests {
},
editable_range,
context_range,
false,
);
assert!(prompt.contains(TeacherPrompt::EDITABLE_REGION_START));

View file

@ -350,6 +350,7 @@ def calculate_square_perimeter(side):
{{cursor_excerpt}}
{{diagnostics}}
-----

View file

@ -22,6 +22,7 @@
//! The `--stratify` flag controls how examples are grouped before splitting:
//!
//! - `cursor-path` (default): group by the `cursor_path` JSON field
//! - `project`: group by the first component of the `cursor_path` JSON field
//! - `repo`: group by the `repository_url` JSON field
//! - `none`: no grouping, split individual examples
//!
@ -35,7 +36,7 @@ use clap::Args;
use rand::SeedableRng;
use rand::seq::SliceRandom;
use serde_json::Value;
use std::collections::HashMap;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
@ -74,6 +75,7 @@ EXAMPLES:
STRATIFICATION:
Controls how examples are grouped before splitting:
cursor-path Group by "cursor_path" field (default)
project Group by the first component of the "cursor_path" field
repo Group by "repository_url" field
none No grouping, split individual examples
@ -96,6 +98,8 @@ pub struct SplitArgs {
pub enum Stratify {
#[strum(serialize = "cursor_path")]
CursorPath,
#[strum(serialize = "project")]
Project,
#[strum(serialize = "repo")]
Repo,
#[strum(serialize = "none")]
@ -324,19 +328,31 @@ fn group_lines(lines: &[String], stratify: Stratify) -> Vec<Vec<String>> {
return lines.iter().map(|line| vec![line.clone()]).collect();
}
let field = match stratify {
Stratify::Repo => "repository_url",
Stratify::CursorPath => "cursor_path",
Stratify::None => unreachable!(),
let get_key = |line: &str| {
let json: Value = serde_json::from_str(line).unwrap_or_default();
match stratify {
Stratify::Repo => json
.get("repository_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
Stratify::CursorPath => json
.get("cursor_path")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
Stratify::Project => json
.get("cursor_path")
.and_then(|v| v.as_str())
.and_then(|s| s.split(['/', '\\']).next())
.map(|s| s.to_string()),
Stratify::None => unreachable!(),
}
};
let mut groups: HashMap<String, Vec<String>> = HashMap::new();
let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut ungrouped: Vec<Vec<String>> = Vec::new();
for line in lines {
let key = serde_json::from_str::<Value>(line)
.ok()
.and_then(|v| v.get(field)?.as_str().map(|s| s.to_string()));
let key = get_key(line);
match key {
Some(key) => groups.entry(key).or_default().push(line.clone()),
None => ungrouped.push(vec![line.clone()]),
@ -601,4 +617,63 @@ mod tests {
assert_eq!(train_lines.len(), 6);
assert_eq!(valid_lines.len(), 9);
}
#[test]
fn test_stratify_by_project() {
// 5 repos × 3 lines each = 15 total lines.
// `train=6` should target ~6 lines (2 groups), NOT 6 groups (all 15 lines).
let input = create_temp_jsonl(&[
r#"{"cursor_path": "project1/some/file.rs", "id": 1}"#,
r#"{"cursor_path": "project2/some/file.rs", "id": 2}"#,
r#"{"cursor_path": "project3/some/file.rs", "id": 3}"#,
r#"{"cursor_path": "project1/other/file.rs", "id": 4}"#,
r#"{"cursor_path": "project2/other/file.rs", "id": 5}"#,
r#"{"cursor_path": "project3/other/file.rs", "id": 6}"#,
r#"{"cursor_path": "project3/another/file.rs", "id": 7}"#,
r#"{"cursor_path": "project3/even/more.rs", "id": 8}"#,
]);
let temp_dir = tempfile::tempdir().unwrap();
let train_path = temp_dir.path().join("train.jsonl");
let valid_path = temp_dir.path().join("valid.jsonl");
let args = SplitArgs {
seed: Some(1),
stratify: Stratify::Project,
};
let inputs = vec![
input.path().to_path_buf(),
PathBuf::from(format!("{}=4", train_path.display())),
PathBuf::from(format!("{}=rest", valid_path.display())),
];
run_split(&args, &inputs).unwrap();
let train_content = std::fs::read_to_string(&train_path).unwrap();
let valid_content = std::fs::read_to_string(&valid_path).unwrap();
// Make sure project 1 and project 2 are in the train set, and project 3 is in the valid set.
let mut train_ids: Vec<u64> = train_content
.lines()
.map(|l| {
serde_json::from_str::<serde_json::Value>(l).unwrap()["id"]
.as_u64()
.unwrap()
})
.collect();
let mut valid_ids: Vec<u64> = valid_content
.lines()
.map(|l| {
serde_json::from_str::<serde_json::Value>(l).unwrap()["id"]
.as_u64()
.unwrap()
})
.collect();
train_ids.sort();
valid_ids.sort();
assert_eq!(train_ids, vec![1, 2, 4, 5]);
assert_eq!(valid_ids, vec![3, 6, 7, 8]);
}
}

View file

@ -323,15 +323,12 @@ impl Render for EditPredictionButton {
.with_handle(self.popover_menu_handle.clone()),
)
}
provider @ (EditPredictionProvider::Experimental(_)
| EditPredictionProvider::Zed
| EditPredictionProvider::Mercury) => {
provider @ (EditPredictionProvider::Zed | EditPredictionProvider::Mercury) => {
let enabled = self.editor_enabled.unwrap_or(true);
let file = self.file.clone();
let language = self.language.clone();
let project = self.project.clone();
let provider_name: &'static str = match provider {
EditPredictionProvider::Experimental(name) => name,
EditPredictionProvider::Zed => "zed",
_ => "unknown",
};
@ -428,6 +425,11 @@ impl Render for EditPredictionButton {
None
};
let zed_cloud_needs_sign_in =
matches!(provider, EditPredictionProvider::Zed) && user.is_none();
let provider_unavailable =
missing_token || mercury_has_error || zed_cloud_needs_sign_in;
let icon_button = IconButton::new("zed-predict-pending-button", ep_icon)
.shape(IconButtonShape::Square)
.when_some(indicator_color, |this, color| {
@ -435,19 +437,15 @@ impl Render for EditPredictionButton {
.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| {
let description = if enabled {
if show_editor_predictions {
tooltip_meta
} else if user.is_none() {
"Sign In Or Configure a Provider"
} else {
"Hidden For This File"
}
} else {
let description = if !enabled {
"Disabled For This File"
} else if zed_cloud_needs_sign_in {
"Sign In Or Configure a Provider"
} else if provider_unavailable || show_editor_predictions {
tooltip_meta
} else {
"Enable to Use"
};
Tooltip::with_meta(

View file

@ -124,6 +124,7 @@ settings = { workspace = true, features = ["test-support"] }
text = { workspace = true, features = ["test-support"] }
theme = { workspace = true, features = ["test-support"] }
tree-sitter-c.workspace = true
tree-sitter-go.workspace = true
tree-sitter-html.workspace = true
tree-sitter-rust.workspace = true
tree-sitter-typescript.workspace = true

View file

@ -192,7 +192,7 @@ pub struct SelectDownByLines {
pub(super) lines: u32,
}
/// Expands all excerpts in the editor.
/// Expands all excerpts with selections.
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
#[action(namespace = editor)]
#[serde(deny_unknown_fields)]

View file

@ -14,7 +14,7 @@ use ui::{Context, Window, div, prelude::*};
use crate::{
Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT, SelectionEffects,
actions::ToggleCodeLens,
display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId, RenderBlock},
hover_links::HoverLink,
};
@ -31,147 +31,28 @@ struct CodeLensItem {
action: CodeAction,
}
pub(super) struct CodeLensBlock {
block_id: CustomBlockId,
anchor: Anchor,
line: CodeLensLine,
}
pub(super) struct CodeLensState {
pub(super) block_ids: HashMap<BufferId, Vec<CustomBlockId>>,
pub(super) blocks: HashMap<BufferId, Vec<CodeLensBlock>>,
actions: HashMap<BufferId, Vec<CodeAction>>,
resolve_task: Task<()>,
}
impl Default for CodeLensState {
fn default() -> Self {
Self {
block_ids: HashMap::default(),
blocks: HashMap::default(),
actions: HashMap::default(),
resolve_task: Task::ready(()),
}
}
}
impl CodeLensState {
fn all_block_ids(&self) -> HashSet<CustomBlockId> {
self.block_ids.values().flatten().copied().collect()
}
}
fn group_lenses_by_row(
lenses: Vec<(Anchor, CodeLensItem)>,
snapshot: &MultiBufferSnapshot,
) -> impl Iterator<Item = CodeLensLine> {
lenses
.into_iter()
.into_group_map_by(|(position, _)| {
let row = position.to_point(snapshot).row;
MultiBufferRow(row)
})
.into_iter()
.sorted_by_key(|(row, _)| *row)
.filter_map(|(row, entries)| {
let position = entries.first()?.0;
let items = entries.into_iter().map(|(_, item)| item).collect();
let indent_column = snapshot.indent_size_for_line(row).len;
Some(CodeLensLine {
position,
indent_column,
items,
})
})
}
fn render_code_lens_line(
lens: CodeLensLine,
editor: WeakEntity<Editor>,
) -> impl Fn(&mut crate::display_map::BlockContext) -> gpui::AnyElement {
move |cx| {
let mut children = Vec::with_capacity((2 * lens.items.len()).saturating_sub(1));
let text_style = &cx.editor_style.text;
let font = text_style.font();
let font_size = text_style.font_size.to_pixels(cx.window.rem_size()) * 0.9;
for (i, item) in lens.items.iter().enumerate() {
if i > 0 {
children.push(
div()
.font(font.clone())
.text_size(font_size)
.text_color(cx.app.theme().colors().text_muted)
.child(" | ")
.into_any_element(),
);
}
let title = item.title.clone();
let action = item.action.clone();
let editor_handle = editor.clone();
let position = lens.position;
children.push(
div()
.id(ElementId::from(i))
.font(font.clone())
.text_size(font_size)
.text_color(cx.app.theme().colors().text_muted)
.cursor_pointer()
.hover(|style| style.text_color(cx.app.theme().colors().text))
.child(title.clone())
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_mouse_down(MouseButton::Right, |_, _, cx| {
cx.stop_propagation();
})
.on_click({
move |_event, window, cx| {
if let Some(editor) = editor_handle.upgrade() {
editor.update(cx, |editor, cx| {
editor.change_selections(
SelectionEffects::default(),
window,
cx,
|s| {
s.select_anchor_ranges([position..position]);
},
);
let action = action.clone();
if let Some(workspace) = editor.workspace() {
if try_handle_client_command(
&action, editor, &workspace, window, cx,
) {
return;
}
let project = workspace.read(cx).project().clone();
if let Some(buffer) = editor
.buffer()
.read(cx)
.buffer(action.range.start.buffer_id)
{
project
.update(cx, |project, cx| {
project
.apply_code_action(buffer, action, true, cx)
})
.detach_and_log_err(cx);
}
}
});
}
}
})
.into_any_element(),
);
}
div()
.id(cx.block_id)
.pl(cx.margins.gutter.full_width() + cx.em_width * (lens.indent_column as f32 + 0.5))
.h_full()
.flex()
.flex_row()
.items_end()
.children(children)
.into_any_element()
}
}
pub(super) fn try_handle_client_command(
action: &CodeAction,
editor: &mut Editor,
@ -345,94 +226,169 @@ impl Editor {
return;
}
let Ok(multi_buffer_snapshot) =
editor.update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx))
else {
return;
};
let mut new_lenses_per_buffer = HashMap::default();
for (buffer_id, result) in results {
let actions = match result {
Ok(Some(actions)) => actions,
Ok(None) => continue,
Err(e) => {
log::error!("Failed to fetch code lenses for buffer {buffer_id:?}: {e:#}");
continue;
}
};
let individual_lenses = actions
.into_iter()
.filter_map(|action| {
let title = match &action.lsp_action {
project::LspAction::CodeLens(lens) => lens
.command
.as_ref()
.map(|cmd| SharedString::from(&cmd.title)),
_ => None,
}?;
let position =
multi_buffer_snapshot.anchor_in_excerpt(action.range.start)?;
Some((position, CodeLensItem { title, action }))
})
.collect();
new_lenses_per_buffer.insert(
buffer_id,
group_lenses_by_row(individual_lenses, &multi_buffer_snapshot)
.collect::<Vec<_>>(),
);
}
editor
.update(cx, |editor, cx| {
let code_lens = editor.code_lens.get_or_insert_with(CodeLensState::default);
let mut blocks_to_remove = HashSet::default();
for buffer_id in new_lenses_per_buffer.keys() {
if let Some(old_ids) = code_lens.block_ids.remove(buffer_id) {
blocks_to_remove.extend(old_ids);
}
let snapshot = editor.buffer().read(cx).snapshot(cx);
for (buffer_id, result) in results {
let actions = match result {
Ok(Some(actions)) => actions,
Ok(None) => continue,
Err(e) => {
log::error!(
"Failed to fetch code lenses for buffer {buffer_id:?}: {e:#}"
);
continue;
}
};
editor.apply_lens_actions_for_buffer(buffer_id, actions, &snapshot, cx);
}
if !blocks_to_remove.is_empty() {
editor.remove_blocks(blocks_to_remove, None, cx);
}
let editor_handle = cx.entity().downgrade();
for (buffer_id, lens_lines) in new_lenses_per_buffer {
if lens_lines.is_empty() {
continue;
}
let blocks = lens_lines
.into_iter()
.map(|lens_line| {
let position = lens_line.position;
BlockProperties {
placement: BlockPlacement::Above(position),
height: Some(1),
style: BlockStyle::Flex,
render: Arc::new(render_code_lens_line(
lens_line,
editor_handle.clone(),
)),
priority: 0,
}
})
.collect::<Vec<_>>();
let block_ids = editor.insert_blocks(blocks, None, cx);
editor
.code_lens
.get_or_insert_with(CodeLensState::default)
.block_ids
.entry(buffer_id)
.or_default()
.extend(block_ids);
}
editor.resolve_visible_code_lenses(cx);
})
.ok();
});
}
/// Reconciles the set of blocks for `buffer_id` with `actions`. For each
/// existing block at row `R`:
/// - if the new fetch has no lens at `R` → remove the block (the lens is
/// gone, e.g. the function was deleted);
/// - if the new fetch has a titled lens at `R` whose rendered text
/// differs from the block's current line → swap the renderer in place
/// via [`Editor::replace_blocks`];
/// - if the new fetch has a titled lens at `R` with the same rendered
/// text → keep the block as-is;
/// - if the new fetch has a lens at `R` but no `command` yet (the server
/// sent a shallow response that needs a separate `resolve`) → keep the
/// block as-is. The previously rendered (resolved) content stays on
/// screen until the next viewport-driven `resolve` produces a new
/// title; only then does the comparison-and-replace happen. This is
/// what keeps the post-edit screen from flickering for shallow servers
/// like `rust-analyzer`.
///
/// Rows present in the new fetch with a title but no existing block get
/// a fresh block inserted.
fn apply_lens_actions_for_buffer(
&mut self,
buffer_id: BufferId,
actions: Vec<CodeAction>,
snapshot: &MultiBufferSnapshot,
cx: &mut Context<Self>,
) {
let mut rows_with_any_lens = HashSet::default();
let mut titled_lenses = Vec::new();
for action in &actions {
let Some(position) = snapshot.anchor_in_excerpt(action.range.start) else {
continue;
};
rows_with_any_lens.insert(MultiBufferRow(position.to_point(snapshot).row));
if let project::LspAction::CodeLens(lens) = &action.lsp_action {
if let Some(title) = lens
.command
.as_ref()
.map(|cmd| SharedString::from(&cmd.title))
{
titled_lenses.push((
position,
CodeLensItem {
title,
action: action.clone(),
},
));
}
}
}
let mut new_lines_by_row = group_lenses_by_row(titled_lenses, snapshot)
.map(|line| (MultiBufferRow(line.position.to_point(snapshot).row), line))
.collect::<HashMap<_, _>>();
let editor_handle = cx.entity().downgrade();
let code_lens = self.code_lens.get_or_insert_with(CodeLensState::default);
let old_blocks = code_lens.blocks.remove(&buffer_id).unwrap_or_default();
let mut kept_blocks = Vec::new();
let mut renderers_to_replace = HashMap::default();
let mut blocks_to_remove = HashSet::default();
let mut covered_rows = HashSet::default();
for old in old_blocks {
let row = MultiBufferRow(old.anchor.to_point(snapshot).row);
if !rows_with_any_lens.contains(&row) {
blocks_to_remove.insert(old.block_id);
continue;
}
covered_rows.insert(row);
let Some(new_line) = new_lines_by_row.remove(&row) else {
kept_blocks.push(old);
continue;
};
if rendered_text_matches(&old.line, &new_line) {
kept_blocks.push(old);
} else {
let mut updated = old;
updated.line = new_line.clone();
renderers_to_replace.insert(
updated.block_id,
build_code_lens_renderer(new_line, editor_handle.clone()),
);
kept_blocks.push(updated);
}
}
let mut to_insert = Vec::new();
for (row, new_line) in new_lines_by_row {
if covered_rows.contains(&row) {
continue;
}
let anchor = new_line.position;
let props = BlockProperties {
placement: BlockPlacement::Above(anchor),
height: Some(1),
style: BlockStyle::Flex,
render: build_code_lens_renderer(new_line.clone(), editor_handle.clone()),
priority: 0,
};
to_insert.push((props, anchor, new_line));
}
if !blocks_to_remove.is_empty() {
self.remove_blocks(blocks_to_remove, None, cx);
}
if !renderers_to_replace.is_empty() {
self.replace_blocks(renderers_to_replace, None, cx);
}
if !to_insert.is_empty() {
let mut props = Vec::with_capacity(to_insert.len());
let mut metadata = Vec::with_capacity(to_insert.len());
for (p, anchor, line) in to_insert {
props.push(p);
metadata.push((anchor, line));
}
let block_ids = self.insert_blocks(props, None, cx);
for (block_id, (anchor, line)) in block_ids.into_iter().zip(metadata) {
kept_blocks.push(CodeLensBlock {
block_id,
anchor,
line,
});
}
}
let code_lens = self.code_lens.get_or_insert_with(CodeLensState::default);
if actions.is_empty() {
code_lens.actions.remove(&buffer_id);
} else {
code_lens.actions.insert(buffer_id, actions);
}
if kept_blocks.is_empty() {
code_lens.blocks.remove(&buffer_id);
} else {
code_lens.blocks.insert(buffer_id, kept_blocks);
}
cx.notify();
}
pub fn supports_code_lens(&self, cx: &ui::App) -> bool {
let Some(project) = self.project.as_ref() else {
return false;
@ -502,7 +458,7 @@ impl Editor {
let code_lens = self.code_lens.get_or_insert_with(CodeLensState::default);
code_lens.resolve_task = cx.spawn(async move |editor, cx| {
let resolved_code_lens = join_all(
let resolved_per_buffer = join_all(
resolve_tasks
.into_iter()
.map(|(buffer_id, task)| async move { (buffer_id, task.await) }),
@ -510,65 +466,42 @@ impl Editor {
.await;
editor
.update(cx, |editor, cx| {
editor.insert_resolved_code_lens_blocks(resolved_code_lens, cx);
let snapshot = editor.buffer().read(cx).snapshot(cx);
for (buffer_id, newly_resolved) in resolved_per_buffer {
if newly_resolved.is_empty() {
continue;
}
let Some(mut actions) = editor
.code_lens
.as_ref()
.and_then(|state| state.actions.get(&buffer_id))
.cloned()
else {
continue;
};
for resolved in newly_resolved {
if let Some(unresolved) = actions.iter_mut().find(|action| {
action.server_id == resolved.server_id
&& action.range == resolved.range
}) {
*unresolved = resolved;
}
}
editor.apply_lens_actions_for_buffer(buffer_id, actions, &snapshot, cx);
}
})
.ok();
});
}
fn insert_resolved_code_lens_blocks(
&mut self,
resolved_code_lens: Vec<(BufferId, Vec<CodeAction>)>,
cx: &mut Context<Self>,
) {
let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx);
let editor_handle = cx.entity().downgrade();
for (buffer_id, actions) in resolved_code_lens {
let lenses = actions
.into_iter()
.filter_map(|action| {
let title = match &action.lsp_action {
project::LspAction::CodeLens(lens) => lens
.command
.as_ref()
.map(|cmd| SharedString::from(&cmd.title)),
_ => None,
}?;
let position = multi_buffer_snapshot.anchor_in_excerpt(action.range.start)?;
Some((position, CodeLensItem { title, action }))
})
.collect();
let blocks = group_lenses_by_row(lenses, &multi_buffer_snapshot)
.map(|lens_line| {
let position = lens_line.position;
BlockProperties {
placement: BlockPlacement::Above(position),
height: Some(1),
style: BlockStyle::Flex,
render: Arc::new(render_code_lens_line(lens_line, editor_handle.clone())),
priority: 0,
}
})
.collect::<Vec<_>>();
if !blocks.is_empty() {
let block_ids = self.insert_blocks(blocks, None, cx);
self.code_lens
.get_or_insert_with(CodeLensState::default)
.block_ids
.entry(buffer_id)
.or_default()
.extend(block_ids);
}
}
cx.notify();
}
pub(super) fn clear_code_lenses(&mut self, cx: &mut Context<Self>) {
if let Some(code_lens) = self.code_lens.take() {
let all_blocks = code_lens.all_block_ids();
let all_blocks = code_lens
.blocks
.into_values()
.flatten()
.map(|block| block.block_id)
.collect::<HashSet<_>>();
if !all_blocks.is_empty() {
self.remove_blocks(all_blocks, None, cx);
}
@ -578,6 +511,138 @@ impl Editor {
}
}
/// Whether two lens lines would render the same on screen — same indent
/// and same titles in the same order. Used to skip recreating a renderer
/// (and thus a click handler) when nothing about the displayed line
/// changed; the captured [`CodeAction`] inside the existing renderer keeps
/// pointing at the right spot because its anchors track buffer edits.
fn rendered_text_matches(a: &CodeLensLine, b: &CodeLensLine) -> bool {
a.indent_column == b.indent_column
&& a.items.len() == b.items.len()
&& a.items
.iter()
.zip(&b.items)
.all(|(x, y)| x.title == y.title)
}
fn group_lenses_by_row(
lenses: Vec<(Anchor, CodeLensItem)>,
snapshot: &MultiBufferSnapshot,
) -> impl Iterator<Item = CodeLensLine> {
lenses
.into_iter()
.into_group_map_by(|(position, _)| {
let row = position.to_point(snapshot).row;
MultiBufferRow(row)
})
.into_iter()
.sorted_by_key(|(row, _)| *row)
.filter_map(|(row, entries)| {
let position = entries.first()?.0;
let items = entries.into_iter().map(|(_, item)| item).collect();
let indent_column = snapshot.indent_size_for_line(row).len;
Some(CodeLensLine {
position,
indent_column,
items,
})
})
}
fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity<Editor>) -> RenderBlock {
Arc::new(move |cx| {
let mut children = Vec::with_capacity((2 * line.items.len()).saturating_sub(1));
let text_style = &cx.editor_style.text;
let font = text_style.font();
let font_size = text_style.font_size.to_pixels(cx.window.rem_size()) * 0.9;
for (i, item) in line.items.iter().enumerate() {
if i > 0 {
children.push(
div()
.font(font.clone())
.text_size(font_size)
.text_color(cx.app.theme().colors().text_muted)
.child(" | ")
.into_any_element(),
);
}
let title = item.title.clone();
let action = item.action.clone();
let position = line.position;
let editor_handle = editor.clone();
children.push(
div()
.id(ElementId::from(i))
.font(font.clone())
.text_size(font_size)
.text_color(cx.app.theme().colors().text_muted)
.cursor_pointer()
.hover(|style| style.text_color(cx.app.theme().colors().text))
.child(title)
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_mouse_down(MouseButton::Right, |_, _, cx| {
cx.stop_propagation();
})
.on_click({
move |_event, window, cx| {
if let Some(editor) = editor_handle.upgrade() {
editor.update(cx, |editor, cx| {
editor.change_selections(
SelectionEffects::default(),
window,
cx,
|s| {
s.select_anchor_ranges([position..position]);
},
);
let action = action.clone();
if let Some(workspace) = editor.workspace() {
if try_handle_client_command(
&action, editor, &workspace, window, cx,
) {
return;
}
let project = workspace.read(cx).project().clone();
if let Some(buffer) = editor
.buffer()
.read(cx)
.buffer(action.range.start.buffer_id)
{
project
.update(cx, |project, cx| {
project
.apply_code_action(buffer, action, true, cx)
})
.detach_and_log_err(cx);
}
}
});
}
}
})
.into_any_element(),
);
}
div()
.id(cx.block_id)
.pl(cx.margins.gutter.full_width() + cx.em_width * (line.indent_column as f32 + 0.5))
.h_full()
.flex()
.flex_row()
.items_end()
.children(children)
.into_any_element()
})
}
#[cfg(test)]
mod tests {
use std::{
@ -592,7 +657,7 @@ mod tests {
use util::path;
use crate::{
Editor,
Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT,
editor_tests::{init_test, update_test_editor_settings},
test::editor_lsp_test_context::EditorLspTestContext,
};
@ -660,12 +725,209 @@ mod tests {
let total_blocks: usize = editor
.code_lens
.as_ref()
.map(|s| s.block_ids.values().map(|v| v.len()).sum())
.map(|s| s.blocks.values().map(|v| v.len()).sum())
.unwrap_or(0);
assert_eq!(total_blocks, 2, "Should have inserted two code lens blocks");
});
}
#[gpui::test]
async fn test_code_lens_blocks_kept_across_refresh(cx: &mut TestAppContext) {
init_test(cx, |_| {});
update_test_editor_settings(cx, &|settings| {
settings.code_lens = Some(CodeLens::On);
});
let mut cx = EditorLspTestContext::new_typescript(
lsp::ServerCapabilities {
code_lens_provider: Some(lsp::CodeLensOptions {
resolve_provider: None,
}),
execute_command_provider: Some(lsp::ExecuteCommandOptions {
commands: vec!["lens_cmd".to_string()],
..lsp::ExecuteCommandOptions::default()
}),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
let mut code_lens_request =
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
Ok(Some(vec![lsp::CodeLens {
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
command: Some(lsp::Command {
title: "1 reference".to_owned(),
command: "lens_cmd".to_owned(),
arguments: None,
}),
data: None,
}]))
});
cx.set_state("ˇfunction hello() {}\nfunction world() {}");
assert!(
code_lens_request.next().await.is_some(),
"should have received the initial code lens request"
);
cx.run_until_parked();
let initial_block_ids = cx.editor.read_with(&cx.cx.cx, |editor, _| {
editor
.code_lens
.as_ref()
.map(|s| {
s.blocks
.values()
.flatten()
.map(|b| b.block_id)
.collect::<HashSet<_>>()
})
.unwrap_or_default()
});
assert_eq!(
initial_block_ids.len(),
1,
"Should have one initial code lens block"
);
cx.update_editor(|editor, window, cx| {
editor.move_to_end(&crate::actions::MoveToEnd, window, cx);
editor.handle_input("\n// trailing comment", window, cx);
});
cx.executor()
.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(50));
assert!(
code_lens_request.next().await.is_some(),
"should have received another code lens request after edit"
);
cx.run_until_parked();
let refreshed_block_ids = cx.editor.read_with(&cx.cx.cx, |editor, _| {
editor
.code_lens
.as_ref()
.map(|s| {
s.blocks
.values()
.flatten()
.map(|b| b.block_id)
.collect::<HashSet<_>>()
})
.unwrap_or_default()
});
assert_eq!(
refreshed_block_ids, initial_block_ids,
"Code lens blocks should be preserved across refreshes when their content is unchanged"
);
}
#[gpui::test]
async fn test_code_lens_blocks_kept_when_only_resolve_fills_titles(cx: &mut TestAppContext) {
init_test(cx, |_| {});
update_test_editor_settings(cx, &|settings| {
settings.code_lens = Some(CodeLens::On);
});
let mut cx = EditorLspTestContext::new_typescript(
lsp::ServerCapabilities {
code_lens_provider: Some(lsp::CodeLensOptions {
resolve_provider: Some(true),
}),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
// The LSP returns shallow code lenses on every fetch; only `resolve`
// populates the command/title. This is the realistic flow with
// servers like rust-analyzer and exercises the path where each
// post-edit refresh comes back unresolved before the resolve catches
// up.
let mut code_lens_request =
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
Ok(Some(vec![lsp::CodeLens {
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
command: None,
data: Some(serde_json::json!({"id": "lens_1"})),
}]))
});
cx.lsp
.set_request_handler::<lsp::request::CodeLensResolve, _, _>(|lens, _| async move {
Ok(lsp::CodeLens {
command: Some(lsp::Command {
title: "1 reference".to_owned(),
command: "resolved_cmd".to_owned(),
arguments: None,
}),
..lens
})
});
cx.set_state("ˇfunction hello() {}\nfunction world() {}");
assert!(
code_lens_request.next().await.is_some(),
"should have received the initial code lens request"
);
cx.run_until_parked();
let initial = cx.editor.read_with(&cx.cx.cx, |editor, _| {
editor
.code_lens
.as_ref()
.map(|s| {
s.blocks
.values()
.flatten()
.map(|b| b.block_id)
.collect::<HashSet<_>>()
})
.unwrap_or_default()
});
assert_eq!(
initial.len(),
1,
"resolve should have inserted exactly one block from the shallow lens"
);
for keystroke in [" ", "x", "y"] {
cx.update_editor(|editor, window, cx| {
editor.move_to_end(&crate::actions::MoveToEnd, window, cx);
editor.handle_input(keystroke, window, cx);
});
cx.executor()
.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(50));
assert!(
code_lens_request.next().await.is_some(),
"should have received another (shallow) code lens request after edit"
);
cx.run_until_parked();
let after = cx.editor.read_with(&cx.cx.cx, |editor, _| {
editor
.code_lens
.as_ref()
.map(|s| {
s.blocks
.values()
.flatten()
.map(|b| b.block_id)
.collect::<HashSet<_>>()
})
.unwrap_or_default()
});
assert_eq!(
after, initial,
"Block IDs must survive the unresolved-fetch → resolve cycle without churn"
);
}
}
#[gpui::test]
async fn test_code_lens_disabled_by_default(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@ -754,7 +1016,7 @@ mod tests {
let total_blocks: usize = editor
.code_lens
.as_ref()
.map(|s| s.block_ids.values().map(|v| v.len()).sum())
.map(|s| s.blocks.values().map(|v| v.len()).sum())
.unwrap_or(0);
assert_eq!(total_blocks, 1, "Should have one code lens block");
});
@ -841,7 +1103,7 @@ mod tests {
let total_blocks: usize = editor
.code_lens
.as_ref()
.map(|s| s.block_ids.values().map(|v| v.len()).sum())
.map(|s| s.blocks.values().map(|v| v.len()).sum())
.unwrap_or(0);
assert_eq!(
total_blocks, 2,

View file

@ -1368,50 +1368,49 @@ impl BlockMap {
let mut delta = their_baseline.0 as i32 - our_baseline.0 as i32;
// If we started out in the middle of a hunk/group, work up to the end of that group to set up the main loop below.
if edit_for_first_point.old.start < first_point {
let mut current_boundary = first_point;
let current_range = edit_for_first_point.new;
while let Some(next_point) = source_points.peek().cloned() {
let edit_for_next_point = excerpt.patch.edit_for_old_position(next_point);
if edit_for_next_point.new.end > current_range.end {
break;
}
source_points.next();
current_boundary = next_point;
}
let (new_delta, spacer) = determine_spacer(
&mut our_wrapper,
&mut companion_wrapper,
current_boundary,
current_range.end.min(excerpt.target_excerpt_range.end),
delta,
Bias::Left,
);
delta = new_delta;
if let Some((wrap_row, height)) = spacer {
result.push((
BlockPlacement::Above(wrap_row),
Block::Spacer {
id: SpacerId(self.next_block_id.fetch_add(1, SeqCst)),
height,
is_below: false,
},
));
}
}
while let Some(source_point) = source_points.next() {
let mut current_boundary = source_point;
let current_range = excerpt.patch.edit_for_old_position(current_boundary).new;
let current_edit = excerpt.patch.edit_for_old_position(current_boundary);
let current_range = current_edit.new;
if current_boundary.column > 0 {
debug_assert_eq!(current_boundary, excerpt.source_excerpt_range.end);
break;
}
if current_edit.old.start < current_boundary {
while let Some(next_point) = source_points.peek().copied() {
let edit_for_next_point = excerpt.patch.edit_for_old_position(next_point);
if edit_for_next_point.new.end > current_range.end {
break;
}
current_boundary = next_point;
source_points.next();
}
let (new_delta, spacer) = determine_spacer(
&mut our_wrapper,
&mut companion_wrapper,
current_boundary,
current_range.end.min(excerpt.target_excerpt_range.end),
delta,
Bias::Left,
);
delta = new_delta;
if let Some((wrap_row, height)) = spacer {
result.push((
BlockPlacement::Above(wrap_row),
Block::Spacer {
id: SpacerId(self.next_block_id.fetch_add(1, SeqCst)),
height,
is_below: false,
},
));
}
continue;
}
let (delta_at_start, mut spacer_at_start) = determine_spacer(
&mut our_wrapper,
&mut companion_wrapper,
@ -4399,8 +4398,7 @@ mod tests {
let mut expected_longest_rows_in_range = vec![];
let mut longest_line_len_in_range = 0;
let mut row = start_row as u32;
for line in &expected_lines[start_row..end_row] {
for (row, line) in (start_row as u32..).zip(&expected_lines[start_row..end_row]) {
let line_char_count = line.chars().count() as isize;
match line_char_count.cmp(&longest_line_len_in_range) {
Ordering::Less => {}
@ -4411,7 +4409,6 @@ mod tests {
expected_longest_rows_in_range.push(row);
}
}
row += 1;
}
let longest_row_in_range = blocks_snapshot

View file

@ -1577,6 +1577,10 @@ mod tests {
let mut all_tab_stops = Vec::new();
let mut byte_offset = 1;
let mut char_offset = 1;
#[expect(
clippy::explicit_counter_loop,
reason = "Lint does not account for char_offset being needed after the loop"
)]
for ch in buffer_snapshot.text().chars() {
if ch == '\t' {
all_tab_stops.push(TabStop {

View file

@ -51,8 +51,8 @@ impl LspColorData {
to_remove: Vec::new(),
to_insert: self
.buffer_colors
.iter()
.flat_map(|(_, buffer_colors)| buffer_colors.colors.iter())
.values()
.flat_map(|buffer_colors| buffer_colors.colors.iter())
.map(|(range, color, id)| {
Inlay::color(
id.id(),
@ -120,8 +120,8 @@ impl LspColorData {
Vec::new()
} else {
self.buffer_colors
.iter()
.flat_map(|(_, buffer_colors)| &buffer_colors.colors)
.values()
.flat_map(|buffer_colors| &buffer_colors.colors)
.map(|(range, color, _)| {
let display_range = range.clone().to_display_points(snapshot);
let color = Hsla::from(Rgba {

View file

@ -902,6 +902,16 @@ pub trait Addon: 'static {
None
}
fn extend_buffer_header_context_menu(
&self,
menu: ui::ContextMenu,
_: &language::BufferSnapshot,
_: &mut Window,
_: &mut App,
) -> ui::ContextMenu {
menu
}
fn override_status_for_buffer_id(&self, _: BufferId, _: &App) -> Option<FileStatus> {
None
}
@ -14003,7 +14013,8 @@ impl Editor {
Some(CommentFormat::BlockCommentWithEnd(config.clone()))
}
(Some(config), _) | (_, Some(config))
if buffer.contains_str_at(indent_end, &config.prefix) =>
if !config.prefix.is_empty()
&& buffer.contains_str_at(indent_end, &config.prefix) =>
{
Some(CommentFormat::BlockLine(config.prefix.to_string()))
}
@ -18339,7 +18350,11 @@ impl Editor {
};
let anchor_range = range.to_anchors(&multibuffer.snapshot(cx));
self.change_selections(
SelectionEffects::scroll(Autoscroll::for_go_to_definition(cx)).nav_history(true),
SelectionEffects::scroll(Autoscroll::for_go_to_definition(
self.cursor_top_offset(cx),
cx,
))
.nav_history(true),
window,
cx,
|s| s.select_anchor_ranges([anchor_range]),
@ -19145,8 +19160,11 @@ impl Editor {
}
editor.change_selections(
SelectionEffects::scroll(Autoscroll::for_go_to_definition(cx))
.nav_history(true),
SelectionEffects::scroll(Autoscroll::for_go_to_definition(
editor.cursor_top_offset(cx),
cx,
))
.nav_history(true),
window,
cx,
|s| s.select_anchor_ranges(target_ranges),
@ -19162,6 +19180,8 @@ impl Editor {
return Navigated::No;
};
let pane = workspace.read(cx).active_pane().clone();
let offset = editor.cursor_top_offset(cx);
window.defer(cx, move |window, cx| {
let (target_editor, target_pane): (Entity<Self>, Entity<Pane>) =
workspace.update(cx, |workspace, cx| {
@ -19225,8 +19245,10 @@ impl Editor {
}
target_editor.change_selections(
SelectionEffects::scroll(Autoscroll::for_go_to_definition(cx))
.nav_history(true),
SelectionEffects::scroll(Autoscroll::for_go_to_definition(
offset, cx,
))
.nav_history(true),
window,
cx,
|s| s.select_anchor_ranges(target_ranges),
@ -19506,7 +19528,10 @@ impl Editor {
let Range { start, end } = locations[destination_location_index];
editor.update_in(cx, |editor, window, cx| {
let effects = SelectionEffects::scroll(Autoscroll::for_go_to_definition(cx));
let effects = SelectionEffects::scroll(Autoscroll::for_go_to_definition(
editor.cursor_top_offset(cx),
cx,
));
editor.unfold_ranges(&[start..end], false, false, cx);
editor.change_selections(effects, window, cx, |s| {
@ -24450,8 +24475,8 @@ impl Editor {
let snapshot = self.snapshot(window, cx);
let mut used_highlight_orders = HashMap::default();
self.highlighted_rows
.iter()
.flat_map(|(_, highlighted_rows)| highlighted_rows.iter())
.values()
.flat_map(|highlighted_rows| highlighted_rows.iter())
.fold(
BTreeMap::<DisplayRow, LineHighlight>::new(),
|mut unique_rows, highlight| {
@ -25671,7 +25696,7 @@ impl Editor {
}
let autoscroll = match scroll_offset {
Some(scroll_offset) => {
Autoscroll::top_relative(scroll_offset as usize)
Autoscroll::top_relative(scroll_offset as ScrollOffset)
}
None => Autoscroll::newest(),
};
@ -26746,6 +26771,27 @@ impl Editor {
self.refresh_runnables(None, window, cx);
}
}
/// Returns the current cursor's vertical offset, in display rows, from the
/// top of the visible viewport.
/// Returns `None` if the cursor is not currently on screen.
pub fn cursor_top_offset(&self, cx: &mut Context<Self>) -> Option<ScrollOffset> {
let visible = self.visible_line_count()?;
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let scroll_top = self.scroll_manager.scroll_position(&display_map, cx).y;
let cursor_display_row = self
.selections
.newest::<Point>(&display_map)
.head()
.to_display_point(&display_map)
.row()
.as_f64();
match cursor_display_row - scroll_top {
offset if offset < 0.0 || offset >= visible => None,
offset => Some(offset),
}
}
}
fn edit_for_markdown_paste<'a>(

View file

@ -49,9 +49,9 @@ use project::{
use serde_json::{self, json};
use settings::{
AllLanguageSettingsContent, DelayMs, EditorSettingsContent, GlobalLspSettingsContent,
IndentGuideBackgroundColoring, IndentGuideColoring, InlayHintSettingsContent,
ProjectSettingsContent, ScrollBeyondLastLine, SearchSettingsContent, SettingsContent,
SettingsStore,
GoToDefinitionScrollStrategy, IndentGuideBackgroundColoring, IndentGuideColoring,
InlayHintSettingsContent, ProjectSettingsContent, ScrollBeyondLastLine, SearchSettingsContent,
SettingsContent, SettingsStore,
};
use std::{borrow::Cow, sync::Arc};
use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
@ -2962,6 +2962,102 @@ async fn test_autoscroll(cx: &mut TestAppContext) {
});
}
#[gpui::test]
async fn test_autoscroll_relative(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let line_height = cx.update_editor(|editor, window, cx| {
editor.set_vertical_scroll_margin(0, cx);
editor
.style(cx)
.text
.line_height_in_pixels(window.rem_size())
});
let window = cx.window;
// Resize the window such that only 6 lines of text fit on screen.
cx.simulate_window_resize(window, size(px(1000.), 6. * line_height));
cx.set_state(
r#"ˇone
two
three
four
five
six
seven
eight
nine
ten
eleven
twelve
thirteen
fourteen
fifteen
"#,
);
cx.update_editor(|editor, window, cx| {
assert_eq!(
editor.snapshot(window, cx).scroll_position(),
gpui::Point::new(0., 0.0)
);
});
// Placing the cursor at row 7 with a top-relative autoscroll of 2 display
// rows, should land the scroll position's y coordinate at 5.0 (7 - 2).
cx.update_editor(|editor, window, cx| {
editor.change_selections(
SelectionEffects::scroll(Autoscroll::top_relative(2.0)),
window,
cx,
|selections| selections.select_ranges([Point::new(7, 0)..Point::new(7, 0)]),
);
});
cx.update_editor(|editor, window, cx| {
assert_eq!(
editor.snapshot(window, cx).scroll_position(),
gpui::Point::new(0., 5.0)
);
});
// Seeing as fractional offsets are supported, with the cursor at row 10 and
// a top-relative autoscroll of 2.5 display rows, the scroll position's y
// coordinate lands at 7.5 (10 - 2.5).
cx.update_editor(|editor, window, cx| {
editor.change_selections(
SelectionEffects::scroll(Autoscroll::top_relative(2.5)),
window,
cx,
|selections| selections.select_ranges([Point::new(10, 0)..Point::new(10, 0)]),
);
});
cx.update_editor(|editor, window, cx| {
assert_eq!(
editor.snapshot(window, cx).scroll_position(),
gpui::Point::new(0., 7.5)
);
});
// When the requested offset would scroll past the top of the buffer,
// `scroll_position.y` is clamped to 0 rather than going negative.
cx.update_editor(|editor, window, cx| {
editor.change_selections(
SelectionEffects::scroll(Autoscroll::top_relative(4.0)),
window,
cx,
|selections| selections.select_ranges([Point::new(1, 0)..Point::new(1, 0)]),
);
});
cx.update_editor(|editor, window, cx| {
assert_eq!(
editor.snapshot(window, cx).scroll_position(),
gpui::Point::new(0., 0.0)
);
});
}
#[gpui::test]
async fn test_exclude_overscroll_margin_clamps_scroll_position(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@ -8451,6 +8547,62 @@ async fn test_rewrap_block_comments(cx: &mut TestAppContext) {
}
}
#[gpui::test]
async fn test_rewrap_line_comment_in_go(cx: &mut TestAppContext) {
init_test(cx, |settings| {
settings.languages.0.extend([(
"Go".into(),
LanguageSettingsContent {
allow_rewrap: Some(language_settings::RewrapBehavior::InComments),
preferred_line_length: Some(40),
..Default::default()
},
)])
});
let mut cx = EditorTestContext::new(cx).await;
let go_lang = languages::language("go", tree_sitter_go::LANGUAGE.into());
cx.update_buffer(|buffer, cx| buffer.set_language(Some(go_lang), cx));
cx.set_state(indoc! {"
// Lorem ipsum dolor sit amet, consectetur adipiscing elit.ˇ
"});
cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx));
cx.assert_editor_state(indoc! {"
// Lorem ipsum dolor sit amet,
// consectetur adipiscing elit.ˇ
"});
}
#[gpui::test]
async fn test_rewrap_line_comment_in_c(cx: &mut TestAppContext) {
init_test(cx, |settings| {
settings.languages.0.extend([(
"C".into(),
LanguageSettingsContent {
allow_rewrap: Some(language_settings::RewrapBehavior::InComments),
preferred_line_length: Some(40),
..Default::default()
},
)])
});
let mut cx = EditorTestContext::new(cx).await;
let c_lang = languages::language("c", tree_sitter_c::LANGUAGE.into());
cx.update_buffer(|buffer, cx| buffer.set_language(Some(c_lang), cx));
cx.set_state(indoc! {"
// Lorem ipsum dolor sit amet, consectetur adipiscing elit.ˇ
"});
cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx));
cx.assert_editor_state(indoc! {"
// Lorem ipsum dolor sit amet,
// consectetur adipiscing elit.ˇ
"});
}
#[gpui::test]
async fn test_hard_wrap(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@ -26315,6 +26467,142 @@ async fn test_goto_definition_contained_ranges(cx: &mut TestAppContext) {
assert_eq!(navigated, Navigated::Yes);
}
#[gpui::test]
async fn test_goto_definition_preserve_scroll_strategy(cx: &mut TestAppContext) {
init_test(cx, |_| {});
update_test_editor_settings(cx, &|settings| {
settings.go_to_definition_scroll_strategy = Some(GoToDefinitionScrollStrategy::Preserve);
settings.vertical_scroll_margin = Some(0.0);
});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
definition_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
let window = cx.window;
let line_height = cx.update_editor(|editor, window, cx| {
editor
.style(cx)
.text
.line_height_in_pixels(window.rem_size())
});
cx.simulate_window_resize(window, size(px(1000.), 8. * line_height));
// Build a buffer where `target` is defined on row 10 and called from
// row 20, with the cursor placed on the call site.
let buffer = indoc! { "
// 0
// 1
// 2
// 3
// 4
// 5
// 6
// 7
// 8
// 9
fn target() // 10
// 11
// 12
// 13
// 14
// 15
// 16
// 17
// 18
// 19
fn caller() { ˇtarget(); } // 20
// 21
// 22
// 23
// 24
// 25
// 26
// 27
// 28
// 29
// 30
"};
// Mock the response from the LSP server when requesting to go to a
// definition so as to always jump to the `target` function.
cx.set_request_handler::<lsp::request::GotoDefinition, _, _>(|url, _, _| async move {
Ok(Some(lsp::GotoDefinitionResponse::Scalar(lsp::Location {
uri: url.clone(),
range: lsp::Range::new(lsp::Position::new(10, 3), lsp::Position::new(10, 9)),
})))
});
let caller_row = 20.0;
let target_row = 10.0;
let offset = 1.5;
let center_offset = cx.update_editor(|editor, _, _| {
editor
.visible_line_count()
.map(|count| ((count - 1.0) / 2.0).floor())
.expect("Visible line count should be available")
});
// When the cursor is visible inside the viewport, going to a definition
// should preserve that same offset value.
// In this case, with the cursor set at row 20 and the scroll position set
// to 18.5 (20 - 1.5), when going to the definition of `target` in row 10,
// the scroll position should end up at 8.5 (10 - 1.5), so as to preserve
// that same offset of 1.5.
cx.set_state(&buffer);
cx.update_editor(|editor, window, cx| {
editor.set_scroll_position(gpui::Point::new(0.0, caller_row - offset), window, cx);
});
cx.update_editor(|editor, window, cx| editor.go_to_definition(&GoToDefinition, window, cx))
.await
.expect("Failed to navigate to definition");
cx.run_until_parked();
cx.update_editor(|editor, window, cx| {
assert_eq!(
editor.snapshot(window, cx).scroll_position(),
gpui::Point::new(0.0, target_row - offset),
);
});
// In the case where the cursor ends up outside of the visible viewport, the
// scroll position's offset should be ignored and the center of the viewport
// should be used instead.
// Since the cursor is jumping to row 10, the scroll position's y coordinate
// should end up at 10 minus the offset from the center of the viewport.
cx.set_state(&buffer);
cx.update_editor(|editor, window, cx| {
editor.set_scroll_position(gpui::Point::new(0.0, 0.0), window, cx);
let snapshot = editor.display_snapshot(cx);
let cursor_row = editor
.selections
.newest_display(&snapshot)
.start
.row()
.as_f64();
let visible_lines = editor
.visible_line_count()
.expect("Visible line count should be available");
assert!(cursor_row >= visible_lines, "Cursor should be offscreen");
});
cx.update_editor(|editor, window, cx| editor.go_to_definition(&GoToDefinition, window, cx))
.await
.expect("Failed to navigate to definition");
cx.run_until_parked();
cx.update_editor(|editor, window, cx| {
assert_eq!(
editor.snapshot(window, cx).scroll_position(),
gpui::Point::new(0.0, (target_row - center_offset).max(0.0)),
);
});
}
#[gpui::test]
async fn test_find_all_references_editor_reuse(cx: &mut TestAppContext) {
init_test(cx, |_| {});

View file

@ -208,7 +208,7 @@ pub enum SplitSide {
}
impl EditorElement {
pub(crate) const SCROLLBAR_WIDTH: Pixels = px(15.);
pub(crate) const SCROLLBAR_WIDTH: Pixels = ui::EDITOR_SCROLLBAR_WIDTH;
pub fn new(editor: &Entity<Editor>, style: EditorStyle) -> Self {
Self {
@ -6814,7 +6814,9 @@ impl EditorElement {
.display_snapshot
.display_point_to_anchor(point_for_position.nearest_valid, Bias::Left);
editor.change_selections(
SelectionEffects::scroll(Autoscroll::top_relative(line_index)),
SelectionEffects::scroll(Autoscroll::top_relative(
line_index as ScrollOffset,
)),
window,
cx,
|selections| {
@ -8734,6 +8736,7 @@ pub(crate) fn render_buffer_header(
let file = buffer.file().cloned();
let editor = editor.clone();
let buffer_snapshot = buffer.clone();
right_click_menu("buffer-header-context-menu")
.trigger(move |_, _, _| header)
@ -8741,6 +8744,7 @@ pub(crate) fn render_buffer_header(
let menu_context = focus_handle.clone();
let editor = editor.clone();
let file = file.clone();
let buffer_snapshot = buffer_snapshot.clone();
ContextMenu::build(window, cx, move |mut menu, window, cx| {
if let Some(file) = file
&& let Some(project) = editor.read(cx).project()
@ -8827,6 +8831,19 @@ pub(crate) fn render_buffer_header(
});
}
menu = editor.update(cx, |editor, cx| {
let mut menu = menu;
for addon in editor.addons.values() {
menu = addon.extend_buffer_header_context_menu(
menu,
&buffer_snapshot,
window,
cx,
);
}
menu
});
menu.context(menu_context)
})
})

View file

@ -715,7 +715,9 @@ mod tests {
use multi_buffer::{MultiBuffer, PathKey};
use project::{
FakeFs, Project, ProjectPath,
lsp_store::lsp_ext_command::{CargoRunnableArgs, Runnable, RunnableArgs, RunnableKind},
lsp_store::lsp_ext_command::{
CargoRunnableArgs, Runnable, RunnableArgs, ShellRunnableArgs,
},
};
use serde_json::json;
use task::{TaskTemplate, TaskTemplates};
@ -1028,7 +1030,6 @@ mod tests {
lsp::Position::new(3, 1),
),
}),
kind: RunnableKind::Cargo,
args: RunnableArgs::Cargo(CargoRunnableArgs {
environment: Default::default(),
cwd: path!("/project").into(),
@ -1174,4 +1175,156 @@ mod tests {
"Runnables should appear after the buffer is saved to disk"
);
}
// Verifies that a shell runnable from rust-analyzer produces
// a task template that uses the shell program and args.
#[gpui::test]
async fn test_shell_runnable_produces_correct_task_template(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
"main.rs": indoc! {"
#[test]
fn test_one() {
assert!(true);
}
"},
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang_with_lsp_task_context());
let mut fake_servers = language_registry.register_fake_lsp(
"Rust",
FakeLspAdapter {
name: FAKE_LSP_NAME,
..FakeLspAdapter::default()
},
);
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/project/main.rs"), cx)
})
.await
.unwrap();
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
let editor = cx.add_window(|window, cx| {
build_editor_with_project(project.clone(), multi_buffer, window, cx)
});
let fake_server = fake_servers.next().await.expect("fake LSP server");
use project::lsp_store::lsp_ext_command::Runnables;
fake_server.set_request_handler::<Runnables, _, _>(move |params, _| async move {
let text = params.text_document.uri.path().to_string();
if text.contains("main.rs") {
let uri = lsp::Uri::from_file_path(path!("/project/main.rs")).expect("valid uri");
Ok(vec![Runnable {
label: "nextest test_one".into(),
location: Some(lsp::LocationLink {
origin_selection_range: None,
target_uri: uri,
target_range: lsp::Range::new(
lsp::Position::new(0, 0),
lsp::Position::new(3, 1),
),
target_selection_range: lsp::Range::new(
lsp::Position::new(0, 0),
lsp::Position::new(3, 1),
),
}),
args: RunnableArgs::Shell(ShellRunnableArgs {
environment: Default::default(),
cwd: path!("/project").into(),
program: "cargo".into(),
args: vec![
"nextest".into(),
"run".into(),
"--package".into(),
"my-crate".into(),
"--lib".into(),
"--".into(),
"test_one".into(),
"--exact".into(),
],
}),
}])
} else {
Ok(Vec::new())
}
});
editor
.update(cx, |editor, window, cx| {
editor.refresh_runnables(None, window, cx);
})
.expect("editor update");
cx.executor().advance_clock(UPDATE_DEBOUNCE);
cx.executor().run_until_parked();
let labels = editor
.update(cx, |editor, _, _| collect_runnable_labels(editor))
.expect("editor update");
assert_eq!(
labels,
vec![(buffer_id, 0, vec!["nextest test_one".to_string()])],
"shell runnable should appear for #[test] fn"
);
let templates = editor
.update(cx, |editor, _, _| {
editor
.runnables
.runnables
.iter()
.flat_map(|(_, (_, tasks))| {
tasks.values().flat_map(|runnable_tasks| {
runnable_tasks
.templates
.iter()
.map(|(_, template)| {
(
template.label.clone(),
template.command.clone(),
template.args.clone(),
)
})
.collect::<Vec<_>>()
})
})
.collect::<Vec<_>>()
})
.expect("editor update");
let (label, command, args) = templates
.iter()
.find(|(label, _, _)| label == "nextest test_one")
.expect("shell runnable task template should exist");
assert_eq!(label, "nextest test_one");
assert_eq!(command, "cargo");
assert_eq!(
args,
&[
"nextest",
"run",
"--package",
"my-crate",
"--lib",
"--",
"test_one",
"--exact",
],
"shell runnable should preserve program args"
);
}
}

View file

@ -36,11 +36,14 @@ impl Autoscroll {
/// Returns the autoscroll strategy configured for navigation to definitions
/// and references, based on `go_to_definition_scroll_strategy`.
pub fn for_go_to_definition(cx: &App) -> Self {
pub fn for_go_to_definition(offset: Option<ScrollOffset>, cx: &App) -> Self {
match EditorSettings::get_global(cx).go_to_definition_scroll_strategy {
GoToDefinitionScrollStrategy::Center => Self::center(),
GoToDefinitionScrollStrategy::Minimum => Self::fit(),
GoToDefinitionScrollStrategy::Top => Self::focused(),
GoToDefinitionScrollStrategy::Preserve => {
offset.map(Self::top_relative).unwrap_or_else(Self::center)
}
}
}
@ -50,9 +53,10 @@ impl Autoscroll {
Self::Strategy(AutoscrollStrategy::Focused, None)
}
/// Scrolls so that the newest cursor is roughly an n-th line from the top.
pub fn top_relative(n: usize) -> Self {
Self::Strategy(AutoscrollStrategy::TopRelative(n), None)
/// Scrolls so that the newest cursor is the given offset (in display rows)
/// from the top of the viewport.
pub fn top_relative(offset: ScrollOffset) -> Self {
Self::Strategy(AutoscrollStrategy::TopRelative(offset), None)
}
/// Scrolls so that the newest cursor is at the top.
@ -60,9 +64,10 @@ impl Autoscroll {
Self::Strategy(AutoscrollStrategy::Top, None)
}
/// Scrolls so that the newest cursor is roughly an n-th line from the bottom.
pub fn bottom_relative(n: usize) -> Self {
Self::Strategy(AutoscrollStrategy::BottomRelative(n), None)
/// Scrolls so that the newest cursor is the given offset (in display rows)
/// from the bottom of the viewport.
pub fn bottom_relative(offset: ScrollOffset) -> Self {
Self::Strategy(AutoscrollStrategy::BottomRelative(offset), None)
}
/// Scrolls so that the newest cursor is at the bottom.
@ -91,7 +96,7 @@ impl Into<SelectionEffects> for Option<Autoscroll> {
}
}
#[derive(Debug, PartialEq, Eq, Default, Clone, Copy)]
#[derive(Debug, PartialEq, Default, Clone, Copy)]
pub enum AutoscrollStrategy {
Fit,
Newest,
@ -100,10 +105,12 @@ pub enum AutoscrollStrategy {
Focused,
Top,
Bottom,
TopRelative(usize),
BottomRelative(usize),
TopRelative(ScrollOffset),
BottomRelative(ScrollOffset),
}
impl Eq for AutoscrollStrategy {}
impl AutoscrollStrategy {
fn next(&self) -> Self {
match self {

View file

@ -6062,6 +6062,121 @@ mod tests {
cx.run_until_parked();
}
#[gpui::test]
async fn test_spacer_blocks_revert_after_temporary_edit(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "
aaa
bbb
"
.unindent();
let current_text = "
aaa
bbb
ccc
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
§ spacer"
.unindent(),
&mut cx,
);
let buffer_snapshot = buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(0, 3)..Point::new(0, 3), "\n")], None, cx);
buffer.text_snapshot()
});
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc"
.unindent(),
"
§ <no file>
§ -----
aaa
§ spacer
bbb
§ spacer"
.unindent(),
&mut cx,
);
let buffer_snapshot = buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(0, 3)..Point::new(1, 0), "")], None, cx);
buffer.text_snapshot()
});
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
§ spacer"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_act_as_type(cx: &mut gpui::TestAppContext) {
let (splittable_editor, cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;

View file

@ -20,15 +20,23 @@ interface dap {
attach(attach-request)
}
type ipv4-address = tuple<u8, u8, u8, u8>;
type ipv6-address = tuple<u16, u16, u16, u16, u16, u16, u16, u16>;
variant ip-address {
ipv4(ipv4-address),
ipv6(ipv6-address),
}
record tcp-arguments {
port: u16,
host: u32,
host: ip-address,
timeout: option<u64>,
}
record tcp-arguments-template {
port: option<u16>,
host: option<u32>,
host: option<ip-address>,
timeout: option<u64>,
}

View file

@ -16,7 +16,7 @@ use lsp::LanguageServerName;
use release_channel::ReleaseChannel;
use task::{DebugScenario, SpawnInTerminal, TaskTemplate, ZedDebugConfig};
use crate::wasm_host::wit::since_v0_6_0::dap::StartDebuggingRequestArgumentsRequest;
use latest::dap::StartDebuggingRequestArgumentsRequest;
use super::{WasmState, wasm_engine};
use anyhow::{Context as _, Result, anyhow};
@ -1072,18 +1072,19 @@ impl Extension {
Ok(Ok(dap_binary))
}
Extension::V0_6_0(ext) => {
let task: latest::DebugTaskDefinition = task.try_into()?;
let dap_binary = ext
.call_get_dap_binary(
store,
&adapter_name,
&task.try_into()?,
&task.into(),
user_installed_path.as_ref().and_then(|p| p.to_str()),
resource,
)
.await?
.map_err(|e| anyhow!("{e:?}"))?;
Ok(Ok(dap_binary))
Ok(Ok(dap_binary.into()))
}
Extension::V0_5_0(_)
| Extension::V0_4_0(_)
@ -1123,7 +1124,7 @@ impl Extension {
.await?
.map_err(|e| anyhow!("{e:?}"))?;
Ok(Ok(dap_binary))
Ok(Ok(dap_binary.into()))
}
Extension::V0_5_0(_)
| Extension::V0_4_0(_)
@ -1154,12 +1155,13 @@ impl Extension {
Ok(Ok(dap_binary.try_into()?))
}
Extension::V0_6_0(ext) => {
let config = config.into();
let config: latest::DebugConfig = config.into();
let dap_binary = ext
.call_dap_config_to_scenario(store, &config)
.call_dap_config_to_scenario(store, &config.into())
.await?
.map_err(|e| anyhow!("{e:?}"))?;
let dap_binary: latest::DebugScenario = dap_binary.into();
Ok(Ok(dap_binary.try_into()?))
}
Extension::V0_5_0(_)
@ -1199,18 +1201,20 @@ impl Extension {
Ok(dap_binary.map(TryInto::try_into).transpose()?)
}
Extension::V0_6_0(ext) => {
let build_config_template = build_config_template.into();
let build_config_template: latest::dap::TaskTemplate = build_config_template.into();
let dap_binary = ext
.call_dap_locator_create_scenario(
store,
&locator_name,
&build_config_template,
&build_config_template.into(),
&resolved_label,
&debug_adapter_name,
)
.await?;
Ok(dap_binary.map(TryInto::try_into).transpose()?)
Ok(dap_binary
.map(|s| latest::DebugScenario::from(s).try_into())
.transpose()?)
}
Extension::V0_5_0(_)
| Extension::V0_4_0(_)
@ -1242,12 +1246,14 @@ impl Extension {
Ok(Ok(dap_request.into()))
}
Extension::V0_6_0(ext) => {
let build_config_template = resolved_build_task.try_into()?;
let build_config_template: latest::dap::TaskTemplate =
resolved_build_task.try_into()?;
let dap_request = ext
.call_run_dap_locator(store, &locator_name, &build_config_template)
.call_run_dap_locator(store, &locator_name, &build_config_template.into())
.await?
.map_err(|e| anyhow!("{e:?}"))?;
let dap_request: latest::DebugRequest = dap_request.into();
Ok(Ok(dap_request.into()))
}
Extension::V0_5_0(_)

View file

@ -30,7 +30,6 @@ wasmtime::component::bindgen!({
"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,
},
});
@ -384,3 +383,328 @@ impl ExtensionImports for WasmState {
latest::ExtensionImports::make_file_executable(self, path).await
}
}
impl From<dap::TcpArguments> for latest::dap::TcpArguments {
fn from(value: dap::TcpArguments) -> Self {
let [a, b, c, d] = std::net::Ipv4Addr::from_bits(value.host).octets();
Self {
host: latest::dap::IpAddress::Ipv4((a, b, c, d)),
port: value.port,
timeout: value.timeout,
}
}
}
impl TryFrom<latest::dap::TcpArguments> for dap::TcpArguments {
type Error = anyhow::Error;
fn try_from(value: latest::dap::TcpArguments) -> Result<Self> {
let host = match value.host {
latest::dap::IpAddress::Ipv4((a, b, c, d)) => {
std::net::Ipv4Addr::new(a, b, c, d).to_bits()
}
latest::dap::IpAddress::Ipv6((a, b, c, d, e, f, g, h)) => {
let addr = std::net::Ipv6Addr::new(a, b, c, d, e, f, g, h);
anyhow::bail!(
"DAP returned IPv6 host {addr}, which the v0.6.0 extension API cannot represent; the extension must be updated to v0.8.0 or later"
);
}
};
Ok(Self {
host,
port: value.port,
timeout: value.timeout,
})
}
}
impl From<dap::TcpArgumentsTemplate> for latest::dap::TcpArgumentsTemplate {
fn from(value: dap::TcpArgumentsTemplate) -> Self {
Self {
host: value.host.map(|host| {
let [a, b, c, d] = std::net::Ipv4Addr::from_bits(host).octets();
latest::dap::IpAddress::Ipv4((a, b, c, d))
}),
port: value.port,
timeout: value.timeout,
}
}
}
impl From<latest::dap::TcpArgumentsTemplate> for dap::TcpArgumentsTemplate {
fn from(value: latest::dap::TcpArgumentsTemplate) -> Self {
Self {
host: value.host.and_then(|host| match host {
latest::dap::IpAddress::Ipv4((a, b, c, d)) => {
Some(std::net::Ipv4Addr::new(a, b, c, d).to_bits())
}
latest::dap::IpAddress::Ipv6((a, b, c, d, e, f, g, h)) => {
let addr = std::net::Ipv6Addr::new(a, b, c, d, e, f, g, h);
log::warn!(
"Dropping IPv6 host {addr} when handing TCP arguments back to a v0.6.0 extension; update the extension to v0.8.0 or later for IPv6 support"
);
None
}
}),
port: value.port,
timeout: value.timeout,
}
}
}
impl From<dap::LaunchRequest> for latest::dap::LaunchRequest {
fn from(value: dap::LaunchRequest) -> Self {
Self {
program: value.program,
cwd: value.cwd,
args: value.args,
envs: value.envs,
}
}
}
impl From<latest::dap::LaunchRequest> for dap::LaunchRequest {
fn from(value: latest::dap::LaunchRequest) -> Self {
Self {
program: value.program,
cwd: value.cwd,
args: value.args,
envs: value.envs,
}
}
}
impl From<dap::AttachRequest> for latest::dap::AttachRequest {
fn from(value: dap::AttachRequest) -> Self {
Self {
process_id: value.process_id,
}
}
}
impl From<latest::dap::AttachRequest> for dap::AttachRequest {
fn from(value: latest::dap::AttachRequest) -> Self {
Self {
process_id: value.process_id,
}
}
}
impl From<DebugRequest> for latest::DebugRequest {
fn from(value: DebugRequest) -> Self {
match value {
DebugRequest::Launch(req) => Self::Launch(req.into()),
DebugRequest::Attach(req) => Self::Attach(req.into()),
}
}
}
impl From<latest::DebugRequest> for DebugRequest {
fn from(value: latest::DebugRequest) -> Self {
match value {
latest::DebugRequest::Launch(req) => Self::Launch(req.into()),
latest::DebugRequest::Attach(req) => Self::Attach(req.into()),
}
}
}
impl From<DebugConfig> for latest::DebugConfig {
fn from(value: DebugConfig) -> Self {
Self {
label: value.label,
adapter: value.adapter,
request: value.request.into(),
stop_on_entry: value.stop_on_entry,
}
}
}
impl From<latest::DebugConfig> for DebugConfig {
fn from(value: latest::DebugConfig) -> Self {
Self {
label: value.label,
adapter: value.adapter,
request: value.request.into(),
stop_on_entry: value.stop_on_entry,
}
}
}
impl From<dap::TaskTemplate> for latest::dap::TaskTemplate {
fn from(value: dap::TaskTemplate) -> Self {
Self {
label: value.label,
command: value.command,
args: value.args,
env: value.env,
cwd: value.cwd,
}
}
}
impl From<latest::dap::TaskTemplate> for dap::TaskTemplate {
fn from(value: latest::dap::TaskTemplate) -> Self {
Self {
label: value.label,
command: value.command,
args: value.args,
env: value.env,
cwd: value.cwd,
}
}
}
impl From<dap::BuildTaskDefinition> for latest::dap::BuildTaskDefinition {
fn from(value: dap::BuildTaskDefinition) -> Self {
match value {
dap::BuildTaskDefinition::ByName(name) => Self::ByName(name),
dap::BuildTaskDefinition::Template(payload) => {
Self::Template(latest::dap::BuildTaskDefinitionTemplatePayload {
locator_name: payload.locator_name,
template: payload.template.into(),
})
}
}
}
}
impl From<latest::dap::BuildTaskDefinition> for dap::BuildTaskDefinition {
fn from(value: latest::dap::BuildTaskDefinition) -> Self {
match value {
latest::dap::BuildTaskDefinition::ByName(name) => Self::ByName(name),
latest::dap::BuildTaskDefinition::Template(payload) => {
Self::Template(dap::BuildTaskDefinitionTemplatePayload {
locator_name: payload.locator_name,
template: payload.template.into(),
})
}
}
}
}
impl From<DebugScenario> for latest::DebugScenario {
fn from(value: DebugScenario) -> Self {
Self {
label: value.label,
adapter: value.adapter,
build: value.build.map(Into::into),
config: value.config,
tcp_connection: value.tcp_connection.map(Into::into),
}
}
}
impl From<latest::DebugScenario> for DebugScenario {
fn from(value: latest::DebugScenario) -> Self {
Self {
label: value.label,
adapter: value.adapter,
build: value.build.map(Into::into),
config: value.config,
tcp_connection: value.tcp_connection.map(Into::into),
}
}
}
impl From<DebugTaskDefinition> for latest::DebugTaskDefinition {
fn from(value: DebugTaskDefinition) -> Self {
Self {
label: value.label,
adapter: value.adapter,
config: value.config,
tcp_connection: value.tcp_connection.map(Into::into),
}
}
}
impl From<latest::DebugTaskDefinition> for DebugTaskDefinition {
fn from(value: latest::DebugTaskDefinition) -> Self {
Self {
label: value.label,
adapter: value.adapter,
config: value.config,
tcp_connection: value.tcp_connection.map(Into::into),
}
}
}
impl From<dap::StartDebuggingRequestArgumentsRequest>
for latest::dap::StartDebuggingRequestArgumentsRequest
{
fn from(value: dap::StartDebuggingRequestArgumentsRequest) -> Self {
match value {
dap::StartDebuggingRequestArgumentsRequest::Launch => Self::Launch,
dap::StartDebuggingRequestArgumentsRequest::Attach => Self::Attach,
}
}
}
impl From<latest::dap::StartDebuggingRequestArgumentsRequest>
for dap::StartDebuggingRequestArgumentsRequest
{
fn from(value: latest::dap::StartDebuggingRequestArgumentsRequest) -> Self {
match value {
latest::dap::StartDebuggingRequestArgumentsRequest::Launch => Self::Launch,
latest::dap::StartDebuggingRequestArgumentsRequest::Attach => Self::Attach,
}
}
}
impl From<dap::StartDebuggingRequestArguments> for latest::dap::StartDebuggingRequestArguments {
fn from(value: dap::StartDebuggingRequestArguments) -> Self {
Self {
configuration: value.configuration,
request: value.request.into(),
}
}
}
impl From<latest::dap::StartDebuggingRequestArguments> for dap::StartDebuggingRequestArguments {
fn from(value: latest::dap::StartDebuggingRequestArguments) -> Self {
Self {
configuration: value.configuration,
request: value.request.into(),
}
}
}
impl From<DebugAdapterBinary> for latest::DebugAdapterBinary {
fn from(value: DebugAdapterBinary) -> Self {
Self {
command: value.command,
arguments: value.arguments,
envs: value.envs,
cwd: value.cwd,
connection: value.connection.map(Into::into),
request_args: value.request_args.into(),
}
}
}
impl TryFrom<latest::DebugAdapterBinary> for DebugAdapterBinary {
type Error = anyhow::Error;
fn try_from(value: latest::DebugAdapterBinary) -> Result<Self> {
Ok(Self {
command: value.command,
arguments: value.arguments,
envs: value.envs,
cwd: value.cwd,
connection: value.connection.map(TryInto::try_into).transpose()?,
request_args: value.request_args.into(),
})
}
}
impl zed::extension::dap::Host for WasmState {
async fn resolve_tcp_template(
&mut self,
template: dap::TcpArgumentsTemplate,
) -> wasmtime::Result<Result<dap::TcpArguments, String>> {
let result = latest::dap::Host::resolve_tcp_template(self, template.into()).await?;
Ok(
result
.and_then(|args| dap::TcpArguments::try_from(args).map_err(|err| err.to_string())),
)
}
}

View file

@ -1,10 +1,4 @@
use crate::wasm_host::wit::since_v0_6_0::{
dap::{
BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, StartDebuggingRequestArguments,
TcpArguments, TcpArgumentsTemplate,
},
slash_command::SlashCommandOutputSection,
};
use crate::wasm_host::wit::since_v0_6_0::slash_command::SlashCommandOutputSection;
use crate::wasm_host::wit::{CompletionKind, CompletionLabelDetails, InsertTextFormat, SymbolKind};
use crate::wasm_host::{WasmState, wit::ToWasmtimeResult};
use ::http_client::{AsyncBody, HttpRequestExt};
@ -24,7 +18,7 @@ use project::project_settings::ProjectSettings;
use semver::Version;
use std::{
env,
net::Ipv4Addr,
net::{IpAddr, Ipv4Addr, Ipv6Addr},
path::{Path, PathBuf},
str::FromStr,
sync::{Arc, OnceLock},
@ -104,18 +98,44 @@ impl From<StartDebuggingRequestArgumentsRequest>
}
}
}
impl TryFrom<StartDebuggingRequestArguments> for extension::StartDebuggingRequestArguments {
impl TryFrom<dap::StartDebuggingRequestArguments> for extension::StartDebuggingRequestArguments {
type Error = anyhow::Error;
fn try_from(value: StartDebuggingRequestArguments) -> Result<Self, Self::Error> {
fn try_from(value: dap::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 {
impl From<dap::IpAddress> for IpAddr {
fn from(value: dap::IpAddress) -> Self {
match value {
dap::IpAddress::Ipv4((a, b, c, d)) => IpAddr::V4(Ipv4Addr::new(a, b, c, d)),
dap::IpAddress::Ipv6((a, b, c, d, e, f, g, h)) => {
IpAddr::V6(Ipv6Addr::new(a, b, c, d, e, f, g, h))
}
}
}
}
impl From<IpAddr> for dap::IpAddress {
fn from(value: IpAddr) -> Self {
match value {
IpAddr::V4(v4) => {
let [a, b, c, d] = v4.octets();
Self::Ipv4((a, b, c, d))
}
IpAddr::V6(v6) => {
let [a, b, c, d, e, f, g, h] = v6.segments();
Self::Ipv6((a, b, c, d, e, f, g, h))
}
}
}
}
impl From<dap::TcpArguments> for extension::TcpArguments {
fn from(value: dap::TcpArguments) -> Self {
Self {
host: value.host.into(),
port: value.port,
@ -124,20 +144,20 @@ impl From<TcpArguments> for extension::TcpArguments {
}
}
impl From<extension::TcpArgumentsTemplate> for TcpArgumentsTemplate {
impl From<extension::TcpArgumentsTemplate> for dap::TcpArgumentsTemplate {
fn from(value: extension::TcpArgumentsTemplate) -> Self {
Self {
host: value.host.map(Ipv4Addr::to_bits),
host: value.host.map(Into::into),
port: value.port,
timeout: value.timeout,
}
}
}
impl From<TcpArgumentsTemplate> for extension::TcpArgumentsTemplate {
fn from(value: TcpArgumentsTemplate) -> Self {
impl From<dap::TcpArgumentsTemplate> for extension::TcpArgumentsTemplate {
fn from(value: dap::TcpArgumentsTemplate) -> Self {
Self {
host: value.host.map(Ipv4Addr::from_bits),
host: value.host.map(Into::into),
port: value.port,
timeout: value.timeout,
}
@ -235,11 +255,11 @@ impl TryFrom<DebugAdapterBinary> for extension::DebugAdapterBinary {
}
}
impl From<BuildTaskDefinition> for extension::BuildTaskDefinition {
fn from(value: BuildTaskDefinition) -> Self {
impl From<dap::BuildTaskDefinition> for extension::BuildTaskDefinition {
fn from(value: dap::BuildTaskDefinition) -> Self {
match value {
BuildTaskDefinition::ByName(name) => Self::ByName(name.into()),
BuildTaskDefinition::Template(build_task_template) => Self::Template {
dap::BuildTaskDefinition::ByName(name) => Self::ByName(name.into()),
dap::BuildTaskDefinition::Template(build_task_template) => Self::Template {
task_template: build_task_template.template.into(),
locator_name: build_task_template.locator_name.map(SharedString::from),
},
@ -247,14 +267,14 @@ impl From<BuildTaskDefinition> for extension::BuildTaskDefinition {
}
}
impl From<extension::BuildTaskDefinition> for BuildTaskDefinition {
impl From<extension::BuildTaskDefinition> for dap::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 {
} => Self::Template(dap::BuildTaskDefinitionTemplatePayload {
template: task_template.into(),
locator_name: locator_name.map(String::from),
}),
@ -898,19 +918,19 @@ 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>> {
template: dap::TcpArgumentsTemplate,
) -> wasmtime::Result<Result<dap::TcpArguments, String>> {
maybe!(async {
let (host, port, timeout) =
::dap::configure_tcp_connection(task::TcpArgumentsTemplate {
port: template.port,
host: template.host.map(Ipv4Addr::from_bits),
host: template.host.map(Into::into),
timeout: template.timeout,
})
.await?;
Ok(TcpArguments {
Ok(dap::TcpArguments {
port,
host: host.to_bits(),
host: host.into(),
timeout,
})
})

View file

@ -47,18 +47,6 @@ impl FeatureFlag for DiffReviewFeatureFlag {
}
register_feature_flag!(DiffReviewFeatureFlag);
pub struct StreamingEditFileToolFeatureFlag;
impl FeatureFlag for StreamingEditFileToolFeatureFlag {
const NAME: &'static str = "streaming-edit-file-tool";
type Value = PresenceFlag;
fn enabled_for_staff() -> bool {
true
}
}
register_feature_flag!(StreamingEditFileToolFeatureFlag);
pub struct UpdatePlanToolFeatureFlag;
impl FeatureFlag for UpdatePlanToolFeatureFlag {

View file

@ -612,10 +612,7 @@ impl Matches {
// We build a sorted Vec<Match>, eliminating duplicate search matches.
// Search matches with the same paths should have equal `ProjectPanelOrdMatch`, so we should
// not have any duplicates after building the final list.
for new_match in new_history_matches
.into_values()
.chain(new_search_matches.into_iter())
{
for new_match in new_history_matches.into_values().chain(new_search_matches) {
match self.position(&new_match, currently_opened) {
Ok(_duplicate) => continue,
Err(i) => {

View file

@ -29,7 +29,7 @@ impl Blame {
) -> Result<Self> {
let output = run_git_blame(git, 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));
entries.sort_unstable_by_key(|entry| entry.range.start);
let mut unique_shas = HashSet::default();

Some files were not shown because too many files have changed in this diff Show more