Compare commits

..

45 Commits

Author SHA1 Message Date
Cole Miller
fbd27148d5 fix 2025-08-08 01:29:07 -04:00
Samuel
d6022dc87c emmet: Enable in Vue.js files (#35599)
Resolves part of #34337

Actually I need also to add:

```
"languages": {
    "Vue.js": {
      "language_servers": [
        "vue-language-server",
        "emmet-language-server",
        "..."
      ]
    }
  },
```

not sure how to resolve fully, happy to continue only little guidance
needed.

Release Notes:

- allow emmet in Vue.js files

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-08-08 02:50:54 +00:00
Dan Wood
0dd480d475 Add spread operator to the @operator list for ECMAScript languages (#35360)
Previously, this was the one thing that could not be styled properly in
ecmascript languages in the zed config, because it was not able to be
targeted.

Now, it is added alongside other operators. This has been tested and
works as expected.

Release Notes:

- N/A
2025-08-08 01:58:26 +00:00
Phoenix Himself
34fc2fd9d0 Treat Arduino files as C++ (#35467)
Closes https://github.com/zed-industries/zed/discussions/35466

Release Notes:

- N/A
2025-08-08 01:54:42 +00:00
Neo Nie
00701b5e99 git_hosting_providers: Extract Bitbucket pull request number (#34584)
git: Extract Bitbucket pull request number

Release Notes:

- git: Extract Bitbucket pull request number

---------

Co-authored-by: Peter Tripp <peter@zed.dev>
2025-08-08 01:39:32 +00:00
Abdelhakim Qbaich
cdfb3348ea git: Make inline blame padding configurable (#33631)
Just like with diagnostics, adding a configurable padding to inline
blame

Release Notes:

- Added configurable padding to inline blame

---------

Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Peter Tripp <petertripp@gmail.com>
2025-08-08 01:35:07 +00:00
Mikayla Maki
35cd1b9ae1 filter out comments in deploy helper env vars (#35847)
Turns out a `.sh` file isn't actually a shell script :(

Release Notes:

- N/A
2025-08-07 18:01:46 -07:00
smit
bd402fdc7d editor: Fix Follow Agent unexpectedly stopping during edits (#35845)
Closes #34881

For horizontal scroll, we weren't keeping track of the `local` bool, so
whenever the agent tries to autoscroll horizontally, it would be seen as
a user scroll event resulting in unfollow.

Release Notes:

- Fixed an issue where the Follow Agent could unexpectedly stop
following during edits.
2025-08-08 06:17:37 +05:30
Mikayla Maki
c7d641ecb8 Revert "chore: Bump Rust to 1.89 (#35788)" (#35843)
This reverts commit efba2cbfd3.

Unfortunately, the Docker image for 1.89 has not shown up yet. Once it
has, we should re-land this.

Release Notes:

- N/A
2025-08-07 23:55:15 +00:00
Agus Zubiaga
3d662ee282 agent2: Port read_file tool (#35840)
Ports the read_file tool from `assistant_tools` to `agent2`. 

Note: Image support not implemented.

Release Notes:

- N/A
2025-08-07 20:46:47 -03:00
Richard Feldman
7d4d8b8398 Add GPT-5 support through OpenAI API (#35822)
(This PR does not add GPT-5 to Zed Pro, but rather adds access if you're
using your own OpenAI API key.)

<img width="772" height="333" alt="Screenshot 2025-08-07 at 2 23 18 PM"
src="https://github.com/user-attachments/assets/42e75082-118a-4737-89b6-a740ae33b169"
/>

---

**NOTE:** If your API key is not through a verified organization, you
may see this error:

<img width="549" height="253" alt="Screenshot 2025-08-07 at 2 04 54 PM"
src="https://github.com/user-attachments/assets/d0b6d739-9c39-4af3-88d7-0c9609b0e6ba"
/>

Even if your org is verified, you still may not have access to GPT-5, in
which case you could see this error:

<img width="543" height="98" alt="Screenshot 2025-08-07 at 2 09 18 PM"
src="https://github.com/user-attachments/assets/e3ed31e3-2a11-4f07-8f3c-5b410fbe4540"
/>

One way to test if you're in this situation is to visit
https://platform.openai.com/chat/edit?models=gpt-5 and see if you get
the same "you don't have access to GPT-5" error on OpenAI's official
playground. It looks like this:

<img width="581" height="196" alt="Screenshot 2025-08-07 at 2 15 25 PM"
src="https://github.com/user-attachments/assets/ea1454ca-3c10-4703-8126-c02cb92a34f2"
/>

Release Notes:

- Added GPT-5, as well as its mini and nano variants. To use this, you
need to have an OpenAI API key configured via the `OPENAI_API_KEY`
environment variable.
2025-08-07 23:35:41 +00:00
Agus Zubiaga
6912dc8399 Fix CC tool state on cancel (#35763)
When we stop the generation, CC tells us the tool completed, but it was
actually cancelled.

Release Notes:

- N/A
2025-08-07 20:26:19 -03:00
Peter Tripp
952e3713d7 ci: Switch to Namespace (#35835)
Follow-up to:
- https://github.com/zed-industries/zed/pull/35826

Release Notes:

- N/A
2025-08-07 23:16:25 +00:00
Mikayla Maki
913e9adf90 Move timing fields into span (#35833)
Release Notes:

- N/A
2025-08-07 23:07:33 +00:00
Marshall Bowers
50482a6bc2 language_model: Refresh the LLM token upon receiving a UserUpdated message from Cloud (#35839)
This PR makes it so we refresh the LLM token upon receiving a
`UserUpdated` message from Cloud over the WebSocket connection.

Release Notes:

- N/A
2025-08-07 23:00:45 +00:00
Marshall Bowers
d110459ef8 collab_ui: Show signed-out state when not connected to Collab (#35832)
This PR updates signed-out state of the Collab panel to show when not
connected to Collab, as opposed to just when the user is signed-out.

Release Notes:

- N/A
2025-08-07 22:29:59 +00:00
Cole Miller
d693f02c63 Settings: fix release channel settings not being respected (#35838)
Typo in #35756 

Release Notes:

- N/A
2025-08-07 22:27:50 +00:00
Ben Brandt
90fa921756 Wire up find_path tool in agent2 (#35799)
Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
2025-08-07 22:21:26 +00:00
Marshall Bowers
11efa32fa7 client: Only connect to Collab automatically for Zed staff (#35827)
This PR makes it so that only Zed staff connect to Collab automatically.

Anyone else can connect to Collab manually when they want to collaborate
(but this is not required for using Zed's LLM features).

Release Notes:

- N/A

---------

Co-authored-by: Richard <richard@zed.dev>
2025-08-07 22:14:25 +00:00
Cole Miller
e6dc6faccf Don't insert resource links for @mentions that have been removed from the message editor (#35831)
Release Notes:

- N/A
2025-08-07 22:10:29 +00:00
Danilo Leal
070f7dbe1a onboarding: Add fast-follow adjustments (#35814)
Release Notes:

- N/A
2025-08-07 19:01:52 -03:00
Marshall Bowers
106d4cfce9 client: Re-fetch the authenticated user when receiving a UserUpdated message from Cloud (#35807)
This PR wires up handling for the new `UserUpdated` message coming from
Cloud over the WebSocket connection.

When we receive this message we will refresh the authenticated user.

Release Notes:

- N/A

Co-authored-by: Richard <richard@zed.dev>
2025-08-07 21:44:53 +00:00
Cole Miller
a1080a0411 Update diff editor font size when agent_font_size setting changes (#35834)
Release Notes:

- N/A
2025-08-07 21:31:30 +00:00
Peter Tripp
7679db99ac ci: Switch from BuildJet to GitHub runners (#35826)
In response to an ongoing BuildJet outage, consider migrating CI to
GitHub hosted runners.

Also includes revert of (causing flaky tests):
- https://github.com/zed-industries/zed/pull/35741

Downsides:
- Cost (2x)
- Force migration to Ubuntu 22.04 from 20.04 will bump our glibc minimum
from 2.31 to 2.35. Which would break RHEL 9.x (glibc 2.34), Ubuntu 20.04
(EOL) and derivatives.

Release Notes:

- N/A
2025-08-07 16:59:11 -04:00
Fabian Bergström
9ade399756 workspace: Don't update platform window title if title has not changed (#34753)
Closes #34749 #34715

Release Notes:

- Fixed window title X event spam
2025-08-07 22:13:51 +03:00
mcwindy
e8db429d24 project_panel: Add file comparison function, supports selecting files for comparison (#35255)
Closes https://github.com/zed-industries/zed/discussions/35010
Closes https://github.com/zed-industries/zed/issues/17100
Closes https://github.com/zed-industries/zed/issues/4523

Release Notes:

- Added file comparison function in project panel

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
2025-08-07 21:34:12 +03:00
Kirill Bulatov
53b69d29c5 Actually update remote collab capabilities (#35809)
Follow-up of https://github.com/zed-industries/zed/pull/35682

Release Notes:

- N/A
2025-08-07 13:58:33 -04:00
Julia Ryan
e2e147ab0e Add OS specific settings (#35756)
Release Notes:

- Settings can now be configured per operating system with the new
top-level fields: `"macos"`/`"windows"`/`"linux"`. These will override
user level settings, but are lower precedence than _release channel_
settings.
2025-08-07 10:52:54 -07:00
Marshall Bowers
fa2ff3ce1c collab: Increase DATABASE_MAX_CONNECTIONS for Collab server (#35818)
This PR increases the `DATABASE_MAX_CONNECTIONS` limit for the Collab
server to 850 (up from 250).

Release Notes:

- N/A

Co-authored-by: Nathan <nathan@zed.dev>
Co-authored-by: Mikayla <mikayla@zed.dev>
2025-08-07 10:26:08 -07:00
Piotr Osiewicz
c1d1d1cff6 chore: Bump to taffy 0.9 (#35802)
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Lukas Wirth <lukas@zed.dev>
Co-authored-by: Ben Kunkle <ben@zed.dev>

Release Notes:

- N/A

Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Lukas Wirth <lukas@zed.dev>
Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-08-07 15:43:37 +00:00
Piotr Osiewicz
efba2cbfd3 chore: Bump Rust to 1.89 (#35788)
Release Notes:

- N/A

---------

Co-authored-by: Julia Ryan <juliaryan3.14@gmail.com>
2025-08-07 15:32:06 +00:00
Raphael Lüthy
2234220618 completions: Add subtle/eager behavior to Supermaven and Copilot (#35548)
This pull request introduces changes to improve the behavior and
consistency of multiple completion providers
(`CopilotCompletionProvider`, `SupermavenCompletionProvider`) and their
integration with UI elements like menus and inline completion buttons.
It now allows to see the prediction with the completion menu open whilst
pressing `opt` and also enables the subtle/eager setting that was
introduced with zeta.

Edit: I managed to get the preview working with correct icons!
<img width="909" height="232" alt="image"
src="https://github.com/user-attachments/assets/65800e67-4bc4-40f8-be78-806fcfe74ad9"
/>
<img width="1460" height="318" alt="CleanShot 2025-08-04 at 01 36 31@2x"
src="https://github.com/user-attachments/assets/15651405-720f-465f-a13c-c7470817810a"
/>

Correct icons are also displayed:
<img width="244" height="96" alt="image"
src="https://github.com/user-attachments/assets/0b8a687f-73e3-452d-aefb-784c52831b73"
/>


Edit2: I added some comments, would be very happy to receive feedback
(still learning rust)

Release Notes:

- Added Subtle and Eager edit prediction modes to Copilot and Supermaven
2025-08-07 18:27:29 +03:00
Piotr Osiewicz
dd840e4b27 editor: Fix multi-buffer headers spilling over at narrow widths (#35800)
Release Notes:

- N/A
2025-08-07 15:01:22 +00:00
Danilo Leal
262365ca24 keymap editor: Refine how we display matching keystrokes (#35796)
| Before | After |
|--------|--------|
| <img width="1092" height="528" alt="CleanShot 2025-08-07 at 10  54
42@2x"
src="https://github.com/user-attachments/assets/8b0a3b50-e1d1-4763-824c-2b419df430fc"
/> | <img width="1096" height="580" alt="CleanShot 2025-08-07 at 11  29
47@2x"
src="https://github.com/user-attachments/assets/bd484655-90a6-46fe-91ef-c9c8d2ab93bc"
/> |

Release Notes:

- N/A
2025-08-07 11:50:11 -03:00
localcc
90fa06dd61 Fix file unlocking after closing the workspace (#35741)
Release Notes:

- Fixed folders being locked after closing them in zed
2025-08-07 16:47:19 +02:00
Kirill Bulatov
740686b883 Batch diagnostics updates (#35794)
Diagnostics updates were programmed in Zed based off the r-a LSP push
diagnostics, with all related updates happening per file.

https://github.com/zed-industries/zed/pull/19230 and especially
https://github.com/zed-industries/zed/pull/32269 brought in pull
diagnostics that could produce results for thousands files
simultaneously.

It was noted and addressed on the local side in
https://github.com/zed-industries/zed/pull/34022 but the remote side was
still not adjusted properly.

This PR 

* removes redundant diagnostics pull updates on remote clients, as
buffer diagnostics are updated via buffer sync operations separately
* batches all diagnostics-related updates and proto messages, so
multiple diagnostic summaries (per file) could be sent at once,
specifically, 1 (potentially large) diagnostics summary update instead
of N*10^3 small ones.

Buffer updates are still sent per buffer and not updated, as happening
separately and not offending the collab traffic that much.

Release Notes:

- Improved diagnostics performance in the collaborative mode
2025-08-07 14:45:41 +00:00
Danilo Leal
a5c25e0366 agent: Improve end of trial card display (#35789)
Now rendering the backdrop behind the card to clean up the UI, bring
focus to the card's content, and direct the user to act on it, either by
ignoring it or upgrading.

<img width="500" height="1242" alt="CleanShot 2025-08-07 at 10  30
58@2x"
src="https://github.com/user-attachments/assets/8c6b9c34-eb22-4f01-b3fa-158ac78b7439"
/>

Release Notes:

- N/A
2025-08-07 10:53:15 -03:00
Tongue_chaude
305c653c62 Add icons for Puppet files (#35778)
Release Notes:

- Added icon for Puppet (.pp) files

Actually puppet icons are available in the extension here :
<https://github.com/AlexandarY/zed-puppet/tree/main/icon_themes>

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-08-07 13:51:29 +00:00
Danilo Leal
e227b5ac30 onboarding: Add young account treatment to AI upsell card (#35785)
Release Notes:

- N/A
2025-08-07 10:42:46 -03:00
Antonio Scandurra
03876d076e Add system prompt and tool permission to agent2 (#35781)
Release Notes:

- N/A

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
2025-08-07 13:40:12 +00:00
Lukas Wirth
4dbd24d75f Reduce amount of allocations in RustLsp label handling (#35786)
There can be a lot of completions after all


Release Notes:

- N/A
2025-08-07 13:24:29 +00:00
Kirill Bulatov
c397027ec2 Add release_channel into the span fields list (#35783)
Follow-up of https://github.com/zed-industries/zed/pull/35729

Release Notes:

- N/A

Co-authored-by: Marshall Bowers <marshall@zed.dev>
2025-08-07 12:56:10 +00:00
Lukas Wirth
f5f837d39a languages: Fix rust completions not having proper detail labels (#35772)
rust-analyzer changed the format here a bit some months ago which
partially broke our nice detailed highlighted completion labels. The
brings that back while also cleaning up the code a bit.

Also fixes a bug where disabling rust-analyzers snippet callable
completions would fully break them.

Release Notes:

- N/A
2025-08-07 10:38:58 +00:00
smit
5b1b3c51d4 language_models: Fix high memory consumption while using Agent Panel (#35764)
Closes #31108

The `num_tokens_from_messages` method we use from `tiktoken-rs` creates
new BPE every time that method is called. This creation of BPE is
expensive as well as has some underlying issue that keeps memory from
releasing once the method is finished, specifically noticeable on Linux.
This leads to a gradual increase in memory every time that method is
called in my case around +50MB on each call. We call this method with
debounce every time user types in Agent Panel to calculate tokens. This
can add up really fast.

This PR lands quick fix, while I/maintainers figure out underlying
issue. See upstream discussion:
https://github.com/zurawiki/tiktoken-rs/issues/39.

Here on fork https://github.com/zed-industries/tiktoken-rs/pull/1,
instead of creating BPE instances every time that method is called, we
use singleton BPE instances instead. So, whatever memory it is holding
on to, at least that is only once per model.

Before: Increase of 700MB+ on extensive use

On init:
<img width="500" alt="prev-init"
src="https://github.com/user-attachments/assets/70da7c44-60cb-477b-84aa-7dd579baa3da"
/>
First message:
<img width="500" alt="prev-first-call"
src="https://github.com/user-attachments/assets/599ffc48-3ad3-4729-b94c-6d88493afdbf"
/>
Extensive use:
<img width="500" alt="prev-extensive-use"
src="https://github.com/user-attachments/assets/e0e6b688-6412-486d-8b2e-7216c6b62470"
/>

After: Increase of 50MB+ on extensive use
On init:
<img width="500" alt="now-init"
src="https://github.com/user-attachments/assets/11a2cd9c-20b0-47ae-be02-07ff876e68ad"
/>
First message:
<img width="500" alt="now-first-call"
src="https://github.com/user-attachments/assets/ef505f8d-cd31-49cd-b6bb-7da3f0838fa7"
/>
Extensive use: 
<img width="500" alt="now-extensive-use"
src="https://github.com/user-attachments/assets/513cb85a-a00b-4f11-8666-69103a9eb2b8"
/>

Release Notes:

- Fixed issue where Agent Panel would cause high memory consumption over
prolonged use.
2025-08-07 11:39:27 +05:30
Gregor
b4a441f12f Add UnwrapSyntaxNode action (#31421)
Remake of #8967

> Hey there,
> 
> I have started relying on this action, that I've also put into VSCode
as [an extension](https://github.com/Gregoor/soy). On some level I don't
know how people code (cope?) without it:
> 
> Release Notes:
> 
> * Added UnwrapSyntaxNode action
> 
>
https://github.com/zed-industries/zed/assets/4051932/d74c98c0-96d8-4075-9b63-cea55bea42f6
> 
> Since I had to put it into Zed anyway to make it my daily driver, I
thought I'd also check here if there's an interest in shipping it by
default (that would ofc also personally make my life better, not having
to maintain my personal fork and all).
> 
> If there is interest, I'd be happy to make any changes to make this
more mergeable. Two TODOs on my mind are:
> 
> * unwrap multiple into single (e.g. `fn(≤a≥, b)` to `fn(≤a≥)`)
> * multi-cursor
> * syntax awareness, i.e. only unwrap if it does not break syntax (I
added [a coarse version of that for my VSC
extension](https://github.com/Gregoor/soy/blob/main/src/actions/unwrap.ts#L29))
> 
> Somewhat off-topic: I was happy to see that you're
[also](https://github.com/Gregoor/soy/blob/main/src/actions/unwrap.test.ts)
using rare special chars in test code to denote cursor positions.


Release Notes:

- Added UnwrapSyntaxNode action

---------

Co-authored-by: Peter Tripp <peter@zed.dev>
2025-08-07 07:52:22 +03:00
121 changed files with 5244 additions and 1698 deletions

View File

@@ -5,25 +5,25 @@ self-hosted-runner:
# GitHub-hosted Runners
- github-8vcpu-ubuntu-2404
- github-16vcpu-ubuntu-2404
- github-32vcpu-ubuntu-2404
- github-8vcpu-ubuntu-2204
- github-16vcpu-ubuntu-2204
- github-32vcpu-ubuntu-2204
- github-16vcpu-ubuntu-2204-arm
- windows-2025-16
- windows-2025-32
- windows-2025-64
# Buildjet Ubuntu 20.04 - AMD x86_64
- buildjet-2vcpu-ubuntu-2004
- buildjet-4vcpu-ubuntu-2004
- buildjet-8vcpu-ubuntu-2004
- buildjet-16vcpu-ubuntu-2004
- buildjet-32vcpu-ubuntu-2004
# Buildjet Ubuntu 22.04 - AMD x86_64
- buildjet-2vcpu-ubuntu-2204
- buildjet-4vcpu-ubuntu-2204
- buildjet-8vcpu-ubuntu-2204
- buildjet-16vcpu-ubuntu-2204
- buildjet-32vcpu-ubuntu-2204
# Buildjet Ubuntu 22.04 - Graviton aarch64
- buildjet-8vcpu-ubuntu-2204-arm
- buildjet-16vcpu-ubuntu-2204-arm
- buildjet-32vcpu-ubuntu-2204-arm
# Namespace Ubuntu 20.04 (Release builds)
- namespace-profile-16x32-ubuntu-2004
- namespace-profile-32x64-ubuntu-2004
- namespace-profile-16x32-ubuntu-2004-arm
- namespace-profile-32x64-ubuntu-2004-arm
# Namespace Ubuntu 22.04 (Everything else)
- namespace-profile-2x4-ubuntu-2204
- namespace-profile-4x8-ubuntu-2204
- namespace-profile-8x16-ubuntu-2204
- namespace-profile-16x32-ubuntu-2204
- namespace-profile-32x64-ubuntu-2204
# Self Hosted Runners
- self-mini-macos
- self-32vcpu-windows-2022

View File

@@ -13,7 +13,7 @@ runs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-provider: "buildjet"
# cache-provider: "buildjet"
- name: Install Linux dependencies
shell: bash -euxo pipefail {0}

View File

@@ -1,132 +0,0 @@
name: Agent Servers E2E Tests
on:
schedule:
- cron: "0 12 * * *"
push:
branches:
- as-e2e-ci
pull_request:
branches:
- "**"
paths:
- "crates/agent_servers/**"
- "crates/acp_thread/**"
- ".github/workflows/agent_servers_e2e.yml"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
jobs:
e2e-tests:
name: Run Agent Servers E2E Tests
if: github.repository_owner == 'zed-industries'
timeout-minutes: 20
runs-on:
- buildjet-16vcpu-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
clean: false
# - name: Checkout gemini-cli repo
# uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
# with:
# repository: zed-industries/gemini-cli
# ref: migrate-acp
# path: gemini-cli
# clean: false
- name: Cache dependencies
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-provider: "buildjet"
- name: Install Linux dependencies
run: ./script/linux
- name: Configure CI
run: |
mkdir -p ./../.cargo
cp ./.cargo/ci-config.toml ./../.cargo/config.toml
- name: Install Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "18"
- name: Install Claude Code CLI
shell: bash -euxo pipefail {0}
run: |
npm install -g @anthropic-ai/claude-code
# Verify installation
which claude || echo "Claude CLI not found in PATH"
# Skip authentication if API key is not set (tests may use mock)
if [ -n "$ANTHROPIC_API_KEY" ]; then
echo "Anthropic API key is configured"
fi
# - name: Install and setup Gemini CLI
# shell: bash -euxo pipefail {0}
# run: |
# # Also install dependencies for local gemini-cli repo
# pushd gemini-cli
# npm install
# npm run build
# popd
# # Verify installations
# which gemini || echo "Gemini CLI not found in PATH"
# # Skip authentication if API key is not set (tests may use mock)
# if [ -n "$GEMINI_API_KEY" ]; then
# echo "Gemini API key is configured"
# fi
- name: Limit target directory size
shell: bash -euxo pipefail {0}
run: script/clear-target-dir-if-larger-than 100
- name: Install nextest
shell: bash -euxo pipefail {0}
run: |
cargo install cargo-nextest --locked
- name: Build Zed
shell: bash -euxo pipefail {0}
run: |
cargo build
- name: Run E2E tests
shell: bash -euxo pipefail {0}
run: |
cargo nextest run \
--package agent_servers \
--features e2e \
--no-fail-fast \
claude
# Even the Linux runner is not stateful, in theory there is no need to do this cleanup.
# But, to avoid potential issues in the future if we choose to use a stateful Linux runner and forget to add code
# to clean up the config file, Ive included the cleanup code here as a precaution.
# While its not strictly necessary at this moment, I believe its better to err on the side of caution.
- name: Clean CI config file
if: always()
run: rm -rf ./../.cargo

View File

@@ -16,7 +16,7 @@ jobs:
bump_patch_version:
if: github.repository_owner == 'zed-industries'
runs-on:
- buildjet-16vcpu-ubuntu-2204
- namespace-profile-16x32-ubuntu-2204
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

View File

@@ -137,7 +137,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- buildjet-8vcpu-ubuntu-2204
- namespace-profile-8x16-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -168,7 +168,7 @@ jobs:
needs: [job_spec]
if: github.repository_owner == 'zed-industries'
runs-on:
- buildjet-8vcpu-ubuntu-2204
- namespace-profile-4x8-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -221,7 +221,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
(needs.job_spec.outputs.run_tests == 'true' || needs.job_spec.outputs.run_docs == 'true')
runs-on:
- buildjet-8vcpu-ubuntu-2204
- namespace-profile-8x16-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -328,7 +328,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- buildjet-16vcpu-ubuntu-2204
- namespace-profile-16x32-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
@@ -342,7 +342,7 @@ jobs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-provider: "buildjet"
# cache-provider: "buildjet"
- name: Install Linux dependencies
run: ./script/linux
@@ -380,7 +380,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- buildjet-8vcpu-ubuntu-2204
- namespace-profile-16x32-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
@@ -394,7 +394,7 @@ jobs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-provider: "buildjet"
# cache-provider: "buildjet"
- name: Install Clang & Mold
run: ./script/remote-server && ./script/install-mold 2.34.0
@@ -597,7 +597,7 @@ jobs:
timeout-minutes: 60
name: Linux x86_x64 release bundle
runs-on:
- buildjet-16vcpu-ubuntu-2004 # ubuntu 20.04 for minimal glibc
- namespace-profile-16x32-ubuntu-2004 # ubuntu 20.04 for minimal glibc
if: |
startsWith(github.ref, 'refs/tags/v')
|| contains(github.event.pull_request.labels.*.name, 'run-bundling')
@@ -650,7 +650,7 @@ jobs:
timeout-minutes: 60
name: Linux arm64 release bundle
runs-on:
- buildjet-32vcpu-ubuntu-2204-arm
- namespace-profile-32x64-ubuntu-2004-arm # ubuntu 20.04 for minimal glibc
if: |
startsWith(github.ref, 'refs/tags/v')
|| contains(github.event.pull_request.labels.*.name, 'run-bundling')

View File

@@ -9,7 +9,7 @@ jobs:
deploy-docs:
name: Deploy Docs
if: github.repository_owner == 'zed-industries'
runs-on: buildjet-16vcpu-ubuntu-2204
runs-on: namespace-profile-16x32-ubuntu-2204
steps:
- name: Checkout repo

View File

@@ -61,7 +61,7 @@ jobs:
- style
- tests
runs-on:
- buildjet-16vcpu-ubuntu-2204
- namespace-profile-16x32-ubuntu-2204
steps:
- name: Install doctl
uses: digitalocean/action-doctl@v2
@@ -94,7 +94,7 @@ jobs:
needs:
- publish
runs-on:
- buildjet-16vcpu-ubuntu-2204
- namespace-profile-16x32-ubuntu-2204
steps:
- name: Checkout repo
@@ -137,12 +137,14 @@ jobs:
export ZED_SERVICE_NAME=collab
export ZED_LOAD_BALANCER_SIZE_UNIT=$ZED_COLLAB_LOAD_BALANCER_SIZE_UNIT
export DATABASE_MAX_CONNECTIONS=850
envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f -
kubectl -n "$ZED_KUBE_NAMESPACE" rollout status deployment/$ZED_SERVICE_NAME --watch
echo "deployed ${ZED_SERVICE_NAME} to ${ZED_KUBE_NAMESPACE}"
export ZED_SERVICE_NAME=api
export ZED_LOAD_BALANCER_SIZE_UNIT=$ZED_API_LOAD_BALANCER_SIZE_UNIT
export DATABASE_MAX_CONNECTIONS=60
envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f -
kubectl -n "$ZED_KUBE_NAMESPACE" rollout status deployment/$ZED_SERVICE_NAME --watch
echo "deployed ${ZED_SERVICE_NAME} to ${ZED_KUBE_NAMESPACE}"

View File

@@ -32,7 +32,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
(github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-eval'))
runs-on:
- buildjet-16vcpu-ubuntu-2204
- namespace-profile-16x32-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
@@ -46,7 +46,7 @@ jobs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-provider: "buildjet"
# cache-provider: "buildjet"
- name: Install Linux dependencies
run: ./script/linux

View File

@@ -20,7 +20,7 @@ jobs:
matrix:
system:
- os: x86 Linux
runner: buildjet-16vcpu-ubuntu-2204
runner: namespace-profile-16x32-ubuntu-2204
install_nix: true
- os: arm Mac
runner: [macOS, ARM64, test]

View File

@@ -20,7 +20,7 @@ jobs:
name: Run randomized tests
if: github.repository_owner == 'zed-industries'
runs-on:
- buildjet-16vcpu-ubuntu-2204
- namespace-profile-16x32-ubuntu-2204
steps:
- name: Install Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

View File

@@ -128,7 +128,7 @@ jobs:
name: Create a Linux *.tar.gz bundle for x86
if: github.repository_owner == 'zed-industries'
runs-on:
- buildjet-16vcpu-ubuntu-2004
- namespace-profile-16x32-ubuntu-2004 # ubuntu 20.04 for minimal glibc
needs: tests
steps:
- name: Checkout repo
@@ -168,7 +168,7 @@ jobs:
name: Create a Linux *.tar.gz bundle for ARM
if: github.repository_owner == 'zed-industries'
runs-on:
- buildjet-32vcpu-ubuntu-2204-arm
- namespace-profile-32x64-ubuntu-2004-arm # ubuntu 20.04 for minimal glibc
needs: tests
steps:
- name: Checkout repo

View File

@@ -23,7 +23,7 @@ jobs:
timeout-minutes: 60
name: Run unit evals
runs-on:
- buildjet-16vcpu-ubuntu-2204
- namespace-profile-16x32-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
@@ -37,7 +37,7 @@ jobs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-provider: "buildjet"
# cache-provider: "buildjet"
- name: Install Linux dependencies
run: ./script/linux

25
Cargo.lock generated
View File

@@ -138,9 +138,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol"
version = "0.0.20"
version = "0.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12dbfec3d27680337ed9d3064eecafe97acf0b0f190148bb4e29d96707c9e403"
checksum = "3fad72b7b8ee4331b3a4c8d43c107e982a4725564b4ee658ae5c4e79d2b486e8"
dependencies = [
"anyhow",
"futures 0.3.31",
@@ -159,6 +159,7 @@ dependencies = [
"agent-client-protocol",
"agent_servers",
"anyhow",
"assistant_tool",
"client",
"clock",
"cloud_llm_client",
@@ -171,10 +172,14 @@ dependencies = [
"gpui_tokio",
"handlebars 4.5.0",
"indoc",
"itertools 0.14.0",
"language",
"language_model",
"language_models",
"log",
"pretty_assertions",
"project",
"prompt_store",
"reqwest_client",
"rust-embed",
"schemars",
@@ -185,6 +190,7 @@ dependencies = [
"ui",
"util",
"uuid",
"watch",
"workspace-hack",
"worktree",
]
@@ -7502,9 +7508,9 @@ dependencies = [
[[package]]
name = "grid"
version = "0.17.0"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71b01d27060ad58be4663b9e4ac9e2d4806918e8876af8912afbddd1a91d5eaa"
checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681"
[[package]]
name = "group"
@@ -9123,6 +9129,7 @@ dependencies = [
"anyhow",
"base64 0.22.1",
"client",
"cloud_api_types",
"cloud_llm_client",
"collections",
"futures 0.3.31",
@@ -12632,6 +12639,7 @@ dependencies = [
"editor",
"file_icons",
"git",
"git_ui",
"gpui",
"indexmap",
"language",
@@ -16215,9 +16223,9 @@ dependencies = [
[[package]]
name = "taffy"
version = "0.8.3"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aaef0ac998e6527d6d0d5582f7e43953bb17221ac75bb8eb2fcc2db3396db1c"
checksum = "a13e5d13f79d558b5d353a98072ca8ca0e99da429467804de959aa8c83c9a004"
dependencies = [
"arrayvec",
"grid",
@@ -16618,9 +16626,8 @@ dependencies = [
[[package]]
name = "tiktoken-rs"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25563eeba904d770acf527e8b370fe9a5547bacd20ff84a0b6c3bc41288e5625"
version = "0.8.0"
source = "git+https://github.com/zed-industries/tiktoken-rs?rev=30c32a4522751699adeda0d5840c71c3b75ae73d#30c32a4522751699adeda0d5840c71c3b75ae73d"
dependencies = [
"anyhow",
"base64 0.22.1",

View File

@@ -425,7 +425,7 @@ zlog_settings = { path = "crates/zlog_settings" }
#
agentic-coding-protocol = "0.0.10"
agent-client-protocol = "0.0.20"
agent-client-protocol = { version = "0.0.23" }
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14"
@@ -601,7 +601,7 @@ sysinfo = "0.31.0"
take-until = "0.2.0"
tempfile = "3.20.0"
thiserror = "2.0.12"
tiktoken-rs = "0.7.0"
tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "30c32a4522751699adeda0d5840c71c3b75ae73d" }
time = { version = "0.3", features = [
"macros",
"parsing",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="16" fill="none"><path fill="#000" d="M12.5 9.639V6.354H9.683L8.18 5.007V2.5H4.5v3.285h2.817l1.51 1.347v1.736l-1.51 1.347H4.5V13.5h3.681v-2.507l1.51-1.347H12.5v-.007ZM5.727 3.595h1.227V4.69H5.727V3.595Zm1.227 8.803H5.727v-1.095h1.227v1.095Z"/></svg>

After

Width:  |  Height:  |  Size: 308 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -848,6 +848,7 @@
"ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }],
"alt-ctrl-r": "project_panel::RevealInFileManager",
"ctrl-shift-enter": "project_panel::OpenWithSystem",
"alt-d": "project_panel::CompareMarkedFiles",
"shift-find": "project_panel::NewSearchInDirectory",
"ctrl-alt-shift-f": "project_panel::NewSearchInDirectory",
"shift-down": "menu::SelectNext",
@@ -1102,6 +1103,13 @@
"ctrl-enter": "menu::Confirm"
}
},
{
"context": "OnboardingAiConfigurationModal",
"use_key_equivalents": true,
"bindings": {
"escape": "menu::Cancel"
}
},
{
"context": "Diagnostics",
"use_key_equivalents": true,
@@ -1178,7 +1186,8 @@
"ctrl-1": "onboarding::ActivateBasicsPage",
"ctrl-2": "onboarding::ActivateEditingPage",
"ctrl-3": "onboarding::ActivateAISetupPage",
"ctrl-escape": "onboarding::Finish"
"ctrl-escape": "onboarding::Finish",
"alt-tab": "onboarding::SignIn"
}
}
]

View File

@@ -907,6 +907,7 @@
"cmd-delete": ["project_panel::Delete", { "skip_prompt": false }],
"alt-cmd-r": "project_panel::RevealInFileManager",
"ctrl-shift-enter": "project_panel::OpenWithSystem",
"alt-d": "project_panel::CompareMarkedFiles",
"cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }],
"cmd-alt-shift-f": "project_panel::NewSearchInDirectory",
"shift-down": "menu::SelectNext",
@@ -1204,6 +1205,13 @@
"cmd-enter": "menu::Confirm"
}
},
{
"context": "OnboardingAiConfigurationModal",
"use_key_equivalents": true,
"bindings": {
"escape": "menu::Cancel"
}
},
{
"context": "Diagnostics",
"use_key_equivalents": true,
@@ -1280,7 +1288,8 @@
"cmd-1": "onboarding::ActivateBasicsPage",
"cmd-2": "onboarding::ActivateEditingPage",
"cmd-3": "onboarding::ActivateAISetupPage",
"cmd-escape": "onboarding::Finish"
"cmd-escape": "onboarding::Finish",
"alt-tab": "onboarding::SignIn"
}
}
]

View File

@@ -813,6 +813,7 @@
"p": "project_panel::Open",
"x": "project_panel::RevealInFileManager",
"s": "project_panel::OpenWithSystem",
"z d": "project_panel::CompareMarkedFiles",
"] c": "project_panel::SelectNextGitEntry",
"[ c": "project_panel::SelectPrevGitEntry",
"] d": "project_panel::SelectNextDiagnostic",

View File

@@ -1171,6 +1171,9 @@
// Sets a delay after which the inline blame information is shown.
// Delay is restarted with every cursor movement.
"delay_ms": 0,
// The amount of padding between the end of the source line and the start
// of the inline blame in units of em widths.
"padding": 7,
// Whether or not to display the git commit summary on the same line.
"show_commit_summary": false,
// The minimum column number to show the inline blame information at

View File

@@ -6,7 +6,6 @@ use anyhow::{Context as _, Result};
use assistant_tool::ActionLog;
use buffer_diff::BufferDiff;
use editor::{Bias, MultiBuffer, PathKey};
use futures::future::{Fuse, FusedFuture};
use futures::{FutureExt, channel::oneshot, future::BoxFuture};
use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task};
use itertools::Itertools;
@@ -167,6 +166,7 @@ pub struct ToolCall {
pub status: ToolCallStatus,
pub locations: Vec<acp::ToolCallLocation>,
pub raw_input: Option<serde_json::Value>,
pub raw_output: Option<serde_json::Value>,
}
impl ToolCall {
@@ -195,6 +195,7 @@ impl ToolCall {
locations: tool_call.locations,
status,
raw_input: tool_call.raw_input,
raw_output: tool_call.raw_output,
}
}
@@ -211,6 +212,7 @@ impl ToolCall {
content,
locations,
raw_input,
raw_output,
} = fields;
if let Some(kind) = kind {
@@ -241,6 +243,10 @@ impl ToolCall {
if let Some(raw_input) = raw_input {
self.raw_input = Some(raw_input);
}
if let Some(raw_output) = raw_output {
self.raw_output = Some(raw_output);
}
}
pub fn diffs(&self) -> impl Iterator<Item = &Diff> {
@@ -573,7 +579,7 @@ pub struct AcpThread {
project: Entity<Project>,
action_log: Entity<ActionLog>,
shared_buffers: HashMap<Entity<Buffer>, BufferSnapshot>,
send_task: Option<Fuse<Task<()>>>,
send_task: Option<Task<()>>,
connection: Rc<dyn AgentConnection>,
session_id: acp::SessionId,
}
@@ -663,11 +669,7 @@ impl AcpThread {
}
pub fn status(&self) -> ThreadStatus {
if self
.send_task
.as_ref()
.map_or(false, |t| !t.is_terminated())
{
if self.send_task.as_ref().map_or(false, |t| !t.is_finished()) {
if self.waiting_for_tool_confirmation() {
ThreadStatus::WaitingForToolConfirmation
} else {
@@ -902,7 +904,7 @@ impl AcpThread {
});
}
pub fn request_tool_call_permission(
pub fn request_tool_call_authorization(
&mut self,
tool_call: acp::ToolCall,
options: Vec<acp::PermissionOption>,
@@ -1042,31 +1044,28 @@ impl AcpThread {
let (tx, rx) = oneshot::channel();
let cancel_task = self.cancel(cx);
self.send_task = Some(
cx.spawn(async move |this, cx| {
async {
cancel_task.await;
self.send_task = Some(cx.spawn(async move |this, cx| {
async {
cancel_task.await;
let result = this
.update(cx, |this, cx| {
this.connection.prompt(
acp::PromptRequest {
prompt: message,
session_id: this.session_id.clone(),
},
cx,
)
})?
.await;
let result = this
.update(cx, |this, cx| {
this.connection.prompt(
acp::PromptRequest {
prompt: message,
session_id: this.session_id.clone(),
},
cx,
)
})?
.await;
tx.send(result).log_err();
anyhow::Ok(())
}
.await
.log_err();
})
.fuse(),
);
tx.send(result).log_err();
anyhow::Ok(())
}
.await
.log_err();
}));
cx.spawn(async move |this, cx| match rx.await {
Ok(Err(e)) => {
@@ -1547,6 +1546,7 @@ mod tests {
content: vec![],
locations: vec![],
raw_input: None,
raw_output: None,
}),
cx,
)
@@ -1659,6 +1659,7 @@ mod tests {
}],
locations: vec![],
raw_input: None,
raw_output: None,
}),
cx,
)

View File

@@ -16,6 +16,7 @@ acp_thread.workspace = true
agent-client-protocol.workspace = true
agent_servers.workspace = true
anyhow.workspace = true
assistant_tool.workspace = true
cloud_llm_client.workspace = true
collections.workspace = true
fs.workspace = true
@@ -23,10 +24,13 @@ futures.workspace = true
gpui.workspace = true
handlebars = { workspace = true, features = ["rust-embed"] }
indoc.workspace = true
itertools.workspace = true
language.workspace = true
language_model.workspace = true
language_models.workspace = true
log.workspace = true
project.workspace = true
prompt_store.workspace = true
rust-embed.workspace = true
schemars.workspace = true
serde.workspace = true
@@ -36,7 +40,7 @@ smol.workspace = true
ui.workspace = true
util.workspace = true
uuid.workspace = true
worktree.workspace = true
watch.workspace = true
workspace-hack.workspace = true
[dev-dependencies]
@@ -47,8 +51,10 @@ env_logger.workspace = true
fs = { workspace = true, "features" = ["test-support"] }
gpui = { workspace = true, "features" = ["test-support"] }
gpui_tokio.workspace = true
language = { workspace = true, "features" = ["test-support"] }
language_model = { workspace = true, "features" = ["test-support"] }
project = { workspace = true, "features" = ["test-support"] }
reqwest_client.workspace = true
settings = { workspace = true, "features" = ["test-support"] }
worktree = { workspace = true, "features" = ["test-support"] }
pretty_assertions.workspace = true

View File

@@ -1,16 +1,39 @@
use crate::{templates::Templates, AgentResponseEvent, Thread};
use crate::{FindPathTool, ReadFileTool, ThinkingTool, ToolCallAuthorization};
use acp_thread::ModelSelector;
use agent_client_protocol as acp;
use anyhow::{anyhow, Result};
use futures::StreamExt;
use gpui::{App, AppContext, AsyncApp, Entity, Subscription, Task, WeakEntity};
use anyhow::{anyhow, Context as _, Result};
use futures::{future, StreamExt};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
};
use language_model::{LanguageModel, LanguageModelRegistry};
use project::Project;
use project::{Project, ProjectItem, ProjectPath, Worktree};
use prompt_store::{
ProjectContext, PromptId, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext,
};
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::Path;
use std::rc::Rc;
use std::sync::Arc;
use util::ResultExt;
use crate::{templates::Templates, AgentResponseEvent, Thread};
const RULES_FILE_NAMES: [&'static str; 9] = [
".rules",
".cursorrules",
".windsurfrules",
".clinerules",
".github/copilot-instructions.md",
"CLAUDE.md",
"AGENT.md",
"AGENTS.md",
"GEMINI.md",
];
pub struct RulesLoadingError {
pub message: SharedString,
}
/// Holds both the internal Thread and the AcpThread for a session
struct Session {
@@ -24,17 +47,247 @@ struct Session {
pub struct NativeAgent {
/// Session ID -> Session mapping
sessions: HashMap<acp::SessionId, Session>,
/// Shared project context for all threads
project_context: Rc<RefCell<ProjectContext>>,
project_context_needs_refresh: watch::Sender<()>,
_maintain_project_context: Task<Result<()>>,
/// Shared templates for all threads
templates: Arc<Templates>,
project: Entity<Project>,
prompt_store: Option<Entity<PromptStore>>,
_subscriptions: Vec<Subscription>,
}
impl NativeAgent {
pub fn new(templates: Arc<Templates>) -> Self {
pub async fn new(
project: Entity<Project>,
templates: Arc<Templates>,
prompt_store: Option<Entity<PromptStore>>,
cx: &mut AsyncApp,
) -> Result<Entity<NativeAgent>> {
log::info!("Creating new NativeAgent");
Self {
sessions: HashMap::new(),
templates,
let project_context = cx
.update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))?
.await;
cx.new(|cx| {
let mut subscriptions = vec![cx.subscribe(&project, Self::handle_project_event)];
if let Some(prompt_store) = prompt_store.as_ref() {
subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event))
}
let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
watch::channel(());
Self {
sessions: HashMap::new(),
project_context: Rc::new(RefCell::new(project_context)),
project_context_needs_refresh: project_context_needs_refresh_tx,
_maintain_project_context: cx.spawn(async move |this, cx| {
Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await
}),
templates,
project,
prompt_store,
_subscriptions: subscriptions,
}
})
}
async fn maintain_project_context(
this: WeakEntity<Self>,
mut needs_refresh: watch::Receiver<()>,
cx: &mut AsyncApp,
) -> Result<()> {
while needs_refresh.changed().await.is_ok() {
let project_context = this
.update(cx, |this, cx| {
Self::build_project_context(&this.project, this.prompt_store.as_ref(), cx)
})?
.await;
this.update(cx, |this, _| this.project_context.replace(project_context))?;
}
Ok(())
}
fn build_project_context(
project: &Entity<Project>,
prompt_store: Option<&Entity<PromptStore>>,
cx: &mut App,
) -> Task<ProjectContext> {
let worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
let worktree_tasks = worktrees
.into_iter()
.map(|worktree| {
Self::load_worktree_info_for_system_prompt(worktree, project.clone(), cx)
})
.collect::<Vec<_>>();
let default_user_rules_task = if let Some(prompt_store) = prompt_store.as_ref() {
prompt_store.read_with(cx, |prompt_store, cx| {
let prompts = prompt_store.default_prompt_metadata();
let load_tasks = prompts.into_iter().map(|prompt_metadata| {
let contents = prompt_store.load(prompt_metadata.id, cx);
async move { (contents.await, prompt_metadata) }
});
cx.background_spawn(future::join_all(load_tasks))
})
} else {
Task::ready(vec![])
};
cx.spawn(async move |_cx| {
let (worktrees, default_user_rules) =
future::join(future::join_all(worktree_tasks), default_user_rules_task).await;
let worktrees = worktrees
.into_iter()
.map(|(worktree, _rules_error)| {
// TODO: show error message
// if let Some(rules_error) = rules_error {
// this.update(cx, |_, cx| cx.emit(rules_error)).ok();
// }
worktree
})
.collect::<Vec<_>>();
let default_user_rules = default_user_rules
.into_iter()
.flat_map(|(contents, prompt_metadata)| match contents {
Ok(contents) => Some(UserRulesContext {
uuid: match prompt_metadata.id {
PromptId::User { uuid } => uuid,
PromptId::EditWorkflow => return None,
},
title: prompt_metadata.title.map(|title| title.to_string()),
contents,
}),
Err(_err) => {
// TODO: show error message
// this.update(cx, |_, cx| {
// cx.emit(RulesLoadingError {
// message: format!("{err:?}").into(),
// });
// })
// .ok();
None
}
})
.collect::<Vec<_>>();
ProjectContext::new(worktrees, default_user_rules)
})
}
fn load_worktree_info_for_system_prompt(
worktree: Entity<Worktree>,
project: Entity<Project>,
cx: &mut App,
) -> Task<(WorktreeContext, Option<RulesLoadingError>)> {
let tree = worktree.read(cx);
let root_name = tree.root_name().into();
let abs_path = tree.abs_path();
let mut context = WorktreeContext {
root_name,
abs_path,
rules_file: None,
};
let rules_task = Self::load_worktree_rules_file(worktree, project, cx);
let Some(rules_task) = rules_task else {
return Task::ready((context, None));
};
cx.spawn(async move |_| {
let (rules_file, rules_file_error) = match rules_task.await {
Ok(rules_file) => (Some(rules_file), None),
Err(err) => (
None,
Some(RulesLoadingError {
message: format!("{err}").into(),
}),
),
};
context.rules_file = rules_file;
(context, rules_file_error)
})
}
fn load_worktree_rules_file(
worktree: Entity<Worktree>,
project: Entity<Project>,
cx: &mut App,
) -> Option<Task<Result<RulesFileContext>>> {
let worktree = worktree.read(cx);
let worktree_id = worktree.id();
let selected_rules_file = RULES_FILE_NAMES
.into_iter()
.filter_map(|name| {
worktree
.entry_for_path(name)
.filter(|entry| entry.is_file())
.map(|entry| entry.path.clone())
})
.next();
// Note that Cline supports `.clinerules` being a directory, but that is not currently
// supported. This doesn't seem to occur often in GitHub repositories.
selected_rules_file.map(|path_in_worktree| {
let project_path = ProjectPath {
worktree_id,
path: path_in_worktree.clone(),
};
let buffer_task =
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
let rope_task = cx.spawn(async move |cx| {
buffer_task.await?.read_with(cx, |buffer, cx| {
let project_entry_id = buffer.entry_id(cx).context("buffer has no file")?;
anyhow::Ok((project_entry_id, buffer.as_rope().clone()))
})?
});
// Build a string from the rope on a background thread.
cx.background_spawn(async move {
let (project_entry_id, rope) = rope_task.await?;
anyhow::Ok(RulesFileContext {
path_in_worktree,
text: rope.to_string().trim().to_string(),
project_entry_id: project_entry_id.to_usize(),
})
})
})
}
fn handle_project_event(
&mut self,
_project: Entity<Project>,
event: &project::Event,
_cx: &mut Context<Self>,
) {
match event {
project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => {
self.project_context_needs_refresh.send(()).ok();
}
project::Event::WorktreeUpdatedEntries(_, items) => {
if items.iter().any(|(path, _, _)| {
RULES_FILE_NAMES
.iter()
.any(|name| path.as_ref() == Path::new(name))
}) {
self.project_context_needs_refresh.send(()).ok();
}
}
_ => {}
}
}
fn handle_prompts_updated_event(
&mut self,
_prompt_store: Entity<PromptStore>,
_event: &prompt_store::PromptsUpdatedEvent,
_cx: &mut Context<Self>,
) {
self.project_context_needs_refresh.send(()).ok();
}
}
@@ -120,8 +373,21 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
cx.spawn(async move |cx| {
log::debug!("Starting thread creation in async context");
// Generate session ID
let session_id = acp::SessionId(uuid::Uuid::new_v4().to_string().into());
log::info!("Created session with ID: {}", session_id);
// Create AcpThread
let acp_thread = cx.update(|cx| {
cx.new(|cx| {
acp_thread::AcpThread::new("agent2", self.clone(), project.clone(), session_id.clone(), cx)
})
})?;
let action_log = cx.update(|cx| acp_thread.read(cx).action_log().clone())?;
// Create Thread
let (session_id, thread) = agent.update(
let thread = agent.update(
cx,
|agent, cx: &mut gpui::Context<NativeAgent>| -> Result<_> {
// Fetch default model from registry settings
@@ -146,22 +412,18 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
anyhow!("No default model configured. Please configure a default model in settings.")
})?;
let thread = cx.new(|_| Thread::new(project.clone(), agent.templates.clone(), default_model));
let thread = cx.new(|_| {
let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model);
thread.add_tool(ThinkingTool);
thread.add_tool(FindPathTool::new(project.clone()));
thread.add_tool(ReadFileTool::new(project.clone(), action_log));
thread
});
// Generate session ID
let session_id = acp::SessionId(uuid::Uuid::new_v4().to_string().into());
log::info!("Created session with ID: {}", session_id);
Ok((session_id, thread))
Ok(thread)
},
)??;
// Create AcpThread
let acp_thread = cx.update(|cx| {
cx.new(|cx| {
acp_thread::AcpThread::new("agent2", self.clone(), project, session_id.clone(), cx)
})
})?;
// Store the session
agent.update(cx, |agent, cx| {
agent.sessions.insert(
@@ -264,6 +526,28 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
)
})??;
}
AgentResponseEvent::ToolCallAuthorization(ToolCallAuthorization {
tool_call,
options,
response,
}) => {
let recv = acp_thread.update(cx, |thread, cx| {
thread.request_tool_call_authorization(tool_call, options, cx)
})?;
cx.background_spawn(async move {
if let Some(option) = recv
.await
.context("authorization sender was dropped")
.log_err()
{
response
.send(option)
.map(|_| anyhow!("authorization receiver was dropped"))
.log_err();
}
})
.detach();
}
AgentResponseEvent::ToolCall(tool_call) => {
acp_thread.update(cx, |thread, cx| {
thread.handle_session_update(
@@ -343,3 +627,77 @@ fn convert_prompt_to_message(blocks: Vec<acp::ContentBlock>) -> String {
message
}
#[cfg(test)]
mod tests {
use super::*;
use fs::FakeFs;
use gpui::TestAppContext;
use serde_json::json;
use settings::SettingsStore;
#[gpui::test]
async fn test_maintaining_project_context(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/",
json!({
"a": {}
}),
)
.await;
let project = Project::test(fs.clone(), [], cx).await;
let agent = NativeAgent::new(project.clone(), Templates::new(), None, &mut cx.to_async())
.await
.unwrap();
agent.read_with(cx, |agent, _| {
assert_eq!(agent.project_context.borrow().worktrees, vec![])
});
let worktree = project
.update(cx, |project, cx| project.create_worktree("/a", true, cx))
.await
.unwrap();
cx.run_until_parked();
agent.read_with(cx, |agent, _| {
assert_eq!(
agent.project_context.borrow().worktrees,
vec![WorktreeContext {
root_name: "a".into(),
abs_path: Path::new("/a").into(),
rules_file: None
}]
)
});
// Creating `/a/.rules` updates the project context.
fs.insert_file("/a/.rules", Vec::new()).await;
cx.run_until_parked();
agent.read_with(cx, |agent, cx| {
let rules_entry = worktree.read(cx).entry_for_path(".rules").unwrap();
assert_eq!(
agent.project_context.borrow().worktrees,
vec![WorktreeContext {
root_name: "a".into(),
abs_path: Path::new("/a").into(),
rules_file: Some(RulesFileContext {
path_in_worktree: Path::new(".rules").into(),
text: "".into(),
project_entry_id: rules_entry.id.to_usize()
})
}]
)
});
}
fn init_test(cx: &mut TestAppContext) {
env_logger::try_init().ok();
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
Project::init_settings(cx);
language::init(cx);
});
}
}

View File

@@ -1,6 +1,5 @@
mod agent;
mod native_agent_server;
mod prompts;
mod templates;
mod thread;
mod tools;
@@ -11,3 +10,4 @@ mod tests;
pub use agent::*;
pub use native_agent_server::NativeAgentServer;
pub use thread::*;
pub use tools::*;

View File

@@ -3,8 +3,9 @@ use std::rc::Rc;
use agent_servers::AgentServer;
use anyhow::Result;
use gpui::{App, AppContext, Entity, Task};
use gpui::{App, Entity, Task};
use project::Project;
use prompt_store::PromptStore;
use crate::{templates::Templates, NativeAgent, NativeAgentConnection};
@@ -32,21 +33,22 @@ impl AgentServer for NativeAgentServer {
fn connect(
&self,
_root_dir: &Path,
_project: &Entity<Project>,
project: &Entity<Project>,
cx: &mut App,
) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
log::info!(
"NativeAgentServer::connect called for path: {:?}",
_root_dir
);
let project = project.clone();
let prompt_store = PromptStore::global(cx);
cx.spawn(async move |cx| {
log::debug!("Creating templates for native agent");
// Create templates (you might want to load these from files or resources)
let templates = Templates::new();
let prompt_store = prompt_store.await?;
// Create the native agent
log::debug!("Creating native agent entity");
let agent = cx.update(|cx| cx.new(|_| NativeAgent::new(templates)))?;
let agent = NativeAgent::new(project, templates, Some(prompt_store), cx).await?;
// Create the connection wrapper
let connection = NativeAgentConnection(agent);

View File

@@ -1,35 +0,0 @@
use crate::{
templates::{BaseTemplate, Template, Templates, WorktreeData},
thread::Prompt,
};
use anyhow::Result;
use gpui::{App, Entity};
use project::Project;
pub struct BasePrompt {
project: Entity<Project>,
}
impl BasePrompt {
pub fn new(project: Entity<Project>) -> Self {
Self { project }
}
}
impl Prompt for BasePrompt {
fn render(&self, templates: &Templates, cx: &App) -> Result<String> {
BaseTemplate {
os: std::env::consts::OS.to_string(),
shell: util::get_system_shell(),
worktrees: self
.project
.read(cx)
.worktrees(cx)
.map(|worktree| WorktreeData {
root_name: worktree.read(cx).root_name().to_string(),
})
.collect(),
}
.render(templates)
}
}

View File

@@ -1,9 +1,9 @@
use std::sync::Arc;
use anyhow::Result;
use gpui::SharedString;
use handlebars::Handlebars;
use rust_embed::RustEmbed;
use serde::Serialize;
use std::sync::Arc;
#[derive(RustEmbed)]
#[folder = "src/templates"]
@@ -15,6 +15,8 @@ pub struct Templates(Handlebars<'static>);
impl Templates {
pub fn new() -> Arc<Self> {
let mut handlebars = Handlebars::new();
handlebars.set_strict_mode(true);
handlebars.register_helper("contains", Box::new(contains));
handlebars.register_embed_templates::<Assets>().unwrap();
Arc::new(Self(handlebars))
}
@@ -32,26 +34,54 @@ pub trait Template: Sized {
}
#[derive(Serialize)]
pub struct BaseTemplate {
pub os: String,
pub shell: String,
pub worktrees: Vec<WorktreeData>,
pub struct SystemPromptTemplate<'a> {
#[serde(flatten)]
pub project: &'a prompt_store::ProjectContext,
pub available_tools: Vec<SharedString>,
}
impl Template for BaseTemplate {
const TEMPLATE_NAME: &'static str = "base.hbs";
impl Template for SystemPromptTemplate<'_> {
const TEMPLATE_NAME: &'static str = "system_prompt.hbs";
}
#[derive(Serialize)]
pub struct WorktreeData {
pub root_name: String,
/// Handlebars helper for checking if an item is in a list
fn contains(
h: &handlebars::Helper,
_: &handlebars::Handlebars,
_: &handlebars::Context,
_: &mut handlebars::RenderContext,
out: &mut dyn handlebars::Output,
) -> handlebars::HelperResult {
let list = h
.param(0)
.and_then(|v| v.value().as_array())
.ok_or_else(|| {
handlebars::RenderError::new("contains: missing or invalid list parameter")
})?;
let query = h.param(1).map(|v| v.value()).ok_or_else(|| {
handlebars::RenderError::new("contains: missing or invalid query parameter")
})?;
if list.contains(&query) {
out.write("true")?;
}
Ok(())
}
#[derive(Serialize)]
pub struct GlobTemplate {
pub project_roots: String,
}
#[cfg(test)]
mod tests {
use super::*;
impl Template for GlobTemplate {
const TEMPLATE_NAME: &'static str = "glob.hbs";
#[test]
fn test_system_prompt_template() {
let project = prompt_store::ProjectContext::default();
let template = SystemPromptTemplate {
project: &project,
available_tools: vec!["echo".into()],
};
let templates = Templates::new();
let rendered = template.render(&templates).unwrap();
assert!(rendered.contains("## Fixing Diagnostics"));
}
}

View File

@@ -1,56 +0,0 @@
You are a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
## Communication
1. Be conversational but professional.
2. Refer to the USER in the second person and yourself in the first person.
3. Format your responses in markdown. Use backticks to format file, directory, function, and class names.
4. NEVER lie or make things up.
5. Refrain from apologizing all the time when results are unexpected. Instead, just try your best to proceed or explain the circumstances to the user without apologizing.
## Tool Use
1. Make sure to adhere to the tools schema.
2. Provide every required argument.
3. DO NOT use tools to access items that are already available in the context section.
4. Use only the tools that are currently available.
5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off.
## Searching and Reading
If you are unsure how to fulfill the user's request, gather more information with tool calls and/or clarifying questions.
If appropriate, use tool calls to explore the current project, which contains the following root directories:
{{#each worktrees}}
- `{{root_name}}`
{{/each}}
- When providing paths to tools, the path should always begin with a path that starts with a project root directory listed above.
- When looking for symbols in the project, prefer the `grep` tool.
- As you learn about the structure of the project, use that information to scope `grep` searches to targeted subtrees of the project.
- Bias towards not asking the user for help if you can find the answer yourself.
## Fixing Diagnostics
1. Make 1-2 attempts at fixing diagnostics, then defer to the user.
2. Never simplify code you've written just to solve diagnostics. Complete, mostly correct code is more valuable than perfect code that doesn't solve the problem.
## Debugging
When debugging, only make code changes if you are certain that you can solve the problem.
Otherwise, follow debugging best practices:
1. Address the root cause instead of the symptoms.
2. Add descriptive logging statements and error messages to track variable and code state.
3. Add test functions and statements to isolate the problem.
## Calling External APIs
1. Unless explicitly requested by the user, use the best suited external APIs and packages to solve the task. There is no need to ask the user for permission.
2. When selecting which version of an API or package to use, choose one that is compatible with the user's dependency management file. If no such file exists or if the package is not present, use the latest version that is in your training data.
3. If an external API requires an API Key, be sure to point this out to the user. Adhere to best security practices (e.g. DO NOT hardcode an API key in a place where it can be exposed)
## System Information
Operating System: {{os}}
Default Shell: {{shell}}

View File

@@ -1,8 +0,0 @@
Find paths on disk with glob patterns.
Assume that all glob patterns are matched in a project directory with the following entries.
{{project_roots}}
When searching with patterns that begin with literal path components, e.g. `foo/bar/**/*.rs`, be
sure to anchor them with one of the directories listed above.

View File

@@ -0,0 +1,178 @@
You are a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
## Communication
1. Be conversational but professional.
2. Refer to the user in the second person and yourself in the first person.
3. Format your responses in markdown. Use backticks to format file, directory, function, and class names.
4. NEVER lie or make things up.
5. Refrain from apologizing all the time when results are unexpected. Instead, just try your best to proceed or explain the circumstances to the user without apologizing.
{{#if (gt (len available_tools) 0)}}
## Tool Use
1. Make sure to adhere to the tools schema.
2. Provide every required argument.
3. DO NOT use tools to access items that are already available in the context section.
4. Use only the tools that are currently available.
5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off.
6. NEVER run commands that don't terminate on their own such as web servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers.
7. Avoid HTML entity escaping - use plain characters instead.
## Searching and Reading
If you are unsure how to fulfill the user's request, gather more information with tool calls and/or clarifying questions.
If appropriate, use tool calls to explore the current project, which contains the following root directories:
{{#each worktrees}}
- `{{abs_path}}`
{{/each}}
- Bias towards not asking the user for help if you can find the answer yourself.
- When providing paths to tools, the path should always start with the name of a project root directory listed above.
- Before you read or edit a file, you must first find the full path. DO NOT ever guess a file path!
{{# if (contains available_tools 'grep') }}
- When looking for symbols in the project, prefer the `grep` tool.
- As you learn about the structure of the project, use that information to scope `grep` searches to targeted subtrees of the project.
- The user might specify a partial file path. If you don't know the full path, use `find_path` (not `grep`) before you read the file.
{{/if}}
{{else}}
You are being tasked with providing a response, but you have no ability to use tools or to read or write any aspect of the user's system (other than any context the user might have provided to you).
As such, if you need the user to perform any actions for you, you must request them explicitly. Bias towards giving a response to the best of your ability, and then making requests for the user to take action (e.g. to give you more context) only optionally.
The one exception to this is if the user references something you don't know about - for example, the name of a source code file, function, type, or other piece of code that you have no awareness of. In this case, you MUST NOT MAKE SOMETHING UP, or assume you know what that thing is or how it works. Instead, you must ask the user for clarification rather than giving a response.
{{/if}}
## Code Block Formatting
Whenever you mention a code block, you MUST use ONLY use the following format:
```path/to/Something.blah#L123-456
(code goes here)
```
The `#L123-456` means the line number range 123 through 456, and the path/to/Something.blah
is a path in the project. (If there is no valid path in the project, then you can use
/dev/null/path.extension for its path.) This is the ONLY valid way to format code blocks, because the Markdown parser
does not understand the more common ```language syntax, or bare ``` blocks. It only
understands this path-based syntax, and if the path is missing, then it will error and you will have to do it over again.
Just to be really clear about this, if you ever find yourself writing three backticks followed by a language name, STOP!
You have made a mistake. You can only ever put paths after triple backticks!
<example>
Based on all the information I've gathered, here's a summary of how this system works:
1. The README file is loaded into the system.
2. The system finds the first two headers, including everything in between. In this case, that would be:
```path/to/README.md#L8-12
# First Header
This is the info under the first header.
## Sub-header
```
3. Then the system finds the last header in the README:
```path/to/README.md#L27-29
## Last Header
This is the last header in the README.
```
4. Finally, it passes this information on to the next process.
</example>
<example>
In Markdown, hash marks signify headings. For example:
```/dev/null/example.md#L1-3
# Level 1 heading
## Level 2 heading
### Level 3 heading
```
</example>
Here are examples of ways you must never render code blocks:
<bad_example_do_not_do_this>
In Markdown, hash marks signify headings. For example:
```
# Level 1 heading
## Level 2 heading
### Level 3 heading
```
</bad_example_do_not_do_this>
This example is unacceptable because it does not include the path.
<bad_example_do_not_do_this>
In Markdown, hash marks signify headings. For example:
```markdown
# Level 1 heading
## Level 2 heading
### Level 3 heading
```
</bad_example_do_not_do_this>
This example is unacceptable because it has the language instead of the path.
<bad_example_do_not_do_this>
In Markdown, hash marks signify headings. For example:
# Level 1 heading
## Level 2 heading
### Level 3 heading
</bad_example_do_not_do_this>
This example is unacceptable because it uses indentation to mark the code block
instead of backticks with a path.
<bad_example_do_not_do_this>
In Markdown, hash marks signify headings. For example:
```markdown
/dev/null/example.md#L1-3
# Level 1 heading
## Level 2 heading
### Level 3 heading
```
</bad_example_do_not_do_this>
This example is unacceptable because the path is in the wrong place. The path must be directly after the opening backticks.
{{#if (gt (len available_tools) 0)}}
## Fixing Diagnostics
1. Make 1-2 attempts at fixing diagnostics, then defer to the user.
2. Never simplify code you've written just to solve diagnostics. Complete, mostly correct code is more valuable than perfect code that doesn't solve the problem.
## Debugging
When debugging, only make code changes if you are certain that you can solve the problem.
Otherwise, follow debugging best practices:
1. Address the root cause instead of the symptoms.
2. Add descriptive logging statements and error messages to track variable and code state.
3. Add test functions and statements to isolate the problem.
{{/if}}
## Calling External APIs
1. Unless explicitly requested by the user, use the best suited external APIs and packages to solve the task. There is no need to ask the user for permission.
2. When selecting which version of an API or package to use, choose one that is compatible with the user's dependency management file(s). If no such file exists or if the package is not present, use the latest version that is in your training data.
3. If an external API requires an API Key, be sure to point this out to the user. Adhere to best security practices (e.g. DO NOT hardcode an API key in a place where it can be exposed)
## System Information
Operating System: {{os}}
Default Shell: {{shell}}
{{#if (or has_rules has_user_rules)}}
## User's Custom Instructions
The following additional instructions are provided by the user, and should be followed to the best of your ability{{#if (gt (len available_tools) 0)}} without interfering with the tool use guidelines{{/if}}.
{{#if has_rules}}
There are project rules that apply to these root directories:
{{#each worktrees}}
{{#if rules_file}}
`{{root_name}}/{{rules_file.path_in_worktree}}`:
``````
{{{rules_file.text}}}
``````
{{/if}}
{{/each}}
{{/if}}
{{#if has_user_rules}}
The user has specified the following rules that should be applied:
{{#each user_rules}}
{{#if title}}
Rules title: {{title}}
{{/if}}
``````
{{contents}}}
``````
{{/each}}
{{/if}}
{{/if}}

View File

@@ -1,30 +1,34 @@
use super::*;
use crate::templates::Templates;
use acp_thread::AgentConnection;
use agent_client_protocol as acp;
use agent_client_protocol::{self as acp};
use anyhow::Result;
use assistant_tool::ActionLog;
use client::{Client, UserStore};
use fs::FakeFs;
use futures::channel::mpsc::UnboundedReceiver;
use gpui::{http_client::FakeHttpClient, AppContext, Entity, Task, TestAppContext};
use indoc::indoc;
use language_model::{
fake_provider::FakeLanguageModel, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelRegistry, MessageContent,
StopReason,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelRegistry, LanguageModelToolResult,
LanguageModelToolUse, MessageContent, Role, StopReason,
};
use project::Project;
use prompt_store::ProjectContext;
use reqwest_client::ReqwestClient;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;
use smol::stream::StreamExt;
use std::{path::Path, rc::Rc, sync::Arc, time::Duration};
use std::{cell::RefCell, path::Path, rc::Rc, sync::Arc, time::Duration};
use util::path;
mod test_tools;
use test_tools::*;
#[gpui::test]
#[ignore = "temporarily disabled until it can be run on CI"]
#[ignore = "can't run on CI yet"]
async fn test_echo(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await;
@@ -44,7 +48,7 @@ async fn test_echo(cx: &mut TestAppContext) {
}
#[gpui::test]
#[ignore = "temporarily disabled until it can be run on CI"]
#[ignore = "can't run on CI yet"]
async fn test_thinking(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4Thinking).await;
@@ -77,7 +81,46 @@ async fn test_thinking(cx: &mut TestAppContext) {
}
#[gpui::test]
#[ignore = "temporarily disabled until it can be run on CI"]
async fn test_system_prompt(cx: &mut TestAppContext) {
let ThreadTest {
model,
thread,
project_context,
..
} = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
project_context.borrow_mut().shell = "test-shell".into();
thread.update(cx, |thread, _| thread.add_tool(EchoTool));
thread.update(cx, |thread, cx| thread.send(model.clone(), "abc", cx));
cx.run_until_parked();
let mut pending_completions = fake_model.pending_completions();
assert_eq!(
pending_completions.len(),
1,
"unexpected pending completions: {:?}",
pending_completions
);
let pending_completion = pending_completions.pop().unwrap();
assert_eq!(pending_completion.messages[0].role, Role::System);
let system_message = &pending_completion.messages[0];
let system_prompt = system_message.content[0].to_str().unwrap();
assert!(
system_prompt.contains("test-shell"),
"unexpected system message: {:?}",
system_message
);
assert!(
system_prompt.contains("## Fixing Diagnostics"),
"unexpected system message: {:?}",
system_message
);
}
#[gpui::test]
#[ignore = "can't run on CI yet"]
async fn test_basic_tool_calls(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await;
@@ -127,7 +170,7 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) {
}
#[gpui::test]
#[ignore = "temporarily disabled until it can be run on CI"]
#[ignore = "can't run on CI yet"]
async fn test_streaming_tool_calls(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await;
@@ -175,7 +218,161 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) {
}
#[gpui::test]
#[ignore = "temporarily disabled until it can be run on CI"]
async fn test_tool_authorization(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
let mut events = thread.update(cx, |thread, cx| {
thread.add_tool(ToolRequiringPermission);
thread.send(model.clone(), "abc", cx)
});
cx.run_until_parked();
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: "tool_id_1".into(),
name: ToolRequiringPermission.name().into(),
raw_input: "{}".into(),
input: json!({}),
is_input_complete: true,
},
));
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: "tool_id_2".into(),
name: ToolRequiringPermission.name().into(),
raw_input: "{}".into(),
input: json!({}),
is_input_complete: true,
},
));
fake_model.end_last_completion_stream();
let tool_call_auth_1 = next_tool_call_authorization(&mut events).await;
let tool_call_auth_2 = next_tool_call_authorization(&mut events).await;
// Approve the first
tool_call_auth_1
.response
.send(tool_call_auth_1.options[1].id.clone())
.unwrap();
cx.run_until_parked();
// Reject the second
tool_call_auth_2
.response
.send(tool_call_auth_1.options[2].id.clone())
.unwrap();
cx.run_until_parked();
let completion = fake_model.pending_completions().pop().unwrap();
let message = completion.messages.last().unwrap();
assert_eq!(
message.content,
vec![
MessageContent::ToolResult(LanguageModelToolResult {
tool_use_id: tool_call_auth_1.tool_call.id.0.to_string().into(),
tool_name: ToolRequiringPermission.name().into(),
is_error: false,
content: "Allowed".into(),
output: None
}),
MessageContent::ToolResult(LanguageModelToolResult {
tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(),
tool_name: ToolRequiringPermission.name().into(),
is_error: true,
content: "Permission to run tool denied by user".into(),
output: None
})
]
);
}
#[gpui::test]
async fn test_tool_hallucination(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
let mut events = thread.update(cx, |thread, cx| thread.send(model.clone(), "abc", cx));
cx.run_until_parked();
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: "tool_id_1".into(),
name: "nonexistent_tool".into(),
raw_input: "{}".into(),
input: json!({}),
is_input_complete: true,
},
));
fake_model.end_last_completion_stream();
let tool_call = expect_tool_call(&mut events).await;
assert_eq!(tool_call.title, "nonexistent_tool");
assert_eq!(tool_call.status, acp::ToolCallStatus::Pending);
let update = expect_tool_call_update(&mut events).await;
assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Failed));
}
async fn expect_tool_call(
events: &mut UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
) -> acp::ToolCall {
let event = events
.next()
.await
.expect("no tool call authorization event received")
.unwrap();
match event {
AgentResponseEvent::ToolCall(tool_call) => return tool_call,
event => {
panic!("Unexpected event {event:?}");
}
}
}
async fn expect_tool_call_update(
events: &mut UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
) -> acp::ToolCallUpdate {
let event = events
.next()
.await
.expect("no tool call authorization event received")
.unwrap();
match event {
AgentResponseEvent::ToolCallUpdate(tool_call_update) => return tool_call_update,
event => {
panic!("Unexpected event {event:?}");
}
}
}
async fn next_tool_call_authorization(
events: &mut UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
) -> ToolCallAuthorization {
loop {
let event = events
.next()
.await
.expect("no tool call authorization event received")
.unwrap();
if let AgentResponseEvent::ToolCallAuthorization(tool_call_authorization) = event {
let permission_kinds = tool_call_authorization
.options
.iter()
.map(|o| o.kind)
.collect::<Vec<_>>();
assert_eq!(
permission_kinds,
vec![
acp::PermissionOptionKind::AllowAlways,
acp::PermissionOptionKind::AllowOnce,
acp::PermissionOptionKind::RejectOnce,
]
);
return tool_call_authorization;
}
}
}
#[gpui::test]
#[ignore = "can't run on CI yet"]
async fn test_concurrent_tool_calls(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await;
@@ -214,7 +411,7 @@ async fn test_concurrent_tool_calls(cx: &mut TestAppContext) {
}
#[gpui::test]
#[ignore = "temporarily disabled until it can be run on CI"]
#[ignore = "can't run on CI yet"]
async fn test_cancellation(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await;
@@ -281,12 +478,10 @@ async fn test_cancellation(cx: &mut TestAppContext) {
#[gpui::test]
async fn test_refusal(cx: &mut TestAppContext) {
let fake_model = Arc::new(FakeLanguageModel::default());
let ThreadTest { thread, .. } = setup(cx, TestModel::Fake(fake_model.clone())).await;
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
let events = thread.update(cx, |thread, cx| {
thread.send(fake_model.clone(), "Hello", cx)
});
let events = thread.update(cx, |thread, cx| thread.send(model.clone(), "Hello", cx));
cx.run_until_parked();
thread.read_with(cx, |thread, _| {
assert_eq!(
@@ -343,8 +538,16 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
});
cx.executor().forbid_parking();
// Create a project for new_thread
let fake_fs = cx.update(|cx| fs::FakeFs::new(cx.background_executor().clone()));
fake_fs.insert_tree(path!("/test"), json!({})).await;
let project = Project::test(fake_fs, [Path::new("/test")], cx).await;
let cwd = Path::new("/test");
// Create agent and connection
let agent = cx.new(|_| NativeAgent::new(templates.clone()));
let agent = NativeAgent::new(project.clone(), templates.clone(), None, &mut cx.to_async())
.await
.unwrap();
let connection = NativeAgentConnection(agent.clone());
// Test model_selector returns Some
@@ -366,12 +569,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
assert!(!listed_models.is_empty(), "should have at least one model");
assert_eq!(listed_models[0].id().0, "fake");
// Create a project for new_thread
let fake_fs = cx.update(|cx| fs::FakeFs::new(cx.background_executor().clone()));
let project = Project::test(fake_fs, [Path::new("/test")], cx).await;
// Create a thread using new_thread
let cwd = Path::new("/test");
let connection_rc = Rc::new(connection.clone());
let acp_thread = cx
.update(|cx| {
@@ -441,6 +639,77 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
thread.update(cx, |thread, _cx| thread.add_tool(ThinkingTool));
let fake_model = model.as_fake();
let mut events = thread.update(cx, |thread, cx| thread.send(model.clone(), "Think", cx));
cx.run_until_parked();
let input = json!({ "content": "Thinking hard!" });
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: "1".into(),
name: ThinkingTool.name().into(),
raw_input: input.to_string(),
input,
is_input_complete: true,
},
));
fake_model.end_last_completion_stream();
cx.run_until_parked();
let tool_call = expect_tool_call(&mut events).await;
assert_eq!(
tool_call,
acp::ToolCall {
id: acp::ToolCallId("1".into()),
title: "Thinking".into(),
kind: acp::ToolKind::Think,
status: acp::ToolCallStatus::Pending,
content: vec![],
locations: vec![],
raw_input: Some(json!({ "content": "Thinking hard!" })),
raw_output: None,
}
);
let update = expect_tool_call_update(&mut events).await;
assert_eq!(
update,
acp::ToolCallUpdate {
id: acp::ToolCallId("1".into()),
fields: acp::ToolCallUpdateFields {
status: Some(acp::ToolCallStatus::InProgress,),
..Default::default()
},
}
);
let update = expect_tool_call_update(&mut events).await;
assert_eq!(
update,
acp::ToolCallUpdate {
id: acp::ToolCallId("1".into()),
fields: acp::ToolCallUpdateFields {
content: Some(vec!["Thinking hard!".into()]),
..Default::default()
},
}
);
let update = expect_tool_call_update(&mut events).await;
assert_eq!(
update,
acp::ToolCallUpdate {
id: acp::ToolCallId("1".into()),
fields: acp::ToolCallUpdateFields {
status: Some(acp::ToolCallStatus::Completed),
..Default::default()
},
}
);
}
/// Filters out the stop events for asserting against in tests
fn stop_events(
result_events: Vec<Result<AgentResponseEvent, LanguageModelCompletionError>>,
@@ -457,12 +726,13 @@ fn stop_events(
struct ThreadTest {
model: Arc<dyn LanguageModel>,
thread: Entity<Thread>,
project_context: Rc<RefCell<ProjectContext>>,
}
enum TestModel {
Sonnet4,
Sonnet4Thinking,
Fake(Arc<FakeLanguageModel>),
Fake,
}
impl TestModel {
@@ -470,7 +740,7 @@ impl TestModel {
match self {
TestModel::Sonnet4 => LanguageModelId("claude-sonnet-4-latest".into()),
TestModel::Sonnet4Thinking => LanguageModelId("claude-sonnet-4-thinking-latest".into()),
TestModel::Fake(fake_model) => fake_model.id(),
TestModel::Fake => unreachable!(),
}
}
}
@@ -499,8 +769,8 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
language_model::init(client.clone(), cx);
language_models::init(user_store.clone(), client.clone(), cx);
if let TestModel::Fake(model) = model {
Task::ready(model as Arc<_>)
if let TestModel::Fake = model {
Task::ready(Arc::new(FakeLanguageModel::default()) as Arc<_>)
} else {
let model_id = model.id();
let models = LanguageModelRegistry::read_global(cx);
@@ -520,9 +790,22 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
})
.await;
let thread = cx.new(|_| Thread::new(project, templates, model.clone()));
ThreadTest { model, thread }
let project_context = Rc::new(RefCell::new(ProjectContext::default()));
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let thread = cx.new(|_| {
Thread::new(
project,
project_context.clone(),
action_log,
templates,
model.clone(),
)
});
ThreadTest {
model,
thread,
project_context,
}
}
#[cfg(test)]

View File

@@ -19,7 +19,20 @@ impl AgentTool for EchoTool {
"echo".into()
}
fn run(self: Arc<Self>, input: Self::Input, _cx: &mut App) -> Task<Result<String>> {
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Other
}
fn initial_title(&self, _: Self::Input) -> SharedString {
"Echo".into()
}
fn run(
self: Arc<Self>,
input: Self::Input,
_event_stream: ToolCallEventStream,
_cx: &mut App,
) -> Task<Result<String>> {
Task::ready(Ok(input.text))
}
}
@@ -40,7 +53,20 @@ impl AgentTool for DelayTool {
"delay".into()
}
fn run(self: Arc<Self>, input: Self::Input, cx: &mut App) -> Task<Result<String>>
fn initial_title(&self, input: Self::Input) -> SharedString {
format!("Delay {}ms", input.ms).into()
}
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Other
}
fn run(
self: Arc<Self>,
input: Self::Input,
_event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>>
where
Self: Sized,
{
@@ -51,6 +77,43 @@ impl AgentTool for DelayTool {
}
}
#[derive(JsonSchema, Serialize, Deserialize)]
pub struct ToolRequiringPermissionInput {}
pub struct ToolRequiringPermission;
impl AgentTool for ToolRequiringPermission {
type Input = ToolRequiringPermissionInput;
fn name(&self) -> SharedString {
"tool_requiring_permission".into()
}
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Other
}
fn initial_title(&self, _input: Self::Input) -> SharedString {
"This tool requires permission".into()
}
fn run(
self: Arc<Self>,
input: Self::Input,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>>
where
Self: Sized,
{
let auth_check = self.authorize(input, event_stream);
cx.foreground_executor().spawn(async move {
auth_check.await?;
Ok("Allowed".to_string())
})
}
}
#[derive(JsonSchema, Serialize, Deserialize)]
pub struct InfiniteToolInput {}
@@ -63,7 +126,20 @@ impl AgentTool for InfiniteTool {
"infinite".into()
}
fn run(self: Arc<Self>, _input: Self::Input, cx: &mut App) -> Task<Result<String>> {
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Other
}
fn initial_title(&self, _input: Self::Input) -> SharedString {
"This is the tool that never ends... it just goes on and on my friends!".into()
}
fn run(
self: Arc<Self>,
_input: Self::Input,
_event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>> {
cx.foreground_executor().spawn(async move {
future::pending::<()>().await;
unreachable!()
@@ -100,7 +176,20 @@ impl AgentTool for WordListTool {
"word_list".into()
}
fn run(self: Arc<Self>, _input: Self::Input, _cx: &mut App) -> Task<Result<String>> {
fn initial_title(&self, _input: Self::Input) -> SharedString {
"List of random words".into()
}
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Other
}
fn run(
self: Arc<Self>,
_input: Self::Input,
_event_stream: ToolCallEventStream,
_cx: &mut App,
) -> Task<Result<String>> {
Task::ready(Ok("ok".to_string()))
}
}

View File

@@ -1,22 +1,27 @@
use crate::{prompts::BasePrompt, templates::Templates};
use crate::templates::{SystemPromptTemplate, Template, Templates};
use agent_client_protocol as acp;
use anyhow::{anyhow, Result};
use anyhow::{anyhow, Context as _, Result};
use assistant_tool::{adapt_schema_to_format, ActionLog};
use cloud_llm_client::{CompletionIntent, CompletionMode};
use collections::HashMap;
use futures::{channel::mpsc, stream::FuturesUnordered};
use gpui::{App, Context, Entity, ImageFormat, SharedString, Task};
use futures::{
channel::{mpsc, oneshot},
stream::FuturesUnordered,
};
use gpui::{App, Context, Entity, SharedString, Task};
use language_model::{
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelImage,
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool,
LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, StopReason,
};
use log;
use project::Project;
use prompt_store::ProjectContext;
use schemars::{JsonSchema, Schema};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use smol::stream::StreamExt;
use std::{collections::BTreeMap, fmt::Write, sync::Arc};
use std::{cell::RefCell, collections::BTreeMap, fmt::Write, future::Future, rc::Rc, sync::Arc};
use util::{markdown::MarkdownCodeBlock, ResultExt};
#[derive(Debug, Clone)]
@@ -97,11 +102,15 @@ pub enum AgentResponseEvent {
Thinking(String),
ToolCall(acp::ToolCall),
ToolCallUpdate(acp::ToolCallUpdate),
ToolCallAuthorization(ToolCallAuthorization),
Stop(acp::StopReason),
}
pub trait Prompt {
fn render(&self, prompts: &Templates, cx: &App) -> Result<String>;
#[derive(Debug)]
pub struct ToolCallAuthorization {
pub tool_call: acp::ToolCall,
pub options: Vec<acp::PermissionOption>,
pub response: oneshot::Sender<acp::PermissionOptionId>,
}
pub struct Thread {
@@ -112,28 +121,31 @@ pub struct Thread {
/// we run tools, report their results.
running_turn: Option<Task<()>>,
pending_tool_uses: HashMap<LanguageModelToolUseId, LanguageModelToolUse>,
system_prompts: Vec<Arc<dyn Prompt>>,
tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
project_context: Rc<RefCell<ProjectContext>>,
templates: Arc<Templates>,
pub selected_model: Arc<dyn LanguageModel>,
// action_log: Entity<ActionLog>,
action_log: Entity<ActionLog>,
}
impl Thread {
pub fn new(
project: Entity<Project>,
_project: Entity<Project>,
project_context: Rc<RefCell<ProjectContext>>,
action_log: Entity<ActionLog>,
templates: Arc<Templates>,
default_model: Arc<dyn LanguageModel>,
) -> Self {
Self {
messages: Vec::new(),
completion_mode: CompletionMode::Normal,
system_prompts: vec![Arc::new(BasePrompt::new(project))],
running_turn: None,
pending_tool_uses: HashMap::default(),
tools: BTreeMap::default(),
project_context,
templates,
selected_model: default_model,
action_log,
}
}
@@ -188,6 +200,7 @@ impl Thread {
cx.notify();
let (events_tx, events_rx) =
mpsc::unbounded::<Result<AgentResponseEvent, LanguageModelCompletionError>>();
let event_stream = AgentResponseEventStream(events_tx);
let user_message_ix = self.messages.len();
self.messages.push(AgentMessage {
@@ -222,12 +235,7 @@ impl Thread {
while let Some(event) = events.next().await {
match event {
Ok(LanguageModelCompletionEvent::Stop(reason)) => {
if let Some(reason) = to_acp_stop_reason(reason) {
events_tx
.unbounded_send(Ok(AgentResponseEvent::Stop(reason)))
.ok();
}
event_stream.send_stop(reason);
if reason == StopReason::Refusal {
thread.update(cx, |thread, _cx| {
thread.messages.truncate(user_message_ix);
@@ -240,14 +248,16 @@ impl Thread {
thread
.update(cx, |thread, cx| {
tool_uses.extend(thread.handle_streamed_completion_event(
event, &events_tx, cx,
event,
&event_stream,
cx,
));
})
.ok();
}
Err(error) => {
log::error!("Error in completion stream: {:?}", error);
events_tx.unbounded_send(Err(error)).ok();
event_stream.send_error(error);
break;
}
}
@@ -266,11 +276,17 @@ impl Thread {
while let Some(tool_result) = tool_uses.next().await {
log::info!("Tool finished {:?}", tool_result);
events_tx
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
to_acp_tool_call_update(&tool_result),
)))
.ok();
event_stream.send_tool_call_update(
&tool_result.tool_use_id,
acp::ToolCallUpdateFields {
status: Some(if tool_result.is_error {
acp::ToolCallStatus::Failed
} else {
acp::ToolCallStatus::Completed
}),
..Default::default()
},
);
thread
.update(cx, |thread, _cx| {
thread.pending_tool_uses.remove(&tool_result.tool_use_id);
@@ -291,7 +307,7 @@ impl Thread {
if let Err(error) = turn_result {
log::error!("Turn execution failed: {:?}", error);
events_tx.unbounded_send(Err(error)).ok();
event_stream.send_error(error);
} else {
log::info!("Turn execution completed successfully");
}
@@ -299,24 +315,24 @@ impl Thread {
events_rx
}
pub fn build_system_message(&self, cx: &App) -> Option<AgentMessage> {
pub fn action_log(&self) -> &Entity<ActionLog> {
&self.action_log
}
pub fn build_system_message(&self) -> AgentMessage {
log::debug!("Building system message");
let mut system_message = AgentMessage {
role: Role::System,
content: Vec::new(),
};
for prompt in &self.system_prompts {
if let Some(rendered_prompt) = prompt.render(&self.templates, cx).log_err() {
system_message
.content
.push(MessageContent::Text(rendered_prompt));
}
let prompt = SystemPromptTemplate {
project: &self.project_context.borrow(),
available_tools: self.tools.keys().cloned().collect(),
}
.render(&self.templates)
.context("failed to build system prompt")
.expect("Invalid template");
log::debug!("System message built");
AgentMessage {
role: Role::System,
content: vec![prompt.into()],
}
let result = (!system_message.content.is_empty()).then_some(system_message);
log::debug!("System message built: {}", result.is_some());
result
}
/// A helper method that's called on every streamed completion event.
@@ -325,7 +341,7 @@ impl Thread {
fn handle_streamed_completion_event(
&mut self,
event: LanguageModelCompletionEvent,
events_tx: &mpsc::UnboundedSender<Result<AgentResponseEvent, LanguageModelCompletionError>>,
event_stream: &AgentResponseEventStream,
cx: &mut Context<Self>,
) -> Option<Task<LanguageModelToolResult>> {
log::trace!("Handling streamed completion event: {:?}", event);
@@ -338,13 +354,13 @@ impl Thread {
content: Vec::new(),
});
}
Text(new_text) => self.handle_text_event(new_text, events_tx, cx),
Text(new_text) => self.handle_text_event(new_text, event_stream, cx),
Thinking { text, signature } => {
self.handle_thinking_event(text, signature, events_tx, cx)
self.handle_thinking_event(text, signature, event_stream, cx)
}
RedactedThinking { data } => self.handle_redacted_thinking_event(data, cx),
ToolUse(tool_use) => {
return self.handle_tool_use_event(tool_use, events_tx, cx);
return self.handle_tool_use_event(tool_use, event_stream, cx);
}
ToolUseJsonParseError {
id,
@@ -369,12 +385,10 @@ impl Thread {
fn handle_text_event(
&mut self,
new_text: String,
events_tx: &mpsc::UnboundedSender<Result<AgentResponseEvent, LanguageModelCompletionError>>,
events_stream: &AgentResponseEventStream,
cx: &mut Context<Self>,
) {
events_tx
.unbounded_send(Ok(AgentResponseEvent::Text(new_text.clone())))
.ok();
events_stream.send_text(&new_text);
let last_message = self.last_assistant_message();
if let Some(MessageContent::Text(text)) = last_message.content.last_mut() {
@@ -390,12 +404,10 @@ impl Thread {
&mut self,
new_text: String,
new_signature: Option<String>,
events_tx: &mpsc::UnboundedSender<Result<AgentResponseEvent, LanguageModelCompletionError>>,
event_stream: &AgentResponseEventStream,
cx: &mut Context<Self>,
) {
events_tx
.unbounded_send(Ok(AgentResponseEvent::Thinking(new_text.clone())))
.ok();
event_stream.send_thinking(&new_text);
let last_message = self.last_assistant_message();
if let Some(MessageContent::Thinking { text, signature }) = last_message.content.last_mut()
@@ -423,11 +435,13 @@ impl Thread {
fn handle_tool_use_event(
&mut self,
tool_use: LanguageModelToolUse,
events_tx: &mpsc::UnboundedSender<Result<AgentResponseEvent, LanguageModelCompletionError>>,
event_stream: &AgentResponseEventStream,
cx: &mut Context<Self>,
) -> Option<Task<LanguageModelToolResult>> {
cx.notify();
let tool = self.tools.get(tool_use.name.as_ref()).cloned();
self.pending_tool_uses
.insert(tool_use.id.clone(), tool_use.clone());
let last_message = self.last_assistant_message();
@@ -445,82 +459,74 @@ impl Thread {
true
}
});
if push_new_tool_use {
events_tx
.unbounded_send(Ok(AgentResponseEvent::ToolCall(acp::ToolCall {
id: acp::ToolCallId(tool_use.id.to_string().into()),
title: tool_use.name.to_string(),
kind: acp::ToolKind::Other,
status: acp::ToolCallStatus::Pending,
content: vec![],
locations: vec![],
raw_input: Some(tool_use.input.clone()),
})))
.ok();
event_stream.send_tool_call(tool.as_ref(), &tool_use);
last_message
.content
.push(MessageContent::ToolUse(tool_use.clone()));
} else {
events_tx
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
acp::ToolCallUpdate {
id: acp::ToolCallId(tool_use.id.to_string().into()),
fields: acp::ToolCallUpdateFields {
raw_input: Some(tool_use.input.clone()),
..Default::default()
},
},
)))
.ok();
event_stream.send_tool_call_update(
&tool_use.id,
acp::ToolCallUpdateFields {
raw_input: Some(tool_use.input.clone()),
..Default::default()
},
);
}
if !tool_use.is_input_complete {
return None;
}
if let Some(tool) = self.tools.get(tool_use.name.as_ref()) {
events_tx
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
acp::ToolCallUpdate {
id: acp::ToolCallId(tool_use.id.to_string().into()),
fields: acp::ToolCallUpdateFields {
status: Some(acp::ToolCallStatus::InProgress),
..Default::default()
},
},
)))
.ok();
let pending_tool_result = tool.clone().run(tool_use.input, cx);
Some(cx.foreground_executor().spawn(async move {
match pending_tool_result.await {
Ok(tool_output) => LanguageModelToolResult {
tool_use_id: tool_use.id,
tool_name: tool_use.name,
is_error: false,
content: LanguageModelToolResultContent::Text(Arc::from(tool_output)),
output: None,
},
Err(error) => LanguageModelToolResult {
tool_use_id: tool_use.id,
tool_name: tool_use.name,
is_error: true,
content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())),
output: None,
},
}
}))
} else {
let Some(tool) = tool else {
let content = format!("No tool named {} exists", tool_use.name);
Some(Task::ready(LanguageModelToolResult {
return Some(Task::ready(LanguageModelToolResult {
content: LanguageModelToolResultContent::Text(Arc::from(content)),
tool_use_id: tool_use.id,
tool_name: tool_use.name,
is_error: true,
output: None,
}))
}
}));
};
let tool_result = self.run_tool(tool, tool_use.clone(), event_stream.clone(), cx);
Some(cx.foreground_executor().spawn(async move {
match tool_result.await {
Ok(tool_output) => LanguageModelToolResult {
tool_use_id: tool_use.id,
tool_name: tool_use.name,
is_error: false,
content: LanguageModelToolResultContent::Text(Arc::from(tool_output)),
output: None,
},
Err(error) => LanguageModelToolResult {
tool_use_id: tool_use.id,
tool_name: tool_use.name,
is_error: true,
content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())),
output: None,
},
}
}))
}
fn run_tool(
&self,
tool: Arc<dyn AnyAgentTool>,
tool_use: LanguageModelToolUse,
event_stream: AgentResponseEventStream,
cx: &mut Context<Self>,
) -> Task<Result<String>> {
cx.spawn(async move |_this, cx| {
let tool_event_stream = ToolCallEventStream::new(tool_use.id, event_stream);
tool_event_stream.send_update(acp::ToolCallUpdateFields {
status: Some(acp::ToolCallStatus::InProgress),
..Default::default()
});
cx.update(|cx| tool.run(tool_use.input, tool_event_stream, cx))?
.await
})
}
fn handle_tool_use_json_parse_error_event(
@@ -575,7 +581,7 @@ impl Thread {
log::debug!("Completion intent: {:?}", completion_intent);
log::debug!("Completion mode: {:?}", self.completion_mode);
let messages = self.build_request_messages(cx);
let messages = self.build_request_messages();
log::info!("Request will include {} messages", messages.len());
let tools: Vec<LanguageModelRequestTool> = self
@@ -588,7 +594,7 @@ impl Thread {
name: tool_name,
description: tool.description(cx).to_string(),
input_schema: tool
.input_schema(LanguageModelToolSchemaFormat::JsonSchema)
.input_schema(self.selected_model.tool_input_format())
.log_err()?,
})
})
@@ -613,14 +619,13 @@ impl Thread {
request
}
fn build_request_messages(&self, cx: &App) -> Vec<LanguageModelRequestMessage> {
fn build_request_messages(&self) -> Vec<LanguageModelRequestMessage> {
log::trace!(
"Building request messages from {} thread messages",
self.messages.len()
);
let messages = self
.build_system_message(cx)
let messages = Some(self.build_system_message())
.iter()
.chain(self.messages.iter())
.map(|message| {
@@ -656,9 +661,10 @@ pub trait AgentTool
where
Self: 'static + Sized,
{
type Input: for<'de> Deserialize<'de> + JsonSchema;
type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema;
fn name(&self) -> SharedString;
fn description(&self, _cx: &mut App) -> SharedString {
let schema = schemars::schema_for!(Self::Input);
SharedString::new(
@@ -669,13 +675,33 @@ where
)
}
fn kind(&self) -> acp::ToolKind;
/// The initial tool title to display. Can be updated during the tool run.
fn initial_title(&self, input: Self::Input) -> SharedString;
/// Returns the JSON schema that describes the tool's input.
fn input_schema(&self, _format: LanguageModelToolSchemaFormat) -> Schema {
fn input_schema(&self) -> Schema {
schemars::schema_for!(Self::Input)
}
/// Allows the tool to authorize a given tool call with the user if necessary
fn authorize(
&self,
input: Self::Input,
event_stream: ToolCallEventStream,
) -> impl use<Self> + Future<Output = Result<()>> {
let json_input = serde_json::json!(&input);
event_stream.authorize(self.initial_title(input).into(), self.kind(), json_input)
}
/// Runs the tool with the provided input.
fn run(self: Arc<Self>, input: Self::Input, cx: &mut App) -> Task<Result<String>>;
fn run(
self: Arc<Self>,
input: Self::Input,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>>;
fn erase(self) -> Arc<dyn AnyAgentTool> {
Arc::new(Erased(Arc::new(self)))
@@ -687,8 +713,15 @@ pub struct Erased<T>(T);
pub trait AnyAgentTool {
fn name(&self) -> SharedString;
fn description(&self, cx: &mut App) -> SharedString;
fn kind(&self) -> acp::ToolKind;
fn initial_title(&self, input: serde_json::Value) -> Result<SharedString>;
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value>;
fn run(self: Arc<Self>, input: serde_json::Value, cx: &mut App) -> Task<Result<String>>;
fn run(
self: Arc<Self>,
input: serde_json::Value,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>>;
}
impl<T> AnyAgentTool for Erased<Arc<T>>
@@ -703,52 +736,220 @@ where
self.0.description(cx)
}
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
Ok(serde_json::to_value(self.0.input_schema(format))?)
fn kind(&self) -> agent_client_protocol::ToolKind {
self.0.kind()
}
fn run(self: Arc<Self>, input: serde_json::Value, cx: &mut App) -> Task<Result<String>> {
fn initial_title(&self, input: serde_json::Value) -> Result<SharedString> {
let parsed_input = serde_json::from_value(input)?;
Ok(self.0.initial_title(parsed_input))
}
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
let mut json = serde_json::to_value(self.0.input_schema())?;
adapt_schema_to_format(&mut json, format)?;
Ok(json)
}
fn run(
self: Arc<Self>,
input: serde_json::Value,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>> {
let parsed_input: Result<T::Input> = serde_json::from_value(input).map_err(Into::into);
match parsed_input {
Ok(input) => self.0.clone().run(input, cx),
Ok(input) => self.0.clone().run(input, event_stream, cx),
Err(error) => Task::ready(Err(anyhow!(error))),
}
}
}
fn to_acp_stop_reason(reason: StopReason) -> Option<acp::StopReason> {
match reason {
StopReason::EndTurn => Some(acp::StopReason::EndTurn),
StopReason::MaxTokens => Some(acp::StopReason::MaxTokens),
StopReason::Refusal => Some(acp::StopReason::Refusal),
StopReason::ToolUse => None,
#[derive(Clone)]
struct AgentResponseEventStream(
mpsc::UnboundedSender<Result<AgentResponseEvent, LanguageModelCompletionError>>,
);
impl AgentResponseEventStream {
fn send_text(&self, text: &str) {
self.0
.unbounded_send(Ok(AgentResponseEvent::Text(text.to_string())))
.ok();
}
fn send_thinking(&self, text: &str) {
self.0
.unbounded_send(Ok(AgentResponseEvent::Thinking(text.to_string())))
.ok();
}
fn authorize_tool_call(
&self,
id: &LanguageModelToolUseId,
title: String,
kind: acp::ToolKind,
input: serde_json::Value,
) -> impl use<> + Future<Output = Result<()>> {
let (response_tx, response_rx) = oneshot::channel();
self.0
.unbounded_send(Ok(AgentResponseEvent::ToolCallAuthorization(
ToolCallAuthorization {
tool_call: Self::initial_tool_call(id, title, kind, input),
options: vec![
acp::PermissionOption {
id: acp::PermissionOptionId("always_allow".into()),
name: "Always Allow".into(),
kind: acp::PermissionOptionKind::AllowAlways,
},
acp::PermissionOption {
id: acp::PermissionOptionId("allow".into()),
name: "Allow".into(),
kind: acp::PermissionOptionKind::AllowOnce,
},
acp::PermissionOption {
id: acp::PermissionOptionId("deny".into()),
name: "Deny".into(),
kind: acp::PermissionOptionKind::RejectOnce,
},
],
response: response_tx,
},
)))
.ok();
async move {
match response_rx.await?.0.as_ref() {
"allow" | "always_allow" => Ok(()),
_ => Err(anyhow!("Permission to run tool denied by user")),
}
}
}
fn send_tool_call(
&self,
tool: Option<&Arc<dyn AnyAgentTool>>,
tool_use: &LanguageModelToolUse,
) {
self.0
.unbounded_send(Ok(AgentResponseEvent::ToolCall(Self::initial_tool_call(
&tool_use.id,
tool.and_then(|t| t.initial_title(tool_use.input.clone()).ok())
.map(|i| i.into())
.unwrap_or_else(|| tool_use.name.to_string()),
tool.map(|t| t.kind()).unwrap_or(acp::ToolKind::Other),
tool_use.input.clone(),
))))
.ok();
}
fn initial_tool_call(
id: &LanguageModelToolUseId,
title: String,
kind: acp::ToolKind,
input: serde_json::Value,
) -> acp::ToolCall {
acp::ToolCall {
id: acp::ToolCallId(id.to_string().into()),
title,
kind,
status: acp::ToolCallStatus::Pending,
content: vec![],
locations: vec![],
raw_input: Some(input),
raw_output: None,
}
}
fn send_tool_call_update(
&self,
tool_use_id: &LanguageModelToolUseId,
fields: acp::ToolCallUpdateFields,
) {
self.0
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
acp::ToolCallUpdate {
id: acp::ToolCallId(tool_use_id.to_string().into()),
fields,
},
)))
.ok();
}
fn send_stop(&self, reason: StopReason) {
match reason {
StopReason::EndTurn => {
self.0
.unbounded_send(Ok(AgentResponseEvent::Stop(acp::StopReason::EndTurn)))
.ok();
}
StopReason::MaxTokens => {
self.0
.unbounded_send(Ok(AgentResponseEvent::Stop(acp::StopReason::MaxTokens)))
.ok();
}
StopReason::Refusal => {
self.0
.unbounded_send(Ok(AgentResponseEvent::Stop(acp::StopReason::Refusal)))
.ok();
}
StopReason::ToolUse => {}
}
}
fn send_error(&self, error: LanguageModelCompletionError) {
self.0.unbounded_send(Err(error)).ok();
}
}
fn to_acp_tool_call_update(tool_result: &LanguageModelToolResult) -> acp::ToolCallUpdate {
let status = if tool_result.is_error {
acp::ToolCallStatus::Failed
} else {
acp::ToolCallStatus::Completed
};
let content = match &tool_result.content {
LanguageModelToolResultContent::Text(text) => text.to_string().into(),
LanguageModelToolResultContent::Image(LanguageModelImage { source, .. }) => {
acp::ToolCallContent::Content {
content: acp::ContentBlock::Image(acp::ImageContent {
annotations: None,
data: source.to_string(),
mime_type: ImageFormat::Png.mime_type().to_string(),
}),
}
#[derive(Clone)]
pub struct ToolCallEventStream {
tool_use_id: LanguageModelToolUseId,
stream: AgentResponseEventStream,
}
impl ToolCallEventStream {
fn new(tool_use_id: LanguageModelToolUseId, stream: AgentResponseEventStream) -> Self {
Self {
tool_use_id,
stream,
}
};
acp::ToolCallUpdate {
id: acp::ToolCallId(tool_result.tool_use_id.to_string().into()),
fields: acp::ToolCallUpdateFields {
status: Some(status),
content: Some(vec![content]),
..Default::default()
},
}
pub fn send_update(&self, fields: acp::ToolCallUpdateFields) {
self.stream.send_tool_call_update(&self.tool_use_id, fields);
}
pub fn authorize(
&self,
title: String,
kind: acp::ToolKind,
input: serde_json::Value,
) -> impl use<> + Future<Output = Result<()>> {
self.stream
.authorize_tool_call(&self.tool_use_id, title, kind, input)
}
}
#[cfg(test)]
pub struct TestToolCallEventStream {
stream: ToolCallEventStream,
_events_rx: mpsc::UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
}
#[cfg(test)]
impl TestToolCallEventStream {
pub fn new() -> Self {
let (events_tx, events_rx) =
mpsc::unbounded::<Result<AgentResponseEvent, LanguageModelCompletionError>>();
let stream = ToolCallEventStream::new("test".into(), AgentResponseEventStream(events_tx));
Self {
stream,
_events_rx: events_rx,
}
}
pub fn stream(&self) -> ToolCallEventStream {
self.stream.clone()
}
}

View File

@@ -1 +1,7 @@
mod glob;
mod find_path_tool;
mod read_file_tool;
mod thinking_tool;
pub use find_path_tool::*;
pub use read_file_tool::*;
pub use thinking_tool::*;

View File

@@ -0,0 +1,231 @@
use agent_client_protocol as acp;
use anyhow::{anyhow, Result};
use gpui::{App, AppContext, Entity, SharedString, Task};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fmt::Write;
use std::{cmp, path::PathBuf, sync::Arc};
use util::paths::PathMatcher;
use crate::{AgentTool, ToolCallEventStream};
/// Fast file path pattern matching tool that works with any codebase size
///
/// - Supports glob patterns like "**/*.js" or "src/**/*.ts"
/// - Returns matching file paths sorted alphabetically
/// - Prefer the `grep` tool to this tool when searching for symbols unless you have specific information about paths.
/// - Use this tool when you need to find files by name patterns
/// - Results are paginated with 50 matches per page. Use the optional 'offset' parameter to request subsequent pages.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct FindPathToolInput {
/// The glob to match against every path in the project.
///
/// <example>
/// If the project has the following root directories:
///
/// - directory1/a/something.txt
/// - directory2/a/things.txt
/// - directory3/a/other.txt
///
/// You can get back the first two paths by providing a glob of "*thing*.txt"
/// </example>
pub glob: String,
/// Optional starting position for paginated results (0-based).
/// When not provided, starts from the beginning.
#[serde(default)]
pub offset: usize,
}
#[derive(Debug, Serialize, Deserialize)]
struct FindPathToolOutput {
paths: Vec<PathBuf>,
}
const RESULTS_PER_PAGE: usize = 50;
pub struct FindPathTool {
project: Entity<Project>,
}
impl FindPathTool {
pub fn new(project: Entity<Project>) -> Self {
Self { project }
}
}
impl AgentTool for FindPathTool {
type Input = FindPathToolInput;
fn name(&self) -> SharedString {
"find_path".into()
}
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Search
}
fn initial_title(&self, input: Self::Input) -> SharedString {
format!("Find paths matching “`{}`”", input.glob).into()
}
fn run(
self: Arc<Self>,
input: Self::Input,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>> {
let search_paths_task = search_paths(&input.glob, self.project.clone(), cx);
cx.background_spawn(async move {
let matches = search_paths_task.await?;
let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len())
..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())];
event_stream.send_update(acp::ToolCallUpdateFields {
title: Some(if paginated_matches.len() == 0 {
"No matches".into()
} else if paginated_matches.len() == 1 {
"1 match".into()
} else {
format!("{} matches", paginated_matches.len())
}),
content: Some(
paginated_matches
.iter()
.map(|path| acp::ToolCallContent::Content {
content: acp::ContentBlock::ResourceLink(acp::ResourceLink {
uri: format!("file://{}", path.display()),
name: path.to_string_lossy().into(),
annotations: None,
description: None,
mime_type: None,
size: None,
title: None,
}),
})
.collect(),
),
raw_output: Some(serde_json::json!({
"paths": &matches,
})),
..Default::default()
});
if matches.is_empty() {
Ok("No matches found".into())
} else {
let mut message = format!("Found {} total matches.", matches.len());
if matches.len() > RESULTS_PER_PAGE {
write!(
&mut message,
"\nShowing results {}-{} (provide 'offset' parameter for more results):",
input.offset + 1,
input.offset + paginated_matches.len()
)
.unwrap();
}
for mat in matches.iter().skip(input.offset).take(RESULTS_PER_PAGE) {
write!(&mut message, "\n{}", mat.display()).unwrap();
}
Ok(message)
}
})
}
}
fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> {
let path_matcher = match PathMatcher::new([
// Sometimes models try to search for "". In this case, return all paths in the project.
if glob.is_empty() { "*" } else { glob },
]) {
Ok(matcher) => matcher,
Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
};
let snapshots: Vec<_> = project
.read(cx)
.worktrees(cx)
.map(|worktree| worktree.read(cx).snapshot())
.collect();
cx.background_spawn(async move {
Ok(snapshots
.iter()
.flat_map(|snapshot| {
let root_name = PathBuf::from(snapshot.root_name());
snapshot
.entries(false, 0)
.map(move |entry| root_name.join(&entry.path))
.filter(|path| path_matcher.is_match(&path))
})
.collect())
})
}
#[cfg(test)]
mod test {
use super::*;
use gpui::TestAppContext;
use project::{FakeFs, Project};
use settings::SettingsStore;
use util::path;
#[gpui::test]
async fn test_find_path_tool(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
serde_json::json!({
"apple": {
"banana": {
"carrot": "1",
},
"bandana": {
"carbonara": "2",
},
"endive": "3"
}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let matches = cx
.update(|cx| search_paths("root/**/car*", project.clone(), cx))
.await
.unwrap();
assert_eq!(
matches,
&[
PathBuf::from("root/apple/banana/carrot"),
PathBuf::from("root/apple/bandana/carbonara")
]
);
let matches = cx
.update(|cx| search_paths("**/car*", project.clone(), cx))
.await
.unwrap();
assert_eq!(
matches,
&[
PathBuf::from("root/apple/banana/carrot"),
PathBuf::from("root/apple/bandana/carbonara")
]
);
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
Project::init_settings(cx);
});
}
}

View File

@@ -1,76 +0,0 @@
use anyhow::{anyhow, Result};
use gpui::{App, AppContext, Entity, SharedString, Task};
use project::Project;
use schemars::JsonSchema;
use serde::Deserialize;
use std::{path::PathBuf, sync::Arc};
use util::paths::PathMatcher;
use worktree::Snapshot as WorktreeSnapshot;
use crate::{
templates::{GlobTemplate, Template, Templates},
thread::AgentTool,
};
// Description is dynamic, see `fn description` below
#[derive(Deserialize, JsonSchema)]
struct GlobInput {
/// A POSIX glob pattern
glob: SharedString,
}
struct GlobTool {
project: Entity<Project>,
templates: Arc<Templates>,
}
impl AgentTool for GlobTool {
type Input = GlobInput;
fn name(&self) -> SharedString {
"glob".into()
}
fn description(&self, cx: &mut App) -> SharedString {
let project_roots = self
.project
.read(cx)
.worktrees(cx)
.map(|worktree| worktree.read(cx).root_name().into())
.collect::<Vec<String>>()
.join("\n");
GlobTemplate { project_roots }
.render(&self.templates)
.expect("template failed to render")
.into()
}
fn run(self: Arc<Self>, input: Self::Input, cx: &mut App) -> Task<Result<String>> {
let path_matcher = match PathMatcher::new([&input.glob]) {
Ok(matcher) => matcher,
Err(error) => return Task::ready(Err(anyhow!(error))),
};
let snapshots: Vec<WorktreeSnapshot> = self
.project
.read(cx)
.worktrees(cx)
.map(|worktree| worktree.read(cx).snapshot())
.collect();
cx.background_spawn(async move {
let paths = snapshots.iter().flat_map(|snapshot| {
let root_name = PathBuf::from(snapshot.root_name());
snapshot
.entries(false, 0)
.map(move |entry| root_name.join(&entry.path))
.filter(|path| path_matcher.is_match(&path))
});
let output = paths
.map(|path| format!("{}\n", path.display()))
.collect::<String>();
Ok(output)
})
}
}

View File

@@ -0,0 +1,970 @@
use agent_client_protocol::{self as acp};
use anyhow::{anyhow, Result};
use assistant_tool::{outline, ActionLog};
use gpui::{Entity, Task};
use indoc::formatdoc;
use language::{Anchor, Point};
use project::{AgentLocation, Project, WorktreeSettings};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use std::sync::Arc;
use ui::{App, SharedString};
use crate::{AgentTool, ToolCallEventStream};
/// Reads the content of the given file in the project.
///
/// - Never attempt to read a path that hasn't been previously mentioned.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct ReadFileToolInput {
/// The relative path of the file to read.
///
/// This path should never be absolute, and the first component
/// of the path should always be a root directory in a project.
///
/// <example>
/// If the project has the following root directories:
///
/// - /a/b/directory1
/// - /c/d/directory2
///
/// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
/// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
/// </example>
pub path: String,
/// Optional line number to start reading on (1-based index)
#[serde(default)]
pub start_line: Option<u32>,
/// Optional line number to end reading on (1-based index, inclusive)
#[serde(default)]
pub end_line: Option<u32>,
}
pub struct ReadFileTool {
project: Entity<Project>,
action_log: Entity<ActionLog>,
}
impl ReadFileTool {
pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
Self {
project,
action_log,
}
}
}
impl AgentTool for ReadFileTool {
type Input = ReadFileToolInput;
fn name(&self) -> SharedString {
"read_file".into()
}
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Read
}
fn initial_title(&self, input: Self::Input) -> SharedString {
let path = &input.path;
match (input.start_line, input.end_line) {
(Some(start), Some(end)) => {
format!(
"[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))",
path, start, end, path, start, end
)
}
(Some(start), None) => {
format!(
"[Read file `{}` (from line {})](@selection:{}:({}-{}))",
path, start, path, start, start
)
}
_ => format!("[Read file `{}`](@file:{})", path, path),
}
.into()
}
fn run(
self: Arc<Self>,
input: Self::Input,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>> {
let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
return Task::ready(Err(anyhow!("Path {} not found in project", &input.path)));
};
// Error out if this path is either excluded or private in global settings
let global_settings = WorktreeSettings::get_global(cx);
if global_settings.is_path_excluded(&project_path.path) {
return Task::ready(Err(anyhow!(
"Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
&input.path
)));
}
if global_settings.is_path_private(&project_path.path) {
return Task::ready(Err(anyhow!(
"Cannot read file because its path matches the global `private_files` setting: {}",
&input.path
)));
}
// Error out if this path is either excluded or private in worktree settings
let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
if worktree_settings.is_path_excluded(&project_path.path) {
return Task::ready(Err(anyhow!(
"Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
&input.path
)));
}
if worktree_settings.is_path_private(&project_path.path) {
return Task::ready(Err(anyhow!(
"Cannot read file because its path matches the worktree `private_files` setting: {}",
&input.path
)));
}
let file_path = input.path.clone();
event_stream.send_update(acp::ToolCallUpdateFields {
locations: Some(vec![acp::ToolCallLocation {
path: project_path.path.to_path_buf(),
line: input.start_line,
// TODO (tracked): use full range
}]),
..Default::default()
});
// TODO (tracked): images
// if image_store::is_image_file(&self.project, &project_path, cx) {
// let model = &self.thread.read(cx).selected_model;
// if !model.supports_images() {
// return Task::ready(Err(anyhow!(
// "Attempted to read an image, but Zed doesn't currently support sending images to {}.",
// model.name().0
// )))
// .into();
// }
// return cx.spawn(async move |cx| -> Result<ToolResultOutput> {
// let image_entity: Entity<ImageItem> = cx
// .update(|cx| {
// self.project.update(cx, |project, cx| {
// project.open_image(project_path.clone(), cx)
// })
// })?
// .await?;
// let image =
// image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?;
// let language_model_image = cx
// .update(|cx| LanguageModelImage::from_image(image, cx))?
// .await
// .context("processing image")?;
// Ok(ToolResultOutput {
// content: ToolResultContent::Image(language_model_image),
// output: None,
// })
// });
// }
//
let project = self.project.clone();
let action_log = self.action_log.clone();
cx.spawn(async move |cx| {
let buffer = cx
.update(|cx| {
project.update(cx, |project, cx| project.open_buffer(project_path, cx))
})?
.await?;
if buffer.read_with(cx, |buffer, _| {
buffer
.file()
.as_ref()
.map_or(true, |file| !file.disk_state().exists())
})? {
anyhow::bail!("{file_path} not found");
}
project.update(cx, |project, cx| {
project.set_agent_location(
Some(AgentLocation {
buffer: buffer.downgrade(),
position: Anchor::MIN,
}),
cx,
);
})?;
// Check if specific line ranges are provided
if input.start_line.is_some() || input.end_line.is_some() {
let mut anchor = None;
let result = buffer.read_with(cx, |buffer, _cx| {
let text = buffer.text();
// .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
let start = input.start_line.unwrap_or(1).max(1);
let start_row = start - 1;
if start_row <= buffer.max_point().row {
let column = buffer.line_indent_for_row(start_row).raw_len();
anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
}
let lines = text.split('\n').skip(start_row as usize);
if let Some(end) = input.end_line {
let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line
itertools::intersperse(lines.take(count as usize), "\n").collect::<String>()
} else {
itertools::intersperse(lines, "\n").collect::<String>()
}
})?;
action_log.update(cx, |log, cx| {
log.buffer_read(buffer.clone(), cx);
})?;
if let Some(anchor) = anchor {
project.update(cx, |project, cx| {
project.set_agent_location(
Some(AgentLocation {
buffer: buffer.downgrade(),
position: anchor,
}),
cx,
);
})?;
}
Ok(result)
} else {
// No line ranges specified, so check file size to see if it's too big.
let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?;
if file_size <= outline::AUTO_OUTLINE_SIZE {
// File is small enough, so return its contents.
let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
action_log.update(cx, |log, cx| {
log.buffer_read(buffer, cx);
})?;
Ok(result)
} else {
// File is too big, so return the outline
// and a suggestion to read again with line numbers.
let outline =
outline::file_outline(project, file_path, action_log, None, cx).await?;
Ok(formatdoc! {"
This file was too big to read all at once.
Here is an outline of its symbols:
{outline}
Using the line numbers in this outline, you can call this tool again
while specifying the start_line and end_line fields to see the
implementations of symbols in the outline.
Alternatively, you can fall back to the `grep` tool (if available)
to search the file for specific content."
})
}
}
})
}
}
#[cfg(test)]
mod test {
use crate::TestToolCallEventStream;
use super::*;
use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
use language::{tree_sitter_rust, Language, LanguageConfig, LanguageMatcher};
use project::{FakeFs, Project};
use serde_json::json;
use settings::SettingsStore;
use util::path;
#[gpui::test]
async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root"), json!({})).await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log));
let event_stream = TestToolCallEventStream::new();
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/nonexistent_file.txt".to_string(),
start_line: None,
end_line: None,
};
tool.run(input, event_stream.stream(), cx)
})
.await;
assert_eq!(
result.unwrap_err().to_string(),
"root/nonexistent_file.txt not found"
);
}
#[gpui::test]
async fn test_read_small_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"small_file.txt": "This is a small file content"
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log));
let event_stream = TestToolCallEventStream::new();
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/small_file.txt".into(),
start_line: None,
end_line: None,
};
tool.run(input, event_stream.stream(), cx)
})
.await;
assert_eq!(result.unwrap(), "This is a small file content");
}
#[gpui::test]
async fn test_read_large_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(Arc::new(rust_lang()));
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log));
let event_stream = TestToolCallEventStream::new();
let content = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/large_file.rs".into(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await
.unwrap();
assert_eq!(
content.lines().skip(4).take(6).collect::<Vec<_>>(),
vec![
"struct Test0 [L1-4]",
" a [L2]",
" b [L3]",
"struct Test1 [L5-8]",
" a [L6]",
" b [L7]",
]
);
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/large_file.rs".into(),
start_line: None,
end_line: None,
};
tool.run(input, event_stream.stream(), cx)
})
.await;
let content = result.unwrap();
let expected_content = (0..1000)
.flat_map(|i| {
vec![
format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
format!(" a [L{}]", i * 4 + 2),
format!(" b [L{}]", i * 4 + 3),
]
})
.collect::<Vec<_>>();
pretty_assertions::assert_eq!(
content
.lines()
.skip(4)
.take(expected_content.len())
.collect::<Vec<_>>(),
expected_content
);
}
#[gpui::test]
async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log));
let event_stream = TestToolCallEventStream::new();
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/multiline.txt".to_string(),
start_line: Some(2),
end_line: Some(4),
};
tool.run(input, event_stream.stream(), cx)
})
.await;
assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4");
}
#[gpui::test]
async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log));
let event_stream = TestToolCallEventStream::new();
// start_line of 0 should be treated as 1
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/multiline.txt".to_string(),
start_line: Some(0),
end_line: Some(2),
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert_eq!(result.unwrap(), "Line 1\nLine 2");
// end_line of 0 should result in at least 1 line
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/multiline.txt".to_string(),
start_line: Some(1),
end_line: Some(0),
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert_eq!(result.unwrap(), "Line 1");
// when start_line > end_line, should still return at least 1 line
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/multiline.txt".to_string(),
start_line: Some(3),
end_line: Some(2),
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert_eq!(result.unwrap(), "Line 3");
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
Project::init_settings(cx);
});
}
fn rust_lang() -> Language {
Language::new(
LanguageConfig {
name: "Rust".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["rs".to_string()],
..Default::default()
},
..Default::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
)
.with_outline_query(
r#"
(line_comment) @annotation
(struct_item
"struct" @context
name: (_) @name) @item
(enum_item
"enum" @context
name: (_) @name) @item
(enum_variant
name: (_) @name) @item
(field_declaration
name: (_) @name) @item
(impl_item
"impl" @context
trait: (_)? @name
"for"? @context
type: (_) @name
body: (_ "{" (_)* "}")) @item
(function_item
"fn" @context
name: (_) @name) @item
(mod_item
"mod" @context
name: (_) @name) @item
"#,
)
.unwrap()
}
#[gpui::test]
async fn test_read_file_security(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/"),
json!({
"project_root": {
"allowed_file.txt": "This file is in the project",
".mysecrets": "SECRET_KEY=abc123",
".secretdir": {
"config": "special configuration"
},
".mymetadata": "custom metadata",
"subdir": {
"normal_file.txt": "Normal file content",
"special.privatekey": "private key content",
"data.mysensitive": "sensitive data"
}
},
"outside_project": {
"sensitive_file.txt": "This file is outside the project"
}
}),
)
.await;
cx.update(|cx| {
use gpui::UpdateGlobal;
use project::WorktreeSettings;
use settings::SettingsStore;
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<WorktreeSettings>(cx, |settings| {
settings.file_scan_exclusions = Some(vec![
"**/.secretdir".to_string(),
"**/.mymetadata".to_string(),
]);
settings.private_files = Some(vec![
"**/.mysecrets".to_string(),
"**/*.privatekey".to_string(),
"**/*.mysensitive".to_string(),
]);
});
});
});
let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log));
let event_stream = TestToolCallEventStream::new();
// Reading a file outside the project worktree should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "/outside_project/sensitive_file.txt".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read an absolute path outside a worktree"
);
// Reading a file within the project should succeed
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/allowed_file.txt".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_ok(),
"read_file_tool should be able to read files inside worktrees"
);
// Reading files that match file_scan_exclusions should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/.secretdir/config".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
);
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/.mymetadata".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
);
// Reading private files should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/.mysecrets".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read .mysecrets (private_files)"
);
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/subdir/special.privatekey".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read .privatekey files (private_files)"
);
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/subdir/data.mysensitive".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read .mysensitive files (private_files)"
);
// Reading a normal file should still work, even with private_files configured
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/subdir/normal_file.txt".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(result.is_ok(), "Should be able to read normal files");
assert_eq!(result.unwrap(), "Normal file content");
// Path traversal attempts with .. should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/../outside_project/sensitive_file.txt".to_string(),
start_line: None,
end_line: None,
};
tool.run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
);
}
#[gpui::test]
async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
// Create first worktree with its own private_files setting
fs.insert_tree(
path!("/worktree1"),
json!({
"src": {
"main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
"secret.rs": "const API_KEY: &str = \"secret_key_1\";",
"config.toml": "[database]\nurl = \"postgres://localhost/db1\""
},
"tests": {
"test.rs": "mod tests { fn test_it() {} }",
"fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
},
".zed": {
"settings.json": r#"{
"file_scan_exclusions": ["**/fixture.*"],
"private_files": ["**/secret.rs", "**/config.toml"]
}"#
}
}),
)
.await;
// Create second worktree with different private_files setting
fs.insert_tree(
path!("/worktree2"),
json!({
"lib": {
"public.js": "export function greet() { return 'Hello from worktree2'; }",
"private.js": "const SECRET_TOKEN = \"private_token_2\";",
"data.json": "{\"api_key\": \"json_secret_key\"}"
},
"docs": {
"README.md": "# Public Documentation",
"internal.md": "# Internal Secrets and Configuration"
},
".zed": {
"settings.json": r#"{
"file_scan_exclusions": ["**/internal.*"],
"private_files": ["**/private.js", "**/data.json"]
}"#
}
}),
)
.await;
// Set global settings
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<WorktreeSettings>(cx, |settings| {
settings.file_scan_exclusions =
Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
settings.private_files = Some(vec!["**/.env".to_string()]);
});
});
});
let project = Project::test(
fs.clone(),
[path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
cx,
)
.await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone()));
let event_stream = TestToolCallEventStream::new();
// Test reading allowed files in worktree1
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree1/src/main.rs".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await
.unwrap();
assert_eq!(result, "fn main() { println!(\"Hello from worktree1\"); }");
// Test reading private file in worktree1 should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree1/src/secret.rs".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("worktree `private_files` setting"),
"Error should mention worktree private_files setting"
);
// Test reading excluded file in worktree1 should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree1/tests/fixture.sql".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("worktree `file_scan_exclusions` setting"),
"Error should mention worktree file_scan_exclusions setting"
);
// Test reading allowed files in worktree2
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree2/lib/public.js".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await
.unwrap();
assert_eq!(
result,
"export function greet() { return 'Hello from worktree2'; }"
);
// Test reading private file in worktree2 should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree2/lib/private.js".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("worktree `private_files` setting"),
"Error should mention worktree private_files setting"
);
// Test reading excluded file in worktree2 should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree2/docs/internal.md".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("worktree `file_scan_exclusions` setting"),
"Error should mention worktree file_scan_exclusions setting"
);
// Test that files allowed in one worktree but not in another are handled correctly
// (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree1/src/config.toml".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("worktree `private_files` setting"),
"Config.toml should be blocked by worktree1's private_files setting"
);
}
}

View File

@@ -0,0 +1,48 @@
use agent_client_protocol as acp;
use anyhow::Result;
use gpui::{App, SharedString, Task};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::{AgentTool, ToolCallEventStream};
/// A tool for thinking through problems, brainstorming ideas, or planning without executing any actions.
/// Use this tool when you need to work through complex problems, develop strategies, or outline approaches before taking action.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct ThinkingToolInput {
/// Content to think about. This should be a description of what to think about or
/// a problem to solve.
content: String,
}
pub struct ThinkingTool;
impl AgentTool for ThinkingTool {
type Input = ThinkingToolInput;
fn name(&self) -> SharedString {
"thinking".into()
}
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Think
}
fn initial_title(&self, _input: Self::Input) -> SharedString {
"Thinking".into()
}
fn run(
self: Arc<Self>,
input: Self::Input,
event_stream: ToolCallEventStream,
_cx: &mut App,
) -> Task<Result<String>> {
event_stream.send_update(acp::ToolCallUpdateFields {
content: Some(vec![input.content.into()]),
..Default::default()
});
Task::ready(Ok("Finished thinking.".to_string()))
}
}

View File

@@ -135,7 +135,7 @@ impl acp_old::Client for OldAcpClientDelegate {
let response = cx
.update(|cx| {
self.thread.borrow().update(cx, |thread, cx| {
thread.request_tool_call_permission(tool_call, acp_options, cx)
thread.request_tool_call_authorization(tool_call, acp_options, cx)
})
})?
.context("Failed to update thread")?
@@ -280,6 +280,7 @@ fn into_new_tool_call(id: acp::ToolCallId, request: acp_old::PushToolCallParams)
.map(into_new_tool_call_location)
.collect(),
raw_input: None,
raw_output: None,
}
}

View File

@@ -210,7 +210,7 @@ impl acp::Client for ClientDelegate {
.context("Failed to get session")?
.thread
.update(cx, |thread, cx| {
thread.request_tool_call_permission(arguments.tool_call, arguments.options, cx)
thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx)
})?;
let result = rx.await;

View File

@@ -893,10 +893,9 @@ pub(crate) mod tests {
#[cfg_attr(not(feature = "e2e"), ignore)]
async fn test_todo_plan(cx: &mut TestAppContext) {
let fs = e2e_tests::init_test(cx).await;
let tempdir = tempfile::tempdir().unwrap();
let project = Project::test(fs, [], cx).await;
let thread =
e2e_tests::new_test_thread(ClaudeCode, project.clone(), tempdir.path(), cx).await;
e2e_tests::new_test_thread(ClaudeCode, project.clone(), "/private/tmp", cx).await;
thread
.update(cx, |thread, cx| {
@@ -950,8 +949,6 @@ pub(crate) mod tests {
));
assert_eq!(thread.plan().entries.len(), entries_len);
});
drop(tempdir);
}
#[test]

View File

@@ -153,7 +153,7 @@ impl McpServerTool for PermissionTool {
let chosen_option = thread
.update(cx, |thread, cx| {
thread.request_tool_call_permission(
thread.request_tool_call_authorization(
claude_tool.as_acp(tool_call_id),
vec![
acp::PermissionOption {

View File

@@ -297,6 +297,7 @@ impl ClaudeTool {
content: self.content(),
locations: self.locations(),
raw_input: None,
raw_output: None,
}
}
}

View File

@@ -13,12 +13,12 @@ use gpui::{Entity, TestAppContext};
use indoc::indoc;
use project::{FakeFs, Project};
use settings::{Settings, SettingsStore};
use util::path;
pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
let fs = init_test(cx).await;
let tempdir = tempfile::tempdir().unwrap();
let project = Project::test(fs, [], cx).await;
let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
thread
.update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
@@ -40,8 +40,6 @@ pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppCont
AgentThreadEntry::AssistantMessage(_)
));
});
drop(tempdir);
}
pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
@@ -120,7 +118,7 @@ pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestApp
std::fs::write(&foo_path, "Lorem ipsum dolor").expect("failed to write file");
let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
thread
.update(cx, |thread, cx| {
@@ -158,9 +156,8 @@ pub async fn test_tool_call_with_permission(
cx: &mut TestAppContext,
) {
let fs = init_test(cx).await;
let tempdir = tempfile::tempdir().unwrap();
let project = Project::test(fs, [tempdir.path()], cx).await;
let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
let full_turn = thread.update(cx, |thread, cx| {
thread.send_raw(
r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#,
@@ -242,15 +239,13 @@ pub async fn test_tool_call_with_permission(
"Expected content to contain 'Hello'"
);
});
drop(tempdir);
}
pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
let fs = init_test(cx).await;
let tempdir = tempfile::tempdir().unwrap();
let project = Project::test(fs, [tempdir.path()], cx).await;
let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
let _ = thread.update(cx, |thread, cx| {
thread.send_raw(
r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#,
@@ -313,15 +308,12 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon
AgentThreadEntry::AssistantMessage(..),
))
});
drop(tempdir);
}
pub async fn test_thread_drop(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
let fs = init_test(cx).await;
let tempdir = tempfile::tempdir().unwrap();
let project = Project::test(fs, [], cx).await;
let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
thread
.update(cx, |thread, cx| thread.send_raw("Hello from test!", cx))
@@ -337,8 +329,6 @@ pub async fn test_thread_drop(server: impl AgentServer + 'static, cx: &mut TestA
cx.executor().run_until_parked();
assert!(!weak_thread.is_upgradable());
drop(tempdir);
}
#[macro_export]

View File

@@ -45,6 +45,11 @@ impl<T> MessageHistory<T> {
None
})
}
#[cfg(test)]
pub fn items(&self) -> &[T] {
&self.items
}
}
#[cfg(test)]
mod tests {

View File

@@ -31,7 +31,7 @@ use language::{Buffer, Language};
use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
use parking_lot::Mutex;
use project::Project;
use settings::Settings as _;
use settings::{Settings as _, SettingsStore};
use text::{Anchor, BufferSnapshot};
use theme::ThemeSettings;
use ui::{
@@ -80,6 +80,7 @@ pub struct AcpThreadView {
editor_expanded: bool,
message_history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
_cancel_task: Option<Task<()>>,
_subscriptions: [Subscription; 1],
}
enum ThreadState {
@@ -178,6 +179,8 @@ impl AcpThreadView {
let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
let subscription = cx.observe_global_in::<SettingsStore>(window, Self::settings_changed);
Self {
agent: agent.clone(),
workspace: workspace.clone(),
@@ -200,6 +203,7 @@ impl AcpThreadView {
plan_expanded: false,
editor_expanded: false,
message_history,
_subscriptions: [subscription],
_cancel_task: None,
}
}
@@ -377,6 +381,11 @@ impl AcpThreadView {
editor.display_map.update(cx, |map, cx| {
let snapshot = map.snapshot(cx);
for (crease_id, crease) in snapshot.crease_snapshot.creases() {
// Skip creases that have been edited out of the message buffer.
if !crease.range().start.is_valid(&snapshot.buffer_snapshot) {
continue;
}
if let Some(project_path) =
self.mention_set.lock().path_for_crease_id(crease_id)
{
@@ -704,15 +713,7 @@ impl AcpThreadView {
editor.set_show_code_actions(false, cx);
editor.set_show_git_diff_gutter(false, cx);
editor.set_expand_all_diff_hunks(cx);
editor.set_text_style_refinement(TextStyleRefinement {
font_size: Some(
TextSize::Small
.rems(cx)
.to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
.into(),
),
..Default::default()
});
editor.set_text_style_refinement(diff_editor_text_style_refinement(cx));
editor
});
let entity_id = multibuffer.entity_id();
@@ -2597,6 +2598,15 @@ impl AcpThreadView {
.cursor_default()
.children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
}
fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
for diff_editor in self.diff_editors.values() {
diff_editor.update(cx, |diff_editor, cx| {
diff_editor.set_text_style_refinement(diff_editor_text_style_refinement(cx));
cx.notify();
})
}
}
}
impl Focusable for AcpThreadView {
@@ -2874,6 +2884,18 @@ fn plan_label_markdown_style(
}
}
fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement {
TextStyleRefinement {
font_size: Some(
TextSize::Small
.rems(cx)
.to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
.into(),
),
..Default::default()
}
}
#[cfg(test)]
mod tests {
use agent_client_protocol::SessionId;
@@ -2881,8 +2903,12 @@ mod tests {
use fs::FakeFs;
use futures::future::try_join_all;
use gpui::{SemanticVersion, TestAppContext, VisualTestContext};
use lsp::{CompletionContext, CompletionTriggerKind};
use project::CompletionIntent;
use rand::Rng;
use serde_json::json;
use settings::SettingsStore;
use util::path;
use super::*;
@@ -2962,6 +2988,7 @@ mod tests {
content: vec!["hi".into()],
locations: vec![],
raw_input: None,
raw_output: None,
};
let connection = StubAgentConnection::new(vec![acp::SessionUpdate::ToolCall(tool_call)])
.with_permission_requests(HashMap::from_iter([(
@@ -2994,6 +3021,109 @@ mod tests {
);
}
#[gpui::test]
async fn test_crease_removal(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let agent = StubAgentServer::default();
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let thread_view = cx.update(|window, cx| {
cx.new(|cx| {
AcpThreadView::new(
Rc::new(agent),
workspace.downgrade(),
project,
Rc::new(RefCell::new(MessageHistory::default())),
1,
None,
window,
cx,
)
})
});
cx.run_until_parked();
let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
let excerpt_id = message_editor.update(cx, |editor, cx| {
editor
.buffer()
.read(cx)
.excerpt_ids()
.into_iter()
.next()
.unwrap()
});
let completions = message_editor.update_in(cx, |editor, window, cx| {
editor.set_text("Hello @", window, cx);
let buffer = editor.buffer().read(cx).as_singleton().unwrap();
let completion_provider = editor.completion_provider().unwrap();
completion_provider.completions(
excerpt_id,
&buffer,
Anchor::MAX,
CompletionContext {
trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: Some("@".into()),
},
window,
cx,
)
});
let [_, completion]: [_; 2] = completions
.await
.unwrap()
.into_iter()
.flat_map(|response| response.completions)
.collect::<Vec<_>>()
.try_into()
.unwrap();
message_editor.update_in(cx, |editor, window, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let start = snapshot
.anchor_in_excerpt(excerpt_id, completion.replace_range.start)
.unwrap();
let end = snapshot
.anchor_in_excerpt(excerpt_id, completion.replace_range.end)
.unwrap();
editor.edit([(start..end, completion.new_text)], cx);
(completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
});
cx.run_until_parked();
// Backspace over the inserted crease (and the following space).
message_editor.update_in(cx, |editor, window, cx| {
editor.backspace(&Default::default(), window, cx);
editor.backspace(&Default::default(), window, cx);
});
thread_view.update_in(cx, |thread_view, window, cx| {
thread_view.chat(&Chat, window, cx);
});
cx.run_until_parked();
let content = thread_view.update_in(cx, |thread_view, _window, _cx| {
thread_view
.message_history
.borrow()
.items()
.iter()
.flatten()
.cloned()
.collect::<Vec<_>>()
});
// We don't send a resource link for the deleted crease.
pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
}
async fn setup_thread_view(
agent: impl AgentServer + 'static,
cx: &mut TestAppContext,
@@ -3147,7 +3277,7 @@ mod tests {
let task = cx.spawn(async move |cx| {
if let Some((tool_call, options)) = permission_request {
let permission = thread.update(cx, |thread, cx| {
thread.request_tool_call_permission(
thread.request_tool_call_authorization(
tool_call.clone(),
options.clone(),
cx,

View File

@@ -2343,6 +2343,16 @@ impl AgentPanel {
)
}
fn render_backdrop(&self, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.absolute()
.inset_0()
.bg(cx.theme().colors().panel_background)
.opacity(0.8)
.block_mouse_except_scroll()
}
fn render_trial_end_upsell(
&self,
_window: &mut Window,
@@ -2352,15 +2362,24 @@ impl AgentPanel {
return None;
}
Some(EndTrialUpsell::new(Arc::new({
let this = cx.entity();
move |_, cx| {
this.update(cx, |_this, cx| {
TrialEndUpsell::set_dismissed(true, cx);
cx.notify();
});
}
})))
Some(
v_flex()
.absolute()
.inset_0()
.size_full()
.bg(cx.theme().colors().panel_background)
.opacity(0.85)
.block_mouse_except_scroll()
.child(EndTrialUpsell::new(Arc::new({
let this = cx.entity();
move |_, cx| {
this.update(cx, |_this, cx| {
TrialEndUpsell::set_dismissed(true, cx);
cx.notify();
});
}
}))),
)
}
fn render_empty_state_section_header(
@@ -3210,9 +3229,10 @@ impl Render for AgentPanel {
// - Scrolling in all views works as expected
// - Files can be dropped into the panel
let content = v_flex()
.key_context(self.key_context())
.justify_between()
.relative()
.size_full()
.justify_between()
.key_context(self.key_context())
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(|this, action: &NewThread, window, cx| {
this.new_thread(action, window, cx);
@@ -3255,14 +3275,12 @@ impl Render for AgentPanel {
.on_action(cx.listener(Self::toggle_burn_mode))
.child(self.render_toolbar(window, cx))
.children(self.render_onboarding(window, cx))
.children(self.render_trial_end_upsell(window, cx))
.map(|parent| match &self.active_view {
ActiveView::Thread {
thread,
message_editor,
..
} => parent
.relative()
.child(
if thread.read(cx).is_empty() && !self.should_render_onboarding(cx) {
self.render_thread_empty_state(window, cx)
@@ -3299,21 +3317,10 @@ impl Render for AgentPanel {
})
.child(h_flex().relative().child(message_editor.clone()).when(
!LanguageModelRegistry::read_global(cx).has_authenticated_provider(cx),
|this| {
this.child(
div()
.size_full()
.absolute()
.inset_0()
.bg(cx.theme().colors().panel_background)
.opacity(0.8)
.block_mouse_except_scroll(),
)
},
|this| this.child(self.render_backdrop(cx)),
))
.child(self.render_drag_target(cx)),
ActiveView::ExternalAgentThread { thread_view, .. } => parent
.relative()
.child(thread_view.clone())
.child(self.render_drag_target(cx)),
ActiveView::History => parent.child(self.history.clone()),
@@ -3352,7 +3359,8 @@ impl Render for AgentPanel {
))
}
ActiveView::Configuration => parent.children(self.configuration.clone()),
});
})
.children(self.render_trial_end_upsell(window, cx));
match self.active_view.which_font_size_used() {
WhichFontSize::AgentFont => {

View File

@@ -1,26 +1,33 @@
use std::{sync::Arc, time::Duration};
use client::{Client, zed_urls};
use client::{Client, UserStore, zed_urls};
use cloud_llm_client::Plan;
use gpui::{
Animation, AnimationExt, AnyElement, App, IntoElement, RenderOnce, Transformation, Window,
percentage,
Animation, AnimationExt, AnyElement, App, Entity, IntoElement, RenderOnce, Transformation,
Window, percentage,
};
use ui::{Divider, Vector, VectorName, prelude::*};
use crate::{SignInStatus, plan_definitions::PlanDefinitions};
use crate::{SignInStatus, YoungAccountBanner, plan_definitions::PlanDefinitions};
#[derive(IntoElement, RegisterComponent)]
pub struct AiUpsellCard {
pub sign_in_status: SignInStatus,
pub sign_in: Arc<dyn Fn(&mut Window, &mut App)>,
pub account_too_young: bool,
pub user_plan: Option<Plan>,
pub tab_index: Option<isize>,
}
impl AiUpsellCard {
pub fn new(client: Arc<Client>, user_plan: Option<Plan>) -> Self {
pub fn new(
client: Arc<Client>,
user_store: &Entity<UserStore>,
user_plan: Option<Plan>,
cx: &mut App,
) -> Self {
let status = *client.status().borrow();
let store = user_store.read(cx);
Self {
user_plan,
@@ -32,6 +39,7 @@ impl AiUpsellCard {
})
.detach_and_log_err(cx);
}),
account_too_young: store.account_too_young(),
tab_index: None,
}
}
@@ -40,6 +48,7 @@ impl AiUpsellCard {
impl RenderOnce for AiUpsellCard {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let plan_definitions = PlanDefinitions;
let young_account_banner = YoungAccountBanner;
let pro_section = v_flex()
.flex_grow()
@@ -128,7 +137,7 @@ impl RenderOnce for AiUpsellCard {
.size(rems_from_px(72.))
.child(
Vector::new(
VectorName::CertifiedUserStamp,
VectorName::ProUserStamp,
rems_from_px(72.),
rems_from_px(72.),
)
@@ -158,36 +167,70 @@ impl RenderOnce for AiUpsellCard {
SignInStatus::SignedIn => match self.user_plan {
None | Some(Plan::ZedFree) => card
.child(Label::new("Try Zed AI").size(LabelSize::Large))
.child(
div()
.max_w_3_4()
.mb_2()
.child(Label::new(description).color(Color::Muted)),
)
.child(plans_section)
.child(
footer_container
.child(
Button::new("start_trial", "Start 14-day Free Pro Trial")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.when_some(self.tab_index, |this, tab_index| {
this.tab_index(tab_index)
})
.on_click(move |_, _window, cx| {
telemetry::event!(
"Start Trial Clicked",
state = "post-sign-in"
);
cx.open_url(&zed_urls::start_trial_url(cx))
}),
.map(|this| {
if self.account_too_young {
this.child(young_account_banner).child(
v_flex()
.mt_2()
.gap_1()
.child(
h_flex()
.gap_2()
.child(
Label::new("Pro")
.size(LabelSize::Small)
.color(Color::Accent)
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(plan_definitions.pro_plan(true))
.child(
Button::new("pro", "Get Started")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(move |_, _window, cx| {
telemetry::event!(
"Upgrade To Pro Clicked",
state = "young-account"
);
cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
}),
),
)
} else {
this.child(
div()
.max_w_3_4()
.mb_2()
.child(Label::new(description).color(Color::Muted)),
)
.child(plans_section)
.child(
Label::new("No credit card required")
.size(LabelSize::Small)
.color(Color::Muted),
),
),
footer_container
.child(
Button::new("start_trial", "Start 14-day Free Pro Trial")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.when_some(self.tab_index, |this, tab_index| {
this.tab_index(tab_index)
})
.on_click(move |_, _window, cx| {
telemetry::event!(
"Start Trial Clicked",
state = "post-sign-in"
);
cx.open_url(&zed_urls::start_trial_url(cx))
}),
)
.child(
Label::new("No credit card required")
.size(LabelSize::Small)
.color(Color::Muted),
),
)
}
}),
Some(Plan::ZedProTrial) => card
.child(pro_trial_stamp)
.child(Label::new("You're in the Zed Pro Trial").size(LabelSize::Large))
@@ -255,48 +298,68 @@ impl Component for AiUpsellCard {
Some(
v_flex()
.gap_4()
.children(vec![example_group(vec![
single_example(
"Signed Out State",
AiUpsellCard {
sign_in_status: SignInStatus::SignedOut,
sign_in: Arc::new(|_, _| {}),
user_plan: None,
tab_index: Some(0),
}
.into_any_element(),
),
single_example(
"Free Plan",
AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
user_plan: Some(Plan::ZedFree),
tab_index: Some(1),
}
.into_any_element(),
),
single_example(
"Pro Trial",
AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
user_plan: Some(Plan::ZedProTrial),
tab_index: Some(1),
}
.into_any_element(),
),
single_example(
"Pro Plan",
AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
user_plan: Some(Plan::ZedPro),
tab_index: Some(1),
}
.into_any_element(),
),
])])
.items_center()
.max_w_4_5()
.child(single_example(
"Signed Out State",
AiUpsellCard {
sign_in_status: SignInStatus::SignedOut,
sign_in: Arc::new(|_, _| {}),
account_too_young: false,
user_plan: None,
tab_index: Some(0),
}
.into_any_element(),
))
.child(example_group_with_title(
"Signed In States",
vec![
single_example(
"Free Plan",
AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
account_too_young: false,
user_plan: Some(Plan::ZedFree),
tab_index: Some(1),
}
.into_any_element(),
),
single_example(
"Free Plan but Young Account",
AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
account_too_young: true,
user_plan: Some(Plan::ZedFree),
tab_index: Some(1),
}
.into_any_element(),
),
single_example(
"Pro Trial",
AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
account_too_young: false,
user_plan: Some(Plan::ZedProTrial),
tab_index: Some(1),
}
.into_any_element(),
),
single_example(
"Pro Plan",
AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
account_too_young: false,
user_plan: Some(Plan::ZedPro),
tab_index: Some(1),
}
.into_any_element(),
),
],
))
.into_any_element(),
)
}

View File

@@ -36,13 +36,12 @@ use crate::delete_path_tool::DeletePathTool;
use crate::diagnostics_tool::DiagnosticsTool;
use crate::edit_file_tool::EditFileTool;
use crate::fetch_tool::FetchTool;
use crate::find_path_tool::FindPathTool;
use crate::list_directory_tool::ListDirectoryTool;
use crate::now_tool::NowTool;
use crate::thinking_tool::ThinkingTool;
pub use edit_file_tool::{EditFileMode, EditFileToolInput};
pub use find_path_tool::FindPathToolInput;
pub use find_path_tool::*;
pub use grep_tool::{GrepTool, GrepToolInput};
pub use open_tool::OpenTool;
pub use project_notifications_tool::ProjectNotificationsTool;

View File

@@ -134,10 +134,15 @@ impl Settings for AutoUpdateSetting {
type FileContent = Option<AutoUpdateSettingContent>;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
let auto_update = [sources.server, sources.release_channel, sources.user]
.into_iter()
.find_map(|value| value.copied().flatten())
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
let auto_update = [
sources.server,
sources.release_channel,
sources.operating_system,
sources.user,
]
.into_iter()
.find_map(|value| value.copied().flatten())
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
Ok(Self(auto_update.0))
}

View File

@@ -16,6 +16,7 @@ use clock::SystemClock;
use cloud_api_client::CloudApiClient;
use cloud_api_client::websocket_protocol::MessageToClient;
use credentials_provider::CredentialsProvider;
use feature_flags::FeatureFlagAppExt as _;
use futures::{
AsyncReadExt, FutureExt, SinkExt, Stream, StreamExt, TryFutureExt as _, TryStreamExt,
channel::oneshot, future::BoxFuture,
@@ -192,6 +193,8 @@ pub fn init(client: &Arc<Client>, cx: &mut App) {
});
}
pub type MessageToClientHandler = Box<dyn Fn(&MessageToClient, &mut App) + Send + Sync + 'static>;
struct GlobalClient(Arc<Client>);
impl Global for GlobalClient {}
@@ -205,6 +208,7 @@ pub struct Client {
credentials_provider: ClientCredentialsProvider,
state: RwLock<ClientState>,
handler_set: parking_lot::Mutex<ProtoMessageHandlerSet>,
message_to_client_handlers: parking_lot::Mutex<Vec<MessageToClientHandler>>,
#[allow(clippy::type_complexity)]
#[cfg(any(test, feature = "test-support"))]
@@ -554,6 +558,7 @@ impl Client {
credentials_provider: ClientCredentialsProvider::new(cx),
state: Default::default(),
handler_set: Default::default(),
message_to_client_handlers: parking_lot::Mutex::new(Vec::new()),
#[cfg(any(test, feature = "test-support"))]
authenticate: Default::default(),
@@ -960,25 +965,51 @@ impl Client {
Ok(())
}
/// Performs a sign-in and also connects to Collab.
/// Performs a sign-in and also (optionally) connects to Collab.
///
/// This is called in places where we *don't* need to connect in the future. We will replace these calls with calls
/// to `sign_in` when we're ready to remove auto-connection to Collab.
/// Only Zed staff automatically connect to Collab.
pub async fn sign_in_with_optional_connect(
self: &Arc<Self>,
try_provider: bool,
cx: &AsyncApp,
) -> Result<()> {
let (is_staff_tx, is_staff_rx) = oneshot::channel::<bool>();
let mut is_staff_tx = Some(is_staff_tx);
cx.update(|cx| {
cx.on_flags_ready(move |state, _cx| {
if let Some(is_staff_tx) = is_staff_tx.take() {
is_staff_tx.send(state.is_staff).log_err();
}
})
.detach();
})
.log_err();
let credentials = self.sign_in(try_provider, cx).await?;
self.connect_to_cloud(cx).await.log_err();
let connect_result = match self.connect_with_credentials(credentials, cx).await {
ConnectionResult::Timeout => Err(anyhow!("connection timed out")),
ConnectionResult::ConnectionReset => Err(anyhow!("connection reset")),
ConnectionResult::Result(result) => result.context("client auth and connect"),
};
connect_result.log_err();
cx.update(move |cx| {
cx.spawn({
let client = self.clone();
async move |cx| {
let is_staff = is_staff_rx.await?;
if is_staff {
match client.connect_with_credentials(credentials, cx).await {
ConnectionResult::Timeout => Err(anyhow!("connection timed out")),
ConnectionResult::ConnectionReset => Err(anyhow!("connection reset")),
ConnectionResult::Result(result) => {
result.context("client auth and connect")
}
}
} else {
Ok(())
}
}
})
.detach_and_log_err(cx);
})
.log_err();
Ok(())
}
@@ -1651,10 +1682,22 @@ impl Client {
}
}
fn handle_message_to_client(self: &Arc<Client>, message: MessageToClient, _cx: &AsyncApp) {
match message {
MessageToClient::UserUpdated => {}
}
pub fn add_message_to_client_handler(
self: &Arc<Client>,
handler: impl Fn(&MessageToClient, &mut App) + Send + Sync + 'static,
) {
self.message_to_client_handlers
.lock()
.push(Box::new(handler));
}
fn handle_message_to_client(self: &Arc<Client>, message: MessageToClient, cx: &AsyncApp) {
cx.update(|cx| {
for handler in self.message_to_client_handlers.lock().iter() {
handler(&message, cx);
}
})
.ok();
}
pub fn telemetry(&self) -> &Arc<Telemetry> {

View File

@@ -1,6 +1,7 @@
use super::{Client, Status, TypedEnvelope, proto};
use anyhow::{Context as _, Result, anyhow};
use chrono::{DateTime, Utc};
use cloud_api_client::websocket_protocol::MessageToClient;
use cloud_api_client::{GetAuthenticatedUserResponse, PlanInfo};
use cloud_llm_client::{
EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME,
@@ -181,6 +182,12 @@ impl UserStore {
client.add_message_handler(cx.weak_entity(), Self::handle_update_invite_info),
client.add_message_handler(cx.weak_entity(), Self::handle_show_contacts),
];
client.add_message_to_client_handler({
let this = cx.weak_entity();
move |message, cx| Self::handle_message_to_client(this.clone(), message, cx)
});
Self {
users: Default::default(),
by_github_login: Default::default(),
@@ -813,6 +820,32 @@ impl UserStore {
cx.emit(Event::PrivateUserInfoUpdated);
}
fn handle_message_to_client(this: WeakEntity<Self>, message: &MessageToClient, cx: &App) {
cx.spawn(async move |cx| {
match message {
MessageToClient::UserUpdated => {
let cloud_client = cx
.update(|cx| {
this.read_with(cx, |this, _cx| {
this.client.upgrade().map(|client| client.cloud_client())
})
})??
.ok_or(anyhow::anyhow!("Failed to get Cloud client"))?;
let response = cloud_client.get_authenticated_user().await?;
cx.update(|cx| {
this.update(cx, |this, cx| {
this.update_authenticated_user(response, cx);
})
})??;
}
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
pub fn watch_current_user(&self) -> watch::Receiver<Option<Arc<User>>> {
self.current_user.clone()
}

View File

@@ -2,5 +2,6 @@ ZED_ENVIRONMENT=production
RUST_LOG=info
INVITE_LINK_PREFIX=https://zed.dev/invites/
AUTO_JOIN_CHANNEL_ID=283
DATABASE_MAX_CONNECTIONS=250
# Set DATABASE_MAX_CONNECTIONS max connections in the `deploy_collab.yml`:
# https://github.com/zed-industries/zed/blob/main/.github/workflows/deploy_collab.yml
LLM_DATABASE_MAX_CONNECTIONS=25

View File

@@ -699,7 +699,10 @@ impl Database {
language_server::Column::ProjectId,
language_server::Column::Id,
])
.update_column(language_server::Column::Name)
.update_columns([
language_server::Column::Name,
language_server::Column::Capabilities,
])
.to_owned(),
)
.exec(&*tx)

View File

@@ -41,9 +41,11 @@ use chrono::Utc;
use collections::{HashMap, HashSet};
pub use connection_pool::{ConnectionPool, ZedVersion};
use core::fmt::{self, Debug, Formatter};
use futures::TryFutureExt as _;
use reqwest_client::ReqwestClient;
use rpc::proto::{MultiLspQuery, split_repository_update};
use supermaven_api::{CreateExternalUserRequest, SupermavenAdminApi};
use tracing::Span;
use futures::{
FutureExt, SinkExt, StreamExt, TryStreamExt, channel::oneshot, future::BoxFuture,
@@ -94,8 +96,13 @@ const MAX_CONCURRENT_CONNECTIONS: usize = 512;
static CONCURRENT_CONNECTIONS: AtomicUsize = AtomicUsize::new(0);
const TOTAL_DURATION_MS: &str = "total_duration_ms";
const PROCESSING_DURATION_MS: &str = "processing_duration_ms";
const QUEUE_DURATION_MS: &str = "queue_duration_ms";
const HOST_WAITING_MS: &str = "host_waiting_ms";
type MessageHandler =
Box<dyn Send + Sync + Fn(Box<dyn AnyTypedEnvelope>, Session) -> BoxFuture<'static, ()>>;
Box<dyn Send + Sync + Fn(Box<dyn AnyTypedEnvelope>, Session, Span) -> BoxFuture<'static, ()>>;
pub struct ConnectionGuard;
@@ -163,6 +170,42 @@ impl Principal {
}
}
#[derive(Clone)]
struct MessageContext {
session: Session,
span: tracing::Span,
}
impl Deref for MessageContext {
type Target = Session;
fn deref(&self) -> &Self::Target {
&self.session
}
}
impl MessageContext {
pub fn forward_request<T: RequestMessage>(
&self,
receiver_id: ConnectionId,
request: T,
) -> impl Future<Output = anyhow::Result<T::Response>> {
let request_start_time = Instant::now();
let span = self.span.clone();
tracing::info!("start forwarding request");
self.peer
.forward_request(self.connection_id, receiver_id, request)
.inspect(move |_| {
span.record(
HOST_WAITING_MS,
request_start_time.elapsed().as_micros() as f64 / 1000.0,
);
})
.inspect_err(|_| tracing::error!("error forwarding request"))
.inspect_ok(|_| tracing::info!("finished forwarding request"))
}
}
#[derive(Clone)]
struct Session {
principal: Principal,
@@ -646,40 +689,37 @@ impl Server {
fn add_handler<F, Fut, M>(&mut self, handler: F) -> &mut Self
where
F: 'static + Send + Sync + Fn(TypedEnvelope<M>, Session) -> Fut,
F: 'static + Send + Sync + Fn(TypedEnvelope<M>, MessageContext) -> Fut,
Fut: 'static + Send + Future<Output = Result<()>>,
M: EnvelopedMessage,
{
let prev_handler = self.handlers.insert(
TypeId::of::<M>(),
Box::new(move |envelope, session| {
Box::new(move |envelope, session, span| {
let envelope = envelope.into_any().downcast::<TypedEnvelope<M>>().unwrap();
let received_at = envelope.received_at;
tracing::info!("message received");
let start_time = Instant::now();
let future = (handler)(*envelope, session);
let future = (handler)(
*envelope,
MessageContext {
session,
span: span.clone(),
},
);
async move {
let result = future.await;
let total_duration_ms = received_at.elapsed().as_micros() as f64 / 1000.0;
let processing_duration_ms = start_time.elapsed().as_micros() as f64 / 1000.0;
let queue_duration_ms = total_duration_ms - processing_duration_ms;
span.record(TOTAL_DURATION_MS, total_duration_ms);
span.record(PROCESSING_DURATION_MS, processing_duration_ms);
span.record(QUEUE_DURATION_MS, queue_duration_ms);
match result {
Err(error) => {
tracing::error!(
?error,
total_duration_ms,
processing_duration_ms,
queue_duration_ms,
"error handling message"
)
tracing::error!(?error, "error handling message")
}
Ok(()) => tracing::info!(
total_duration_ms,
processing_duration_ms,
queue_duration_ms,
"finished handling message"
),
Ok(()) => tracing::info!("finished handling message"),
}
}
.boxed()
@@ -693,7 +733,7 @@ impl Server {
fn add_message_handler<F, Fut, M>(&mut self, handler: F) -> &mut Self
where
F: 'static + Send + Sync + Fn(M, Session) -> Fut,
F: 'static + Send + Sync + Fn(M, MessageContext) -> Fut,
Fut: 'static + Send + Future<Output = Result<()>>,
M: EnvelopedMessage,
{
@@ -703,7 +743,7 @@ impl Server {
fn add_request_handler<F, Fut, M>(&mut self, handler: F) -> &mut Self
where
F: 'static + Send + Sync + Fn(M, Response<M>, Session) -> Fut,
F: 'static + Send + Sync + Fn(M, Response<M>, MessageContext) -> Fut,
Fut: Send + Future<Output = Result<()>>,
M: RequestMessage,
{
@@ -761,7 +801,8 @@ impl Server {
login=field::Empty,
impersonator=field::Empty,
user_agent=field::Empty,
geoip_country_code=field::Empty
geoip_country_code=field::Empty,
release_channel=field::Empty,
);
principal.update_span(&span);
if let Some(user_agent) = user_agent {
@@ -888,12 +929,16 @@ impl Server {
login=field::Empty,
impersonator=field::Empty,
multi_lsp_query_request=field::Empty,
{ TOTAL_DURATION_MS }=field::Empty,
{ PROCESSING_DURATION_MS }=field::Empty,
{ QUEUE_DURATION_MS }=field::Empty,
{ HOST_WAITING_MS }=field::Empty
);
principal.update_span(&span);
let span_enter = span.enter();
if let Some(handler) = this.handlers.get(&message.payload_type_id()) {
let is_background = message.is_background();
let handle_message = (handler)(message, session.clone());
let handle_message = (handler)(message, session.clone(), span.clone());
drop(span_enter);
let handle_message = async move {
@@ -1385,7 +1430,11 @@ async fn connection_lost(
}
/// Acknowledges a ping from a client, used to keep the connection alive.
async fn ping(_: proto::Ping, response: Response<proto::Ping>, _session: Session) -> Result<()> {
async fn ping(
_: proto::Ping,
response: Response<proto::Ping>,
_session: MessageContext,
) -> Result<()> {
response.send(proto::Ack {})?;
Ok(())
}
@@ -1394,7 +1443,7 @@ async fn ping(_: proto::Ping, response: Response<proto::Ping>, _session: Session
async fn create_room(
_request: proto::CreateRoom,
response: Response<proto::CreateRoom>,
session: Session,
session: MessageContext,
) -> Result<()> {
let livekit_room = nanoid::nanoid!(30);
@@ -1434,7 +1483,7 @@ async fn create_room(
async fn join_room(
request: proto::JoinRoom,
response: Response<proto::JoinRoom>,
session: Session,
session: MessageContext,
) -> Result<()> {
let room_id = RoomId::from_proto(request.id);
@@ -1501,7 +1550,7 @@ async fn join_room(
async fn rejoin_room(
request: proto::RejoinRoom,
response: Response<proto::RejoinRoom>,
session: Session,
session: MessageContext,
) -> Result<()> {
let room;
let channel;
@@ -1629,15 +1678,15 @@ fn notify_rejoined_projects(
}
// Stream this worktree's diagnostics.
for summary in worktree.diagnostic_summaries {
session.peer.send(
session.connection_id,
proto::UpdateDiagnosticSummary {
project_id: project.id.to_proto(),
worktree_id: worktree.id,
summary: Some(summary),
},
)?;
let mut worktree_diagnostics = worktree.diagnostic_summaries.into_iter();
if let Some(summary) = worktree_diagnostics.next() {
let message = proto::UpdateDiagnosticSummary {
project_id: project.id.to_proto(),
worktree_id: worktree.id,
summary: Some(summary),
more_summaries: worktree_diagnostics.collect(),
};
session.peer.send(session.connection_id, message)?;
}
for settings_file in worktree.settings_files {
@@ -1678,7 +1727,7 @@ fn notify_rejoined_projects(
async fn leave_room(
_: proto::LeaveRoom,
response: Response<proto::LeaveRoom>,
session: Session,
session: MessageContext,
) -> Result<()> {
leave_room_for_session(&session, session.connection_id).await?;
response.send(proto::Ack {})?;
@@ -1689,7 +1738,7 @@ async fn leave_room(
async fn set_room_participant_role(
request: proto::SetRoomParticipantRole,
response: Response<proto::SetRoomParticipantRole>,
session: Session,
session: MessageContext,
) -> Result<()> {
let user_id = UserId::from_proto(request.user_id);
let role = ChannelRole::from(request.role());
@@ -1737,7 +1786,7 @@ async fn set_room_participant_role(
async fn call(
request: proto::Call,
response: Response<proto::Call>,
session: Session,
session: MessageContext,
) -> Result<()> {
let room_id = RoomId::from_proto(request.room_id);
let calling_user_id = session.user_id();
@@ -1806,7 +1855,7 @@ async fn call(
async fn cancel_call(
request: proto::CancelCall,
response: Response<proto::CancelCall>,
session: Session,
session: MessageContext,
) -> Result<()> {
let called_user_id = UserId::from_proto(request.called_user_id);
let room_id = RoomId::from_proto(request.room_id);
@@ -1841,7 +1890,7 @@ async fn cancel_call(
}
/// Decline an incoming call.
async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<()> {
async fn decline_call(message: proto::DeclineCall, session: MessageContext) -> Result<()> {
let room_id = RoomId::from_proto(message.room_id);
{
let room = session
@@ -1876,7 +1925,7 @@ async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<(
async fn update_participant_location(
request: proto::UpdateParticipantLocation,
response: Response<proto::UpdateParticipantLocation>,
session: Session,
session: MessageContext,
) -> Result<()> {
let room_id = RoomId::from_proto(request.room_id);
let location = request.location.context("invalid location")?;
@@ -1895,7 +1944,7 @@ async fn update_participant_location(
async fn share_project(
request: proto::ShareProject,
response: Response<proto::ShareProject>,
session: Session,
session: MessageContext,
) -> Result<()> {
let (project_id, room) = &*session
.db()
@@ -1916,7 +1965,7 @@ async fn share_project(
}
/// Unshare a project from the room.
async fn unshare_project(message: proto::UnshareProject, session: Session) -> Result<()> {
async fn unshare_project(message: proto::UnshareProject, session: MessageContext) -> Result<()> {
let project_id = ProjectId::from_proto(message.project_id);
unshare_project_internal(project_id, session.connection_id, &session).await
}
@@ -1963,7 +2012,7 @@ async fn unshare_project_internal(
async fn join_project(
request: proto::JoinProject,
response: Response<proto::JoinProject>,
session: Session,
session: MessageContext,
) -> Result<()> {
let project_id = ProjectId::from_proto(request.project_id);
@@ -2059,15 +2108,15 @@ async fn join_project(
}
// Stream this worktree's diagnostics.
for summary in worktree.diagnostic_summaries {
session.peer.send(
session.connection_id,
proto::UpdateDiagnosticSummary {
project_id: project_id.to_proto(),
worktree_id: worktree.id,
summary: Some(summary),
},
)?;
let mut worktree_diagnostics = worktree.diagnostic_summaries.into_iter();
if let Some(summary) = worktree_diagnostics.next() {
let message = proto::UpdateDiagnosticSummary {
project_id: project.id.to_proto(),
worktree_id: worktree.id,
summary: Some(summary),
more_summaries: worktree_diagnostics.collect(),
};
session.peer.send(session.connection_id, message)?;
}
for settings_file in worktree.settings_files {
@@ -2110,7 +2159,7 @@ async fn join_project(
}
/// Leave someone elses shared project.
async fn leave_project(request: proto::LeaveProject, session: Session) -> Result<()> {
async fn leave_project(request: proto::LeaveProject, session: MessageContext) -> Result<()> {
let sender_id = session.connection_id;
let project_id = ProjectId::from_proto(request.project_id);
let db = session.db().await;
@@ -2133,7 +2182,7 @@ async fn leave_project(request: proto::LeaveProject, session: Session) -> Result
async fn update_project(
request: proto::UpdateProject,
response: Response<proto::UpdateProject>,
session: Session,
session: MessageContext,
) -> Result<()> {
let project_id = ProjectId::from_proto(request.project_id);
let (room, guest_connection_ids) = &*session
@@ -2162,7 +2211,7 @@ async fn update_project(
async fn update_worktree(
request: proto::UpdateWorktree,
response: Response<proto::UpdateWorktree>,
session: Session,
session: MessageContext,
) -> Result<()> {
let guest_connection_ids = session
.db()
@@ -2186,7 +2235,7 @@ async fn update_worktree(
async fn update_repository(
request: proto::UpdateRepository,
response: Response<proto::UpdateRepository>,
session: Session,
session: MessageContext,
) -> Result<()> {
let guest_connection_ids = session
.db()
@@ -2210,7 +2259,7 @@ async fn update_repository(
async fn remove_repository(
request: proto::RemoveRepository,
response: Response<proto::RemoveRepository>,
session: Session,
session: MessageContext,
) -> Result<()> {
let guest_connection_ids = session
.db()
@@ -2234,7 +2283,7 @@ async fn remove_repository(
/// Updates other participants with changes to the diagnostics
async fn update_diagnostic_summary(
message: proto::UpdateDiagnosticSummary,
session: Session,
session: MessageContext,
) -> Result<()> {
let guest_connection_ids = session
.db()
@@ -2258,7 +2307,7 @@ async fn update_diagnostic_summary(
/// Updates other participants with changes to the worktree settings
async fn update_worktree_settings(
message: proto::UpdateWorktreeSettings,
session: Session,
session: MessageContext,
) -> Result<()> {
let guest_connection_ids = session
.db()
@@ -2282,7 +2331,7 @@ async fn update_worktree_settings(
/// Notify other participants that a language server has started.
async fn start_language_server(
request: proto::StartLanguageServer,
session: Session,
session: MessageContext,
) -> Result<()> {
let guest_connection_ids = session
.db()
@@ -2305,7 +2354,7 @@ async fn start_language_server(
/// Notify other participants that a language server has changed.
async fn update_language_server(
request: proto::UpdateLanguageServer,
session: Session,
session: MessageContext,
) -> Result<()> {
let project_id = ProjectId::from_proto(request.project_id);
let db = session.db().await;
@@ -2338,7 +2387,7 @@ async fn update_language_server(
async fn forward_read_only_project_request<T>(
request: T,
response: Response<T>,
session: Session,
session: MessageContext,
) -> Result<()>
where
T: EntityMessage + RequestMessage,
@@ -2349,10 +2398,7 @@ where
.await
.host_for_read_only_project_request(project_id, session.connection_id)
.await?;
let payload = session
.peer
.forward_request(session.connection_id, host_connection_id, request)
.await?;
let payload = session.forward_request(host_connection_id, request).await?;
response.send(payload)?;
Ok(())
}
@@ -2362,7 +2408,7 @@ where
async fn forward_mutating_project_request<T>(
request: T,
response: Response<T>,
session: Session,
session: MessageContext,
) -> Result<()>
where
T: EntityMessage + RequestMessage,
@@ -2374,10 +2420,7 @@ where
.await
.host_for_mutating_project_request(project_id, session.connection_id)
.await?;
let payload = session
.peer
.forward_request(session.connection_id, host_connection_id, request)
.await?;
let payload = session.forward_request(host_connection_id, request).await?;
response.send(payload)?;
Ok(())
}
@@ -2385,7 +2428,7 @@ where
async fn multi_lsp_query(
request: MultiLspQuery,
response: Response<MultiLspQuery>,
session: Session,
session: MessageContext,
) -> Result<()> {
tracing::Span::current().record("multi_lsp_query_request", request.request_str());
tracing::info!("multi_lsp_query message received");
@@ -2395,7 +2438,7 @@ async fn multi_lsp_query(
/// Notify other participants that a new buffer has been created
async fn create_buffer_for_peer(
request: proto::CreateBufferForPeer,
session: Session,
session: MessageContext,
) -> Result<()> {
session
.db()
@@ -2417,7 +2460,7 @@ async fn create_buffer_for_peer(
async fn update_buffer(
request: proto::UpdateBuffer,
response: Response<proto::UpdateBuffer>,
session: Session,
session: MessageContext,
) -> Result<()> {
let project_id = ProjectId::from_proto(request.project_id);
let mut capability = Capability::ReadOnly;
@@ -2452,17 +2495,14 @@ async fn update_buffer(
};
if host != session.connection_id {
session
.peer
.forward_request(session.connection_id, host, request.clone())
.await?;
session.forward_request(host, request.clone()).await?;
}
response.send(proto::Ack {})?;
Ok(())
}
async fn update_context(message: proto::UpdateContext, session: Session) -> Result<()> {
async fn update_context(message: proto::UpdateContext, session: MessageContext) -> Result<()> {
let project_id = ProjectId::from_proto(message.project_id);
let operation = message.operation.as_ref().context("invalid operation")?;
@@ -2507,7 +2547,7 @@ async fn update_context(message: proto::UpdateContext, session: Session) -> Resu
/// Notify other participants that a project has been updated.
async fn broadcast_project_message_from_host<T: EntityMessage<Entity = ShareProject>>(
request: T,
session: Session,
session: MessageContext,
) -> Result<()> {
let project_id = ProjectId::from_proto(request.remote_entity_id());
let project_connection_ids = session
@@ -2532,7 +2572,7 @@ async fn broadcast_project_message_from_host<T: EntityMessage<Entity = ShareProj
async fn follow(
request: proto::Follow,
response: Response<proto::Follow>,
session: Session,
session: MessageContext,
) -> Result<()> {
let room_id = RoomId::from_proto(request.room_id);
let project_id = request.project_id.map(ProjectId::from_proto);
@@ -2545,10 +2585,7 @@ async fn follow(
.check_room_participants(room_id, leader_id, session.connection_id)
.await?;
let response_payload = session
.peer
.forward_request(session.connection_id, leader_id, request)
.await?;
let response_payload = session.forward_request(leader_id, request).await?;
response.send(response_payload)?;
if let Some(project_id) = project_id {
@@ -2564,7 +2601,7 @@ async fn follow(
}
/// Stop following another user in a call.
async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> {
async fn unfollow(request: proto::Unfollow, session: MessageContext) -> Result<()> {
let room_id = RoomId::from_proto(request.room_id);
let project_id = request.project_id.map(ProjectId::from_proto);
let leader_id = request.leader_id.context("invalid leader id")?.into();
@@ -2593,7 +2630,7 @@ async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> {
}
/// Notify everyone following you of your current location.
async fn update_followers(request: proto::UpdateFollowers, session: Session) -> Result<()> {
async fn update_followers(request: proto::UpdateFollowers, session: MessageContext) -> Result<()> {
let room_id = RoomId::from_proto(request.room_id);
let database = session.db.lock().await;
@@ -2628,7 +2665,7 @@ async fn update_followers(request: proto::UpdateFollowers, session: Session) ->
async fn get_users(
request: proto::GetUsers,
response: Response<proto::GetUsers>,
session: Session,
session: MessageContext,
) -> Result<()> {
let user_ids = request
.user_ids
@@ -2656,7 +2693,7 @@ async fn get_users(
async fn fuzzy_search_users(
request: proto::FuzzySearchUsers,
response: Response<proto::FuzzySearchUsers>,
session: Session,
session: MessageContext,
) -> Result<()> {
let query = request.query;
let users = match query.len() {
@@ -2688,7 +2725,7 @@ async fn fuzzy_search_users(
async fn request_contact(
request: proto::RequestContact,
response: Response<proto::RequestContact>,
session: Session,
session: MessageContext,
) -> Result<()> {
let requester_id = session.user_id();
let responder_id = UserId::from_proto(request.responder_id);
@@ -2735,7 +2772,7 @@ async fn request_contact(
async fn respond_to_contact_request(
request: proto::RespondToContactRequest,
response: Response<proto::RespondToContactRequest>,
session: Session,
session: MessageContext,
) -> Result<()> {
let responder_id = session.user_id();
let requester_id = UserId::from_proto(request.requester_id);
@@ -2793,7 +2830,7 @@ async fn respond_to_contact_request(
async fn remove_contact(
request: proto::RemoveContact,
response: Response<proto::RemoveContact>,
session: Session,
session: MessageContext,
) -> Result<()> {
let requester_id = session.user_id();
let responder_id = UserId::from_proto(request.user_id);
@@ -3052,7 +3089,10 @@ async fn update_user_plan(session: &Session) -> Result<()> {
Ok(())
}
async fn subscribe_to_channels(_: proto::SubscribeToChannels, session: Session) -> Result<()> {
async fn subscribe_to_channels(
_: proto::SubscribeToChannels,
session: MessageContext,
) -> Result<()> {
subscribe_user_to_channels(session.user_id(), &session).await?;
Ok(())
}
@@ -3078,7 +3118,7 @@ async fn subscribe_user_to_channels(user_id: UserId, session: &Session) -> Resul
async fn create_channel(
request: proto::CreateChannel,
response: Response<proto::CreateChannel>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
@@ -3133,7 +3173,7 @@ async fn create_channel(
async fn delete_channel(
request: proto::DeleteChannel,
response: Response<proto::DeleteChannel>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
@@ -3161,7 +3201,7 @@ async fn delete_channel(
async fn invite_channel_member(
request: proto::InviteChannelMember,
response: Response<proto::InviteChannelMember>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
@@ -3198,7 +3238,7 @@ async fn invite_channel_member(
async fn remove_channel_member(
request: proto::RemoveChannelMember,
response: Response<proto::RemoveChannelMember>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
@@ -3242,7 +3282,7 @@ async fn remove_channel_member(
async fn set_channel_visibility(
request: proto::SetChannelVisibility,
response: Response<proto::SetChannelVisibility>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
@@ -3287,7 +3327,7 @@ async fn set_channel_visibility(
async fn set_channel_member_role(
request: proto::SetChannelMemberRole,
response: Response<proto::SetChannelMemberRole>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
@@ -3335,7 +3375,7 @@ async fn set_channel_member_role(
async fn rename_channel(
request: proto::RenameChannel,
response: Response<proto::RenameChannel>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
@@ -3367,7 +3407,7 @@ async fn rename_channel(
async fn move_channel(
request: proto::MoveChannel,
response: Response<proto::MoveChannel>,
session: Session,
session: MessageContext,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
let to = ChannelId::from_proto(request.to);
@@ -3409,7 +3449,7 @@ async fn move_channel(
async fn reorder_channel(
request: proto::ReorderChannel,
response: Response<proto::ReorderChannel>,
session: Session,
session: MessageContext,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
let direction = request.direction();
@@ -3455,7 +3495,7 @@ async fn reorder_channel(
async fn get_channel_members(
request: proto::GetChannelMembers,
response: Response<proto::GetChannelMembers>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
@@ -3475,7 +3515,7 @@ async fn get_channel_members(
async fn respond_to_channel_invite(
request: proto::RespondToChannelInvite,
response: Response<proto::RespondToChannelInvite>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
@@ -3516,7 +3556,7 @@ async fn respond_to_channel_invite(
async fn join_channel(
request: proto::JoinChannel,
response: Response<proto::JoinChannel>,
session: Session,
session: MessageContext,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
join_channel_internal(channel_id, Box::new(response), session).await
@@ -3539,7 +3579,7 @@ impl JoinChannelInternalResponse for Response<proto::JoinRoom> {
async fn join_channel_internal(
channel_id: ChannelId,
response: Box<impl JoinChannelInternalResponse>,
session: Session,
session: MessageContext,
) -> Result<()> {
let joined_room = {
let mut db = session.db().await;
@@ -3634,7 +3674,7 @@ async fn join_channel_internal(
async fn join_channel_buffer(
request: proto::JoinChannelBuffer,
response: Response<proto::JoinChannelBuffer>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
@@ -3665,7 +3705,7 @@ async fn join_channel_buffer(
/// Edit the channel notes
async fn update_channel_buffer(
request: proto::UpdateChannelBuffer,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
@@ -3717,7 +3757,7 @@ async fn update_channel_buffer(
async fn rejoin_channel_buffers(
request: proto::RejoinChannelBuffers,
response: Response<proto::RejoinChannelBuffers>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
let buffers = db
@@ -3752,7 +3792,7 @@ async fn rejoin_channel_buffers(
async fn leave_channel_buffer(
request: proto::LeaveChannelBuffer,
response: Response<proto::LeaveChannelBuffer>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
@@ -3814,7 +3854,7 @@ fn send_notifications(
async fn send_channel_message(
request: proto::SendChannelMessage,
response: Response<proto::SendChannelMessage>,
session: Session,
session: MessageContext,
) -> Result<()> {
// Validate the message body.
let body = request.body.trim().to_string();
@@ -3907,7 +3947,7 @@ async fn send_channel_message(
async fn remove_channel_message(
request: proto::RemoveChannelMessage,
response: Response<proto::RemoveChannelMessage>,
session: Session,
session: MessageContext,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
let message_id = MessageId::from_proto(request.message_id);
@@ -3942,7 +3982,7 @@ async fn remove_channel_message(
async fn update_channel_message(
request: proto::UpdateChannelMessage,
response: Response<proto::UpdateChannelMessage>,
session: Session,
session: MessageContext,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
let message_id = MessageId::from_proto(request.message_id);
@@ -4026,7 +4066,7 @@ async fn update_channel_message(
/// Mark a channel message as read
async fn acknowledge_channel_message(
request: proto::AckChannelMessage,
session: Session,
session: MessageContext,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
let message_id = MessageId::from_proto(request.message_id);
@@ -4046,7 +4086,7 @@ async fn acknowledge_channel_message(
/// Mark a buffer version as synced
async fn acknowledge_buffer_version(
request: proto::AckBufferOperation,
session: Session,
session: MessageContext,
) -> Result<()> {
let buffer_id = BufferId::from_proto(request.buffer_id);
session
@@ -4066,7 +4106,7 @@ async fn acknowledge_buffer_version(
async fn get_supermaven_api_key(
_request: proto::GetSupermavenApiKey,
response: Response<proto::GetSupermavenApiKey>,
session: Session,
session: MessageContext,
) -> Result<()> {
let user_id: String = session.user_id().to_string();
if !session.is_staff() {
@@ -4095,7 +4135,7 @@ async fn get_supermaven_api_key(
async fn join_channel_chat(
request: proto::JoinChannelChat,
response: Response<proto::JoinChannelChat>,
session: Session,
session: MessageContext,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
@@ -4113,7 +4153,10 @@ async fn join_channel_chat(
}
/// Stop receiving chat updates for a channel
async fn leave_channel_chat(request: proto::LeaveChannelChat, session: Session) -> Result<()> {
async fn leave_channel_chat(
request: proto::LeaveChannelChat,
session: MessageContext,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
session
.db()
@@ -4127,7 +4170,7 @@ async fn leave_channel_chat(request: proto::LeaveChannelChat, session: Session)
async fn get_channel_messages(
request: proto::GetChannelMessages,
response: Response<proto::GetChannelMessages>,
session: Session,
session: MessageContext,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
let messages = session
@@ -4151,7 +4194,7 @@ async fn get_channel_messages(
async fn get_channel_messages_by_id(
request: proto::GetChannelMessagesById,
response: Response<proto::GetChannelMessagesById>,
session: Session,
session: MessageContext,
) -> Result<()> {
let message_ids = request
.message_ids
@@ -4174,7 +4217,7 @@ async fn get_channel_messages_by_id(
async fn get_notifications(
request: proto::GetNotifications,
response: Response<proto::GetNotifications>,
session: Session,
session: MessageContext,
) -> Result<()> {
let notifications = session
.db()
@@ -4196,7 +4239,7 @@ async fn get_notifications(
async fn mark_notification_as_read(
request: proto::MarkNotificationRead,
response: Response<proto::MarkNotificationRead>,
session: Session,
session: MessageContext,
) -> Result<()> {
let database = &session.db().await;
let notifications = database
@@ -4218,7 +4261,7 @@ async fn mark_notification_as_read(
async fn get_private_user_info(
_request: proto::GetPrivateUserInfo,
response: Response<proto::GetPrivateUserInfo>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
@@ -4242,7 +4285,7 @@ async fn get_private_user_info(
async fn accept_terms_of_service(
_request: proto::AcceptTermsOfService,
response: Response<proto::AcceptTermsOfService>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;
@@ -4266,7 +4309,7 @@ async fn accept_terms_of_service(
async fn get_llm_api_token(
_request: proto::GetLlmToken,
response: Response<proto::GetLlmToken>,
session: Session,
session: MessageContext,
) -> Result<()> {
let db = session.db().await;

View File

@@ -3101,9 +3101,7 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
// Turn inline-blame-off by default so no state is transferred without us explicitly doing so
let inline_blame_off_settings = Some(InlineBlameSettings {
enabled: false,
delay_ms: None,
min_column: None,
show_commit_summary: false,
..Default::default()
});
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {

View File

@@ -3053,7 +3053,7 @@ impl Render for CollabPanel {
.on_action(cx.listener(CollabPanel::move_channel_down))
.track_focus(&self.focus_handle)
.size_full()
.child(if self.user_store.read(cx).current_user().is_none() {
.child(if !self.client.status().borrow().is_connected() {
self.render_signed_out(cx)
} else {
self.render_signed_in(window, cx)

View File

@@ -58,11 +58,19 @@ impl EditPredictionProvider for CopilotCompletionProvider {
}
fn show_completions_in_menu() -> bool {
true
}
fn show_tab_accept_marker() -> bool {
true
}
fn supports_jump_to_edit() -> bool {
false
}
fn is_refreshing(&self) -> bool {
self.pending_refresh.is_some()
self.pending_refresh.is_some() && self.completions.is_empty()
}
fn is_enabled(
@@ -343,8 +351,8 @@ mod tests {
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
assert!(editor.context_menu_visible());
assert!(!editor.has_active_edit_prediction());
// Since we have both, the copilot suggestion is not shown inline
assert!(editor.has_active_edit_prediction());
// Since we have both, the copilot suggestion is existing but does not show up as ghost text
assert_eq!(editor.text(cx), "one.\ntwo\nthree\n");
assert_eq!(editor.display_text(cx), "one.\ntwo\nthree\n");
@@ -934,8 +942,9 @@ mod tests {
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
assert!(editor.context_menu_visible());
assert!(!editor.has_active_edit_prediction(),);
assert!(editor.has_active_edit_prediction());
assert_eq!(editor.text(cx), "one\ntwo.\nthree\n");
assert_eq!(editor.display_text(cx), "one\ntwo.\nthree\n");
});
}
@@ -1077,8 +1086,6 @@ mod tests {
vec![complete_from_marker.clone(), replace_range_marker.clone()],
);
let complete_from_position =
cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start);
let replace_range =
cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone());
@@ -1087,10 +1094,6 @@ mod tests {
let completions = completions.clone();
async move {
assert_eq!(params.text_document_position.text_document.uri, url.clone());
assert_eq!(
params.text_document_position.position,
complete_from_position
);
Ok(Some(lsp::CompletionResponse::Array(
completions
.iter()

View File

@@ -177,9 +177,9 @@ impl ProjectDiagnosticsEditor {
}
project::Event::DiagnosticsUpdated {
language_server_id,
path,
paths,
} => {
this.paths_to_update.insert(path.clone());
this.paths_to_update.extend(paths.clone());
let project = project.clone();
this.diagnostic_summary_update = cx.spawn(async move |this, cx| {
cx.background_executor()
@@ -193,9 +193,9 @@ impl ProjectDiagnosticsEditor {
cx.emit(EditorEvent::TitleChanged);
if this.editor.focus_handle(cx).contains_focused(window, cx) || this.focus_handle.contains_focused(window, cx) {
log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change");
log::debug!("diagnostics updated for server {language_server_id}, paths {paths:?}. recording change");
} else {
log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts");
log::debug!("diagnostics updated for server {language_server_id}, paths {paths:?}. updating excerpts");
this.update_stale_excerpts(window, cx);
}
}

View File

@@ -876,7 +876,7 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S
vec![Inlay::edit_prediction(
post_inc(&mut next_inlay_id),
snapshot.buffer_snapshot.anchor_before(position),
format!("Test inlay {next_inlay_id}"),
Rope::from_iter(["Test inlay ", "next_inlay_id"]),
)],
cx,
);

View File

@@ -61,6 +61,10 @@ pub trait EditPredictionProvider: 'static + Sized {
fn show_tab_accept_marker() -> bool {
false
}
fn supports_jump_to_edit() -> bool {
true
}
fn data_collection_state(&self, _cx: &App) -> DataCollectionState {
DataCollectionState::Unsupported
}
@@ -116,6 +120,7 @@ pub trait EditPredictionProviderHandle {
) -> bool;
fn show_completions_in_menu(&self) -> bool;
fn show_tab_accept_marker(&self) -> bool;
fn supports_jump_to_edit(&self) -> bool;
fn data_collection_state(&self, cx: &App) -> DataCollectionState;
fn usage(&self, cx: &App) -> Option<EditPredictionUsage>;
fn toggle_data_collection(&self, cx: &mut App);
@@ -166,6 +171,10 @@ where
T::show_tab_accept_marker()
}
fn supports_jump_to_edit(&self) -> bool {
T::supports_jump_to_edit()
}
fn data_collection_state(&self, cx: &App) -> DataCollectionState {
self.read(cx).data_collection_state(cx)
}

View File

@@ -491,7 +491,12 @@ impl EditPredictionButton {
let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
if matches!(provider, EditPredictionProvider::Zed) {
if matches!(
provider,
EditPredictionProvider::Zed
| EditPredictionProvider::Copilot
| EditPredictionProvider::Supermaven
) {
menu = menu
.separator()
.header("Display Modes")

View File

@@ -745,5 +745,6 @@ actions!(
UniqueLinesCaseInsensitive,
/// Removes duplicate lines (case-sensitive).
UniqueLinesCaseSensitive,
UnwrapSyntaxNode
]
);

View File

@@ -48,16 +48,16 @@ pub struct Inlay {
impl Inlay {
pub fn hint(id: usize, position: Anchor, hint: &project::InlayHint) -> Self {
let mut text = hint.text();
if hint.padding_right && !text.ends_with(' ') {
text.push(' ');
if hint.padding_right && text.chars_at(text.len().saturating_sub(1)).next() != Some(' ') {
text.push(" ");
}
if hint.padding_left && !text.starts_with(' ') {
text.insert(0, ' ');
if hint.padding_left && text.chars_at(0).next() != Some(' ') {
text.push_front(" ");
}
Self {
id: InlayId::Hint(id),
position,
text: text.into(),
text,
color: None,
}
}
@@ -737,13 +737,13 @@ impl InlayMap {
Inlay::mock_hint(
post_inc(next_inlay_id),
snapshot.buffer.anchor_at(position, bias),
text.clone(),
&text,
)
} else {
Inlay::edit_prediction(
post_inc(next_inlay_id),
snapshot.buffer.anchor_at(position, bias),
text.clone(),
&text,
)
};
let inlay_id = next_inlay.id;
@@ -1694,7 +1694,7 @@ mod tests {
(offset, inlay.clone())
})
.collect::<Vec<_>>();
let mut expected_text = Rope::from(buffer_snapshot.text());
let mut expected_text = Rope::from(&buffer_snapshot.text());
for (offset, inlay) in inlays.iter().rev() {
expected_text.replace(*offset..*offset, &inlay.text.to_string());
}

View File

@@ -228,6 +228,49 @@ async fn test_edit_prediction_invalidation_range(cx: &mut gpui::TestAppContext)
});
}
#[gpui::test]
async fn test_edit_prediction_jump_disabled_for_non_zed_providers(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let provider = cx.new(|_| FakeNonZedEditPredictionProvider::default());
assign_editor_completion_provider_non_zed(provider.clone(), &mut cx);
// Cursor is 2+ lines above the proposed edit
cx.set_state(indoc! {"
line 0
line ˇ1
line 2
line 3
line
"});
propose_edits_non_zed(
&provider,
vec![(Point::new(4, 3)..Point::new(4, 3), " 4")],
&mut cx,
);
cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx));
// For non-Zed providers, there should be no move completion (jump functionality disabled)
cx.editor(|editor, _, _| {
if let Some(completion_state) = &editor.active_edit_prediction {
// Should be an Edit prediction, not a Move prediction
match &completion_state.completion {
EditPrediction::Edit { .. } => {
// This is expected for non-Zed providers
}
EditPrediction::Move { .. } => {
panic!(
"Non-Zed providers should not show Move predictions (jump functionality)"
);
}
}
}
});
}
fn assert_editor_active_edit_completion(
cx: &mut EditorTestContext,
assert: impl FnOnce(MultiBufferSnapshot, &Vec<(Range<Anchor>, String)>),
@@ -301,6 +344,37 @@ fn assign_editor_completion_provider(
})
}
fn propose_edits_non_zed<T: ToOffset>(
provider: &Entity<FakeNonZedEditPredictionProvider>,
edits: Vec<(Range<T>, &str)>,
cx: &mut EditorTestContext,
) {
let snapshot = cx.buffer_snapshot();
let edits = edits.into_iter().map(|(range, text)| {
let range = snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end);
(range, text.into())
});
cx.update(|_, cx| {
provider.update(cx, |provider, _| {
provider.set_edit_prediction(Some(edit_prediction::EditPrediction {
id: None,
edits: edits.collect(),
edit_preview: None,
}))
})
});
}
fn assign_editor_completion_provider_non_zed(
provider: Entity<FakeNonZedEditPredictionProvider>,
cx: &mut EditorTestContext,
) {
cx.update_editor(|editor, window, cx| {
editor.set_edit_prediction_provider(Some(provider), window, cx);
})
}
#[derive(Default, Clone)]
pub struct FakeEditPredictionProvider {
pub completion: Option<edit_prediction::EditPrediction>,
@@ -325,6 +399,84 @@ impl EditPredictionProvider for FakeEditPredictionProvider {
false
}
fn supports_jump_to_edit() -> bool {
true
}
fn is_enabled(
&self,
_buffer: &gpui::Entity<language::Buffer>,
_cursor_position: language::Anchor,
_cx: &gpui::App,
) -> bool {
true
}
fn is_refreshing(&self) -> bool {
false
}
fn refresh(
&mut self,
_project: Option<Entity<Project>>,
_buffer: gpui::Entity<language::Buffer>,
_cursor_position: language::Anchor,
_debounce: bool,
_cx: &mut gpui::Context<Self>,
) {
}
fn cycle(
&mut self,
_buffer: gpui::Entity<language::Buffer>,
_cursor_position: language::Anchor,
_direction: edit_prediction::Direction,
_cx: &mut gpui::Context<Self>,
) {
}
fn accept(&mut self, _cx: &mut gpui::Context<Self>) {}
fn discard(&mut self, _cx: &mut gpui::Context<Self>) {}
fn suggest<'a>(
&mut self,
_buffer: &gpui::Entity<language::Buffer>,
_cursor_position: language::Anchor,
_cx: &mut gpui::Context<Self>,
) -> Option<edit_prediction::EditPrediction> {
self.completion.clone()
}
}
#[derive(Default, Clone)]
pub struct FakeNonZedEditPredictionProvider {
pub completion: Option<edit_prediction::EditPrediction>,
}
impl FakeNonZedEditPredictionProvider {
pub fn set_edit_prediction(&mut self, completion: Option<edit_prediction::EditPrediction>) {
self.completion = completion;
}
}
impl EditPredictionProvider for FakeNonZedEditPredictionProvider {
fn name() -> &'static str {
"fake-non-zed-provider"
}
fn display_name() -> &'static str {
"Fake Non-Zed Provider"
}
fn show_completions_in_menu() -> bool {
false
}
fn supports_jump_to_edit() -> bool {
false
}
fn is_enabled(
&self,
_buffer: &gpui::Entity<language::Buffer>,

View File

@@ -2705,6 +2705,11 @@ impl Editor {
self.completion_provider = provider;
}
#[cfg(any(test, feature = "test-support"))]
pub fn completion_provider(&self) -> Option<Rc<dyn CompletionProvider>> {
self.completion_provider.clone()
}
pub fn semantics_provider(&self) -> Option<Rc<dyn SemanticsProvider>> {
self.semantics_provider.clone()
}
@@ -7760,8 +7765,14 @@ impl Editor {
} else {
None
};
let is_move =
move_invalidation_row_range.is_some() || self.edit_predictions_hidden_for_vim_mode;
let supports_jump = self
.edit_prediction_provider
.as_ref()
.map(|provider| provider.provider.supports_jump_to_edit())
.unwrap_or(true);
let is_move = supports_jump
&& (move_invalidation_row_range.is_some() || self.edit_predictions_hidden_for_vim_mode);
let completion = if is_move {
invalidation_row_range =
move_invalidation_row_range.unwrap_or(edit_start_row..edit_end_row);
@@ -8799,8 +8810,12 @@ impl Editor {
return None;
}
let highlighted_edits =
crate::edit_prediction_edit_text(&snapshot, edits, edit_preview.as_ref()?, false, cx);
let highlighted_edits = if let Some(edit_preview) = edit_preview.as_ref() {
crate::edit_prediction_edit_text(&snapshot, edits, edit_preview, false, cx)
} else {
// Fallback for providers without edit_preview
crate::edit_prediction_fallback_text(edits, cx)
};
let styled_text = highlighted_edits.to_styled_text(&style.text);
let line_count = highlighted_edits.text.lines().count();
@@ -9068,6 +9083,18 @@ impl Editor {
let editor_bg_color = cx.theme().colors().editor_background;
editor_bg_color.blend(accent_color.opacity(0.6))
}
fn get_prediction_provider_icon_name(
provider: &Option<RegisteredEditPredictionProvider>,
) -> IconName {
match provider {
Some(provider) => match provider.provider.name() {
"copilot" => IconName::Copilot,
"supermaven" => IconName::Supermaven,
_ => IconName::ZedPredict,
},
None => IconName::ZedPredict,
}
}
fn render_edit_prediction_cursor_popover(
&self,
@@ -9080,6 +9107,7 @@ impl Editor {
cx: &mut Context<Editor>,
) -> Option<AnyElement> {
let provider = self.edit_prediction_provider.as_ref()?;
let provider_icon = Self::get_prediction_provider_icon_name(&self.edit_prediction_provider);
if provider.provider.needs_terms_acceptance(cx) {
return Some(
@@ -9106,7 +9134,7 @@ impl Editor {
h_flex()
.flex_1()
.gap_2()
.child(Icon::new(IconName::ZedPredict))
.child(Icon::new(provider_icon))
.child(Label::new("Accept Terms of Service"))
.child(div().w_full())
.child(
@@ -9122,12 +9150,8 @@ impl Editor {
let is_refreshing = provider.provider.is_refreshing(cx);
fn pending_completion_container() -> Div {
h_flex()
.h_full()
.flex_1()
.gap_2()
.child(Icon::new(IconName::ZedPredict))
fn pending_completion_container(icon: IconName) -> Div {
h_flex().h_full().flex_1().gap_2().child(Icon::new(icon))
}
let completion = match &self.active_edit_prediction {
@@ -9157,7 +9181,7 @@ impl Editor {
Icon::new(IconName::ZedPredictUp)
}
}
EditPrediction::Edit { .. } => Icon::new(IconName::ZedPredict),
EditPrediction::Edit { .. } => Icon::new(provider_icon),
}))
.child(
h_flex()
@@ -9224,15 +9248,15 @@ impl Editor {
cx,
)?,
None => {
pending_completion_container().child(Label::new("...").size(LabelSize::Small))
}
None => pending_completion_container(provider_icon)
.child(Label::new("...").size(LabelSize::Small)),
},
None => pending_completion_container().child(Label::new("No Prediction")),
None => pending_completion_container(provider_icon)
.child(Label::new("...").size(LabelSize::Small)),
};
let completion = if is_refreshing {
let completion = if is_refreshing || self.active_edit_prediction.is_none() {
completion
.with_animation(
"loading-completion",
@@ -9332,23 +9356,35 @@ impl Editor {
.child(Icon::new(arrow).color(Color::Muted).size(IconSize::Small))
}
let supports_jump = self
.edit_prediction_provider
.as_ref()
.map(|provider| provider.provider.supports_jump_to_edit())
.unwrap_or(true);
match &completion.completion {
EditPrediction::Move {
target, snapshot, ..
} => Some(
h_flex()
.px_2()
.gap_2()
.flex_1()
.child(
if target.text_anchor.to_point(&snapshot).row > cursor_point.row {
Icon::new(IconName::ZedPredictDown)
} else {
Icon::new(IconName::ZedPredictUp)
},
)
.child(Label::new("Jump to Edit")),
),
} => {
if !supports_jump {
return None;
}
Some(
h_flex()
.px_2()
.gap_2()
.flex_1()
.child(
if target.text_anchor.to_point(&snapshot).row > cursor_point.row {
Icon::new(IconName::ZedPredictDown)
} else {
Icon::new(IconName::ZedPredictUp)
},
)
.child(Label::new("Jump to Edit")),
)
}
EditPrediction::Edit {
edits,
@@ -9358,14 +9394,13 @@ impl Editor {
} => {
let first_edit_row = edits.first()?.0.start.text_anchor.to_point(&snapshot).row;
let (highlighted_edits, has_more_lines) = crate::edit_prediction_edit_text(
&snapshot,
&edits,
edit_preview.as_ref()?,
true,
cx,
)
.first_line_preview();
let (highlighted_edits, has_more_lines) =
if let Some(edit_preview) = edit_preview.as_ref() {
crate::edit_prediction_edit_text(&snapshot, &edits, edit_preview, true, cx)
.first_line_preview()
} else {
crate::edit_prediction_fallback_text(&edits, cx).first_line_preview()
};
let styled_text = gpui::StyledText::new(highlighted_edits.text)
.with_default_highlights(&style.text, highlighted_edits.highlights);
@@ -9376,11 +9411,13 @@ impl Editor {
.child(styled_text)
.when(has_more_lines, |parent| parent.child(""));
let left = if first_edit_row != cursor_point.row {
let left = if supports_jump && first_edit_row != cursor_point.row {
render_relative_row_jump("", cursor_point.row, first_edit_row)
.into_any_element()
} else {
Icon::new(IconName::ZedPredict).into_any_element()
let icon_name =
Editor::get_prediction_provider_icon_name(&self.edit_prediction_provider);
Icon::new(icon_name).into_any_element()
};
Some(
@@ -14711,6 +14748,81 @@ impl Editor {
}
}
pub fn unwrap_syntax_node(
&mut self,
_: &UnwrapSyntaxNode,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
let buffer = self.buffer.read(cx).snapshot(cx);
let old_selections: Box<[_]> = self.selections.all::<usize>(cx).into();
let edits = old_selections
.iter()
// only consider the first selection for now
.take(1)
.map(|selection| {
// Only requires two branches once if-let-chains stabilize (#53667)
let selection_range = if !selection.is_empty() {
selection.range()
} else if let Some((_, ancestor_range)) =
buffer.syntax_ancestor(selection.start..selection.end)
{
match ancestor_range {
MultiOrSingleBufferOffsetRange::Single(range) => range,
MultiOrSingleBufferOffsetRange::Multi(range) => range,
}
} else {
selection.range()
};
let mut new_range = selection_range.clone();
while let Some((_, ancestor_range)) = buffer.syntax_ancestor(new_range.clone()) {
new_range = match ancestor_range {
MultiOrSingleBufferOffsetRange::Single(range) => range,
MultiOrSingleBufferOffsetRange::Multi(range) => range,
};
if new_range.start < selection_range.start
|| new_range.end > selection_range.end
{
break;
}
}
(selection, selection_range, new_range)
})
.collect::<Vec<_>>();
self.transact(window, cx, |editor, window, cx| {
for (_, child, parent) in &edits {
let text = buffer.text_for_range(child.clone()).collect::<String>();
editor.replace_text_in_range(Some(parent.clone()), &text, window, cx);
}
editor.change_selections(
SelectionEffects::scroll(Autoscroll::fit()),
window,
cx,
|s| {
s.select(
edits
.iter()
.map(|(s, old, new)| Selection {
id: s.id,
start: new.start,
end: new.start + old.len(),
goal: SelectionGoal::None,
reversed: s.reversed,
})
.collect(),
);
},
);
});
}
fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
if !EditorSettings::get_global(cx).gutter.runnables {
self.clear_tasks();
@@ -23195,6 +23307,33 @@ fn edit_prediction_edit_text(
edit_preview.highlight_edits(current_snapshot, &edits, include_deletions, cx)
}
fn edit_prediction_fallback_text(edits: &[(Range<Anchor>, String)], cx: &App) -> HighlightedText {
// Fallback for providers that don't provide edit_preview (like Copilot/Supermaven)
// Just show the raw edit text with basic styling
let mut text = String::new();
let mut highlights = Vec::new();
let insertion_highlight_style = HighlightStyle {
color: Some(cx.theme().colors().text),
..Default::default()
};
for (_, edit_text) in edits {
let start_offset = text.len();
text.push_str(edit_text);
let end_offset = text.len();
if start_offset < end_offset {
highlights.push((start_offset..end_offset, insertion_highlight_style));
}
}
HighlightedText {
text: text.into(),
highlights,
}
}
pub fn diagnostic_style(severity: lsp::DiagnosticSeverity, colors: &StatusColors) -> Hsla {
match severity {
lsp::DiagnosticSeverity::ERROR => colors.error,

View File

@@ -7969,6 +7969,38 @@ async fn test_select_larger_smaller_syntax_node_for_string(cx: &mut TestAppConte
});
}
#[gpui::test]
async fn test_unwrap_syntax_node(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let language = Arc::new(Language::new(
LanguageConfig::default(),
Some(tree_sitter_rust::LANGUAGE.into()),
));
cx.update_buffer(|buffer, cx| {
buffer.set_language(Some(language), cx);
});
cx.set_state(
&r#"
use mod1::mod2::{«mod3ˇ», mod4};
"#
.unindent(),
);
cx.update_editor(|editor, window, cx| {
editor.unwrap_syntax_node(&UnwrapSyntaxNode, window, cx);
});
cx.assert_editor_state(
&r#"
use mod1::mod2::«mod3ˇ»;
"#
.unindent(),
);
}
#[gpui::test]
async fn test_fold_function_bodies(cx: &mut TestAppContext) {
init_test(cx, |_| {});

View File

@@ -86,8 +86,6 @@ use util::post_inc;
use util::{RangeExt, ResultExt, debug_panic};
use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt};
const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.;
/// Determines what kinds of highlights should be applied to a lines background.
#[derive(Clone, Copy, Default)]
struct LineHighlightSpec {
@@ -357,6 +355,7 @@ impl EditorElement {
register_action(editor, window, Editor::toggle_comments);
register_action(editor, window, Editor::select_larger_syntax_node);
register_action(editor, window, Editor::select_smaller_syntax_node);
register_action(editor, window, Editor::unwrap_syntax_node);
register_action(editor, window, Editor::select_enclosing_symbol);
register_action(editor, window, Editor::move_to_enclosing_bracket);
register_action(editor, window, Editor::undo_selection);
@@ -2427,10 +2426,13 @@ impl EditorElement {
let editor = self.editor.read(cx);
let blame = editor.blame.clone()?;
let padding = {
const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 6.;
const INLINE_ACCEPT_SUGGESTION_EM_WIDTHS: f32 = 14.;
let mut padding = INLINE_BLAME_PADDING_EM_WIDTHS;
let mut padding = ProjectSettings::get_global(cx)
.git
.inline_blame
.unwrap_or_default()
.padding as f32;
if let Some(edit_prediction) = editor.active_edit_prediction.as_ref() {
match &edit_prediction.completion {
@@ -2468,7 +2470,7 @@ impl EditorElement {
let min_column_in_pixels = ProjectSettings::get_global(cx)
.git
.inline_blame
.and_then(|settings| settings.min_column)
.map(|settings| settings.min_column)
.map(|col| self.column_pixels(col as usize, window))
.unwrap_or(px(0.));
let min_start = content_origin.x - scroll_pixel_position.x + min_column_in_pixels;
@@ -3681,6 +3683,7 @@ impl EditorElement {
.id("path header block")
.size_full()
.justify_between()
.overflow_hidden()
.child(
h_flex()
.gap_2()
@@ -8028,12 +8031,20 @@ impl Element for EditorElement {
autoscroll_containing_element,
needs_horizontal_autoscroll,
) = self.editor.update(cx, |editor, cx| {
let autoscroll_request = editor.autoscroll_request();
let autoscroll_request = editor.scroll_manager.take_autoscroll_request();
let autoscroll_containing_element =
autoscroll_request.is_some() || editor.has_pending_selection();
let (needs_horizontal_autoscroll, was_scrolled) = editor
.autoscroll_vertically(bounds, line_height, max_scroll_top, window, cx);
.autoscroll_vertically(
bounds,
line_height,
max_scroll_top,
autoscroll_request,
window,
cx,
);
if was_scrolled.0 {
snapshot = editor.snapshot(window, cx);
}
@@ -8355,7 +8366,13 @@ impl Element for EditorElement {
})
.flatten()?;
let mut element = render_inline_blame_entry(blame_entry, &style, cx)?;
let inline_blame_padding = INLINE_BLAME_PADDING_EM_WIDTHS * em_advance;
let inline_blame_padding = ProjectSettings::get_global(cx)
.git
.inline_blame
.unwrap_or_default()
.padding
as f32
* em_advance;
Some(
element
.layout_as_root(AvailableSpace::min_size(), window, cx)
@@ -8423,7 +8440,11 @@ impl Element for EditorElement {
Ok(blocks) => blocks,
Err(resized_blocks) => {
self.editor.update(cx, |editor, cx| {
editor.resize_blocks(resized_blocks, autoscroll_request, cx)
editor.resize_blocks(
resized_blocks,
autoscroll_request.map(|(autoscroll, _)| autoscroll),
cx,
)
});
return self.prepaint(None, _inspector_id, bounds, &mut (), window, cx);
}
@@ -8468,6 +8489,7 @@ impl Element for EditorElement {
scroll_width,
em_advance,
&line_layouts,
autoscroll_request,
window,
cx,
)

View File

@@ -3546,7 +3546,7 @@ pub mod tests {
let excerpt_hints = excerpt_hints.read();
for id in &excerpt_hints.ordered_hints {
let hint = &excerpt_hints.hints_by_id[id];
let mut label = hint.text();
let mut label = hint.text().to_string();
if hint.padding_left {
label.insert(0, ' ');
}

View File

@@ -348,8 +348,8 @@ impl ScrollManager {
self.show_scrollbars
}
pub fn autoscroll_request(&self) -> Option<Autoscroll> {
self.autoscroll_request.map(|(autoscroll, _)| autoscroll)
pub fn take_autoscroll_request(&mut self) -> Option<(Autoscroll, bool)> {
self.autoscroll_request.take()
}
pub fn active_scrollbar_state(&self) -> Option<&ActiveScrollbarState> {

View File

@@ -102,15 +102,12 @@ impl AutoscrollStrategy {
pub(crate) struct NeedsHorizontalAutoscroll(pub(crate) bool);
impl Editor {
pub fn autoscroll_request(&self) -> Option<Autoscroll> {
self.scroll_manager.autoscroll_request()
}
pub(crate) fn autoscroll_vertically(
&mut self,
bounds: Bounds<Pixels>,
line_height: Pixels,
max_scroll_top: f32,
autoscroll_request: Option<(Autoscroll, bool)>,
window: &mut Window,
cx: &mut Context<Editor>,
) -> (NeedsHorizontalAutoscroll, WasScrolled) {
@@ -137,7 +134,7 @@ impl Editor {
WasScrolled(false)
};
let Some((autoscroll, local)) = self.scroll_manager.autoscroll_request.take() else {
let Some((autoscroll, local)) = autoscroll_request else {
return (NeedsHorizontalAutoscroll(false), editor_was_scrolled);
};
@@ -284,9 +281,12 @@ impl Editor {
scroll_width: Pixels,
em_advance: Pixels,
layouts: &[LineWithInvisibles],
autoscroll_request: Option<(Autoscroll, bool)>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<gpui::Point<f32>> {
let (_, local) = autoscroll_request?;
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let selections = self.selections.all::<Point>(cx);
let mut scroll_position = self.scroll_manager.scroll_position(&display_map);
@@ -335,10 +335,10 @@ impl Editor {
let was_scrolled = if target_left < scroll_left {
scroll_position.x = target_left / em_advance;
self.set_scroll_position_internal(scroll_position, true, true, window, cx)
self.set_scroll_position_internal(scroll_position, local, true, window, cx)
} else if target_right > scroll_right {
scroll_position.x = (target_right - viewport_width) / em_advance;
self.set_scroll_position_internal(scroll_position, true, true, window, cx)
self.set_scroll_position_internal(scroll_position, local, true, window, cx)
} else {
WasScrolled(false)
};

View File

@@ -191,7 +191,7 @@ impl Editor {
if let Some(language) = language {
for signature in &mut signature_help.signatures {
let text = Rope::from(signature.label.to_string());
let text = Rope::from(signature.label.as_ref());
let highlights = language
.highlight_text(&text, 0..signature.label.len())
.into_iter()

View File

@@ -158,6 +158,11 @@ where
}
}
#[derive(Debug)]
pub struct OnFlagsReady {
pub is_staff: bool,
}
pub trait FeatureFlagAppExt {
fn wait_for_flag<T: FeatureFlag>(&mut self) -> WaitForFlag;
@@ -169,6 +174,10 @@ pub trait FeatureFlagAppExt {
fn has_flag<T: FeatureFlag>(&self) -> bool;
fn is_staff(&self) -> bool;
fn on_flags_ready<F>(&mut self, callback: F) -> Subscription
where
F: FnMut(OnFlagsReady, &mut App) + 'static;
fn observe_flag<T: FeatureFlag, F>(&mut self, callback: F) -> Subscription
where
F: FnMut(bool, &mut App) + 'static;
@@ -198,6 +207,21 @@ impl FeatureFlagAppExt for App {
.unwrap_or(false)
}
fn on_flags_ready<F>(&mut self, mut callback: F) -> Subscription
where
F: FnMut(OnFlagsReady, &mut App) + 'static,
{
self.observe_global::<FeatureFlags>(move |cx| {
let feature_flags = cx.global::<FeatureFlags>();
callback(
OnFlagsReady {
is_staff: feature_flags.staff,
},
cx,
);
})
}
fn observe_flag<T: FeatureFlag, F>(&mut self, mut callback: F) -> Subscription
where
F: FnMut(bool, &mut App) + 'static,

View File

@@ -1,12 +1,22 @@
use std::str::FromStr;
use std::sync::LazyLock;
use regex::Regex;
use url::Url;
use git::{
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
RemoteUrl,
PullRequest, RemoteUrl,
};
fn pull_request_regex() -> &'static Regex {
static PULL_REQUEST_REGEX: LazyLock<Regex> = LazyLock::new(|| {
// This matches Bitbucket PR reference pattern: (pull request #xxx)
Regex::new(r"\(pull request #(\d+)\)").unwrap()
});
&PULL_REQUEST_REGEX
}
pub struct Bitbucket {
name: String,
base_url: Url,
@@ -96,6 +106,22 @@ impl GitHostingProvider for Bitbucket {
);
permalink
}
fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
// Check first line of commit message for PR references
let first_line = message.lines().next()?;
// Try to match against our PR patterns
let capture = pull_request_regex().captures(first_line)?;
let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
// Construct the PR URL in Bitbucket format
let mut url = self.base_url();
let path = format!("/{}/{}/pull-requests/{}", remote.owner, remote.repo, number);
url.set_path(&path);
Some(PullRequest { number, url })
}
}
#[cfg(test)]
@@ -203,4 +229,34 @@ mod tests {
"https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_bitbucket_pull_requests() {
use indoc::indoc;
let remote = ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
};
let bitbucket = Bitbucket::public_instance();
// Test message without PR reference
let message = "This does not contain a pull request";
assert!(bitbucket.extract_pull_request(&remote, message).is_none());
// Pull request number at end of first line
let message = indoc! {r#"
Merged in feature-branch (pull request #123)
Some detailed description of the changes.
"#};
let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
assert_eq!(pr.number, 123);
assert_eq!(
pr.url.as_str(),
"https://bitbucket.org/zed-industries/zed/pull-requests/123"
);
}
}

View File

@@ -308,10 +308,14 @@ impl Settings for LineIndicatorFormat {
type FileContent = Option<LineIndicatorFormatContent>;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> anyhow::Result<Self> {
let format = [sources.release_channel, sources.user]
.into_iter()
.find_map(|value| value.copied().flatten())
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
let format = [
sources.release_channel,
sources.operating_system,
sources.user,
]
.into_iter()
.find_map(|value| value.copied().flatten())
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
Ok(format.0)
}

View File

@@ -121,7 +121,7 @@ smallvec.workspace = true
smol.workspace = true
strum.workspace = true
sum_tree.workspace = true
taffy = "=0.8.3"
taffy = "=0.9.0"
thiserror.workspace = true
util.workspace = true
uuid.workspace = true

View File

@@ -79,6 +79,14 @@ impl<T> Task<T> {
Task(TaskState::Spawned(task)) => task.detach(),
}
}
/// Whether the task has run to completion.
pub fn is_finished(&self) -> bool {
match self {
Task(TaskState::Ready(_)) => true,
Task(TaskState::Spawned(task)) => task.is_finished(),
}
}
}
impl<E, T> Task<Result<T, E>>

View File

@@ -20,6 +20,7 @@ anthropic = { workspace = true, features = ["schemars"] }
anyhow.workspace = true
base64.workspace = true
client.workspace = true
cloud_api_types.workspace = true
cloud_llm_client.workspace = true
collections.workspace = true
futures.workspace = true

View File

@@ -3,11 +3,9 @@ use std::sync::Arc;
use anyhow::Result;
use client::Client;
use cloud_api_types::websocket_protocol::MessageToClient;
use cloud_llm_client::Plan;
use gpui::{
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, ReadGlobal as _,
};
use proto::TypedEnvelope;
use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _};
use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard};
use thiserror::Error;
@@ -82,9 +80,7 @@ impl Global for GlobalRefreshLlmTokenListener {}
pub struct RefreshLlmTokenEvent;
pub struct RefreshLlmTokenListener {
_llm_token_subscription: client::Subscription,
}
pub struct RefreshLlmTokenListener;
impl EventEmitter<RefreshLlmTokenEvent> for RefreshLlmTokenListener {}
@@ -99,17 +95,21 @@ impl RefreshLlmTokenListener {
}
fn new(client: Arc<Client>, cx: &mut Context<Self>) -> Self {
Self {
_llm_token_subscription: client
.add_message_handler(cx.weak_entity(), Self::handle_refresh_llm_token),
}
client.add_message_to_client_handler({
let this = cx.entity();
move |message, cx| {
Self::handle_refresh_llm_token(this.clone(), message, cx);
}
});
Self
}
async fn handle_refresh_llm_token(
this: Entity<Self>,
_: TypedEnvelope<proto::RefreshLlmToken>,
mut cx: AsyncApp,
) -> Result<()> {
this.update(&mut cx, |_this, cx| cx.emit(RefreshLlmTokenEvent))
fn handle_refresh_llm_token(this: Entity<Self>, message: &MessageToClient, cx: &mut App) {
match message {
MessageToClient::UserUpdated => {
this.update(cx, |_this, cx| cx.emit(RefreshLlmTokenEvent));
}
}
}
}

View File

@@ -674,6 +674,10 @@ pub fn count_open_ai_tokens(
| Model::O3
| Model::O3Mini
| Model::O4Mini => tiktoken_rs::num_tokens_from_messages(model.id(), &messages),
// GPT-5 models don't have tiktoken support yet; fall back on gpt-4o tokenizer
Model::Five | Model::FiveMini | Model::FiveNano => {
tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages)
}
}
.map(|tokens| tokens as u64)
})

View File

@@ -1,6 +1,6 @@
name = "C++"
grammar = "cpp"
path_suffixes = ["cc", "hh", "cpp", "h", "hpp", "cxx", "hxx", "c++", "ipp", "inl", "ixx", "cu", "cuh", "C", "H"]
path_suffixes = ["cc", "hh", "cpp", "h", "hpp", "cxx", "hxx", "c++", "ipp", "inl", "ino", "ixx", "cu", "cuh", "C", "H"]
line_comments = ["// ", "/// ", "//! "]
decrease_indent_patterns = [
{ pattern = "^\\s*\\{.*\\}?\\s*$", valid_after = ["if", "for", "while", "do", "switch", "else"] },

View File

@@ -146,6 +146,7 @@
"&&="
"||="
"??="
"..."
] @operator
(regex "/" @string.regex)

View File

@@ -305,66 +305,63 @@ impl LspAdapter for RustLspAdapter {
completion: &lsp::CompletionItem,
language: &Arc<Language>,
) -> Option<CodeLabel> {
let detail = completion
// rust-analyzer calls these detail left and detail right in terms of where it expects things to be rendered
// this usually contains signatures of the thing to be completed
let detail_right = completion
.label_details
.as_ref()
.and_then(|detail| detail.detail.as_ref())
.and_then(|detail| detail.description.as_ref())
.or(completion.detail.as_ref())
.map(|detail| detail.trim());
let function_signature = completion
// this tends to contain alias and import information
let detail_left = completion
.label_details
.as_ref()
.and_then(|detail| detail.description.as_deref())
.or(completion.detail.as_deref());
match (detail, completion.kind) {
(Some(detail), Some(lsp::CompletionItemKind::FIELD)) => {
.and_then(|detail| detail.detail.as_deref());
let mk_label = |text: String, runs| {
let filter_range = completion
.filter_text
.as_deref()
.and_then(|filter| {
completion
.label
.find(filter)
.map(|ix| ix..ix + filter.len())
})
.unwrap_or(0..completion.label.len());
CodeLabel {
text,
runs,
filter_range,
}
};
let mut label = match (detail_right, completion.kind) {
(Some(signature), Some(lsp::CompletionItemKind::FIELD)) => {
let name = &completion.label;
let text = format!("{name}: {detail}");
let text = format!("{name}: {signature}");
let prefix = "struct S { ";
let source = Rope::from(format!("{prefix}{text} }}"));
let source = Rope::from_iter([prefix, &text, " }"]);
let runs =
language.highlight_text(&source, prefix.len()..prefix.len() + text.len());
let filter_range = completion
.filter_text
.as_deref()
.and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
.unwrap_or(0..name.len());
return Some(CodeLabel {
text,
runs,
filter_range,
});
mk_label(text, runs)
}
(
Some(detail),
Some(signature),
Some(lsp::CompletionItemKind::CONSTANT | lsp::CompletionItemKind::VARIABLE),
) if completion.insert_text_format != Some(lsp::InsertTextFormat::SNIPPET) => {
let name = &completion.label;
let text = format!(
"{}: {}",
name,
completion.detail.as_deref().unwrap_or(detail)
);
let text = format!("{name}: {signature}",);
let prefix = "let ";
let source = Rope::from(format!("{prefix}{text} = ();"));
let source = Rope::from_iter([prefix, &text, " = ();"]);
let runs =
language.highlight_text(&source, prefix.len()..prefix.len() + text.len());
let filter_range = completion
.filter_text
.as_deref()
.and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
.unwrap_or(0..name.len());
return Some(CodeLabel {
text,
runs,
filter_range,
});
mk_label(text, runs)
}
(
Some(detail),
function_signature,
Some(lsp::CompletionItemKind::FUNCTION | lsp::CompletionItemKind::METHOD),
) => {
static REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new("\\(…?\\)").unwrap());
const FUNCTION_PREFIXES: [&str; 6] = [
"async fn",
"async unsafe fn",
@@ -373,34 +370,27 @@ impl LspAdapter for RustLspAdapter {
"unsafe fn",
"fn",
];
// Is it function `async`?
let fn_keyword = FUNCTION_PREFIXES.iter().find_map(|prefix| {
function_signature.as_ref().and_then(|signature| {
signature
.strip_prefix(*prefix)
.map(|suffix| (*prefix, suffix))
})
let fn_prefixed = FUNCTION_PREFIXES.iter().find_map(|&prefix| {
function_signature?
.strip_prefix(prefix)
.map(|suffix| (prefix, suffix))
});
// fn keyword should be followed by opening parenthesis.
if let Some((prefix, suffix)) = fn_keyword {
let mut text = REGEX.replace(&completion.label, suffix).to_string();
let source = Rope::from(format!("{prefix} {text} {{}}"));
if let Some((prefix, suffix)) = fn_prefixed {
let label = if let Some(label) = completion
.label
.strip_suffix("(…)")
.or_else(|| completion.label.strip_suffix("()"))
{
label
} else {
&completion.label
};
let text = format!("{label}{suffix}");
let source = Rope::from_iter([prefix, " ", &text, " {}"]);
let run_start = prefix.len() + 1;
let runs = language.highlight_text(&source, run_start..run_start + text.len());
if detail.starts_with("(") {
text.push(' ');
text.push_str(&detail);
}
let filter_range = completion
.filter_text
.as_deref()
.and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
.unwrap_or(0..completion.label.find('(').unwrap_or(text.len()));
return Some(CodeLabel {
filter_range,
text,
runs,
});
mk_label(text, runs)
} else if completion
.detail
.as_ref()
@@ -410,20 +400,13 @@ impl LspAdapter for RustLspAdapter {
let len = text.len();
let source = Rope::from(text.as_str());
let runs = language.highlight_text(&source, 0..len);
let filter_range = completion
.filter_text
.as_deref()
.and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
.unwrap_or(0..len);
return Some(CodeLabel {
filter_range,
text,
runs,
});
mk_label(text, runs)
} else {
mk_label(completion.label.clone(), vec![])
}
}
(_, Some(kind)) => {
let highlight_name = match kind {
(_, kind) => {
let highlight_name = kind.and_then(|kind| match kind {
lsp::CompletionItemKind::STRUCT
| lsp::CompletionItemKind::INTERFACE
| lsp::CompletionItemKind::ENUM => Some("type"),
@@ -433,27 +416,32 @@ impl LspAdapter for RustLspAdapter {
Some("constant")
}
_ => None,
};
});
let mut label = completion.label.clone();
if let Some(detail) = detail.filter(|detail| detail.starts_with("(")) {
label.push(' ');
label.push_str(detail);
}
let mut label = CodeLabel::plain(label, completion.filter_text.as_deref());
let label = completion.label.clone();
let mut runs = vec![];
if let Some(highlight_name) = highlight_name {
let highlight_id = language.grammar()?.highlight_id_for_name(highlight_name)?;
label.runs.push((
0..label.text.rfind('(').unwrap_or(completion.label.len()),
runs.push((
0..label.rfind('(').unwrap_or(completion.label.len()),
highlight_id,
));
}
return Some(label);
mk_label(label, runs)
}
};
if let Some(detail_left) = detail_left {
label.text.push(' ');
if !detail_left.starts_with('(') {
label.text.push('(');
}
label.text.push_str(detail_left);
if !detail_left.ends_with(')') {
label.text.push(')');
}
_ => {}
}
None
Some(label)
}
async fn label_for_symbol(
@@ -462,55 +450,22 @@ impl LspAdapter for RustLspAdapter {
kind: lsp::SymbolKind,
language: &Arc<Language>,
) -> Option<CodeLabel> {
let (text, filter_range, display_range) = match kind {
lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
let text = format!("fn {} () {{}}", name);
let filter_range = 3..3 + name.len();
let display_range = 0..filter_range.end;
(text, filter_range, display_range)
}
lsp::SymbolKind::STRUCT => {
let text = format!("struct {} {{}}", name);
let filter_range = 7..7 + name.len();
let display_range = 0..filter_range.end;
(text, filter_range, display_range)
}
lsp::SymbolKind::ENUM => {
let text = format!("enum {} {{}}", name);
let filter_range = 5..5 + name.len();
let display_range = 0..filter_range.end;
(text, filter_range, display_range)
}
lsp::SymbolKind::INTERFACE => {
let text = format!("trait {} {{}}", name);
let filter_range = 6..6 + name.len();
let display_range = 0..filter_range.end;
(text, filter_range, display_range)
}
lsp::SymbolKind::CONSTANT => {
let text = format!("const {}: () = ();", name);
let filter_range = 6..6 + name.len();
let display_range = 0..filter_range.end;
(text, filter_range, display_range)
}
lsp::SymbolKind::MODULE => {
let text = format!("mod {} {{}}", name);
let filter_range = 4..4 + name.len();
let display_range = 0..filter_range.end;
(text, filter_range, display_range)
}
lsp::SymbolKind::TYPE_PARAMETER => {
let text = format!("type {} {{}}", name);
let filter_range = 5..5 + name.len();
let display_range = 0..filter_range.end;
(text, filter_range, display_range)
}
let (prefix, suffix) = match kind {
lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => ("fn ", " () {}"),
lsp::SymbolKind::STRUCT => ("struct ", " {}"),
lsp::SymbolKind::ENUM => ("enum ", " {}"),
lsp::SymbolKind::INTERFACE => ("trait ", " {}"),
lsp::SymbolKind::CONSTANT => ("const ", ": () = ();"),
lsp::SymbolKind::MODULE => ("mod ", " {}"),
lsp::SymbolKind::TYPE_PARAMETER => ("type ", " {}"),
_ => return None,
};
let filter_range = prefix.len()..prefix.len() + name.len();
let display_range = 0..filter_range.end;
Some(CodeLabel {
runs: language.highlight_text(&text.as_str().into(), display_range.clone()),
text: text[display_range].to_string(),
runs: language.highlight_text(&Rope::from_iter([prefix, name, suffix]), display_range),
text: format!("{prefix}{name}"),
filter_range,
})
}
@@ -1169,7 +1124,7 @@ mod tests {
.await,
Some(CodeLabel {
text: "hello(&mut Option<T>) -> Vec<T> (use crate::foo)".to_string(),
filter_range: 0..5,
filter_range: 0..10,
runs: vec![
(0..5, highlight_function),
(7..10, highlight_keyword),
@@ -1187,7 +1142,7 @@ mod tests {
kind: Some(lsp::CompletionItemKind::FUNCTION),
label: "hello(…)".to_string(),
label_details: Some(CompletionItemLabelDetails {
detail: Some(" (use crate::foo)".into()),
detail: Some("(use crate::foo)".into()),
description: Some("async fn(&mut Option<T>) -> Vec<T>".to_string()),
}),
..Default::default()
@@ -1197,7 +1152,7 @@ mod tests {
.await,
Some(CodeLabel {
text: "hello(&mut Option<T>) -> Vec<T> (use crate::foo)".to_string(),
filter_range: 0..5,
filter_range: 0..10,
runs: vec![
(0..5, highlight_function),
(7..10, highlight_keyword),
@@ -1234,7 +1189,7 @@ mod tests {
kind: Some(lsp::CompletionItemKind::FUNCTION),
label: "hello(…)".to_string(),
label_details: Some(CompletionItemLabelDetails {
detail: Some(" (use crate::foo)".to_string()),
detail: Some("(use crate::foo)".to_string()),
description: Some("fn(&mut Option<T>) -> Vec<T>".to_string()),
}),
@@ -1243,6 +1198,35 @@ mod tests {
&language
)
.await,
Some(CodeLabel {
text: "hello(&mut Option<T>) -> Vec<T> (use crate::foo)".to_string(),
filter_range: 0..10,
runs: vec![
(0..5, highlight_function),
(7..10, highlight_keyword),
(11..17, highlight_type),
(18..19, highlight_type),
(25..28, highlight_type),
(29..30, highlight_type),
],
})
);
assert_eq!(
adapter
.label_for_completion(
&lsp::CompletionItem {
kind: Some(lsp::CompletionItemKind::FUNCTION),
label: "hello".to_string(),
label_details: Some(CompletionItemLabelDetails {
detail: Some("(use crate::foo)".to_string()),
description: Some("fn(&mut Option<T>) -> Vec<T>".to_string()),
}),
..Default::default()
},
&language
)
.await,
Some(CodeLabel {
text: "hello(&mut Option<T>) -> Vec<T> (use crate::foo)".to_string(),
filter_range: 0..5,
@@ -1274,9 +1258,14 @@ mod tests {
)
.await,
Some(CodeLabel {
text: "await.as_deref_mut()".to_string(),
text: "await.as_deref_mut(&mut self) -> IterMut<'_, T>".to_string(),
filter_range: 6..18,
runs: vec![],
runs: vec![
(6..18, HighlightId(2)),
(20..23, HighlightId(1)),
(33..40, HighlightId(0)),
(45..46, HighlightId(0))
],
})
);

View File

@@ -146,6 +146,7 @@
"&&="
"||="
"??="
"..."
] @operator
(regex "/" @string.regex)

View File

@@ -167,6 +167,7 @@
"&&="
"||="
"??="
"..."
] @operator
(regex "/" @string.regex)

View File

@@ -1,7 +1,7 @@
use std::sync::Arc;
use ai_onboarding::{AiUpsellCard, SignInStatus};
use client::UserStore;
use ai_onboarding::AiUpsellCard;
use client::{Client, UserStore};
use fs::Fs;
use gpui::{
Action, AnyView, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity,
@@ -12,8 +12,8 @@ use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageMod
use project::DisableAiSettings;
use settings::{Settings, update_settings_file};
use ui::{
Badge, ButtonLike, Divider, Modal, ModalFooter, ModalHeader, Section, SwitchField, ToggleState,
prelude::*, tooltip_container,
Badge, ButtonLike, Divider, KeyBinding, Modal, ModalFooter, ModalHeader, Section, SwitchField,
ToggleState, prelude::*, tooltip_container,
};
use util::ResultExt;
use workspace::{ModalView, Workspace};
@@ -88,7 +88,7 @@ fn render_privacy_card(tab_index: &mut isize, disabled: bool, cx: &mut App) -> i
h_flex()
.gap_2()
.justify_between()
.child(Label::new("We don't train models using your data"))
.child(Label::new("Privacy is the default for Zed"))
.child(
h_flex().gap_1().child(privacy_badge()).child(
Button::new("learn_more", "Learn More")
@@ -109,7 +109,7 @@ fn render_privacy_card(tab_index: &mut isize, disabled: bool, cx: &mut App) -> i
)
.child(
Label::new(
"Feel confident in the security and privacy of your projects using Zed.",
"Any use or storage of your data is with your explicit, single-use, opt-in consent.",
)
.size(LabelSize::Small)
.color(Color::Muted),
@@ -240,6 +240,7 @@ fn render_llm_provider_card(
pub(crate) fn render_ai_setup_page(
workspace: WeakEntity<Workspace>,
user_store: Entity<UserStore>,
client: Arc<Client>,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
@@ -283,14 +284,16 @@ pub(crate) fn render_ai_setup_page(
v_flex()
.mt_2()
.gap_6()
.child(AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
user_plan: user_store.read(cx).plan(),
tab_index: Some({
.child({
let mut ai_upsell_card =
AiUpsellCard::new(client, &user_store, user_store.read(cx).plan(), cx);
ai_upsell_card.tab_index = Some({
tab_index += 1;
tab_index - 1
}),
});
ai_upsell_card
})
.child(render_llm_provider_section(
&mut tab_index,
@@ -335,6 +338,10 @@ impl AiConfigurationModal {
selected_provider,
}
}
fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
}
impl ModalView for AiConfigurationModal {}
@@ -348,11 +355,15 @@ impl Focusable for AiConfigurationModal {
}
impl Render for AiConfigurationModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("OnboardingAiConfigurationModal")
.w(rems(34.))
.elevation_3(cx)
.track_focus(&self.focus_handle)
.on_action(
cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(&menu::Cancel, cx)),
)
.child(
Modal::new("onboarding-ai-setup-modal", None)
.header(
@@ -367,18 +378,19 @@ impl Render for AiConfigurationModal {
.section(Section::new().child(self.configuration_view.clone()))
.footer(
ModalFooter::new().end_slot(
h_flex()
.gap_1()
.child(
Button::new("onboarding-closing-cancel", "Cancel")
.on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
Button::new("ai-onb-modal-Done", "Done")
.key_binding(
KeyBinding::for_action_in(
&menu::Cancel,
&self.focus_handle.clone(),
window,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.child(Button::new("save-btn", "Done").on_click(cx.listener(
|_, _, window, cx| {
window.dispatch_action(menu::Confirm.boxed_clone(), cx);
cx.emit(DismissEvent);
},
))),
.on_click(cx.listener(|this, _event, _window, cx| {
this.cancel(&menu::Cancel, cx)
})),
),
),
)
@@ -395,7 +407,7 @@ impl AiPrivacyTooltip {
impl Render for AiPrivacyTooltip {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const DESCRIPTION: &'static str = "One of Zed's most important principles is transparency. This is why we are and value open-source so much. And it wouldn't be any different with AI.";
const DESCRIPTION: &'static str = "We believe in opt-in data sharing as the default for building AI products, rather than opt-out. We'll only use or store your data if you affirmatively send it to us. ";
tooltip_container(window, cx, move |this, _, _| {
this.child(
@@ -406,7 +418,7 @@ impl Render for AiPrivacyTooltip {
.size(IconSize::Small)
.color(Color::Muted),
)
.child(Label::new("Privacy Principle")),
.child(Label::new("Privacy First")),
)
.child(
div().max_w_64().child(

View File

@@ -201,12 +201,15 @@ fn render_telemetry_section(tab_index: &mut isize, cx: &App) -> impl IntoElement
let fs = <dyn Fs>::global(cx);
v_flex()
.pt_6()
.gap_4()
.border_t_1()
.border_color(cx.theme().colors().border_variant.opacity(0.5))
.child(Label::new("Telemetry").size(LabelSize::Large))
.child(SwitchField::new(
"onboarding-telemetry-metrics",
"Help Improve Zed",
Some("Sending anonymous usage data helps us build the right features and create the best experience.".into()),
Some("Anonymous usage data helps us build the right features and improve your experience.".into()),
if TelemetrySettings::get_global(cx).metrics {
ui::ToggleState::Selected
} else {
@@ -294,7 +297,7 @@ fn render_base_keymap_section(tab_index: &mut isize, cx: &mut App) -> impl IntoE
ToggleButtonWithIcon::new("Emacs", IconName::EditorEmacs, |_, _, cx| {
write_keymap_base(BaseKeymap::Emacs, cx);
}),
ToggleButtonWithIcon::new("Cursor (Beta)", IconName::EditorCursor, |_, _, cx| {
ToggleButtonWithIcon::new("Cursor", IconName::EditorCursor, |_, _, cx| {
write_keymap_base(BaseKeymap::Cursor, cx);
}),
],
@@ -326,10 +329,7 @@ fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoEleme
SwitchField::new(
"onboarding-vim-mode",
"Vim Mode",
Some(
"Coming from Neovim? Zed's first-class implementation of Vim Mode has got your back."
.into(),
),
Some("Coming from Neovim? Use our first-class implementation of Vim Mode.".into()),
toggle_state,
{
let fs = <dyn Fs>::global(cx);

View File

@@ -584,11 +584,15 @@ fn render_popular_settings_section(
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
const LIGATURE_TOOLTIP: &'static str = "Ligatures are when a font creates a special character out of combining two characters into one. For example, with ligatures turned on, =/= would become ≠.";
const LIGATURE_TOOLTIP: &'static str =
"Font ligatures combine two characters into one. For example, turning =/= into ≠.";
v_flex()
.gap_5()
.child(Label::new("Popular Settings").size(LabelSize::Large).mt_8())
.pt_6()
.gap_4()
.border_t_1()
.border_color(cx.theme().colors().border_variant.opacity(0.5))
.child(Label::new("Popular Settings").size(LabelSize::Large))
.child(render_font_customization_section(tab_index, window, cx))
.child(
SwitchField::new(
@@ -683,7 +687,10 @@ fn render_popular_settings_section(
[
ToggleButtonSimple::new("Auto", |_, _, cx| {
write_show_mini_map(ShowMinimap::Auto, cx);
}),
})
.tooltip(Tooltip::text(
"Show the minimap if the editor's scrollbar is visible.",
)),
ToggleButtonSimple::new("Always", |_, _, cx| {
write_show_mini_map(ShowMinimap::Always, cx);
}),
@@ -707,7 +714,7 @@ fn render_popular_settings_section(
pub(crate) fn render_editing_page(window: &mut Window, cx: &mut App) -> impl IntoElement {
let mut tab_index = 0;
v_flex()
.gap_4()
.gap_6()
.child(render_import_settings_section(&mut tab_index, cx))
.child(render_popular_settings_section(&mut tab_index, window, cx))
}

View File

@@ -77,6 +77,8 @@ actions!(
ActivateAISetupPage,
/// Finish the onboarding process.
Finish,
/// Sign in while in the onboarding flow.
SignIn
]
);
@@ -376,6 +378,7 @@ impl Onboarding {
cx,
)
.map(|kb| kb.size(rems_from_px(12.)));
if ai_setup_page {
this.child(
ButtonLike::new("start_building")
@@ -387,14 +390,7 @@ impl Onboarding {
.w_full()
.justify_between()
.child(Label::new("Start Building"))
.child(keybinding.map_or_else(
|| {
Icon::new(IconName::Check)
.size(IconSize::Small)
.into_any_element()
},
IntoElement::into_any_element,
)),
.children(keybinding),
)
.on_click(|_, window, cx| {
window.dispatch_action(Finish.boxed_clone(), cx);
@@ -409,11 +405,10 @@ impl Onboarding {
.ml_1()
.w_full()
.justify_between()
.child(Label::new("Skip All"))
.child(keybinding.map_or_else(
|| gpui::Empty.into_any_element(),
IntoElement::into_any_element,
)),
.child(
Label::new("Skip All").color(Color::Muted),
)
.children(keybinding),
)
.on_click(|_, window, cx| {
window.dispatch_action(Finish.boxed_clone(), cx);
@@ -435,23 +430,39 @@ impl Onboarding {
Button::new("sign_in", "Sign In")
.full_width()
.style(ButtonStyle::Outlined)
.size(ButtonSize::Medium)
.key_binding(
KeyBinding::for_action_in(&SignIn, &self.focus_handle, window, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
let client = Client::global(cx);
window
.spawn(cx, async move |cx| {
client
.sign_in_with_optional_connect(true, &cx)
.await
.notify_async_err(cx);
})
.detach();
window.dispatch_action(SignIn.boxed_clone(), cx);
})
.into_any_element()
},
)
}
fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
go_to_welcome_page(cx);
}
fn handle_sign_in(_: &SignIn, window: &mut Window, cx: &mut App) {
let client = Client::global(cx);
window
.spawn(cx, async move |cx| {
client
.sign_in_with_optional_connect(true, &cx)
.await
.notify_async_err(cx);
})
.detach();
}
fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let client = Client::global(cx);
match self.selected_page {
SelectedPage::Basics => crate::basics_page::render_basics_page(cx).into_any_element(),
SelectedPage::Editing => {
@@ -460,16 +471,13 @@ impl Onboarding {
SelectedPage::AiSetup => crate::ai_setup_page::render_ai_setup_page(
self.workspace.clone(),
self.user_store.clone(),
client,
window,
cx,
)
.into_any_element(),
}
}
fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
go_to_welcome_page(cx);
}
}
impl Render for Onboarding {
@@ -486,6 +494,7 @@ impl Render for Onboarding {
.size_full()
.bg(cx.theme().colors().editor_background)
.on_action(Self::on_finish)
.on_action(Self::handle_sign_in)
.on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| {
this.set_page(SelectedPage::Basics, cx);
}))

View File

@@ -74,6 +74,12 @@ pub enum Model {
O3,
#[serde(rename = "o4-mini")]
O4Mini,
#[serde(rename = "gpt-5")]
Five,
#[serde(rename = "gpt-5-mini")]
FiveMini,
#[serde(rename = "gpt-5-nano")]
FiveNano,
#[serde(rename = "custom")]
Custom {
@@ -105,6 +111,9 @@ impl Model {
"o3-mini" => Ok(Self::O3Mini),
"o3" => Ok(Self::O3),
"o4-mini" => Ok(Self::O4Mini),
"gpt-5" => Ok(Self::Five),
"gpt-5-mini" => Ok(Self::FiveMini),
"gpt-5-nano" => Ok(Self::FiveNano),
invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"),
}
}
@@ -123,6 +132,9 @@ impl Model {
Self::O3Mini => "o3-mini",
Self::O3 => "o3",
Self::O4Mini => "o4-mini",
Self::Five => "gpt-5",
Self::FiveMini => "gpt-5-mini",
Self::FiveNano => "gpt-5-nano",
Self::Custom { name, .. } => name,
}
}
@@ -141,6 +153,9 @@ impl Model {
Self::O3Mini => "o3-mini",
Self::O3 => "o3",
Self::O4Mini => "o4-mini",
Self::Five => "gpt-5",
Self::FiveMini => "gpt-5-mini",
Self::FiveNano => "gpt-5-nano",
Self::Custom {
name, display_name, ..
} => display_name.as_ref().unwrap_or(name),
@@ -161,6 +176,9 @@ impl Model {
Self::O3Mini => 200_000,
Self::O3 => 200_000,
Self::O4Mini => 200_000,
Self::Five => 272_000,
Self::FiveMini => 272_000,
Self::FiveNano => 272_000,
Self::Custom { max_tokens, .. } => *max_tokens,
}
}
@@ -182,6 +200,9 @@ impl Model {
Self::O3Mini => Some(100_000),
Self::O3 => Some(100_000),
Self::O4Mini => Some(100_000),
Self::Five => Some(128_000),
Self::FiveMini => Some(128_000),
Self::FiveNano => Some(128_000),
}
}
@@ -197,7 +218,10 @@ impl Model {
| Self::FourOmniMini
| Self::FourPointOne
| Self::FourPointOneMini
| Self::FourPointOneNano => true,
| Self::FourPointOneNano
| Self::Five
| Self::FiveMini
| Self::FiveNano => true,
Self::O1 | Self::O3 | Self::O3Mini | Self::O4Mini | Model::Custom { .. } => false,
}
}

View File

@@ -140,6 +140,20 @@ impl FormatTrigger {
}
}
#[derive(Debug)]
pub struct DocumentDiagnosticsUpdate<'a, D> {
pub diagnostics: D,
pub result_id: Option<String>,
pub server_id: LanguageServerId,
pub disk_based_sources: Cow<'a, [String]>,
}
pub struct DocumentDiagnostics {
diagnostics: Vec<DiagnosticEntry<Unclipped<PointUtf16>>>,
document_abs_path: PathBuf,
version: Option<i32>,
}
pub struct LocalLspStore {
weak: WeakEntity<LspStore>,
worktree_store: Entity<WorktreeStore>,
@@ -503,12 +517,16 @@ impl LocalLspStore {
adapter.process_diagnostics(&mut params, server_id, buffer);
}
this.merge_diagnostics(
server_id,
params,
None,
this.merge_lsp_diagnostics(
DiagnosticSourceKind::Pushed,
&adapter.disk_based_diagnostic_sources,
vec![DocumentDiagnosticsUpdate {
server_id,
diagnostics: params,
result_id: None,
disk_based_sources: Cow::Borrowed(
&adapter.disk_based_diagnostic_sources,
),
}],
|_, diagnostic, cx| match diagnostic.source_kind {
DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => {
adapter.retain_old_diagnostic(diagnostic, cx)
@@ -3610,8 +3628,8 @@ pub enum LspStoreEvent {
RefreshInlayHints,
RefreshCodeLens,
DiagnosticsUpdated {
language_server_id: LanguageServerId,
path: ProjectPath,
server_id: LanguageServerId,
paths: Vec<ProjectPath>,
},
DiskBasedDiagnosticsStarted {
language_server_id: LanguageServerId,
@@ -4440,17 +4458,24 @@ impl LspStore {
pub(crate) fn send_diagnostic_summaries(&self, worktree: &mut Worktree) {
if let Some((client, downstream_project_id)) = self.downstream_client.clone() {
if let Some(summaries) = self.diagnostic_summaries.get(&worktree.id()) {
for (path, summaries) in summaries {
for (&server_id, summary) in summaries {
client
.send(proto::UpdateDiagnosticSummary {
project_id: downstream_project_id,
worktree_id: worktree.id().to_proto(),
summary: Some(summary.to_proto(server_id, path)),
})
.log_err();
}
if let Some(diangostic_summaries) = self.diagnostic_summaries.get(&worktree.id()) {
let mut summaries =
diangostic_summaries
.into_iter()
.flat_map(|(path, summaries)| {
summaries
.into_iter()
.map(|(server_id, summary)| summary.to_proto(*server_id, path))
});
if let Some(summary) = summaries.next() {
client
.send(proto::UpdateDiagnosticSummary {
project_id: downstream_project_id,
worktree_id: worktree.id().to_proto(),
summary: Some(summary),
more_summaries: summaries.collect(),
})
.log_err();
}
}
}
@@ -6564,7 +6589,7 @@ impl LspStore {
&mut self,
buffer: Entity<Buffer>,
cx: &mut Context<Self>,
) -> Task<Result<Vec<LspPullDiagnostics>>> {
) -> Task<Result<Option<Vec<LspPullDiagnostics>>>> {
let buffer_id = buffer.read(cx).remote_id();
if let Some((client, upstream_project_id)) = self.upstream_client() {
@@ -6575,7 +6600,7 @@ impl LspStore {
},
cx,
) {
return Task::ready(Ok(Vec::new()));
return Task::ready(Ok(None));
}
let request_task = client.request(proto::MultiLspQuery {
buffer_id: buffer_id.to_proto(),
@@ -6593,7 +6618,7 @@ impl LspStore {
)),
});
cx.background_spawn(async move {
Ok(request_task
let _proto_responses = request_task
.await?
.responses
.into_iter()
@@ -6606,8 +6631,11 @@ impl LspStore {
None
}
})
.flat_map(GetDocumentDiagnostics::diagnostics_from_proto)
.collect())
.collect::<Vec<_>>();
// Proto requests cause the diagnostics to be pulled from language server(s) on the local side
// and then, buffer state updated with the diagnostics received, which will be later propagated to the client.
// Do not attempt to further process the dummy responses here.
Ok(None)
})
} else {
let server_ids = buffer.update(cx, |buffer, cx| {
@@ -6635,7 +6663,7 @@ impl LspStore {
for diagnostics in join_all(pull_diagnostics).await {
responses.extend(diagnostics?);
}
Ok(responses)
Ok(Some(responses))
})
}
}
@@ -6701,75 +6729,93 @@ impl LspStore {
buffer: Entity<Buffer>,
cx: &mut Context<Self>,
) -> Task<anyhow::Result<()>> {
let buffer_id = buffer.read(cx).remote_id();
let diagnostics = self.pull_diagnostics(buffer, cx);
cx.spawn(async move |lsp_store, cx| {
let diagnostics = diagnostics.await.context("pulling diagnostics")?;
let Some(diagnostics) = diagnostics.await.context("pulling diagnostics")? else {
return Ok(());
};
lsp_store.update(cx, |lsp_store, cx| {
if lsp_store.as_local().is_none() {
return;
}
for diagnostics_set in diagnostics {
let LspPullDiagnostics::Response {
server_id,
uri,
diagnostics,
} = diagnostics_set
else {
continue;
};
let adapter = lsp_store.language_server_adapter_for_id(server_id);
let disk_based_sources = adapter
.as_ref()
.map(|adapter| adapter.disk_based_diagnostic_sources.as_slice())
.unwrap_or(&[]);
match diagnostics {
PulledDiagnostics::Unchanged { result_id } => {
lsp_store
.merge_diagnostics(
server_id,
lsp::PublishDiagnosticsParams {
uri: uri.clone(),
diagnostics: Vec::new(),
version: None,
},
Some(result_id),
DiagnosticSourceKind::Pulled,
disk_based_sources,
|_, _, _| true,
cx,
)
.log_err();
}
PulledDiagnostics::Changed {
let mut unchanged_buffers = HashSet::default();
let mut changed_buffers = HashSet::default();
let server_diagnostics_updates = diagnostics
.into_iter()
.filter_map(|diagnostics_set| match diagnostics_set {
LspPullDiagnostics::Response {
server_id,
uri,
diagnostics,
result_id,
} => {
lsp_store
.merge_diagnostics(
} => Some((server_id, uri, diagnostics)),
LspPullDiagnostics::Default => None,
})
.fold(
HashMap::default(),
|mut acc, (server_id, uri, diagnostics)| {
let (result_id, diagnostics) = match diagnostics {
PulledDiagnostics::Unchanged { result_id } => {
unchanged_buffers.insert(uri.clone());
(Some(result_id), Vec::new())
}
PulledDiagnostics::Changed {
result_id,
diagnostics,
} => {
changed_buffers.insert(uri.clone());
(result_id, diagnostics)
}
};
let disk_based_sources = Cow::Owned(
lsp_store
.language_server_adapter_for_id(server_id)
.as_ref()
.map(|adapter| adapter.disk_based_diagnostic_sources.as_slice())
.unwrap_or(&[])
.to_vec(),
);
acc.entry(server_id).or_insert_with(Vec::new).push(
DocumentDiagnosticsUpdate {
server_id,
lsp::PublishDiagnosticsParams {
uri: uri.clone(),
diagnostics: lsp::PublishDiagnosticsParams {
uri,
diagnostics,
version: None,
},
result_id,
DiagnosticSourceKind::Pulled,
disk_based_sources,
|buffer, old_diagnostic, _| match old_diagnostic.source_kind {
DiagnosticSourceKind::Pulled => {
buffer.remote_id() != buffer_id
}
DiagnosticSourceKind::Other
| DiagnosticSourceKind::Pushed => true,
},
cx,
)
.log_err();
}
}
},
);
acc
},
);
for diagnostic_updates in server_diagnostics_updates.into_values() {
lsp_store
.merge_lsp_diagnostics(
DiagnosticSourceKind::Pulled,
diagnostic_updates,
|buffer, old_diagnostic, cx| {
File::from_dyn(buffer.file())
.and_then(|file| {
let abs_path = file.as_local()?.abs_path(cx);
lsp::Url::from_file_path(abs_path).ok()
})
.is_none_or(|buffer_uri| {
unchanged_buffers.contains(&buffer_uri)
|| match old_diagnostic.source_kind {
DiagnosticSourceKind::Pulled => {
!changed_buffers.contains(&buffer_uri)
}
DiagnosticSourceKind::Other
| DiagnosticSourceKind::Pushed => true,
}
})
},
cx,
)
.log_err();
}
})
})
@@ -7791,88 +7837,135 @@ impl LspStore {
cx: &mut Context<Self>,
) -> anyhow::Result<()> {
self.merge_diagnostic_entries(
server_id,
abs_path,
result_id,
version,
diagnostics,
vec![DocumentDiagnosticsUpdate {
diagnostics: DocumentDiagnostics {
diagnostics,
document_abs_path: abs_path,
version,
},
result_id,
server_id,
disk_based_sources: Cow::Borrowed(&[]),
}],
|_, _, _| false,
cx,
)?;
Ok(())
}
pub fn merge_diagnostic_entries(
pub fn merge_diagnostic_entries<'a>(
&mut self,
server_id: LanguageServerId,
abs_path: PathBuf,
result_id: Option<String>,
version: Option<i32>,
mut diagnostics: Vec<DiagnosticEntry<Unclipped<PointUtf16>>>,
filter: impl Fn(&Buffer, &Diagnostic, &App) -> bool + Clone,
diagnostic_updates: Vec<DocumentDiagnosticsUpdate<'a, DocumentDiagnostics>>,
merge: impl Fn(&Buffer, &Diagnostic, &App) -> bool + Clone,
cx: &mut Context<Self>,
) -> anyhow::Result<()> {
let Some((worktree, relative_path)) =
self.worktree_store.read(cx).find_worktree(&abs_path, cx)
else {
log::warn!("skipping diagnostics update, no worktree found for path {abs_path:?}");
return Ok(());
};
let mut diagnostics_summary = None::<proto::UpdateDiagnosticSummary>;
let mut updated_diagnostics_paths = HashMap::default();
for mut update in diagnostic_updates {
let abs_path = &update.diagnostics.document_abs_path;
let server_id = update.server_id;
let Some((worktree, relative_path)) =
self.worktree_store.read(cx).find_worktree(abs_path, cx)
else {
log::warn!("skipping diagnostics update, no worktree found for path {abs_path:?}");
return Ok(());
};
let project_path = ProjectPath {
worktree_id: worktree.read(cx).id(),
path: relative_path.into(),
};
let worktree_id = worktree.read(cx).id();
let project_path = ProjectPath {
worktree_id,
path: relative_path.into(),
};
if let Some(buffer_handle) = self.buffer_store.read(cx).get_by_path(&project_path) {
let snapshot = buffer_handle.read(cx).snapshot();
let buffer = buffer_handle.read(cx);
let reused_diagnostics = buffer
.get_diagnostics(server_id)
.into_iter()
.flat_map(|diag| {
diag.iter()
.filter(|v| filter(buffer, &v.diagnostic, cx))
.map(|v| {
let start = Unclipped(v.range.start.to_point_utf16(&snapshot));
let end = Unclipped(v.range.end.to_point_utf16(&snapshot));
DiagnosticEntry {
range: start..end,
diagnostic: v.diagnostic.clone(),
}
})
})
.collect::<Vec<_>>();
if let Some(buffer_handle) = self.buffer_store.read(cx).get_by_path(&project_path) {
let snapshot = buffer_handle.read(cx).snapshot();
let buffer = buffer_handle.read(cx);
let reused_diagnostics = buffer
.get_diagnostics(server_id)
.into_iter()
.flat_map(|diag| {
diag.iter()
.filter(|v| merge(buffer, &v.diagnostic, cx))
.map(|v| {
let start = Unclipped(v.range.start.to_point_utf16(&snapshot));
let end = Unclipped(v.range.end.to_point_utf16(&snapshot));
DiagnosticEntry {
range: start..end,
diagnostic: v.diagnostic.clone(),
}
})
})
.collect::<Vec<_>>();
self.as_local_mut()
.context("cannot merge diagnostics on a remote LspStore")?
.update_buffer_diagnostics(
&buffer_handle,
self.as_local_mut()
.context("cannot merge diagnostics on a remote LspStore")?
.update_buffer_diagnostics(
&buffer_handle,
server_id,
update.result_id,
update.diagnostics.version,
update.diagnostics.diagnostics.clone(),
reused_diagnostics.clone(),
cx,
)?;
update.diagnostics.diagnostics.extend(reused_diagnostics);
}
let updated = worktree.update(cx, |worktree, cx| {
self.update_worktree_diagnostics(
worktree.id(),
server_id,
result_id,
version,
diagnostics.clone(),
reused_diagnostics.clone(),
project_path.path.clone(),
update.diagnostics.diagnostics,
cx,
)?;
diagnostics.extend(reused_diagnostics);
)
})?;
match updated {
ControlFlow::Continue(new_summary) => {
if let Some((project_id, new_summary)) = new_summary {
match &mut diagnostics_summary {
Some(diagnostics_summary) => {
diagnostics_summary
.more_summaries
.push(proto::DiagnosticSummary {
path: project_path.path.as_ref().to_proto(),
language_server_id: server_id.0 as u64,
error_count: new_summary.error_count,
warning_count: new_summary.warning_count,
})
}
None => {
diagnostics_summary = Some(proto::UpdateDiagnosticSummary {
project_id: project_id,
worktree_id: worktree_id.to_proto(),
summary: Some(proto::DiagnosticSummary {
path: project_path.path.as_ref().to_proto(),
language_server_id: server_id.0 as u64,
error_count: new_summary.error_count,
warning_count: new_summary.warning_count,
}),
more_summaries: Vec::new(),
})
}
}
}
updated_diagnostics_paths
.entry(server_id)
.or_insert_with(Vec::new)
.push(project_path);
}
ControlFlow::Break(()) => {}
}
}
let updated = worktree.update(cx, |worktree, cx| {
self.update_worktree_diagnostics(
worktree.id(),
server_id,
project_path.path.clone(),
diagnostics,
cx,
)
})?;
if updated {
cx.emit(LspStoreEvent::DiagnosticsUpdated {
language_server_id: server_id,
path: project_path,
})
if let Some((diagnostics_summary, (downstream_client, _))) =
diagnostics_summary.zip(self.downstream_client.as_ref())
{
downstream_client.send(diagnostics_summary).log_err();
}
for (server_id, paths) in updated_diagnostics_paths {
cx.emit(LspStoreEvent::DiagnosticsUpdated { server_id, paths });
}
Ok(())
}
@@ -7881,10 +7974,10 @@ impl LspStore {
&mut self,
worktree_id: WorktreeId,
server_id: LanguageServerId,
worktree_path: Arc<Path>,
path_in_worktree: Arc<Path>,
diagnostics: Vec<DiagnosticEntry<Unclipped<PointUtf16>>>,
_: &mut Context<Worktree>,
) -> Result<bool> {
) -> Result<ControlFlow<(), Option<(u64, proto::DiagnosticSummary)>>> {
let local = match &mut self.mode {
LspStoreMode::Local(local_lsp_store) => local_lsp_store,
_ => anyhow::bail!("update_worktree_diagnostics called on remote"),
@@ -7892,7 +7985,9 @@ impl LspStore {
let summaries_for_tree = self.diagnostic_summaries.entry(worktree_id).or_default();
let diagnostics_for_tree = local.diagnostics.entry(worktree_id).or_default();
let summaries_by_server_id = summaries_for_tree.entry(worktree_path.clone()).or_default();
let summaries_by_server_id = summaries_for_tree
.entry(path_in_worktree.clone())
.or_default();
let old_summary = summaries_by_server_id
.remove(&server_id)
@@ -7900,18 +7995,19 @@ impl LspStore {
let new_summary = DiagnosticSummary::new(&diagnostics);
if new_summary.is_empty() {
if let Some(diagnostics_by_server_id) = diagnostics_for_tree.get_mut(&worktree_path) {
if let Some(diagnostics_by_server_id) = diagnostics_for_tree.get_mut(&path_in_worktree)
{
if let Ok(ix) = diagnostics_by_server_id.binary_search_by_key(&server_id, |e| e.0) {
diagnostics_by_server_id.remove(ix);
}
if diagnostics_by_server_id.is_empty() {
diagnostics_for_tree.remove(&worktree_path);
diagnostics_for_tree.remove(&path_in_worktree);
}
}
} else {
summaries_by_server_id.insert(server_id, new_summary);
let diagnostics_by_server_id = diagnostics_for_tree
.entry(worktree_path.clone())
.entry(path_in_worktree.clone())
.or_default();
match diagnostics_by_server_id.binary_search_by_key(&server_id, |e| e.0) {
Ok(ix) => {
@@ -7924,23 +8020,22 @@ impl LspStore {
}
if !old_summary.is_empty() || !new_summary.is_empty() {
if let Some((downstream_client, project_id)) = &self.downstream_client {
downstream_client
.send(proto::UpdateDiagnosticSummary {
project_id: *project_id,
worktree_id: worktree_id.to_proto(),
summary: Some(proto::DiagnosticSummary {
path: worktree_path.to_proto(),
language_server_id: server_id.0 as u64,
error_count: new_summary.error_count as u32,
warning_count: new_summary.warning_count as u32,
}),
})
.log_err();
if let Some((_, project_id)) = &self.downstream_client {
Ok(ControlFlow::Continue(Some((
*project_id,
proto::DiagnosticSummary {
path: path_in_worktree.to_proto(),
language_server_id: server_id.0 as u64,
error_count: new_summary.error_count as u32,
warning_count: new_summary.warning_count as u32,
},
))))
} else {
Ok(ControlFlow::Continue(None))
}
} else {
Ok(ControlFlow::Break(()))
}
Ok(!old_summary.is_empty() || !new_summary.is_empty())
}
pub fn open_buffer_for_symbol(
@@ -8793,23 +8888,30 @@ impl LspStore {
envelope: TypedEnvelope<proto::UpdateDiagnosticSummary>,
mut cx: AsyncApp,
) -> Result<()> {
this.update(&mut cx, |this, cx| {
this.update(&mut cx, |lsp_store, cx| {
let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
if let Some(message) = envelope.payload.summary {
let mut updated_diagnostics_paths = HashMap::default();
let mut diagnostics_summary = None::<proto::UpdateDiagnosticSummary>;
for message_summary in envelope
.payload
.summary
.into_iter()
.chain(envelope.payload.more_summaries)
{
let project_path = ProjectPath {
worktree_id,
path: Arc::<Path>::from_proto(message.path),
path: Arc::<Path>::from_proto(message_summary.path),
};
let path = project_path.path.clone();
let server_id = LanguageServerId(message.language_server_id as usize);
let server_id = LanguageServerId(message_summary.language_server_id as usize);
let summary = DiagnosticSummary {
error_count: message.error_count as usize,
warning_count: message.warning_count as usize,
error_count: message_summary.error_count as usize,
warning_count: message_summary.warning_count as usize,
};
if summary.is_empty() {
if let Some(worktree_summaries) =
this.diagnostic_summaries.get_mut(&worktree_id)
lsp_store.diagnostic_summaries.get_mut(&worktree_id)
{
if let Some(summaries) = worktree_summaries.get_mut(&path) {
summaries.remove(&server_id);
@@ -8819,31 +8921,55 @@ impl LspStore {
}
}
} else {
this.diagnostic_summaries
lsp_store
.diagnostic_summaries
.entry(worktree_id)
.or_default()
.entry(path)
.or_default()
.insert(server_id, summary);
}
if let Some((downstream_client, project_id)) = &this.downstream_client {
downstream_client
.send(proto::UpdateDiagnosticSummary {
project_id: *project_id,
worktree_id: worktree_id.to_proto(),
summary: Some(proto::DiagnosticSummary {
path: project_path.path.as_ref().to_proto(),
language_server_id: server_id.0 as u64,
error_count: summary.error_count as u32,
warning_count: summary.warning_count as u32,
}),
})
.log_err();
if let Some((_, project_id)) = &lsp_store.downstream_client {
match &mut diagnostics_summary {
Some(diagnostics_summary) => {
diagnostics_summary
.more_summaries
.push(proto::DiagnosticSummary {
path: project_path.path.as_ref().to_proto(),
language_server_id: server_id.0 as u64,
error_count: summary.error_count as u32,
warning_count: summary.warning_count as u32,
})
}
None => {
diagnostics_summary = Some(proto::UpdateDiagnosticSummary {
project_id: *project_id,
worktree_id: worktree_id.to_proto(),
summary: Some(proto::DiagnosticSummary {
path: project_path.path.as_ref().to_proto(),
language_server_id: server_id.0 as u64,
error_count: summary.error_count as u32,
warning_count: summary.warning_count as u32,
}),
more_summaries: Vec::new(),
})
}
}
}
cx.emit(LspStoreEvent::DiagnosticsUpdated {
language_server_id: LanguageServerId(message.language_server_id as usize),
path: project_path,
});
updated_diagnostics_paths
.entry(server_id)
.or_insert_with(Vec::new)
.push(project_path);
}
if let Some((diagnostics_summary, (downstream_client, _))) =
diagnostics_summary.zip(lsp_store.downstream_client.as_ref())
{
downstream_client.send(diagnostics_summary).log_err();
}
for (server_id, paths) in updated_diagnostics_paths {
cx.emit(LspStoreEvent::DiagnosticsUpdated { server_id, paths });
}
Ok(())
})?
@@ -10361,6 +10487,7 @@ impl LspStore {
error_count: 0,
warning_count: 0,
}),
more_summaries: Vec::new(),
})
.log_err();
}
@@ -10649,52 +10776,80 @@ impl LspStore {
)
}
#[cfg(any(test, feature = "test-support"))]
pub fn update_diagnostics(
&mut self,
language_server_id: LanguageServerId,
params: lsp::PublishDiagnosticsParams,
server_id: LanguageServerId,
diagnostics: lsp::PublishDiagnosticsParams,
result_id: Option<String>,
source_kind: DiagnosticSourceKind,
disk_based_sources: &[String],
cx: &mut Context<Self>,
) -> Result<()> {
self.merge_diagnostics(
language_server_id,
params,
result_id,
self.merge_lsp_diagnostics(
source_kind,
disk_based_sources,
vec![DocumentDiagnosticsUpdate {
diagnostics,
result_id,
server_id,
disk_based_sources: Cow::Borrowed(disk_based_sources),
}],
|_, _, _| false,
cx,
)
}
pub fn merge_diagnostics(
pub fn merge_lsp_diagnostics(
&mut self,
language_server_id: LanguageServerId,
mut params: lsp::PublishDiagnosticsParams,
result_id: Option<String>,
source_kind: DiagnosticSourceKind,
disk_based_sources: &[String],
filter: impl Fn(&Buffer, &Diagnostic, &App) -> bool + Clone,
lsp_diagnostics: Vec<DocumentDiagnosticsUpdate<lsp::PublishDiagnosticsParams>>,
merge: impl Fn(&Buffer, &Diagnostic, &App) -> bool + Clone,
cx: &mut Context<Self>,
) -> Result<()> {
anyhow::ensure!(self.mode.is_local(), "called update_diagnostics on remote");
let abs_path = params
.uri
.to_file_path()
.map_err(|()| anyhow!("URI is not a file"))?;
let updates = lsp_diagnostics
.into_iter()
.filter_map(|update| {
let abs_path = update.diagnostics.uri.to_file_path().ok()?;
Some(DocumentDiagnosticsUpdate {
diagnostics: self.lsp_to_document_diagnostics(
abs_path,
source_kind,
update.server_id,
update.diagnostics,
&update.disk_based_sources,
),
result_id: update.result_id,
server_id: update.server_id,
disk_based_sources: update.disk_based_sources,
})
})
.collect();
self.merge_diagnostic_entries(updates, merge, cx)?;
Ok(())
}
fn lsp_to_document_diagnostics(
&mut self,
document_abs_path: PathBuf,
source_kind: DiagnosticSourceKind,
server_id: LanguageServerId,
mut lsp_diagnostics: lsp::PublishDiagnosticsParams,
disk_based_sources: &[String],
) -> DocumentDiagnostics {
let mut diagnostics = Vec::default();
let mut primary_diagnostic_group_ids = HashMap::default();
let mut sources_by_group_id = HashMap::default();
let mut supporting_diagnostics = HashMap::default();
let adapter = self.language_server_adapter_for_id(language_server_id);
let adapter = self.language_server_adapter_for_id(server_id);
// Ensure that primary diagnostics are always the most severe
params.diagnostics.sort_by_key(|item| item.severity);
lsp_diagnostics
.diagnostics
.sort_by_key(|item| item.severity);
for diagnostic in &params.diagnostics {
for diagnostic in &lsp_diagnostics.diagnostics {
let source = diagnostic.source.as_ref();
let range = range_from_lsp(diagnostic.range);
let is_supporting = diagnostic
@@ -10716,7 +10871,7 @@ impl LspStore {
.map_or(false, |tags| tags.contains(&DiagnosticTag::UNNECESSARY));
let underline = self
.language_server_adapter_for_id(language_server_id)
.language_server_adapter_for_id(server_id)
.map_or(true, |adapter| adapter.underline_diagnostic(diagnostic));
if is_supporting {
@@ -10758,7 +10913,7 @@ impl LspStore {
});
if let Some(infos) = &diagnostic.related_information {
for info in infos {
if info.location.uri == params.uri && !info.message.is_empty() {
if info.location.uri == lsp_diagnostics.uri && !info.message.is_empty() {
let range = range_from_lsp(info.location.range);
diagnostics.push(DiagnosticEntry {
range,
@@ -10806,16 +10961,11 @@ impl LspStore {
}
}
self.merge_diagnostic_entries(
language_server_id,
abs_path,
result_id,
params.version,
DocumentDiagnostics {
diagnostics,
filter,
cx,
)?;
Ok(())
document_abs_path,
version: lsp_diagnostics.version,
}
}
fn insert_newly_running_language_server(
@@ -11571,67 +11721,84 @@ impl LspStore {
) {
let workspace_diagnostics =
GetDocumentDiagnostics::deserialize_workspace_diagnostics_report(report, server_id);
for workspace_diagnostics in workspace_diagnostics {
let LspPullDiagnostics::Response {
server_id,
uri,
diagnostics,
} = workspace_diagnostics.diagnostics
else {
continue;
};
let adapter = self.language_server_adapter_for_id(server_id);
let disk_based_sources = adapter
.as_ref()
.map(|adapter| adapter.disk_based_diagnostic_sources.as_slice())
.unwrap_or(&[]);
match diagnostics {
PulledDiagnostics::Unchanged { result_id } => {
self.merge_diagnostics(
let mut unchanged_buffers = HashSet::default();
let mut changed_buffers = HashSet::default();
let workspace_diagnostics_updates = workspace_diagnostics
.into_iter()
.filter_map(
|workspace_diagnostics| match workspace_diagnostics.diagnostics {
LspPullDiagnostics::Response {
server_id,
lsp::PublishDiagnosticsParams {
uri: uri.clone(),
diagnostics: Vec::new(),
version: None,
},
Some(result_id),
DiagnosticSourceKind::Pulled,
disk_based_sources,
|_, _, _| true,
cx,
)
.log_err();
}
PulledDiagnostics::Changed {
diagnostics,
result_id,
} => {
self.merge_diagnostics(
server_id,
lsp::PublishDiagnosticsParams {
uri: uri.clone(),
uri,
diagnostics,
} => Some((server_id, uri, diagnostics, workspace_diagnostics.version)),
LspPullDiagnostics::Default => None,
},
)
.fold(
HashMap::default(),
|mut acc, (server_id, uri, diagnostics, version)| {
let (result_id, diagnostics) = match diagnostics {
PulledDiagnostics::Unchanged { result_id } => {
unchanged_buffers.insert(uri.clone());
(Some(result_id), Vec::new())
}
PulledDiagnostics::Changed {
result_id,
diagnostics,
version: workspace_diagnostics.version,
},
result_id,
DiagnosticSourceKind::Pulled,
disk_based_sources,
|buffer, old_diagnostic, cx| match old_diagnostic.source_kind {
DiagnosticSourceKind::Pulled => {
let buffer_url = File::from_dyn(buffer.file())
.map(|f| f.abs_path(cx))
.and_then(|abs_path| file_path_to_lsp_url(&abs_path).ok());
buffer_url.is_none_or(|buffer_url| buffer_url != uri)
}
DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => true,
},
cx,
)
.log_err();
}
}
} => {
changed_buffers.insert(uri.clone());
(result_id, diagnostics)
}
};
let disk_based_sources = Cow::Owned(
self.language_server_adapter_for_id(server_id)
.as_ref()
.map(|adapter| adapter.disk_based_diagnostic_sources.as_slice())
.unwrap_or(&[])
.to_vec(),
);
acc.entry(server_id)
.or_insert_with(Vec::new)
.push(DocumentDiagnosticsUpdate {
server_id,
diagnostics: lsp::PublishDiagnosticsParams {
uri,
diagnostics,
version,
},
result_id,
disk_based_sources,
});
acc
},
);
for diagnostic_updates in workspace_diagnostics_updates.into_values() {
self.merge_lsp_diagnostics(
DiagnosticSourceKind::Pulled,
diagnostic_updates,
|buffer, old_diagnostic, cx| {
File::from_dyn(buffer.file())
.and_then(|file| {
let abs_path = file.as_local()?.abs_path(cx);
lsp::Url::from_file_path(abs_path).ok()
})
.is_none_or(|buffer_uri| {
unchanged_buffers.contains(&buffer_uri)
|| match old_diagnostic.source_kind {
DiagnosticSourceKind::Pulled => {
!changed_buffers.contains(&buffer_uri)
}
DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => {
true
}
}
})
},
cx,
)
.log_err();
}
}
}

View File

@@ -1,4 +1,4 @@
use std::sync::Arc;
use std::{borrow::Cow, sync::Arc};
use ::serde::{Deserialize, Serialize};
use gpui::WeakEntity;
@@ -6,7 +6,7 @@ use language::{CachedLspAdapter, Diagnostic, DiagnosticSourceKind};
use lsp::{LanguageServer, LanguageServerName};
use util::ResultExt as _;
use crate::LspStore;
use crate::{LspStore, lsp_store::DocumentDiagnosticsUpdate};
pub const CLANGD_SERVER_NAME: LanguageServerName = LanguageServerName::new_static("clangd");
const INACTIVE_REGION_MESSAGE: &str = "inactive region";
@@ -81,12 +81,16 @@ pub fn register_notifications(
version: params.text_document.version,
diagnostics,
};
this.merge_diagnostics(
server_id,
mapped_diagnostics,
None,
this.merge_lsp_diagnostics(
DiagnosticSourceKind::Pushed,
&adapter.disk_based_diagnostic_sources,
vec![DocumentDiagnosticsUpdate {
server_id,
diagnostics: mapped_diagnostics,
result_id: None,
disk_based_sources: Cow::Borrowed(
&adapter.disk_based_diagnostic_sources,
),
}],
|_, diag, _| !is_inactive_region(diag),
cx,
)

View File

@@ -73,11 +73,10 @@ use gpui::{
App, AppContext, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, Hsla, SharedString,
Task, WeakEntity, Window,
};
use itertools::Itertools;
use language::{
Buffer, BufferEvent, Capability, CodeLabel, CursorShape, DiagnosticSourceKind, Language,
LanguageName, LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainList,
Transaction, Unclipped, language_settings::InlayHintKind, proto::split_operations,
Buffer, BufferEvent, Capability, CodeLabel, CursorShape, Language, LanguageName,
LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainList, Transaction,
Unclipped, language_settings::InlayHintKind, proto::split_operations,
};
use lsp::{
CodeActionKind, CompletionContext, CompletionItemKind, DocumentHighlightKind, InsertTextMode,
@@ -113,7 +112,7 @@ use std::{
use task_store::TaskStore;
use terminals::Terminals;
use text::{Anchor, BufferId, OffsetRangeExt, Point};
use text::{Anchor, BufferId, OffsetRangeExt, Point, Rope};
use toolchain_store::EmptyToolchainStore;
use util::{
ResultExt as _,
@@ -306,7 +305,7 @@ pub enum Event {
language_server_id: LanguageServerId,
},
DiagnosticsUpdated {
path: ProjectPath,
paths: Vec<ProjectPath>,
language_server_id: LanguageServerId,
},
RemoteIdChanged(Option<u64>),
@@ -668,10 +667,10 @@ pub enum ResolveState {
}
impl InlayHint {
pub fn text(&self) -> String {
pub fn text(&self) -> Rope {
match &self.label {
InlayHintLabel::String(s) => s.to_owned(),
InlayHintLabel::LabelParts(parts) => parts.iter().map(|part| &part.value).join(""),
InlayHintLabel::String(s) => Rope::from(s),
InlayHintLabel::LabelParts(parts) => parts.iter().map(|part| &*part.value).collect(),
}
}
}
@@ -2896,18 +2895,17 @@ impl Project {
cx: &mut Context<Self>,
) {
match event {
LspStoreEvent::DiagnosticsUpdated {
language_server_id,
path,
} => cx.emit(Event::DiagnosticsUpdated {
path: path.clone(),
language_server_id: *language_server_id,
}),
LspStoreEvent::LanguageServerAdded(language_server_id, name, worktree_id) => cx.emit(
Event::LanguageServerAdded(*language_server_id, name.clone(), *worktree_id),
LspStoreEvent::DiagnosticsUpdated { server_id, paths } => {
cx.emit(Event::DiagnosticsUpdated {
paths: paths.clone(),
language_server_id: *server_id,
})
}
LspStoreEvent::LanguageServerAdded(server_id, name, worktree_id) => cx.emit(
Event::LanguageServerAdded(*server_id, name.clone(), *worktree_id),
),
LspStoreEvent::LanguageServerRemoved(language_server_id) => {
cx.emit(Event::LanguageServerRemoved(*language_server_id))
LspStoreEvent::LanguageServerRemoved(server_id) => {
cx.emit(Event::LanguageServerRemoved(*server_id))
}
LspStoreEvent::LanguageServerLog(server_id, log_type, string) => cx.emit(
Event::LanguageServerLog(*server_id, log_type.clone(), string.clone()),
@@ -3830,27 +3828,6 @@ impl Project {
})
}
pub fn update_diagnostics(
&mut self,
language_server_id: LanguageServerId,
source_kind: DiagnosticSourceKind,
result_id: Option<String>,
params: lsp::PublishDiagnosticsParams,
disk_based_sources: &[String],
cx: &mut Context<Self>,
) -> Result<(), anyhow::Error> {
self.lsp_store.update(cx, |lsp_store, cx| {
lsp_store.update_diagnostics(
language_server_id,
params,
result_id,
source_kind,
disk_based_sources,
cx,
)
})
}
pub fn search(&mut self, query: SearchQuery, cx: &mut Context<Self>) -> Receiver<SearchResult> {
let (result_tx, result_rx) = smol::channel::unbounded();

View File

@@ -431,10 +431,9 @@ impl GitSettings {
pub fn inline_blame_delay(&self) -> Option<Duration> {
match self.inline_blame {
Some(InlineBlameSettings {
delay_ms: Some(delay_ms),
..
}) if delay_ms > 0 => Some(Duration::from_millis(delay_ms)),
Some(InlineBlameSettings { delay_ms, .. }) if delay_ms > 0 => {
Some(Duration::from_millis(delay_ms))
}
_ => None,
}
}
@@ -470,7 +469,7 @@ pub enum GitGutterSetting {
Hide,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct InlineBlameSettings {
/// Whether or not to show git blame data inline in
@@ -483,11 +482,19 @@ pub struct InlineBlameSettings {
/// after a delay once the cursor stops moving.
///
/// Default: 0
pub delay_ms: Option<u64>,
#[serde(default)]
pub delay_ms: u64,
/// The amount of padding between the end of the source line and the start
/// of the inline blame in units of columns.
///
/// Default: 7
#[serde(default = "default_inline_blame_padding")]
pub padding: u32,
/// The minimum column number to show the inline blame information at
///
/// Default: 0
pub min_column: Option<u32>,
#[serde(default)]
pub min_column: u32,
/// Whether to show commit summary as part of the inline blame.
///
/// Default: false
@@ -495,6 +502,22 @@ pub struct InlineBlameSettings {
pub show_commit_summary: bool,
}
fn default_inline_blame_padding() -> u32 {
7
}
impl Default for InlineBlameSettings {
fn default() -> Self {
Self {
enabled: true,
delay_ms: 0,
padding: default_inline_blame_padding(),
min_column: 0,
show_commit_summary: false,
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
pub struct BinarySettings {
pub path: Option<String>,

View File

@@ -18,9 +18,10 @@ use git::{
use git2::RepositoryInitOptions;
use gpui::{App, BackgroundExecutor, SemanticVersion, UpdateGlobal};
use http_client::Url;
use itertools::Itertools;
use language::{
Diagnostic, DiagnosticEntry, DiagnosticSet, DiskState, FakeLspAdapter, LanguageConfig,
LanguageMatcher, LanguageName, LineEnding, OffsetRangeExt, Point, ToPoint,
Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, DiskState, FakeLspAdapter,
LanguageConfig, LanguageMatcher, LanguageName, LineEnding, OffsetRangeExt, Point, ToPoint,
language_settings::{AllLanguageSettings, LanguageSettingsContent, language_settings},
tree_sitter_rust, tree_sitter_typescript,
};
@@ -1618,7 +1619,7 @@ async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) {
events.next().await.unwrap(),
Event::DiagnosticsUpdated {
language_server_id: LanguageServerId(0),
path: (worktree_id, Path::new("a.rs")).into()
paths: vec![(worktree_id, Path::new("a.rs")).into()],
}
);
@@ -1666,7 +1667,7 @@ async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) {
events.next().await.unwrap(),
Event::DiagnosticsUpdated {
language_server_id: LanguageServerId(0),
path: (worktree_id, Path::new("a.rs")).into()
paths: vec![(worktree_id, Path::new("a.rs")).into()],
}
);

View File

@@ -19,6 +19,7 @@ command_palette_hooks.workspace = true
db.workspace = true
editor.workspace = true
file_icons.workspace = true
git_ui.workspace = true
indexmap.workspace = true
git.workspace = true
gpui.workspace = true

View File

@@ -16,6 +16,7 @@ use editor::{
};
use file_icons::FileIcons;
use git::status::GitSummary;
use git_ui::file_diff_view::FileDiffView;
use gpui::{
Action, AnyElement, App, ArcCow, AsyncWindowContext, Bounds, ClipboardItem, Context,
CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths,
@@ -93,7 +94,7 @@ pub struct ProjectPanel {
unfolded_dir_ids: HashSet<ProjectEntryId>,
// Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
selection: Option<SelectedEntry>,
marked_entries: BTreeSet<SelectedEntry>,
marked_entries: Vec<SelectedEntry>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
edit_state: Option<EditState>,
filename_editor: Entity<Editor>,
@@ -280,6 +281,8 @@ actions!(
SelectNextDirectory,
/// Selects the previous directory.
SelectPrevDirectory,
/// Opens a diff view to compare two marked files.
CompareMarkedFiles,
]
);
@@ -376,7 +379,7 @@ struct DraggedProjectEntryView {
selection: SelectedEntry,
details: EntryDetails,
click_offset: Point<Pixels>,
selections: Arc<BTreeSet<SelectedEntry>>,
selections: Arc<[SelectedEntry]>,
}
struct ItemColors {
@@ -442,7 +445,15 @@ impl ProjectPanel {
}
}
project::Event::ActiveEntryChanged(None) => {
this.marked_entries.clear();
let is_active_item_file_diff_view = this
.workspace
.upgrade()
.and_then(|ws| ws.read(cx).active_item(cx))
.map(|item| item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some())
.unwrap_or(false);
if !is_active_item_file_diff_view {
this.marked_entries.clear();
}
}
project::Event::RevealInProjectPanel(entry_id) => {
if let Some(()) = this
@@ -676,7 +687,7 @@ impl ProjectPanel {
project_panel.update(cx, |project_panel, _| {
let entry = SelectedEntry { worktree_id, entry_id };
project_panel.marked_entries.clear();
project_panel.marked_entries.insert(entry);
project_panel.marked_entries.push(entry);
project_panel.selection = Some(entry);
});
if !focus_opened_item {
@@ -887,6 +898,7 @@ impl ProjectPanel {
let should_hide_rename = is_root
&& (cfg!(target_os = "windows")
|| (settings.hide_root && visible_worktrees_count == 1));
let should_show_compare = !is_dir && self.file_abs_paths_to_diff(cx).is_some();
let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
menu.context(self.focus_handle.clone()).map(|menu| {
@@ -918,6 +930,10 @@ impl ProjectPanel {
.when(is_foldable, |menu| {
menu.action("Fold Directory", Box::new(FoldDirectory))
})
.when(should_show_compare, |menu| {
menu.separator()
.action("Compare marked files", Box::new(CompareMarkedFiles))
})
.separator()
.action("Cut", Box::new(Cut))
.action("Copy", Box::new(Copy))
@@ -1262,7 +1278,7 @@ impl ProjectPanel {
};
self.selection = Some(selection);
if window.modifiers().shift {
self.marked_entries.insert(selection);
self.marked_entries.push(selection);
}
self.autoscroll(cx);
cx.notify();
@@ -2007,7 +2023,7 @@ impl ProjectPanel {
};
self.selection = Some(selection);
if window.modifiers().shift {
self.marked_entries.insert(selection);
self.marked_entries.push(selection);
}
self.autoscroll(cx);
@@ -2244,7 +2260,7 @@ impl ProjectPanel {
};
self.selection = Some(selection);
if window.modifiers().shift {
self.marked_entries.insert(selection);
self.marked_entries.push(selection);
}
self.autoscroll(cx);
cx.notify();
@@ -2572,6 +2588,43 @@ impl ProjectPanel {
}
}
fn file_abs_paths_to_diff(&self, cx: &Context<Self>) -> Option<(PathBuf, PathBuf)> {
let mut selections_abs_path = self
.marked_entries
.iter()
.filter_map(|entry| {
let project = self.project.read(cx);
let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
let entry = worktree.read(cx).entry_for_id(entry.entry_id)?;
if !entry.is_file() {
return None;
}
worktree.read(cx).absolutize(&entry.path).ok()
})
.rev();
let last_path = selections_abs_path.next()?;
let previous_to_last = selections_abs_path.next()?;
Some((previous_to_last, last_path))
}
fn compare_marked_files(
&mut self,
_: &CompareMarkedFiles,
window: &mut Window,
cx: &mut Context<Self>,
) {
let selected_files = self.file_abs_paths_to_diff(cx);
if let Some((file_path1, file_path2)) = selected_files {
self.workspace
.update(cx, |workspace, cx| {
FileDiffView::open(file_path1, file_path2, workspace, window, cx)
.detach_and_log_err(cx);
})
.ok();
}
}
fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context<Self>) {
if let Some((worktree, entry)) = self.selected_entry(cx) {
let abs_path = worktree.abs_path().join(&entry.path);
@@ -3914,11 +3967,9 @@ impl ProjectPanel {
let depth = details.depth;
let worktree_id = details.worktree_id;
let selections = Arc::new(self.marked_entries.clone());
let dragged_selection = DraggedSelection {
active_selection: selection,
marked_selections: selections,
marked_selections: Arc::from(self.marked_entries.clone()),
};
let bg_color = if is_marked {
@@ -4089,7 +4140,7 @@ impl ProjectPanel {
});
if drag_state.items().count() == 1 {
this.marked_entries.clear();
this.marked_entries.insert(drag_state.active_selection);
this.marked_entries.push(drag_state.active_selection);
}
this.hover_expand_task.take();
@@ -4156,65 +4207,69 @@ impl ProjectPanel {
}),
)
.on_click(
cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
cx.listener(move |project_panel, event: &gpui::ClickEvent, window, cx| {
if event.is_right_click() || event.first_focus()
|| show_editor
{
return;
}
if event.standard_click() {
this.mouse_down = false;
project_panel.mouse_down = false;
}
cx.stop_propagation();
if let Some(selection) = this.selection.filter(|_| event.modifiers().shift) {
let current_selection = this.index_for_selection(selection);
if let Some(selection) = project_panel.selection.filter(|_| event.modifiers().shift) {
let current_selection = project_panel.index_for_selection(selection);
let clicked_entry = SelectedEntry {
entry_id,
worktree_id,
};
let target_selection = this.index_for_selection(clicked_entry);
let target_selection = project_panel.index_for_selection(clicked_entry);
if let Some(((_, _, source_index), (_, _, target_index))) =
current_selection.zip(target_selection)
{
let range_start = source_index.min(target_index);
let range_end = source_index.max(target_index) + 1;
let mut new_selections = BTreeSet::new();
this.for_each_visible_entry(
let mut new_selections = Vec::new();
project_panel.for_each_visible_entry(
range_start..range_end,
window,
cx,
|entry_id, details, _, _| {
new_selections.insert(SelectedEntry {
new_selections.push(SelectedEntry {
entry_id,
worktree_id: details.worktree_id,
});
},
);
this.marked_entries = this
.marked_entries
.union(&new_selections)
.cloned()
.collect();
for selection in &new_selections {
if !project_panel.marked_entries.contains(selection) {
project_panel.marked_entries.push(*selection);
}
}
this.selection = Some(clicked_entry);
this.marked_entries.insert(clicked_entry);
project_panel.selection = Some(clicked_entry);
if !project_panel.marked_entries.contains(&clicked_entry) {
project_panel.marked_entries.push(clicked_entry);
}
}
} else if event.modifiers().secondary() {
if event.click_count() > 1 {
this.split_entry(entry_id, cx);
project_panel.split_entry(entry_id, cx);
} else {
this.selection = Some(selection);
if !this.marked_entries.insert(selection) {
this.marked_entries.remove(&selection);
project_panel.selection = Some(selection);
if let Some(position) = project_panel.marked_entries.iter().position(|e| *e == selection) {
project_panel.marked_entries.remove(position);
} else {
project_panel.marked_entries.push(selection);
}
}
} else if kind.is_dir() {
this.marked_entries.clear();
project_panel.marked_entries.clear();
if is_sticky {
if let Some((_, _, index)) = this.index_for_entry(entry_id, worktree_id) {
this.scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, sticky_index.unwrap_or(0));
if let Some((_, _, index)) = project_panel.index_for_entry(entry_id, worktree_id) {
project_panel.scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, sticky_index.unwrap_or(0));
cx.notify();
// move down by 1px so that clicked item
// don't count as sticky anymore
@@ -4230,16 +4285,16 @@ impl ProjectPanel {
}
}
if event.modifiers().alt {
this.toggle_expand_all(entry_id, window, cx);
project_panel.toggle_expand_all(entry_id, window, cx);
} else {
this.toggle_expanded(entry_id, window, cx);
project_panel.toggle_expanded(entry_id, window, cx);
}
} else {
let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
let click_count = event.click_count();
let focus_opened_item = !preview_tabs_enabled || click_count > 1;
let allow_preview = preview_tabs_enabled && click_count == 1;
this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
project_panel.open_entry(entry_id, focus_opened_item, allow_preview, cx);
}
}),
)
@@ -4810,12 +4865,21 @@ impl ProjectPanel {
{
anyhow::bail!("can't reveal an ignored entry in the project panel");
}
let is_active_item_file_diff_view = self
.workspace
.upgrade()
.and_then(|ws| ws.read(cx).active_item(cx))
.map(|item| item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some())
.unwrap_or(false);
if is_active_item_file_diff_view {
return Ok(());
}
let worktree_id = worktree.id();
self.expand_entry(worktree_id, entry_id, cx);
self.update_visible_entries(Some((worktree_id, entry_id)), cx);
self.marked_entries.clear();
self.marked_entries.insert(SelectedEntry {
self.marked_entries.push(SelectedEntry {
worktree_id,
entry_id,
});
@@ -5170,6 +5234,7 @@ impl Render for ProjectPanel {
.on_action(cx.listener(Self::unfold_directory))
.on_action(cx.listener(Self::fold_directory))
.on_action(cx.listener(Self::remove_from_project))
.on_action(cx.listener(Self::compare_marked_files))
.when(!project.is_read_only(cx), |el| {
el.on_action(cx.listener(Self::new_file))
.on_action(cx.listener(Self::new_directory))

View File

@@ -8,7 +8,7 @@ use settings::SettingsStore;
use std::path::{Path, PathBuf};
use util::path;
use workspace::{
AppState, Pane,
AppState, ItemHandle, Pane,
item::{Item, ProjectItem},
register_project_item,
};
@@ -3068,7 +3068,7 @@ async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
panel.update(cx, |this, cx| {
let drag = DraggedSelection {
active_selection: this.selection.unwrap(),
marked_selections: Arc::new(this.marked_entries.clone()),
marked_selections: this.marked_entries.clone().into(),
};
let target_entry = this
.project
@@ -5562,10 +5562,10 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
worktree_id,
entry_id: child_file.id,
},
marked_selections: Arc::new(BTreeSet::from([SelectedEntry {
marked_selections: Arc::new([SelectedEntry {
worktree_id,
entry_id: child_file.id,
}])),
}]),
};
let result =
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
@@ -5604,7 +5604,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
worktree_id,
entry_id: child_file.id,
},
marked_selections: Arc::new(BTreeSet::from([
marked_selections: Arc::new([
SelectedEntry {
worktree_id,
entry_id: child_file.id,
@@ -5613,7 +5613,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
worktree_id,
entry_id: sibling_file.id,
},
])),
]),
};
let result =
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
@@ -5821,6 +5821,186 @@ async fn test_hide_root(cx: &mut gpui::TestAppContext) {
}
}
#[gpui::test]
async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"file1.txt": "content of file1",
"file2.txt": "content of file2",
"dir1": {
"file3.txt": "content of file3"
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
let file1_path = path!("root/file1.txt");
let file2_path = path!("root/file2.txt");
select_path_with_mark(&panel, file1_path, cx);
select_path_with_mark(&panel, file2_path, cx);
panel.update_in(cx, |panel, window, cx| {
panel.compare_marked_files(&CompareMarkedFiles, window, cx);
});
cx.executor().run_until_parked();
workspace
.update(cx, |workspace, _, cx| {
let active_items = workspace
.panes()
.iter()
.filter_map(|pane| pane.read(cx).active_item())
.collect::<Vec<_>>();
assert_eq!(active_items.len(), 1);
let diff_view = active_items
.into_iter()
.next()
.unwrap()
.downcast::<FileDiffView>()
.expect("Open item should be an FileDiffView");
assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
assert_eq!(
diff_view.tab_tooltip_text(cx).unwrap(),
format!("{}{}", file1_path, file2_path)
);
})
.unwrap();
let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
let worktree_id = panel.update(cx, |panel, cx| {
panel
.project
.read(cx)
.worktrees(cx)
.next()
.unwrap()
.read(cx)
.id()
});
let expected_entries = [
SelectedEntry {
worktree_id,
entry_id: file1_entry_id,
},
SelectedEntry {
worktree_id,
entry_id: file2_entry_id,
},
];
panel.update(cx, |panel, _cx| {
assert_eq!(
&panel.marked_entries, &expected_entries,
"Should keep marked entries after comparison"
);
});
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
})
});
panel.update(cx, |panel, _cx| {
assert_eq!(
&panel.marked_entries, &expected_entries,
"Marked entries should persist after focusing back on the project panel"
);
});
}
#[gpui::test]
async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"file1.txt": "content of file1",
"file2.txt": "content of file2",
"dir1": {},
"dir2": {
"file3.txt": "content of file3"
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
// Test 1: When only one file is selected, there should be no compare option
select_path(&panel, "root/file1.txt", cx);
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
assert_eq!(
selected_files, None,
"Should not have compare option when only one file is selected"
);
// Test 2: When multiple files are selected, there should be a compare option
select_path_with_mark(&panel, "root/file1.txt", cx);
select_path_with_mark(&panel, "root/file2.txt", cx);
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
assert!(
selected_files.is_some(),
"Should have files selected for comparison"
);
if let Some((file1, file2)) = selected_files {
assert!(
file1.to_string_lossy().ends_with("file1.txt")
&& file2.to_string_lossy().ends_with("file2.txt"),
"Should have file1.txt and file2.txt as the selected files when multi-selecting"
);
}
// Test 3: Selecting a directory shouldn't count as a comparable file
select_path_with_mark(&panel, "root/dir1", cx);
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
assert!(
selected_files.is_some(),
"Directory selection should not affect comparable files"
);
if let Some((file1, file2)) = selected_files {
assert!(
file1.to_string_lossy().ends_with("file1.txt")
&& file2.to_string_lossy().ends_with("file2.txt"),
"Selecting a directory should not affect the number of comparable files"
);
}
// Test 4: Selecting one more file
select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
assert!(
selected_files.is_some(),
"Directory selection should not affect comparable files"
);
if let Some((file1, file2)) = selected_files {
assert!(
file1.to_string_lossy().ends_with("file2.txt")
&& file2.to_string_lossy().ends_with("file3.txt"),
"Selecting a directory should not affect the number of comparable files"
);
}
}
fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
let path = path.as_ref();
panel.update(cx, |panel, cx| {
@@ -5855,7 +6035,7 @@ fn select_path_with_mark(
entry_id,
};
if !panel.marked_entries.contains(&entry) {
panel.marked_entries.insert(entry);
panel.marked_entries.push(entry);
}
panel.selection = Some(entry);
return;

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