Compare commits

..

95 Commits

Author SHA1 Message Date
Conrad Irwin
323511f3c2 TEMP 2025-09-03 22:07:34 -07:00
Conrad Irwin
eb0436e84e branch diff 2025-09-03 17:43:23 -07:00
Conrad Irwin
ddb467de90 WIP
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
2025-09-03 15:57:41 -07:00
Conrad Irwin
6dc68e02a7 TEMP 2025-09-03 15:42:10 -07:00
Danilo Leal
bf1ae1d196 docs: Fix typo in the CLAUDE.md section (#37497)
Follow-up to https://github.com/zed-industries/zed/pull/37496. Fix a
typo and improves writing overall.

Release Notes:

- N/A
2025-09-03 18:46:35 -03:00
Danilo Leal
3b7dbb87b0 docs: Add note about CLAUDE.md usage (#37496)
Some users asked whether Claude Code in Zed can also observe/consume
`CLAUDE.md` guidelines, regardless of whether they're at the root
`.claude` directory or within the project. Answer is yes and the
documentation will mention it now!

Release Notes:

- N/A
2025-09-03 18:31:54 -03:00
Max Brunsfeld
bb13228ad5 Revert "Remote: Change "sh -c" to "sh -lc" (#36760)" (#37417)
This reverts commit bf5ed6d1c9.

We believe this may be breaking some users whose shell initialization
scripts change the working directory.

Release Notes:

- N/A
2025-09-03 14:24:32 -07:00
Danilo Leal
ec1528b890 thread view: Refine the terminal tool card header UI (#37488)
Rendering the disclosure button last (on the far right of the header
container) to avoid awkward layouts when there's truncation and elapsed
time information being displayed.

Release Notes:

- N/A
2025-09-03 18:09:59 -03:00
Danilo Leal
2aa0114b40 ai onboarding: Add some fast-follow adjustments (#37486)
Closes https://github.com/zed-industries/zed/issues/37305

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
2025-09-03 17:59:12 -03:00
localcc
bb2d833373 Revert "gpui: Fix overflow_hidden to support clip with border radius" (#37480)
This reverts commit 40199266b6.

The issue with the commit is: ContentMask<Pixels>::intersect is doing
intersection of corner radii which makes inner containers use the max
corner radius out of all the parents when it should be more complex to
correctly clip children (clip sorting..?)

Release Notes:

- N/A
2025-09-03 19:52:47 +00:00
Cole Miller
eedfc5be5a acp: Improve handling of invalid external agent server downloads (#37465)
Related to #37213, #37150

When listing previously-downloaded versions of an external agent, don't
try to use any downloads that are missing the agent entrypoint
(indicating that they're corrupt/unusable), and delete those versions,
so that we can attempt to download the latest version again.

Also report clearer errors when failing to start a session due to an
agent server entrypoint or root directory not existing.

Release Notes:

- N/A
2025-09-03 15:47:39 -04:00
Agus Zubiaga
0e76cc8036 acp: Display a new version call out when one is available (#37479)
<img width="500" alt="CleanShot 2025-09-03 at 16 13 59@2x"
src="https://github.com/user-attachments/assets/beb91365-28e2-4f87-a2c5-7136d37382c7"></img>



Release Notes:

- Agent Panel: Display a callout when a new version of an external agent
is available

---------

Co-authored-by: Cole Miller <cole@zed.dev>
2025-09-03 19:39:04 +00:00
Ben Kunkle
6bd5251882 settings_ui: Add test for default values (#37466)
Closes #ISSUE

Adds a test that checks that all settings have default values in
`default.json`. Currently only tests that settings supported by
SettingsUi have defaults, as more settings are added to the settings
editor they will be added to the test as well.

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-09-03 15:25:30 -04:00
Smit Barmase
13de400a2a editor: Do not correct text contrast on non-opaque editor (#37471)
We don’t know the background color behind a non-opaque editor, so we
should skip contrast correction in that case. This prevents
single-editor mode (which is always transparent) from showing weird text
colors when text is selected.

We can’t account for the actual background during contrast correction
because we compute contrast outside gpui, while the actual color
blending happens inside gpui during drawing.

<img width="522" height="145" alt="image"
src="https://github.com/user-attachments/assets/6ee71475-f666-482d-87e6-15cf4c4fceef"
/>

Release Notes:

- Fixed an issue where Command Palette text looked faded when selected.
2025-09-04 00:03:48 +05:30
Danilo Leal
c3480c3d6f docs: Update external agents content (#37413)
Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-03 09:59:49 -05:00
Kirill Bulatov
0cbacb8500 Make word deletions less greedy (#37352)
Closes https://github.com/zed-industries/zed/issues/37144

Adjusts `editor::DeleteToPreviousWordStart`,
`editor::DeleteToNextWordEnd`, `editor::DeleteToNextSubwordEnd` and
`editor::DeleteToPreviousSubwordStart` actions to

* take whitespace sequences with length >= 2 into account and stop after
removing them (whilst movement would also include the word after such
sequences)

* take current language's brackets into account and stop after removing
the text before them

The latter is configurable and can be disabled with `"ignore_brackets":
true` parameter in the action.

Release Notes:

- Improved word deletions to consider whitespace sequences and brackets
by default
2025-09-03 17:48:17 +03:00
Moritz von Göwels
7327ef662b terminal_view: Fix focusing of center-pane terminals (#37359)
With `reveal_stragegy=always` + `reveal_target=center`,
`TerminalPanel::spawn_task` activates & focuses the pane of the task.
This works fine in the terminal pane but doesn't for
`reveal_target=center`.

Please note: I'm not verified familiar with the architecture and
internal APIs of zed. If there's a better way or if this fix is a bad
idea, I'm fine with adapting this 😃

Closes #35908

Release Notes:

- Fixed task focus when re-spawning a task with `reveal_target=center`

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-09-03 14:23:46 +00:00
Lukas Wirth
c1ca7303a8 editor: Make blame and inline blame work for multibuffers (#37366)
Release Notes:

- Added blame view and inline blame support for multi buffer editors

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
2025-09-03 14:22:35 +00:00
localcc
92283285ae Fix rendering on devices that don't support MapOnDefaultTextures (#37456)
Closes #37231

Release Notes:

- N/A
2025-09-03 14:14:56 +00:00
Lukas Wirth
d80f9dda75 languages: Fix python tasks failing when binary contains whitespaces (#37454)
Fixes https://github.com/zed-industries/zed/issues/33459

Release Notes:

- Fixed python tasks failing when the python binary path contains
whitespaces
2025-09-03 16:11:36 +02:00
Nia
ebc22c290b gpui: Don't risk accidentally panicking during tests (#37457)
See the failure in
https://github.com/zed-industries/zed/actions/runs/17413839503/job/49437345296

Release Notes:

- N/A
2025-09-03 15:44:07 +02:00
Bennet Bo Fenner
7633bbf55a acp: Fix issue with claude code /logout command (#37452)
### First issue

In the scenario where you have an API key configured in Zed and you run
`/logout`, clicking on `Use Anthropic API Key` would show `Method not
implemented`.

This happened because we were only intercepting the `Use Anthropic API
Key` click if the provider was NOT authenticated, which would not be the
case when the user has an API key set.

### Second issue

When clicking on `Reset API Key` the modal would be dismissed even
though you picked no Authentication Method (which means you still would
be unauthenticated)

---

This PR fixes both of these issues

Release Notes:

- N/A
2025-09-03 12:08:48 +00:00
Bennet Bo Fenner
91cbb2ec25 Add onboarding banner for claude code support (#37443)
Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-09-03 10:59:14 +00:00
Jason Lee
40199266b6 gpui: Fix overflow_hidden to support clip with border radius (#35083)
Release Notes:

- N/A

---

Same case in HTML example:


https://developer.mozilla.org/en-US/play?id=p7FhB3JAhiVfLHAXnsbrn7JYYX%2Byq1gje%2B%2BTZarnXvvjmaAx3NlrXqMAoI35s4zeakShKee6lydHYeHr

```html
<div style="padding: 50px; text-align: center;">
  <div style="overflow: hidden; border-radius: 24px">
    <div style="background: #000; border: 3px solid red; color: #fff; padding: 8px 28px;">
      Let build applications with GPUI.
    </div>
    <div style="background: #333; border: 3px dashed black; color: #fff; padding: 8px 28px;">
      Let build applications with GPUI.
    </div>
  </div>

  <div style="margin-top: 20px; border-radius: 24px">
    <div style="background: #000; color: #fff; padding: 8px 28px;">
      This is not overflow: hidden.
    </div>
  </div>
</div>
```

<img width="610" height="213" alt="image"
src="https://github.com/user-attachments/assets/5f95e263-e52c-414f-8f0c-e6aa04ceb802"
/>

### Before

<img width="912" height="740" alt="image"
src="https://github.com/user-attachments/assets/f09c1936-52fc-4381-9a50-93977e9d64a6"
/>

### After 

```bash
cargo run -p gpui --example content_mask
```

<img width="912" height="740" alt="image"
src="https://github.com/user-attachments/assets/4bde58f3-c850-418d-9dc7-d2245852e7d7"
/> |


- [x] Metal
- [x] Blade
- [x] DirectX
- [x] ContentMask radius must reduce the container border widths.
- [x] The dash border render not correct, when not all side have
borders.
2025-09-03 12:44:33 +02:00
Danilo Leal
9a8c5053c2 agent: Update message editor placeholder (#37441)
Release Notes:

- N/A
2025-09-03 06:54:31 -03:00
localcc
c446662862 Fix font rendering at very large scales (#37440)
Release Notes:

- Fixed fonts disappearing at very large scales on windows
2025-09-03 09:21:45 +00:00
Finn Evers
6feae92616 rust: Improve highlighting in derive macros (#37439)
Follow-up to https://github.com/zed-industries/zed/pull/37049

This fixes an issue where we would lose highlighting in derive macros if
one of the names was qualified.

| Before | After |
| --- | --- |
| <img width="886" height="398" alt="Bildschirmfoto 2025-09-03 um 10 39
25"
src="https://github.com/user-attachments/assets/dbc680e3-6ce3-4059-9934-9daa4c59d4a0"
/> | <img width="886" height="398" alt="Bildschirmfoto 2025-09-03 um 10
38 14"
src="https://github.com/user-attachments/assets/6e10df6f-5158-4bfd-81ab-8f2b384f1e99"
/> |


Release Notes:

- N/A
2025-09-03 09:02:21 +00:00
Cole Miller
ae840c6ef3 acp: Fix handling of single-file worktrees (#37412)
When the first visible worktree is a single-file worktree, we would
previously try to use the absolute path of that file as the root
directory for external agents, causing an error. This PR changes how we
handle this situation: we'll use the root of the first non-single-file
visible worktree if there are any, and if there are none, the parent
directory of the first single-file visible worktree.

Related to #37213

Release Notes:

- acp: Fixed being unable to run external agents when a single file (not
part of a project) was opened in Zed.
2025-09-03 03:40:14 -04:00
Michael Sloan
d7fd5910d7 Use slice from Rope chunk when possible while iterating lines (#37430)
Release Notes:

- N/A
2025-09-03 06:35:31 +00:00
Kirill Bulatov
8d5861322b Allow wrapping markdown text into * by selecting text and writing the * (#37426)
Release Notes:

- Allowed wrapping markdown text into `*` by selecting text and writing
the `*`
2025-09-03 05:50:53 +00:00
Jakub Konka
5a9e18603d gpui: Fix intra rustdoc links (#37320)
The only warnings remaining are links to private modules/items, but I
lack knowledge to work out if the referenced modules/items should be
made public, or if the links should be rewritten into exposed
traits/items.

Links to associated items such as trait implementations have to be
written using full markdown format such as:

... [[ `App::update_global` ]](( BorrowAppContext::update_global ))

This is due to https://github.com/rust-lang/rust/issues/74563 which
sadly prohibits fully-qualified syntax:

... [[ `<App as BorrowAppContext>::update_global` ]]

Release Notes:

- N/A

Probably related to https://github.com/zed-industries/zed/pull/37072
2025-09-03 07:31:48 +02:00
chris
2a7761fe17 Instruct macOS users to run xcodebuild -downloadComponent MetalToolchain (#37411)
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Closes #ISSUE

Release Notes:

- N/A

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-09-02 21:36:36 -07:00
Max Brunsfeld
f23096034b Remove wsl command line args on non-windows platforms (#37422)
Release Notes:

- N/A
2025-09-03 03:49:04 +00:00
Peter Tripp
1ed17fdd94 Bump Zed to v0.204 (#37415)
Release Notes:

-N/A
2025-09-02 21:00:19 -04:00
Marshall Bowers
7ea7f4e767 reqwest_client: Remove example (#37410)
This PR removes the example from the `reqwest_client` crate, as it
doesn't seem worth maintaining.

Release Notes:

- N/A
2025-09-03 00:52:04 +00:00
Peter Tripp
035d7ddcf8 ci: Skip Nix for commits on release branches and tags (#37407)
When doing stable/preview releases simultaneously there are two tags and
two branches pushed. Previously nix was attempting 1 job for each. Our
current mac parallelism is 4.
 
Can't easily test this. 🤷 

Release Notes:

- N/A
2025-09-02 20:37:40 -04:00
Danilo Leal
9d67276090 agent: Fix cut off slash command descriptions (#37408)
Release Notes:

- N/A
2025-09-03 00:28:35 +00:00
Richard Feldman
161d128d45 Handle model refusal in ACP threads (#37383)
If the model refuses a prompt, we now:
* Show an error if it was a user prompt (and truncate it out of the
history)
* Respond with a failed tool call if the refusal was for a tool call

<img width="607" height="260" alt="Screenshot 2025-09-02 at 5 11 45 PM"
src="https://github.com/user-attachments/assets/070b5ee7-6ad6-4a63-8395-f9a5093cc40e"
/>
<img width="607" height="265" alt="Screenshot 2025-09-02 at 5 11 38 PM"
src="https://github.com/user-attachments/assets/98862586-390b-494e-b1f8-71d8341c8d9d"
/>



Release Notes:

- Improve handling of model refusals in ACP threads
2025-09-02 20:25:10 -04:00
Cole Miller
e1b0a98c34 ci: Remove Windows crash analysis CI scripts (#36694)
We'll just SSH into the Windows runners and look for crashes there.

Reverts #35926 

Release Notes:

- N/A

---------

Co-authored-by: Peter Tripp <petertripp@gmail.com>
2025-09-03 00:24:00 +00:00
Rafał Krzyważnia
ae0ee70abd Add configurable timeout for context server tool calls (#33348)
Closes: #32668

- Add
[tool_call_timeout_millis](https://github.com/cline/cline/pull/1904)
field to ContextServerCommand, like in Cline
- Update ModelContextServerBinary to include timeout configuration
- Modify Client to store and use configurable request timeout
- Replace hardcoded REQUEST_TIMEOUT with self.request_timeout
- Rename REQUEST_TIMEOUT to DEFAULT_REQUEST_TIMEOUT for clarity
- Maintain backward compatibility with 60-second default

Release Notes:

- context_server: Add support for configurable timeout for MCP tool
calls

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-03 00:03:56 +00:00
versecafe
893eb92f91 docs: Note edge case for macOS 26 (#37392)
- I believe this is caused by metal not being found due to it being on
the XcodeBeta path, not sure if there's a better fix for this but it'll
work until 26 is the latest release

Release Notes:

- N/A
2025-09-02 19:40:07 -04:00
Vitaly Slobodin
45fa6d81ac tailwind: Add HTML+ERB to the list of supported languages (#36797)
Hi! As part of https://github.com/zed-extensions/ruby/issues/162 we
would like to rename HTML/ERB to HTML+ERB since it is more syntactically
correct to treat such language as ERB on top of HTML rather than HTML or
ERB.

To keep the user experience intact, we outlined the prerequisites in the
linked issue. This is the first PR that adds the HTML+ERB language name
to the list of enabled languages for the Emmet extension. We will do the
same for the Tailwind configuration in the Zed codebase. Once the new
versions of Emmet and Zed are released, we will merge the pull request
in the Ruby extension repository and release the updated version. After
that, we will remove the old HTML/ERB and YAML/ERB languages. Let me
know if that sounds good. Thanks!

Release Notes:

- N/A

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-09-02 23:32:43 +00:00
Ben Brandt
60ad82cc94 Fix typo in clippy lint name (#37405)
Release Notes:

- N/A
2025-09-02 23:30:32 +00:00
Cole Miller
564ded71c1 acp: Disable external agents over SSH (#37402)
Follow-up to #37377 

Show a clearer error here until SSH support is implemented.

Release Notes:

- N/A
2025-09-02 19:29:21 -04:00
Umesh Yadav
63b3839a83 language_models: Prevent sending the tools object to unsupported models for Ollama (#37221)
Closes #32758

Release Notes:

- Resolved an issue with the Ollama provider that caused requests to
fail with a 400 error for models that don't support tools. The tools
object is now only sent to compatible models to ensure successful
requests.
2025-09-03 01:28:36 +02:00
Umesh Yadav
9f749881b3 language_models: Fix tool_choice null issue for other providers (#34554)
Follow up: #34532

Closes #35434 

Mostly fixes a issue were when the tool_choice is none it was getting
serialised as null. This was fixed for openrouter just wanted to follow
up and cleanup for other providers which might have this issue as this
is against the spec.

Release Notes:

- N/A
2025-09-03 01:22:57 +02:00
Danilo Leal
946efb03df Add option for code context menu items to have dynamic width (#37404)
Follow up to https://github.com/zed-industries/zed/pull/30598

This PR introduces the `display_options` field in the
`CompletionResponse`, allowing a code context menu width to be
dynamically dictated based on its larger item. This will allow us to
have the @-mentions and slash commands completion menus in the agent
panel not be bigger than it needs to be. It may also be relevant/useful
in the future for other use cases.

For now, we set all instances of code context menus to use a fixed
width, as defined in the PR linked above, which means this PR shouldn't
cause any visual change.

Release Notes:

- N/A

Co-authored-by: Michael Sloan <mgsloan+github@gmail.com>
2025-09-02 20:18:15 -03:00
Marshall Bowers
4b96ad3fba gpui: Remove http_client feature (#37401)
This PR removes the `http_client` feature from the `gpui` crate, as it
wasn't really doing anything.

It only controlled whether we depend on the `http_client` crate, but
from what I can tell we always depended on it anyways.

Obviates https://github.com/zed-industries/zed/pull/36615.

Release Notes:

- N/A
2025-09-02 23:14:47 +00:00
Umesh Yadav
4368c1b56b language_models: Add OpenRouterError and map OpenRouter errors to LanguageModelCompletionError (#34227)
Improves the error handling for openrouter and adds automatic retry like
anthropic for few of the status codes.
Release Notes:

- Improves error messages for Openrouter provider
- Automatic retry when rate limited or Server error from Openrouter
2025-09-03 01:13:46 +02:00
Dino
e5a968b709 vim: Fix change surround with any brackets text object (#37386)
This commit fixes an issue with how the `AnyBrackets` object was handled
with change surrounds (`cs`). With the keymap below, if one was to use
`csb{` with the text `(bracketed)` and the cursor inside the
parentheses, the text would not change.

```json
{
  "context": "vim_operator == a || vim_operator == i || vim_operator == cs",
  "bindings": {
    "b": "vim::AnyBrackets"
  }
}
```

Unfortunately there was no implementation for finding a corresponding
`BracketPair` for the `AnyBrackets` object, meaning that, when using
`cs` (change surrounds) the code would simply do nothing.

This commit updates this logic so as to try and find the nearest
surrounding bracket (parentheses, curly brackets, square brackets or
angle brackets), ensuring that `cs` also works with `AnyBrackets`.

Closes #24439

Release Notes:

- Fixed handling of `AnyBrackets` in vim's change surrounds (`cs`)
2025-09-02 16:03:14 -07:00
Ben Brandt
7aecab8e14 agent2: Only setup real client for real models (#37403)
Before we were setting up lots of test setup regardless of if we were
actually going to be making real requests or not.

This will hopefully help with intermittent test errors we're seeing on
Windows in CI.

Release Notes:

- N/A
2025-09-02 23:02:36 +00:00
Smit Barmase
e4df866664 editor: Do not show edit prediction during in-progress IME composition (#37400)
Closes #37249

We no longer show edit prediction when composing IME since it isn't
useful for unfinished alphabet.

Release Notes:

- Fixed edit predictions showing up during partial IME composition.
2025-09-03 03:41:10 +05:30
Bennet Bo Fenner
8770fcc841 acp: Enable claude code feature flag for everyone (#37390)
Release Notes:

- N/A
2025-09-02 17:57:29 -04:00
Smit Barmase
6dcae2711d terminal: Fix not able to select text during continuous output (#37395)
Closes #37211

Regressed in https://github.com/zed-industries/zed/pull/33305

Every time the terminal updates, we emit
`SearchEvent::MatchesInvalidated` to trigger a re-run of the buffer
search, which calls `clear_matches` to drop stale results.
https://github.com/zed-industries/zed/pull/33305 PR also cleared the
selection when clearing matches, which caused this issue. We could fix
it by only clearing matches and selection when they’re non-empty, but
it’s better to not clear the selection at all. This matches how the
editor behaves and keeps it consistent. This PR reverts that part of
code.


Release Notes:

- Fixed an issue where text selection was lost during continuous
terminal output.
2025-09-03 03:00:09 +05:30
Richard Feldman
5e01fb8f1c Nice errors for unsupported ACP slash commands (#37393)
If we get back slash-commands that aren't supported, tell the user that
this is the problem.

Release Notes:

- Improve error messages for unsupported ACP slash-commands

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-09-02 20:39:24 +00:00
Conrad Irwin
88a79750cc Disable external agents over collab (#37377)
Release Notes:

- Disable UI to boot external agents in collab projects (as they don't
work)
2025-09-02 12:53:53 -07:00
Umesh Yadav
4c411b9fc8 language_models: Make JsonSchemaSubset the default tool_input_format for the OpenAI-compatible provider (#34921)
Closes #30188
Closes #34911
Closes #34906

Many OpenAI-compatible providers do not automatically filter the tool
schema to comply with the underlying model's requirements; they simply
proxy the request. This creates issues, as models like **Gemini**,
**Grok**, and **Claude** (when accessed via LiteLLM on Bedrock) are
incompatible with Zed's default tool schema.

This PR addresses this by defaulting to a more compatible schema subset
instead of the full schema.

### Why this approach?

* **Avoids Poor User Experience:** One alternative was to add an option
for users to manually set the JSON schema for models that return a `400
Bad Request` due to an invalid tool schema. This was discarded as it
provides a poor user experience.
* **Simplifies Complex Logic:** Another option was to filter the schema
based on the model ID. However, as demonstrated in the attached issues,
this is unreliable. For instance, `claude-4-sonnet` fails when proxied
through LiteLLM on Bedrock. Reliably determining behavior would require
a non-trivial implementation to manage provider-and-model combinations.
* **Better Default Behavior:** The current approach ensures that tool
usage works out-of-the-box for the majority of cases by default,
providing the most robust and user-friendly solution.


Release Notes:

- Improved tool compatibility with OpenAI API-compatible providers

Signed-off-by: Umesh Yadav <git@umesh.dev>
Co-authored-by: Peter Tripp <peter@zed.dev>
2025-09-02 14:29:07 -04:00
Peter Tripp
5ac6ae501f docs: Link glossary (#37387)
Follow-up to: https://github.com/zed-industries/zed/pull/37360

Add glossary.md to SUMMARY.md so it's linked to the public
documentation.

Release Notes:

- N/A
2025-09-02 17:57:48 +00:00
Michael Sloan
c01f12b15d zeta: Small refactoring in license detection check - rfind instead of iterated ends_with (#37329)
Release Notes:

- N/A
2025-09-02 17:23:35 +00:00
Agus Zubiaga
dfa066dfe8 acp: Display slash command hints (#37376)
Displays the slash command's argument hint while it hasn't been
provided:


https://github.com/user-attachments/assets/f3bb148c-247d-43bc-810d-92055a313514


Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-02 16:39:55 +00:00
Richard Feldman
ac8c653ae6 Fix race condition between feature flag and deserialization (#37381)
Right now if you open Zed, and we deserialize an agent that's behind a
feature flag (e.g. CC), we don't restore it because the feature flag
check hasn't happened yet at the time we're deserializing (due to auth
not having finished yet).

This is a simple fix: assume that if you had serialized it in the first
place, you must have had the feature flag enabled, so go ahead and
reopen it for you.

Release Notes:

- N/A
2025-09-02 12:28:07 -04:00
Danilo Leal
d2318be8d9 terminal view: Hide inline assist button if AI is disabled (#37378)
Closes https://github.com/zed-industries/zed/issues/37372

Release Notes:

- Fix the terminal inline assistant button showing despite `disable_ai`
being turned on.

---------

Co-authored-by: MrSubidubi <finn@zed.dev>
2025-09-02 13:27:06 -03:00
Danilo Leal
a026163746 inline assistant: Adjust completion menu item font size (#37375)
Now the @ completion menu items font size respect/match the buffer's
font size, as opposed to being rendered a bit bigger.

| Before | After |
|--------|--------|
| <img width="1226" height="468" alt="Screenshot 2025-09-02 at 11 
09@2x"
src="https://github.com/user-attachments/assets/a6d37110-b544-40c3-bf7a-447ea003d4d7"
/> | <img width="1218" height="462" alt="Screenshot 2025-09-02 at 11  09
2@2x"
src="https://github.com/user-attachments/assets/19e58bf8-2db5-442e-8f60-02dd9ee1308f"
/> |

Release Notes:

- inline assistant: Improved @-mention menu item font size, better
matching the buffer's font size.
2025-09-02 13:26:56 -03:00
Marshall Bowers
ad3ddd381d Revert "gpui: Do not render ligatures between different styled text runs (#37175) (#37382)
This reverts commit 62083fe796.

We're reverting this as it causes layout shift when typing/selecting
with ligatures:


https://github.com/user-attachments/assets/80b78909-62f5-404f-8cca-3535c5594ceb

Release Notes:

- Reverted #37175
2025-09-02 16:18:49 +00:00
David Kleingeld
7e3fbeb59d Add the Glossary from the channel into Zed (#37360)
This should make it easier for contributors to learn all the terms used
in the Zed code base.

Release Notes:

- N/A
2025-09-02 15:59:58 +00:00
Jonathan Camp
8e7caa429d remove extra brace in rules template (#37356)
Release Notes:

- Fixed: remove extra brace in rules template
2025-09-02 15:26:12 +00:00
Dino
c894351544 vim: Fix change surrounding quotes with whitespace within (#37321)
This commit fixes a bug with Zed's vim mode surrounds plugin when
dealing with replacing pairs with quote and the contents between the
pairs had some whitespace within them.

For example, with the following string:

```
' str '
```

If one was to use the `cs'"` command, to replace single quotes with
double quotes, the result would actually be:

```
"str"
```

As the whitespace before and after the closing character was removed.

This happens because of the way the plugin decides whether to add or
remove whitespace after and before the opening and closing characters,
repsectively. For example, using `cs{[` yields a different result from
using `cs{]`, the former adds a space while the latter does not.

However, since for quotes the opening and closing character is exactly
the same, this behavior is not possible, so this commit updates the code
in `vim::surrounds::Vim.change_surrounds` so that it never adds or
removes whitespace when dealing with any type of quotes.

Closes #12247 

Release Notes:

- Fixed whitespace handling when changing surrounding pairs to quotes in
vim mode
2025-09-02 09:11:35 -06:00
Finn Evers
a96015b3c5 activity_indicator: Show extension installation and updates (#37374)
This PR fixes an issue where extension operations would never show in
the activity indicator despite this being implemented for ages. This
happened because we were always returning `None` whenever the app has a
global auto updater, which is always the case, so the code path for
showing extension updates in the indicator could never be hit despite
existing prior. Also slightly improves the messages shown for ongoing
extension operations, as these were previously context unaware.

While I was at this, I also quickly took a stab at cleaning up some
remotely related stuff, namely:
- The `AnimationExt` trait is now by default only implemented for
anything that also implements `IntoElement`. This prevents
`with_animation` from showing up for e.g. `u32` within the suggestions
(finally).
- Commonly used animations are now implemented in the
`CommonAnimationExt` trait within the `ui` crate so the needed code does
not always need to be copied and element IDs for the animations are
truly unique.

Relevant change here regarding the original issue is the change from the
`return match` to just a `match` within the activitiy indicator, which
solved the issue at hand.

If we find this to be too noisy at some point, we can easily revisit,
but I think this holds important enough information to be shown in the
activity indicator, especially whilst developing extensions.

Release Notes:

- Extension installation and updates will now be shown in the activity
indicator.
2025-09-02 16:51:13 +02:00
张小白
2eb7ac97e0 windows: Use a message-only window for WindowsPlatform (#37313)
Previously, we were using `PostThreadMessage` to pass messages to
`WindowsPlatform`. This PR switches to an approach similar to `winit`
which using a hidden window as the message window (I guess that’s why
winit uses a hidden window?). The difference is that this PR creates it
as a message-only window.

Thanks to @reflectronic for the original PR #37255, this implementation
just fits better with the current code style.


Release Notes:

- N/A

---------

Co-authored-by: reflectronic <john-tur@outlook.com>
2025-09-02 22:32:24 +08:00
张小白
f06c18765f Rename from create_ssh_worktree to create_remote_worktree (#37358)
This is a left-over issue of #37035

Release Notes:

- N/A
2025-09-02 14:12:24 +00:00
Max Brunsfeld
2f279c5de4 Fix small errors preventing WSL support from working (#37350)
On nightly, when I run `zed` under WSL, I get an error parsing the
shebang line

```
/usr/bin/env: ‘sh\r’: No such file or directory
```

I believe that this is because in CI, Git checks out the file with CRLF
line endings, and that is how it is copied into the installer.

Also, the file extension was incorrect when downloading the production
remote server (a gzipped binary), preventing extraction from working
properly.

Release Notes:

- N/A
2025-09-02 07:07:23 -07:00
localcc
60b95d9253 Use premultiplied alpha for emoji rendering (#37370)
This improves emoji rendering on windows removing artifacts at the edges
by using premultiplied alpha. A bit more context can be found in #37167

Release Notes:

- N/A
2025-09-02 13:59:27 +00:00
Cole Miller
47ad1b2143 agent2: Fix terminal tool call content not being shown once truncated (#37318)
We render terminals as inline if their content is below a certain line
count, and scrollable past that point. In the scrollable case we weren't
setting a height for the terminal's container, causing it to be rendered
at height 0, which means no lines would be displayed. This PR fixes that
by setting an explicit height for the scrollable case, like we do in the
agent1 UI code.

Release Notes:

- agent: Fixed a bug that caused terminals in the panel to be empty
after their content reached a certain size.
2025-09-02 09:03:11 -04:00
Lukas Wirth
35c0d02c7c project: Temporarily disable terminal activation scripts on windows (#37361)
They seem to break things on window right now

Release Notes:

- N/A
2025-09-02 10:42:29 +00:00
Bennet Bo Fenner
374a8bc4cb acp: Add support for slash commands (#37304)
Depends on
https://github.com/zed-industries/agent-client-protocol/pull/45

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-09-02 08:48:33 +00:00
Maksim Bondarenkov
f06be6f3ec docs: Add link to msys2 docs page (#37327)
it was removed earlier. better to keep this link because the page
contains some useful information

Release Notes:

- N/A
2025-09-02 07:02:41 +00:00
Ben Kunkle
970242480a settings_ui: Improve case handling (#37342)
Closes #ISSUE

Improves the derive macro for `SettingsUi` so that titles generated from
struct and field names are shown in title case, and toggle button groups
use title case for rendering, while using lower case/snake case in JSON

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-09-02 01:17:27 +00:00
Ben Kunkle
54cec5b484 settings_ui: Get editor settings working (#37330)
Closes #ISSUE

This PR includes the necessary work to get `EditorSettings` showing up
in the settings UI. Including making the `path` field on
`SettingsUiItem`'s optional so that top level items such as
`EditorSettings` which have `Settings::KEY = None` (i.e. are treated
like `serde(flatten)`) have their paths computed correctly for JSON
reading/updating.

It includes the first examples of a pattern I expect to continue with
the `SettingsUi` work with respect to settings reorganization, that
being adding missing defaults, and adding explicit values (or aliases)
to settings which previously relied on `null` being a value for optional
fields.

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-09-02 00:26:42 +00:00
Ben Kunkle
60d17cccd3 settings_ui: Move settings UI trait to file content (#37337)
Closes #ISSUE

Initially, the `SettingsUi` trait was tied to `Settings`, however, given
that the `Settings::FileContent` type (which may be the same as the type
that implements `Settings`) will be the type that more directly maps to
the JSON structure (and therefore have the documentation, correct field
names (or `serde` rename attributes), etc) it makes more sense to have
the deriving of `SettingsUi` occur on the `FileContent` type rather than
the `Settings` type.

In order for this to work a relatively important change had to be made
to the derive macro, that being that it now "unwraps" options into their
inner type, so a field with type `Option<Foo>` where `Foo: SettingsUi`
will treat the field as if it were just `Foo`, expecting there to be a
default set in `default.json`. This imposes some restrictions on what
`Settings::FileContent` can be as seen in 1e19398 where `FileContent`
itself can't be optional without manually implementing `SettingsUi`, as
well as introducing some risk that if the `FileContent` type has
`serde(default)`, the default value will override the default value from
`default.json` in the UI even though it may differ (but it should!).

A future PR should probably replace the other settings with `FileContent
= Option<T>` (all of which currently have `T == bool`) with wrapper
structs and have `KEY = None` so the further niceties
`derive(SettingsUi)` will provide such as path renaming, custom UI, auto
naming and doc comment extraction can be used.

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-09-01 18:42:33 -04:00
Ben Kunkle
8a8a9a4f07 settings_ui: Add dynamic settings UI item (#37331)
Closes #ISSUE

Adds a first draft of a way for "Dynamic" settings items to be added,
where Dynamic means settings where multiple sets of options are possible
(i.e. discriminated union, rust enum, etc). The implementation is very
similar to that of `Group`, except that instead of rendering all of it's
descendants, it contains a function to determine _which_ descendant to
render, whether that be a single item or a nested group of items.
Currently this is done in a type-unsafe way with indices, a future
improvement could be to make the API more type safe, and easier to
manually implement correctly.

An example of a "Dynamic" setting is `theme`, where it can either be a
string of the desired theme name, or an object with `mode: "light" |
"dark" | "system"` as well as theme names for `light` and `dark`. In the
system implemented by this PR, this would become a dynamic settings UI
item, where option `0` is a single item, the theme name selector, and
option `1` is a group, containing items for the `mode`, and
`light`/`dark` options.

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-09-01 17:53:43 -04:00
Kirill Bulatov
634a1343dd Bump xcb dependency (#37335)
Deals with https://github.com/zed-industries/zed/security/dependabot/65

Release Notes:

- N/A
2025-09-01 21:51:15 +00:00
claytonrcarter
2ba25b5c94 editor: Support rewrap in block comments (#34418)
This updates `editor: rewrap` to work within doc comments, based on the
code that extends such comments on newline. I added some tests, and I've
tested it out in JS, C and PHP. (Though PHP depends on
https://github.com/zed-extensions/php/pull/40)

Closes #19794
Closes #18221

**Caveat:**
~~This will not rewrap an existing single-line block comment, such as
the one provided in #18221:~~ this will now rewrap as expected
```c
/* we can triangulate any convex polygon by picking a vertex and connecting it to the next two vertices; we first read two vertices, and then, for every subsequent vertex, we can form a triangle by connecting it to the first and previous vertex */
```
However, it will rewrap a similar comment if it is shaped like a doc
comment. In other words, this will rewrap as expected:
```c
/* 
 * we can triangulate any convex polygon by picking a vertex and connecting it to the next two vertices; we first read two vertices, and then, for every subsequent vertex, we can form a triangle by connecting it to the first and previous vertex 
 */
```

This seems like a reasonable improvement and limitation to me,
especially as a first step.

cc @smitbarmase because I think that you've been making a lot of the
`newline` and `rewrap` changes recently. (Thank you for those, by the
way!)

Release Notes:

- Added support for rewrap in block comments.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-09-01 20:00:01 +00:00
Marshall Bowers
965dbc988f gpui: Fix typo in Windows alpha correction shader (#37328)
This PR fixes a typo in the Windows alpha correction shader that is now
caught by https://github.com/zed-industries/zed/pull/37314.

Another case that could be addressed by Bors.

Release Notes:

- N/A
2025-09-01 15:33:11 -04:00
Agus Zubiaga
5b73b40df8 ACP Terminal support (#37129)
Exposes terminal support via ACP and migrates our agent to use it.

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-01 18:57:15 +00:00
localcc
d910feac1d Implement perceptual gamma / contrast correction (#37167)
Closes #36023 

This improves font rendering quality by doing perceptual gamma+contrast
correction which makes font edges look nicer and more legible.

A comparison image: (left is old, right is new)
<img width="1638" height="854" alt="Screenshot 2025-08-29 140015"
src="https://github.com/user-attachments/assets/85ca9818-0d55-4af0-a796-19e8cf9ed36b"
/>

This is most noticeable on smaller fonts / low-dpi displays

Release Notes:

- Improved font rendering quality
2025-09-01 20:07:45 +02:00
张小白
61175ab9cd windows: Don’t skip the typo check for the windows folder (#37314)
Try to narrow down the scope of typo checking


Release Notes:

- N/A
2025-09-01 15:26:25 +00:00
雷电梅
2790eb604a deepseek: Fix API URL (#33905)
Closes #33904 

Release Notes:

- Add support for custom API Urls for DeepSeek Provider

---------

Co-authored-by: Peter Tripp <peter@zed.dev>
2025-09-01 10:49:09 +02:00
张小白
acff65ed3f windows: Update documents about WSL (#37292)
Release Notes:

- N/A
2025-09-01 08:33:59 +00:00
Ivan Trubach
3315fd94d2 editor: Add an option to disable rounded corners for text selection (#36987)
Closes #19891

Similar to VSCode’s `editor.roundedSelection` option.

#### Before/after

<table>
<tr><th><th>Enabled (default)</th><th>Disabled</th>
<tr><td>Editor-based UIs<td><img width="268" height="58" alt="image"
src="https://github.com/user-attachments/assets/f58c6817-88fc-4cba-b2bc-f7eff58ec6e5"
/>
<img width="146" height="97" alt="image"
src="https://github.com/user-attachments/assets/0cd08afa-8243-4d4e-a5c6-9055f6834ecf"
/><td><img width="272" height="54" alt="image"
src="https://github.com/user-attachments/assets/286c8f53-1973-442e-8446-4f48e3feca30"
/>
<img width="133" height="90" alt="image"
src="https://github.com/user-attachments/assets/4aea2044-403c-47a5-bb6d-a88a0b65814e"
/></td>
<tr><td>Terminal<td><img width="287" height="84" alt="image"
src="https://github.com/user-attachments/assets/b1594f68-2ef6-4bdc-9030-e67d55a5bf99"
/><td><img width="289" height="79" alt="image"
src="https://github.com/user-attachments/assets/6d095d9d-b408-4440-a9f5-6a2af2b84b61"
/></td>
</table>

Release Notes:

- Added setting `rounded_selection` to disable rounded corners for text
selection.
2025-09-01 11:21:55 +03:00
Lukas Wirth
62083fe796 gpui: Do not render ligatures between different styled text runs (#37175)
Currently when we render text with differing styles adjacently we might
form a ligature between the text, causing the ligature forming
characters to take on one of the two styles. This can especially become
confusing when a ligature is formed between actual text and inlay hints.

Annoyingly, the only ways to prevent this with core text is to either
render each run separately, or to insert a zero-width non-joiner to
force core text to break the ligatures apart, as it otherwise will merge
subsequent font runs of the same fonts.

We currently do layouting on a per line basis and it is unlikely we want
to change that as it would incur a lot of complexity and annoyances to
merge things back into a line, so this goes with the other approach of
inserting ZWNJ characters instead.

Note that neither linux nor windows seem to currently render ligatures,
so this only concerns macOS rendering at the moment.

Release Notes:

- Fixed ligatures forming between real text and inlay hints on macOS
2025-09-01 09:49:52 +02:00
Gaauwe Rombouts
a852bcc094 Improve system window tabs visibility (#37244)
Follow up of https://github.com/zed-industries/zed/pull/33334

After chatting with @MrSubidubi we found out that he had an old defaults
setting (most likely from when he encountered a previous window tabbing
bug):
```
❯ defaults read dev.zed.Zed-Nightly
{
    NSNavPanelExpandedSizeForOpenMode = "{800, 448}";
    NSNavPanelExpandedSizeForSaveMode = "{800, 448}";
    NSNavPanelExpandedStateForSaveMode = 1;
    NSOSPLastRootDirectory = {length = 828, bytes = 0x626f6f6b 3c030000 00000410 30000000 ... dc010000 00000000 };
    "NSWindow Frame NSNavPanelAutosaveName" = "557 1726 800 448 -323 982 2560 1440 ";
    "NSWindowTabbingShoudShowTabBarKey-GPUIWindow-GPUIWindow-(null)-HT-FS" = 1;
}
```

> That suffix is AppKit’s fallback autosave name when no tabbing
identifier is set. It encodes the NSWindow subclass (GPUIWindow), plus
traits like HT (hidden titlebar) and FS (fullscreen).

Which explains why it only happened on the Nightly build, since each
bundle has it's own defaults. It also explains why the tabbar would
disappear when he activated the `use_system_window_tabs` setting,
because with that setting activated, the tabbing identifier becomes
"zed" (instead of the default one when omitted) for which he didn't have
the `NSWindowTabbingShoudShowTabBarKey` default.

The original implementation was perhaps a bit naive and relied fully on
macOS to determine if the tabbar should be shown. I've updated the code
to always hide the tabbar, if the setting is turned off and there is
only 1 tab entry.

While testing, I also noticed that the menu's like 'merge all windows'
wouldn't become active when the setting was turned on, only after a full
workspace reload. So I added a setting observer as well, to immediately
set the correct window properties to enable all the features without a
reload.

Release Notes:

- N/A
2025-08-31 18:24:00 -06:00
Peter Tripp
f290daf7ea docs: Improve Bedrock suggested IAM policy (#37278)
Closes https://github.com/zed-industries/zed/issues/37251

H/T: @brandon-fryslie

Release Notes:

- N/A
2025-08-31 20:08:17 -04:00
Peter Tripp
129bff8358 agent: Make it so delete_path tool needs user confirmation (#37191)
Closes https://github.com/zed-industries/zed/issues/37048

Release Notes:

- agent: Make delete_path tool require user confirmation by default
2025-08-31 19:52:43 -04:00
Umesh Yadav
c833f8905b language_models: Fix grok-code-fast-1 support for Copilot (#37116)
This PR fixes a deserialization issue in GitHub Copilot Chat that was
causing warnings when encountering xAI models from the GitHub Copilot
API and skipping the Grok model from model selector.

Release Notes:

- Fixed support for xAI models that are now available through GitHub
Copilot Chat.
2025-08-31 18:51:17 -04:00
tidely
d74384f6e2 anthropic: Remove logging when no credentials are available (#37276)
Removes excess log which got through on each start of Zed
```
ERROR [agent_ui::language_model_selector] Failed to authenticate provider: Anthropic: credentials not found
```

The `AnthropicLanguageModelProvider::api_key` method returned a
`anyhow::Result` which would convert
`AuthenticateError::CredentialsNotFound` into a generic error because of
the implicit `Into` when using the `?` operator. This would then get
converted into a `AuthenticateError::Other` later.

By specifying the error type as `AuthenticateError`, we remove this
implicit conversion and the log gets removed.

Release Notes:

- N/A
2025-09-01 00:42:57 +03:00
226 changed files with 7166 additions and 4786 deletions

3
.gitattributes vendored
View File

@@ -1,2 +1,5 @@
# Prevent GitHub from displaying comments within JSON files as errors.
*.json linguist-language=JSON-with-Comments
# Ensure the WSL script always has LF line endings, even on Windows
crates/zed/resources/windows/zed-wsl text eol=lf

View File

@@ -20,167 +20,8 @@ runs:
with:
node-version: "18"
- name: Configure crash dumps
shell: powershell
run: |
# Record the start time for this CI run
$runStartTime = Get-Date
$runStartTimeStr = $runStartTime.ToString("yyyy-MM-dd HH:mm:ss")
Write-Host "CI run started at: $runStartTimeStr"
# Save the timestamp for later use
echo "CI_RUN_START_TIME=$($runStartTime.Ticks)" >> $env:GITHUB_ENV
# Create crash dump directory in workspace (non-persistent)
$dumpPath = "$env:GITHUB_WORKSPACE\crash_dumps"
New-Item -ItemType Directory -Force -Path $dumpPath | Out-Null
Write-Host "Setting up crash dump detection..."
Write-Host "Workspace dump path: $dumpPath"
# Note: We're NOT modifying registry on stateful runners
# Instead, we'll check default Windows crash locations after tests
- name: Run tests
shell: powershell
working-directory: ${{ inputs.working-directory }}
run: |
$env:RUST_BACKTRACE = "full"
# Enable Windows debugging features
$env:_NT_SYMBOL_PATH = "srv*https://msdl.microsoft.com/download/symbols"
# .NET crash dump environment variables (ephemeral)
$env:COMPlus_DbgEnableMiniDump = "1"
$env:COMPlus_DbgMiniDumpType = "4"
$env:COMPlus_CreateDumpDiagnostics = "1"
cargo nextest run --workspace --no-fail-fast
- name: Analyze crash dumps
if: always()
shell: powershell
run: |
Write-Host "Checking for crash dumps..."
# Get the CI run start time from the environment
$runStartTime = [DateTime]::new([long]$env:CI_RUN_START_TIME)
Write-Host "Only analyzing dumps created after: $($runStartTime.ToString('yyyy-MM-dd HH:mm:ss'))"
# Check all possible crash dump locations
$searchPaths = @(
"$env:GITHUB_WORKSPACE\crash_dumps",
"$env:LOCALAPPDATA\CrashDumps",
"$env:TEMP",
"$env:GITHUB_WORKSPACE",
"$env:USERPROFILE\AppData\Local\CrashDumps",
"C:\Windows\System32\config\systemprofile\AppData\Local\CrashDumps"
)
$dumps = @()
foreach ($path in $searchPaths) {
if (Test-Path $path) {
Write-Host "Searching in: $path"
$found = Get-ChildItem "$path\*.dmp" -ErrorAction SilentlyContinue | Where-Object {
$_.CreationTime -gt $runStartTime
}
if ($found) {
$dumps += $found
Write-Host " Found $($found.Count) dump(s) from this CI run"
}
}
}
if ($dumps) {
Write-Host "Found $($dumps.Count) crash dump(s)"
# Install debugging tools if not present
$cdbPath = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\cdb.exe"
if (-not (Test-Path $cdbPath)) {
Write-Host "Installing Windows Debugging Tools..."
$url = "https://go.microsoft.com/fwlink/?linkid=2237387"
Invoke-WebRequest -Uri $url -OutFile winsdksetup.exe
Start-Process -Wait winsdksetup.exe -ArgumentList "/features OptionId.WindowsDesktopDebuggers /quiet"
}
foreach ($dump in $dumps) {
Write-Host "`n=================================="
Write-Host "Analyzing crash dump: $($dump.Name)"
Write-Host "Size: $([math]::Round($dump.Length / 1MB, 2)) MB"
Write-Host "Time: $($dump.CreationTime)"
Write-Host "=================================="
# Set symbol path
$env:_NT_SYMBOL_PATH = "srv*C:\symbols*https://msdl.microsoft.com/download/symbols"
# Run analysis
$analysisOutput = & $cdbPath -z $dump.FullName -c "!analyze -v; ~*k; lm; q" 2>&1 | Out-String
# Extract key information
if ($analysisOutput -match "ExceptionCode:\s*([\w]+)") {
Write-Host "Exception Code: $($Matches[1])"
if ($Matches[1] -eq "c0000005") {
Write-Host "Exception Type: ACCESS VIOLATION"
}
}
if ($analysisOutput -match "EXCEPTION_RECORD:\s*(.+)") {
Write-Host "Exception Record: $($Matches[1])"
}
if ($analysisOutput -match "FAULTING_IP:\s*\n(.+)") {
Write-Host "Faulting Instruction: $($Matches[1])"
}
# Save full analysis
$analysisFile = "$($dump.FullName).analysis.txt"
$analysisOutput | Out-File -FilePath $analysisFile
Write-Host "`nFull analysis saved to: $analysisFile"
# Print stack trace section
Write-Host "`n--- Stack Trace Preview ---"
$stackSection = $analysisOutput -split "STACK_TEXT:" | Select-Object -Last 1
$stackLines = $stackSection -split "`n" | Select-Object -First 20
$stackLines | ForEach-Object { Write-Host $_ }
Write-Host "--- End Stack Trace Preview ---"
}
Write-Host "`n⚠ Crash dumps detected! Download the 'crash-dumps' artifact for detailed analysis."
# Copy dumps to workspace for artifact upload
$artifactPath = "$env:GITHUB_WORKSPACE\crash_dumps_collected"
New-Item -ItemType Directory -Force -Path $artifactPath | Out-Null
foreach ($dump in $dumps) {
$destName = "$($dump.Directory.Name)_$($dump.Name)"
Copy-Item $dump.FullName -Destination "$artifactPath\$destName"
if (Test-Path "$($dump.FullName).analysis.txt") {
Copy-Item "$($dump.FullName).analysis.txt" -Destination "$artifactPath\$destName.analysis.txt"
}
}
Write-Host "Copied $($dumps.Count) dump(s) to artifact directory"
} else {
Write-Host "No crash dumps from this CI run found"
}
- name: Upload crash dumps
if: always()
uses: actions/upload-artifact@v4
with:
name: crash-dumps-${{ github.run_id }}-${{ github.run_attempt }}
path: |
crash_dumps_collected/*.dmp
crash_dumps_collected/*.txt
if-no-files-found: ignore
retention-days: 7
- name: Check test results
shell: powershell
working-directory: ${{ inputs.working-directory }}
run: |
# Re-check test results to fail the job if tests failed
if ($LASTEXITCODE -ne 0) {
Write-Host "Tests failed with exit code: $LASTEXITCODE"
exit $LASTEXITCODE
}

View File

@@ -81,6 +81,7 @@ jobs:
echo "run_license=false" >> "$GITHUB_OUTPUT"
echo "$CHANGED_FILES" | grep -qP '^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)' && \
echo "$GITHUB_REF_NAME" | grep -qvP '^v[0-9]+\.[0-9]+\.[0-9x](-pre)?$' && \
echo "run_nix=true" >> "$GITHUB_OUTPUT" || \
echo "run_nix=false" >> "$GITHUB_OUTPUT"

View File

@@ -65,6 +65,8 @@ If you would like to add a new icon to the Zed icon theme, [open a Discussion](h
## Bird's-eye view of Zed
We suggest you keep the [zed glossary](docs/src/development/GLOSSARY.md) at your side when starting out. It lists and explains some of the structures and terms you will see throughout the codebase.
Zed is made up of several smaller crates - let's go over those you're most likely to interact with:
- [`gpui`](/crates/gpui) is a GPU-accelerated UI framework which provides all of the building blocks for Zed. **We recommend familiarizing yourself with the root level GPUI documentation.**

33
Cargo.lock generated
View File

@@ -23,6 +23,7 @@ dependencies = [
"language_model",
"markdown",
"parking_lot",
"portable-pty",
"project",
"prompt_store",
"rand 0.8.5",
@@ -30,6 +31,7 @@ dependencies = [
"serde_json",
"settings",
"smol",
"task",
"tempfile",
"terminal",
"ui",
@@ -37,6 +39,7 @@ dependencies = [
"util",
"uuid",
"watch",
"which 6.0.3",
"workspace-hack",
]
@@ -192,9 +195,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol"
version = "0.1.1"
version = "0.2.0-alpha.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b91e5ec3ce05e8effb2a7a3b7b1a587daa6699b9f98bbde6a35e44b8c6c773a"
checksum = "603941db1d130ee275840c465b73a2312727d4acef97449550ccf033de71301f"
dependencies = [
"anyhow",
"async-broadcast",
@@ -248,7 +251,6 @@ dependencies = [
"open",
"parking_lot",
"paths",
"portable-pty",
"pretty_assertions",
"project",
"prompt_store",
@@ -274,7 +276,6 @@ dependencies = [
"uuid",
"watch",
"web_search",
"which 6.0.3",
"workspace-hack",
"worktree",
"zlog",
@@ -9146,6 +9147,7 @@ dependencies = [
"icons",
"image",
"log",
"open_router",
"parking_lot",
"proto",
"schemars",
@@ -11221,6 +11223,8 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"strum 0.27.1",
"thiserror 2.0.12",
"workspace-hack",
]
@@ -13744,7 +13748,6 @@ dependencies = [
"regex",
"reqwest 0.12.15 (git+https://github.com/zed-industries/reqwest.git?rev=951c770a32f1998d6e999cef3e59e0013e6c4415)",
"serde",
"smol",
"tokio",
"workspace-hack",
]
@@ -14349,18 +14352,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "scheduler"
version = "0.1.0"
dependencies = [
"anyhow",
"async-task",
"futures 0.3.31",
"parking_lot",
"rand 0.8.5",
"rand_chacha 0.3.1",
]
[[package]]
name = "schema_generator"
version = "0.1.0"
@@ -14915,6 +14906,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"command_palette_hooks",
"debugger_ui",
"editor",
"feature_flags",
"gpui",
@@ -14932,6 +14924,7 @@ dependencies = [
name = "settings_ui_macros"
version = "0.1.0"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.101",
@@ -20147,9 +20140,9 @@ dependencies = [
[[package]]
name = "xcb"
version = "1.5.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1e2f212bb1a92cd8caac8051b829a6582ede155ccb60b5d5908b81b100952be"
checksum = "f07c123b796139bfe0603e654eaf08e132e52387ba95b252c78bad3640ba37ea"
dependencies = [
"bitflags 1.3.2",
"libc",
@@ -20406,7 +20399,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.203.0"
version = "0.204.0"
dependencies = [
"acp_tools",
"activity_indicator",

View File

@@ -140,7 +140,6 @@ members = [
"crates/rpc",
"crates/rules_library",
"crates/schema_generator",
"crates/scheduler",
"crates/search",
"crates/semantic_index",
"crates/semantic_version",
@@ -300,9 +299,7 @@ git_hosting_providers = { path = "crates/git_hosting_providers" }
git_ui = { path = "crates/git_ui" }
go_to_line = { path = "crates/go_to_line" }
google_ai = { path = "crates/google_ai" }
gpui = { path = "crates/gpui", default-features = false, features = [
"http_client",
] }
gpui = { path = "crates/gpui", default-features = false }
gpui_macros = { path = "crates/gpui_macros" }
gpui_tokio = { path = "crates/gpui_tokio" }
html_to_markdown = { path = "crates/html_to_markdown" }
@@ -431,7 +428,7 @@ zlog_settings = { path = "crates/zlog_settings" }
# External crates
#
agent-client-protocol = "0.1"
agent-client-protocol = { version = "0.2.0-alpha.4", features = ["unstable"]}
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14"
@@ -697,6 +694,7 @@ features = [
"Win32_Graphics_Dxgi_Common",
"Win32_Graphics_Gdi",
"Win32_Graphics_Imaging",
"Win32_Graphics_Hlsl",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_Security_Credentials",
@@ -848,6 +846,9 @@ too_many_arguments = "allow"
# We often have large enum variants yet we rarely actually bother with splitting them up.
large_enum_variant = "allow"
# Boolean expressions can be hard to read, requiring only the minimal form gets in the way
nonminimal_bool = "allow"
[workspace.metadata.cargo-machete]
ignored = [
"bindgen",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -63,8 +63,8 @@
"ctrl-k": "editor::CutToEndOfLine",
"ctrl-k ctrl-q": "editor::Rewrap",
"ctrl-k q": "editor::Rewrap",
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
"ctrl-delete": "editor::DeleteToNextWordEnd",
"ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
"ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
"cut": "editor::Cut",
"shift-delete": "editor::Cut",
"ctrl-x": "editor::Cut",

View File

@@ -70,9 +70,9 @@
"cmd-k q": "editor::Rewrap",
"cmd-backspace": "editor::DeleteToBeginningOfLine",
"cmd-delete": "editor::DeleteToEndOfLine",
"alt-backspace": "editor::DeleteToPreviousWordStart",
"ctrl-w": "editor::DeleteToPreviousWordStart",
"alt-delete": "editor::DeleteToNextWordEnd",
"alt-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
"ctrl-w": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
"alt-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
"cmd-x": "editor::Cut",
"cmd-c": "editor::Copy",
"cmd-v": "editor::Paste",

View File

@@ -66,8 +66,8 @@
"ctrl-k": "editor::CutToEndOfLine",
"ctrl-k ctrl-q": "editor::Rewrap",
"ctrl-k q": "editor::Rewrap",
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
"ctrl-delete": "editor::DeleteToNextWordEnd",
"ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
"ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
"cut": "editor::Cut",
"shift-delete": "editor::Cut",
"ctrl-x": "editor::Cut",

View File

@@ -42,7 +42,7 @@
"alt-,": "pane::GoBack", // xref-pop-marker-stack
"ctrl-x h": "editor::SelectAll", // mark-whole-buffer
"ctrl-d": "editor::Delete", // delete-char
"alt-d": "editor::DeleteToNextWordEnd", // kill-word
"alt-d": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], // kill-word
"ctrl-k": "editor::KillRingCut", // kill-line
"ctrl-w": "editor::Cut", // kill-region
"alt-w": "editor::Copy", // kill-ring-save

View File

@@ -50,8 +50,8 @@
"ctrl-k ctrl-u": "editor::ConvertToUpperCase",
"ctrl-k ctrl-l": "editor::ConvertToLowerCase",
"shift-alt-m": "markdown::OpenPreviewToTheSide",
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
"ctrl-delete": "editor::DeleteToNextWordEnd",
"ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
"ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
"alt-right": "editor::MoveToNextSubwordEnd",
"alt-left": "editor::MoveToPreviousSubwordStart",
"alt-shift-right": "editor::SelectToNextSubwordEnd",

View File

@@ -42,7 +42,7 @@
"alt-,": "pane::GoBack", // xref-pop-marker-stack
"ctrl-x h": "editor::SelectAll", // mark-whole-buffer
"ctrl-d": "editor::Delete", // delete-char
"alt-d": "editor::DeleteToNextWordEnd", // kill-word
"alt-d": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], // kill-word
"ctrl-k": "editor::KillRingCut", // kill-line
"ctrl-w": "editor::Cut", // kill-region
"alt-w": "editor::Copy", // kill-ring-save

View File

@@ -52,8 +52,8 @@
"cmd-k cmd-l": "editor::ConvertToLowerCase",
"cmd-shift-j": "editor::JoinLines",
"shift-alt-m": "markdown::OpenPreviewToTheSide",
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
"ctrl-delete": "editor::DeleteToNextWordEnd",
"ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
"ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
"ctrl-right": "editor::MoveToNextSubwordEnd",
"ctrl-left": "editor::MoveToPreviousSubwordStart",
"ctrl-shift-right": "editor::SelectToNextSubwordEnd",

View File

@@ -21,10 +21,10 @@
{
"context": "Editor",
"bindings": {
"alt-backspace": "editor::DeleteToPreviousWordStart",
"alt-shift-backspace": "editor::DeleteToNextWordEnd",
"alt-delete": "editor::DeleteToNextWordEnd",
"alt-shift-delete": "editor::DeleteToNextWordEnd",
"alt-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
"alt-shift-backspace": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
"alt-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
"alt-shift-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
"ctrl-backspace": "editor::DeleteToPreviousSubwordStart",
"ctrl-delete": "editor::DeleteToNextSubwordEnd",
"alt-left": ["editor::MoveToPreviousWordStart", { "stop_at_soft_wraps": true }],

View File

@@ -337,7 +337,7 @@
"ctrl-x ctrl-z": "editor::Cancel",
"ctrl-x ctrl-e": "vim::LineDown",
"ctrl-x ctrl-y": "vim::LineUp",
"ctrl-w": "editor::DeleteToPreviousWordStart",
"ctrl-w": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
"ctrl-u": "editor::DeleteToBeginningOfLine",
"ctrl-t": "vim::Indent",
"ctrl-d": "vim::Outdent",

View File

@@ -172,7 +172,7 @@ The user has specified the following rules that should be applied:
Rules title: {{title}}
{{/if}}
``````
{{contents}}}
{{contents}}
``````
{{/each}}
{{/if}}

View File

@@ -188,8 +188,8 @@
// 4. A box drawn around the following character
// "hollow"
//
// Default: not set, defaults to "bar"
"cursor_shape": null,
// Default: "bar"
"cursor_shape": "bar",
// Determines when the mouse cursor should be hidden in an editor or input box.
//
// 1. Never hide the mouse cursor:
@@ -223,6 +223,8 @@
"current_line_highlight": "all",
// Whether to highlight all occurrences of the selected text in an editor.
"selection_highlight": true,
// Whether the text selection should have rounded corners.
"rounded_selection": true,
// The debounce delay before querying highlights from the language
// server based on the current cursor location.
"lsp_highlight_debounce": 75,
@@ -280,8 +282,8 @@
// - "warning"
// - "info"
// - "hint"
// - null — allow all diagnostics (default)
"diagnostics_max_severity": null,
// - "all" — allow all diagnostics (default)
"diagnostics_max_severity": "all",
// Whether to show wrap guides (vertical rulers) in the editor.
// Setting this to true will show a guide at the 'preferred_line_length' value
// if 'soft_wrap' is set to 'preferred_line_length', and will show any
@@ -1774,7 +1776,7 @@
"api_url": "http://localhost:1234/api/v0"
},
"deepseek": {
"api_url": "https://api.deepseek.com"
"api_url": "https://api.deepseek.com/v1"
},
"mistral": {
"api_url": "https://api.mistral.ai/v1"

View File

@@ -31,18 +31,21 @@ language.workspace = true
language_model.workspace = true
markdown.workspace = true
parking_lot = { workspace = true, optional = true }
portable-pty.workspace = true
project.workspace = true
prompt_store.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
task.workspace = true
terminal.workspace = true
ui.workspace = true
url.workspace = true
util.workspace = true
uuid.workspace = true
watch.workspace = true
which.workspace = true
workspace-hack.workspace = true
[dev-dependencies]

View File

@@ -7,6 +7,7 @@ use agent_settings::AgentSettings;
use collections::HashSet;
pub use connection::*;
pub use diff::*;
use futures::future::Shared;
use language::language_settings::FormatOnSave;
pub use mention::*;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
@@ -15,7 +16,7 @@ use settings::Settings as _;
pub use terminal::*;
use action_log::ActionLog;
use agent_client_protocol as acp;
use agent_client_protocol::{self as acp};
use anyhow::{Context as _, Result, anyhow};
use editor::Bias;
use futures::{FutureExt, channel::oneshot, future::BoxFuture};
@@ -33,7 +34,8 @@ use std::rc::Rc;
use std::time::{Duration, Instant};
use std::{fmt::Display, mem, path::PathBuf, sync::Arc};
use ui::App;
use util::ResultExt;
use util::{ResultExt, get_system_shell};
use uuid::Uuid;
#[derive(Debug)]
pub struct UserMessage {
@@ -183,37 +185,46 @@ impl ToolCall {
tool_call: acp::ToolCall,
status: ToolCallStatus,
language_registry: Arc<LanguageRegistry>,
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
cx: &mut App,
) -> Self {
) -> Result<Self> {
let title = if let Some((first_line, _)) = tool_call.title.split_once("\n") {
first_line.to_owned() + ""
} else {
tool_call.title
};
Self {
let mut content = Vec::with_capacity(tool_call.content.len());
for item in tool_call.content {
content.push(ToolCallContent::from_acp(
item,
language_registry.clone(),
terminals,
cx,
)?);
}
let result = Self {
id: tool_call.id,
label: cx
.new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)),
kind: tool_call.kind,
content: tool_call
.content
.into_iter()
.map(|content| ToolCallContent::from_acp(content, language_registry.clone(), cx))
.collect(),
content,
locations: tool_call.locations,
resolved_locations: Vec::default(),
status,
raw_input: tool_call.raw_input,
raw_output: tool_call.raw_output,
}
};
Ok(result)
}
fn update_fields(
&mut self,
fields: acp::ToolCallUpdateFields,
language_registry: Arc<LanguageRegistry>,
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
cx: &mut App,
) {
) -> Result<()> {
let acp::ToolCallUpdateFields {
kind,
status,
@@ -248,14 +259,15 @@ impl ToolCall {
// Reuse existing content if we can
for (old, new) in self.content.iter_mut().zip(content.by_ref()) {
old.update_from_acp(new, language_registry.clone(), cx);
old.update_from_acp(new, language_registry.clone(), terminals, cx)?;
}
for new in content {
self.content.push(ToolCallContent::from_acp(
new,
language_registry.clone(),
terminals,
cx,
))
)?)
}
self.content.truncate(new_content_len);
}
@@ -279,6 +291,7 @@ impl ToolCall {
}
self.raw_output = Some(raw_output);
}
Ok(())
}
pub fn diffs(&self) -> impl Iterator<Item = &Entity<Diff>> {
@@ -549,13 +562,16 @@ impl ToolCallContent {
pub fn from_acp(
content: acp::ToolCallContent,
language_registry: Arc<LanguageRegistry>,
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
cx: &mut App,
) -> Self {
) -> Result<Self> {
match content {
acp::ToolCallContent::Content { content } => {
Self::ContentBlock(ContentBlock::new(content, &language_registry, cx))
}
acp::ToolCallContent::Diff { diff } => Self::Diff(cx.new(|cx| {
acp::ToolCallContent::Content { content } => Ok(Self::ContentBlock(ContentBlock::new(
content,
&language_registry,
cx,
))),
acp::ToolCallContent::Diff { diff } => Ok(Self::Diff(cx.new(|cx| {
Diff::finalized(
diff.path,
diff.old_text,
@@ -563,7 +579,12 @@ impl ToolCallContent {
language_registry,
cx,
)
})),
}))),
acp::ToolCallContent::Terminal { terminal_id } => terminals
.get(&terminal_id)
.cloned()
.map(Self::Terminal)
.ok_or_else(|| anyhow::anyhow!("Terminal with id `{}` not found", terminal_id)),
}
}
@@ -571,8 +592,9 @@ impl ToolCallContent {
&mut self,
new: acp::ToolCallContent,
language_registry: Arc<LanguageRegistry>,
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
cx: &mut App,
) {
) -> Result<()> {
let needs_update = match (&self, &new) {
(Self::Diff(old_diff), acp::ToolCallContent::Diff { diff: new_diff }) => {
old_diff.read(cx).needs_update(
@@ -585,8 +607,9 @@ impl ToolCallContent {
};
if needs_update {
*self = Self::from_acp(new, language_registry, cx);
*self = Self::from_acp(new, language_registry, terminals, cx)?;
}
Ok(())
}
pub fn to_markdown(&self, cx: &App) -> String {
@@ -762,7 +785,10 @@ pub struct AcpThread {
session_id: acp::SessionId,
token_usage: Option<TokenUsage>,
prompt_capabilities: acp::PromptCapabilities,
available_commands: Vec<acp::AvailableCommand>,
_observe_prompt_capabilities: Task<anyhow::Result<()>>,
determine_shell: Shared<Task<String>>,
terminals: HashMap<acp::TerminalId, Entity<Terminal>>,
}
#[derive(Debug)]
@@ -778,6 +804,7 @@ pub enum AcpThreadEvent {
Error,
LoadError(LoadError),
PromptCapabilitiesUpdated,
Refusal,
}
impl EventEmitter<AcpThreadEvent> for AcpThread {}
@@ -833,6 +860,7 @@ impl AcpThread {
action_log: Entity<ActionLog>,
session_id: acp::SessionId,
mut prompt_capabilities_rx: watch::Receiver<acp::PromptCapabilities>,
available_commands: Vec<acp::AvailableCommand>,
cx: &mut Context<Self>,
) -> Self {
let prompt_capabilities = *prompt_capabilities_rx.borrow();
@@ -846,6 +874,20 @@ impl AcpThread {
}
});
let determine_shell = cx
.background_spawn(async move {
if cfg!(windows) {
return get_system_shell();
}
if which::which("bash").is_ok() {
"bash".into()
} else {
get_system_shell()
}
})
.shared();
Self {
action_log,
shared_buffers: Default::default(),
@@ -858,7 +900,10 @@ impl AcpThread {
session_id,
token_usage: None,
prompt_capabilities,
available_commands,
_observe_prompt_capabilities: task,
terminals: HashMap::default(),
determine_shell,
}
}
@@ -866,6 +911,10 @@ impl AcpThread {
self.prompt_capabilities
}
pub fn available_commands(&self) -> Vec<acp::AvailableCommand> {
self.available_commands.clone()
}
pub fn connection(&self) -> &Rc<dyn AgentConnection> {
&self.connection
}
@@ -1082,27 +1131,28 @@ impl AcpThread {
let update = update.into();
let languages = self.project.read(cx).languages().clone();
let (ix, current_call) = self
.tool_call_mut(update.id())
let ix = self
.index_for_tool_call(update.id())
.context("Tool call not found")?;
let AgentThreadEntry::ToolCall(call) = &mut self.entries[ix] else {
unreachable!()
};
match update {
ToolCallUpdate::UpdateFields(update) => {
let location_updated = update.fields.locations.is_some();
current_call.update_fields(update.fields, languages, cx);
call.update_fields(update.fields, languages, &self.terminals, cx)?;
if location_updated {
self.resolve_locations(update.id, cx);
}
}
ToolCallUpdate::UpdateDiff(update) => {
current_call.content.clear();
current_call
.content
.push(ToolCallContent::Diff(update.diff));
call.content.clear();
call.content.push(ToolCallContent::Diff(update.diff));
}
ToolCallUpdate::UpdateTerminal(update) => {
current_call.content.clear();
current_call
.content
call.content.clear();
call.content
.push(ToolCallContent::Terminal(update.terminal));
}
}
@@ -1125,21 +1175,30 @@ impl AcpThread {
/// Fails if id does not match an existing entry.
pub fn upsert_tool_call_inner(
&mut self,
tool_call_update: acp::ToolCallUpdate,
update: acp::ToolCallUpdate,
status: ToolCallStatus,
cx: &mut Context<Self>,
) -> Result<(), acp::Error> {
let language_registry = self.project.read(cx).languages().clone();
let id = tool_call_update.id.clone();
let id = update.id.clone();
if let Some((ix, current_call)) = self.tool_call_mut(&id) {
current_call.update_fields(tool_call_update.fields, language_registry, cx);
current_call.status = status;
if let Some(ix) = self.index_for_tool_call(&id) {
let AgentThreadEntry::ToolCall(call) = &mut self.entries[ix] else {
unreachable!()
};
call.update_fields(update.fields, language_registry, &self.terminals, cx)?;
call.status = status;
cx.emit(AcpThreadEvent::EntryUpdated(ix));
} else {
let call =
ToolCall::from_acp(tool_call_update.try_into()?, status, language_registry, cx);
let call = ToolCall::from_acp(
update.try_into()?,
status,
language_registry,
&self.terminals,
cx,
)?;
self.push_entry(AgentThreadEntry::ToolCall(call), cx);
};
@@ -1147,6 +1206,22 @@ impl AcpThread {
Ok(())
}
fn index_for_tool_call(&self, id: &acp::ToolCallId) -> Option<usize> {
self.entries
.iter()
.enumerate()
.rev()
.find_map(|(index, entry)| {
if let AgentThreadEntry::ToolCall(tool_call) = entry
&& &tool_call.id == id
{
Some(index)
} else {
None
}
})
}
fn tool_call_mut(&mut self, id: &acp::ToolCallId) -> Option<(usize, &mut ToolCall)> {
// The tool call we are looking for is typically the last one, or very close to the end.
// At the moment, it doesn't seem like a hashmap would be a good fit for this use case.
@@ -1495,15 +1570,42 @@ impl AcpThread {
this.send_task.take();
}
// Truncate entries if the last prompt was refused.
// Handle refusal - distinguish between user prompt and tool call refusals
if let Ok(Ok(acp::PromptResponse {
stop_reason: acp::StopReason::Refusal,
})) = result
&& let Some((ix, _)) = this.last_user_message()
{
let range = ix..this.entries.len();
this.entries.truncate(ix);
cx.emit(AcpThreadEvent::EntriesRemoved(range));
if let Some((user_msg_ix, _)) = this.last_user_message() {
// Check if there's a completed tool call with results after the last user message
// This indicates the refusal is in response to tool output, not the user's prompt
let has_completed_tool_call_after_user_msg =
this.entries.iter().skip(user_msg_ix + 1).any(|entry| {
if let AgentThreadEntry::ToolCall(tool_call) = entry {
// Check if the tool call has completed and has output
matches!(tool_call.status, ToolCallStatus::Completed)
&& tool_call.raw_output.is_some()
} else {
false
}
});
if has_completed_tool_call_after_user_msg {
// Refusal is due to tool output - don't truncate, just notify
// The model refused based on what the tool returned
cx.emit(AcpThreadEvent::Refusal);
} else {
// User prompt was refused - truncate back to before the user message
let range = user_msg_ix..this.entries.len();
if range.start < range.end {
this.entries.truncate(user_msg_ix);
cx.emit(AcpThreadEvent::EntriesRemoved(range));
}
cx.emit(AcpThreadEvent::Refusal);
}
} else {
// No user message found, treat as general refusal
cx.emit(AcpThreadEvent::Refusal);
}
}
cx.emit(AcpThreadEvent::Stopped);
@@ -1829,6 +1931,133 @@ impl AcpThread {
})
}
pub fn create_terminal(
&self,
mut command: String,
args: Vec<String>,
extra_env: Vec<acp::EnvVariable>,
cwd: Option<PathBuf>,
output_byte_limit: Option<u64>,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Terminal>>> {
for arg in args {
command.push(' ');
command.push_str(&arg);
}
let shell_command = if cfg!(windows) {
format!("$null | & {{{}}}", command.replace("\"", "'"))
} else if let Some(cwd) = cwd.as_ref().and_then(|cwd| cwd.as_os_str().to_str()) {
// Make sure once we're *inside* the shell, we cd into `cwd`
format!("(cd {cwd}; {}) </dev/null", command)
} else {
format!("({}) </dev/null", command)
};
let args = vec!["-c".into(), shell_command];
let env = match &cwd {
Some(dir) => self.project.update(cx, |project, cx| {
project.directory_environment(dir.as_path().into(), cx)
}),
None => Task::ready(None).shared(),
};
let env = cx.spawn(async move |_, _| {
let mut env = env.await.unwrap_or_default();
if cfg!(unix) {
env.insert("PAGER".into(), "cat".into());
}
for var in extra_env {
env.insert(var.name, var.value);
}
env
});
let project = self.project.clone();
let language_registry = project.read(cx).languages().clone();
let determine_shell = self.determine_shell.clone();
let terminal_id = acp::TerminalId(Uuid::new_v4().to_string().into());
let terminal_task = cx.spawn({
let terminal_id = terminal_id.clone();
async move |_this, cx| {
let program = determine_shell.await;
let env = env.await;
let terminal = project
.update(cx, |project, cx| {
project.create_terminal_task(
task::SpawnInTerminal {
command: Some(program),
args,
cwd: cwd.clone(),
env,
..Default::default()
},
cx,
)
})?
.await?;
cx.new(|cx| {
Terminal::new(
terminal_id,
command,
cwd,
output_byte_limit.map(|l| l as usize),
terminal,
language_registry,
cx,
)
})
}
});
cx.spawn(async move |this, cx| {
let terminal = terminal_task.await?;
this.update(cx, |this, _cx| {
this.terminals.insert(terminal_id, terminal.clone());
terminal
})
})
}
pub fn kill_terminal(
&mut self,
terminal_id: acp::TerminalId,
cx: &mut Context<Self>,
) -> Result<()> {
self.terminals
.get(&terminal_id)
.context("Terminal not found")?
.update(cx, |terminal, cx| {
terminal.kill(cx);
});
Ok(())
}
pub fn release_terminal(
&mut self,
terminal_id: acp::TerminalId,
cx: &mut Context<Self>,
) -> Result<()> {
self.terminals
.remove(&terminal_id)
.context("Terminal not found")?
.update(cx, |terminal, cx| {
terminal.kill(cx);
});
Ok(())
}
pub fn terminal(&self, terminal_id: acp::TerminalId) -> Result<Entity<Terminal>> {
self.terminals
.get(&terminal_id)
.context("Terminal not found")
.cloned()
}
pub fn to_markdown(&self, cx: &App) -> String {
self.entries.iter().map(|e| e.to_markdown(cx)).collect()
}
@@ -2480,6 +2709,187 @@ mod tests {
assert_eq!(fs.files(), vec![Path::new(path!("/test/file-0"))]);
}
#[gpui::test]
async fn test_tool_result_refusal(cx: &mut TestAppContext) {
use std::sync::atomic::AtomicUsize;
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
// Create a connection that simulates refusal after tool result
let prompt_count = Arc::new(AtomicUsize::new(0));
let connection = Rc::new(FakeAgentConnection::new().on_user_message({
let prompt_count = prompt_count.clone();
move |_request, thread, mut cx| {
let count = prompt_count.fetch_add(1, SeqCst);
async move {
if count == 0 {
// First prompt: Generate a tool call with result
thread.update(&mut cx, |thread, cx| {
thread
.handle_session_update(
acp::SessionUpdate::ToolCall(acp::ToolCall {
id: acp::ToolCallId("tool1".into()),
title: "Test Tool".into(),
kind: acp::ToolKind::Fetch,
status: acp::ToolCallStatus::Completed,
content: vec![],
locations: vec![],
raw_input: Some(serde_json::json!({"query": "test"})),
raw_output: Some(
serde_json::json!({"result": "inappropriate content"}),
),
}),
cx,
)
.unwrap();
})?;
// Now return refusal because of the tool result
Ok(acp::PromptResponse {
stop_reason: acp::StopReason::Refusal,
})
} else {
Ok(acp::PromptResponse {
stop_reason: acp::StopReason::EndTurn,
})
}
}
.boxed_local()
}
}));
let thread = cx
.update(|cx| connection.new_thread(project, Path::new("/test"), cx))
.await
.unwrap();
// Track if we see a Refusal event
let saw_refusal_event = Arc::new(std::sync::Mutex::new(false));
let saw_refusal_event_captured = saw_refusal_event.clone();
thread.update(cx, |_thread, cx| {
cx.subscribe(
&thread,
move |_thread, _event_thread, event: &AcpThreadEvent, _cx| {
if matches!(event, AcpThreadEvent::Refusal) {
*saw_refusal_event_captured.lock().unwrap() = true;
}
},
)
.detach();
});
// Send a user message - this will trigger tool call and then refusal
let send_task = thread.update(cx, |thread, cx| {
thread.send(
vec![acp::ContentBlock::Text(acp::TextContent {
text: "Hello".into(),
annotations: None,
})],
cx,
)
});
cx.background_executor.spawn(send_task).detach();
cx.run_until_parked();
// Verify that:
// 1. A Refusal event WAS emitted (because it's a tool result refusal, not user prompt)
// 2. The user message was NOT truncated
assert!(
*saw_refusal_event.lock().unwrap(),
"Refusal event should be emitted for tool result refusals"
);
thread.read_with(cx, |thread, _| {
let entries = thread.entries();
assert!(entries.len() >= 2, "Should have user message and tool call");
// Verify user message is still there
assert!(
matches!(entries[0], AgentThreadEntry::UserMessage(_)),
"User message should not be truncated"
);
// Verify tool call is there with result
if let AgentThreadEntry::ToolCall(tool_call) = &entries[1] {
assert!(
tool_call.raw_output.is_some(),
"Tool call should have output"
);
} else {
panic!("Expected tool call at index 1");
}
});
}
#[gpui::test]
async fn test_user_prompt_refusal_emits_event(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let refuse_next = Arc::new(AtomicBool::new(false));
let connection = Rc::new(FakeAgentConnection::new().on_user_message({
let refuse_next = refuse_next.clone();
move |_request, _thread, _cx| {
if refuse_next.load(SeqCst) {
async move {
Ok(acp::PromptResponse {
stop_reason: acp::StopReason::Refusal,
})
}
.boxed_local()
} else {
async move {
Ok(acp::PromptResponse {
stop_reason: acp::StopReason::EndTurn,
})
}
.boxed_local()
}
}
}));
let thread = cx
.update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
.await
.unwrap();
// Track if we see a Refusal event
let saw_refusal_event = Arc::new(std::sync::Mutex::new(false));
let saw_refusal_event_captured = saw_refusal_event.clone();
thread.update(cx, |_thread, cx| {
cx.subscribe(
&thread,
move |_thread, _event_thread, event: &AcpThreadEvent, _cx| {
if matches!(event, AcpThreadEvent::Refusal) {
*saw_refusal_event_captured.lock().unwrap() = true;
}
},
)
.detach();
});
// Send a message that will be refused
refuse_next.store(true, SeqCst);
cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)))
.await
.unwrap();
// Verify that a Refusal event WAS emitted for user prompt refusal
assert!(
*saw_refusal_event.lock().unwrap(),
"Refusal event should be emitted for user prompt refusals"
);
// Verify the message was truncated (user prompt refusal)
thread.read_with(cx, |thread, cx| {
assert_eq!(thread.to_markdown(cx), "");
});
}
#[gpui::test]
async fn test_refusal(cx: &mut TestAppContext) {
init_test(cx);
@@ -2543,8 +2953,8 @@ mod tests {
);
});
// Simulate refusing the second message, ensuring the conversation gets
// truncated to before sending it.
// Simulate refusing the second message. The message should be truncated
// when a user prompt is refused.
refuse_next.store(true, SeqCst);
cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["world".into()], cx)))
.await
@@ -2670,6 +3080,7 @@ mod tests {
audio: true,
embedded_context: true,
}),
vec![],
cx,
)
});

View File

@@ -75,7 +75,6 @@ pub trait AgentConnection {
fn telemetry(&self) -> Option<Rc<dyn AgentTelemetry>> {
None
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
}
@@ -339,6 +338,7 @@ mod test_support {
audio: true,
embedded_context: true,
}),
vec![],
cx,
)
});

View File

@@ -1,34 +1,43 @@
use gpui::{App, AppContext, Context, Entity};
use agent_client_protocol as acp;
use futures::{FutureExt as _, future::Shared};
use gpui::{App, AppContext, Context, Entity, Task};
use language::LanguageRegistry;
use markdown::Markdown;
use std::{path::PathBuf, process::ExitStatus, sync::Arc, time::Instant};
pub struct Terminal {
id: acp::TerminalId,
command: Entity<Markdown>,
working_dir: Option<PathBuf>,
terminal: Entity<terminal::Terminal>,
started_at: Instant,
output: Option<TerminalOutput>,
output_byte_limit: Option<usize>,
_output_task: Shared<Task<acp::TerminalExitStatus>>,
}
pub struct TerminalOutput {
pub ended_at: Instant,
pub exit_status: Option<ExitStatus>,
pub was_content_truncated: bool,
pub content: String,
pub original_content_len: usize,
pub content_line_count: usize,
pub finished_with_empty_output: bool,
}
impl Terminal {
pub fn new(
id: acp::TerminalId,
command: String,
working_dir: Option<PathBuf>,
output_byte_limit: Option<usize>,
terminal: Entity<terminal::Terminal>,
language_registry: Arc<LanguageRegistry>,
cx: &mut Context<Self>,
) -> Self {
let command_task = terminal.read(cx).wait_for_completed_task(cx);
Self {
id,
command: cx.new(|cx| {
Markdown::new(
format!("```\n{}\n```", command).into(),
@@ -41,27 +50,93 @@ impl Terminal {
terminal,
started_at: Instant::now(),
output: None,
output_byte_limit,
_output_task: cx
.spawn(async move |this, cx| {
let exit_status = command_task.await;
this.update(cx, |this, cx| {
let (content, original_content_len) = this.truncated_output(cx);
let content_line_count = this.terminal.read(cx).total_lines();
this.output = Some(TerminalOutput {
ended_at: Instant::now(),
exit_status,
content,
original_content_len,
content_line_count,
});
cx.notify();
})
.ok();
let exit_status = exit_status.map(portable_pty::ExitStatus::from);
acp::TerminalExitStatus {
exit_code: exit_status.as_ref().map(|e| e.exit_code()),
signal: exit_status.and_then(|e| e.signal().map(Into::into)),
}
})
.shared(),
}
}
pub fn finish(
&mut self,
exit_status: Option<ExitStatus>,
original_content_len: usize,
truncated_content_len: usize,
content_line_count: usize,
finished_with_empty_output: bool,
cx: &mut Context<Self>,
) {
self.output = Some(TerminalOutput {
ended_at: Instant::now(),
exit_status,
was_content_truncated: truncated_content_len < original_content_len,
original_content_len,
content_line_count,
finished_with_empty_output,
pub fn id(&self) -> &acp::TerminalId {
&self.id
}
pub fn wait_for_exit(&self) -> Shared<Task<acp::TerminalExitStatus>> {
self._output_task.clone()
}
pub fn kill(&mut self, cx: &mut App) {
self.terminal.update(cx, |terminal, _cx| {
terminal.kill_active_task();
});
cx.notify();
}
pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse {
if let Some(output) = self.output.as_ref() {
let exit_status = output.exit_status.map(portable_pty::ExitStatus::from);
acp::TerminalOutputResponse {
output: output.content.clone(),
truncated: output.original_content_len > output.content.len(),
exit_status: Some(acp::TerminalExitStatus {
exit_code: exit_status.as_ref().map(|e| e.exit_code()),
signal: exit_status.and_then(|e| e.signal().map(Into::into)),
}),
}
} else {
let (current_content, original_len) = self.truncated_output(cx);
acp::TerminalOutputResponse {
truncated: current_content.len() < original_len,
output: current_content,
exit_status: None,
}
}
}
fn truncated_output(&self, cx: &App) -> (String, usize) {
let terminal = self.terminal.read(cx);
let mut content = terminal.get_content();
let original_content_len = content.len();
if let Some(limit) = self.output_byte_limit
&& content.len() > limit
{
let mut end_ix = limit.min(content.len());
while !content.is_char_boundary(end_ix) {
end_ix -= 1;
}
// Don't truncate mid-line, clear the remainder of the last line
end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
content.truncate(end_ix);
}
(content, original_content_len)
}
pub fn command(&self) -> &Entity<Markdown> {

View File

@@ -1,11 +1,10 @@
use auto_update::{AutoUpdateStatus, AutoUpdater, DismissErrorMessage, VersionCheckType};
use editor::Editor;
use extension_host::ExtensionStore;
use extension_host::{ExtensionOperation, ExtensionStore};
use futures::StreamExt;
use gpui::{
Animation, AnimationExt as _, App, Context, CursorStyle, Entity, EventEmitter,
InteractiveElement as _, ParentElement as _, Render, SharedString, StatefulInteractiveElement,
Styled, Transformation, Window, actions, percentage,
App, Context, CursorStyle, Entity, EventEmitter, InteractiveElement as _, ParentElement as _,
Render, SharedString, StatefulInteractiveElement, Styled, Window, actions,
};
use language::{
BinaryStatus, LanguageRegistry, LanguageServerId, LanguageServerName,
@@ -25,7 +24,10 @@ use std::{
sync::Arc,
time::{Duration, Instant},
};
use ui::{ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
use ui::{
ButtonLike, CommonAnimationExt, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip,
prelude::*,
};
use util::truncate_and_trailoff;
use workspace::{StatusItemView, Workspace, item::ItemHandle};
@@ -405,13 +407,7 @@ impl ActivityIndicator {
icon: Some(
Icon::new(IconName::ArrowCircle)
.size(IconSize::Small)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(percentage(delta)))
},
)
.with_rotate_animation(2)
.into_any_element(),
),
message,
@@ -433,11 +429,7 @@ impl ActivityIndicator {
icon: Some(
Icon::new(IconName::ArrowCircle)
.size(IconSize::Small)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
)
.with_rotate_animation(2)
.into_any_element(),
),
message: format!("Debug: {}", session.read(cx).adapter()),
@@ -460,11 +452,7 @@ impl ActivityIndicator {
icon: Some(
Icon::new(IconName::ArrowCircle)
.size(IconSize::Small)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
)
.with_rotate_animation(2)
.into_any_element(),
),
message: job_info.message.into(),
@@ -671,8 +659,9 @@ impl ActivityIndicator {
}
// Show any application auto-update info.
if let Some(updater) = &self.auto_updater {
return match &updater.read(cx).status() {
self.auto_updater
.as_ref()
.and_then(|updater| match &updater.read(cx).status() {
AutoUpdateStatus::Checking => Some(Content {
icon: Some(
Icon::new(IconName::Download)
@@ -728,28 +717,49 @@ impl ActivityIndicator {
tooltip_message: None,
}),
AutoUpdateStatus::Idle => None,
};
}
})
.or_else(|| {
if let Some(extension_store) =
ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx))
&& let Some((extension_id, operation)) =
extension_store.outstanding_operations().iter().next()
{
let (message, icon, rotate) = match operation {
ExtensionOperation::Install => (
format!("Installing {extension_id} extension…"),
IconName::LoadCircle,
true,
),
ExtensionOperation::Upgrade => (
format!("Updating {extension_id} extension…"),
IconName::Download,
false,
),
ExtensionOperation::Remove => (
format!("Removing {extension_id} extension…"),
IconName::LoadCircle,
true,
),
};
if let Some(extension_store) =
ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx))
&& let Some(extension_id) = extension_store.outstanding_operations().keys().next()
{
return Some(Content {
icon: Some(
Icon::new(IconName::Download)
.size(IconSize::Small)
.into_any_element(),
),
message: format!("Updating {extension_id} extension…"),
on_click: Some(Arc::new(|this, window, cx| {
this.dismiss_error_message(&DismissErrorMessage, window, cx)
})),
tooltip_message: None,
});
}
None
Some(Content {
icon: Some(Icon::new(icon).size(IconSize::Small).map(|this| {
if rotate {
this.with_rotate_animation(3).into_any_element()
} else {
this.into_any_element()
}
})),
message,
on_click: Some(Arc::new(|this, window, cx| {
this.dismiss_error_message(&Default::default(), window, cx)
})),
tooltip_message: None,
})
} else {
None
}
})
}
fn version_tooltip_message(version: &VersionCheckType) -> String {

View File

@@ -48,7 +48,6 @@ log.workspace = true
open.workspace = true
parking_lot.workspace = true
paths.workspace = true
portable-pty.workspace = true
project.workspace = true
prompt_store.workspace = true
rust-embed.workspace = true
@@ -68,7 +67,6 @@ util.workspace = true
uuid.workspace = true
watch.workspace = true
web_search.workspace = true
which.workspace = true
workspace-hack.workspace = true
zstd.workspace = true

View File

@@ -2,7 +2,7 @@ use crate::{
ContextServerRegistry, Thread, ThreadEvent, ThreadsDatabase, ToolCallAuthorization,
UserMessageContent, templates::Templates,
};
use crate::{HistoryStore, TitleUpdated, TokenUsageUpdated};
use crate::{HistoryStore, TerminalHandle, ThreadEnvironment, TitleUpdated, TokenUsageUpdated};
use acp_thread::{AcpThread, AgentModelSelector};
use action_log::ActionLog;
use agent_client_protocol as acp;
@@ -10,7 +10,8 @@ use agent_settings::AgentSettings;
use anyhow::{Context as _, Result, anyhow};
use collections::{HashSet, IndexMap};
use fs::Fs;
use futures::channel::mpsc;
use futures::channel::{mpsc, oneshot};
use futures::future::Shared;
use futures::{StreamExt, future};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
@@ -23,7 +24,7 @@ use prompt_store::{
use settings::update_settings_file;
use std::any::Any;
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use util::ResultExt;
@@ -276,13 +277,6 @@ impl NativeAgent {
cx: &mut Context<Self>,
) -> Entity<AcpThread> {
let connection = Rc::new(NativeAgentConnection(cx.entity()));
let registry = LanguageModelRegistry::read_global(cx);
let summarization_model = registry.thread_summary_model().map(|c| c.model);
thread_handle.update(cx, |thread, cx| {
thread.set_summarization_model(summarization_model, cx);
thread.add_default_tools(cx)
});
let thread = thread_handle.read(cx);
let session_id = thread.id().clone();
@@ -298,9 +292,24 @@ impl NativeAgent {
action_log.clone(),
session_id.clone(),
prompt_capabilities_rx,
vec![],
cx,
)
});
let registry = LanguageModelRegistry::read_global(cx);
let summarization_model = registry.thread_summary_model().map(|c| c.model);
thread_handle.update(cx, |thread, cx| {
thread.set_summarization_model(summarization_model, cx);
thread.add_default_tools(
Rc::new(AcpThreadEnvironment {
acp_thread: acp_thread.downgrade(),
}) as _,
cx,
)
});
let subscriptions = vec![
cx.observe_release(&acp_thread, |this, acp_thread, _cx| {
this.sessions.remove(acp_thread.session_id());
@@ -1001,7 +1010,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
) -> Option<Rc<dyn acp_thread::AgentSessionTruncate>> {
self.0.read_with(cx, |agent, _cx| {
agent.sessions.get(session_id).map(|session| {
Rc::new(NativeAgentSessionEditor {
Rc::new(NativeAgentSessionTruncate {
thread: session.thread.clone(),
acp_thread: session.acp_thread.clone(),
}) as _
@@ -1050,12 +1059,12 @@ impl acp_thread::AgentTelemetry for NativeAgentConnection {
}
}
struct NativeAgentSessionEditor {
struct NativeAgentSessionTruncate {
thread: Entity<Thread>,
acp_thread: WeakEntity<AcpThread>,
}
impl acp_thread::AgentSessionTruncate for NativeAgentSessionEditor {
impl acp_thread::AgentSessionTruncate for NativeAgentSessionTruncate {
fn run(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task<Result<()>> {
match self.thread.update(cx, |thread, cx| {
thread.truncate(message_id.clone(), cx)?;
@@ -1104,6 +1113,66 @@ impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle {
}
}
pub struct AcpThreadEnvironment {
acp_thread: WeakEntity<AcpThread>,
}
impl ThreadEnvironment for AcpThreadEnvironment {
fn create_terminal(
&self,
command: String,
cwd: Option<PathBuf>,
output_byte_limit: Option<u64>,
cx: &mut AsyncApp,
) -> Task<Result<Rc<dyn TerminalHandle>>> {
let task = self.acp_thread.update(cx, |thread, cx| {
thread.create_terminal(command, vec![], vec![], cwd, output_byte_limit, cx)
});
let acp_thread = self.acp_thread.clone();
cx.spawn(async move |cx| {
let terminal = task?.await?;
let (drop_tx, drop_rx) = oneshot::channel();
let terminal_id = terminal.read_with(cx, |terminal, _cx| terminal.id().clone())?;
cx.spawn(async move |cx| {
drop_rx.await.ok();
acp_thread.update(cx, |thread, cx| thread.release_terminal(terminal_id, cx))
})
.detach();
let handle = AcpTerminalHandle {
terminal,
_drop_tx: Some(drop_tx),
};
Ok(Rc::new(handle) as _)
})
}
}
pub struct AcpTerminalHandle {
terminal: Entity<acp_thread::Terminal>,
_drop_tx: Option<oneshot::Sender<()>>,
}
impl TerminalHandle for AcpTerminalHandle {
fn id(&self, cx: &AsyncApp) -> Result<acp::TerminalId> {
self.terminal.read_with(cx, |term, _cx| term.id().clone())
}
fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>> {
self.terminal
.read_with(cx, |term, _cx| term.wait_for_exit())
}
fn current_output(&self, cx: &AsyncApp) -> Result<acp::TerminalOutputResponse> {
self.terminal
.read_with(cx, |term, cx| term.current_output(cx))
}
}
#[cfg(test)]
mod tests {
use crate::HistoryEntryId;

View File

@@ -72,7 +72,6 @@ async fn test_echo(cx: &mut TestAppContext) {
}
#[gpui::test]
#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
async fn test_thinking(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
@@ -1349,7 +1348,6 @@ async fn test_cancellation(cx: &mut TestAppContext) {
}
#[gpui::test]
#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
@@ -1688,7 +1686,6 @@ async fn test_truncate_second_message(cx: &mut TestAppContext) {
}
#[gpui::test]
#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
async fn test_title_generation(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
@@ -2353,15 +2350,20 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
settings::init(cx);
Project::init_settings(cx);
agent_settings::init(cx);
gpui_tokio::init(cx);
let http_client = ReqwestClient::user_agent("agent tests").unwrap();
cx.set_http_client(Arc::new(http_client));
client::init_settings(cx);
let client = Client::production(cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
language_model::init(client.clone(), cx);
language_models::init(user_store, client.clone(), cx);
match model {
TestModel::Fake => {}
TestModel::Sonnet4 => {
gpui_tokio::init(cx);
let http_client = ReqwestClient::user_agent("agent tests").unwrap();
cx.set_http_client(Arc::new(http_client));
client::init_settings(cx);
let client = Client::production(cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
language_model::init(client.clone(), cx);
language_models::init(user_store, client.clone(), cx);
}
};
watch_settings(fs.clone(), cx);
});
@@ -2475,6 +2477,7 @@ fn setup_context_server(
path: "somebinary".into(),
args: Vec::new(),
env: None,
timeout: None,
},
},
);

View File

@@ -45,14 +45,15 @@ use schemars::{JsonSchema, Schema};
use serde::{Deserialize, Serialize};
use settings::{Settings, update_settings_file};
use smol::stream::StreamExt;
use std::fmt::Write;
use std::{
collections::BTreeMap,
ops::RangeInclusive,
path::Path,
rc::Rc,
sync::Arc,
time::{Duration, Instant},
};
use std::{fmt::Write, path::PathBuf};
use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock};
use uuid::Uuid;
@@ -523,6 +524,22 @@ pub enum AgentMessageContent {
ToolUse(LanguageModelToolUse),
}
pub trait TerminalHandle {
fn id(&self, cx: &AsyncApp) -> Result<acp::TerminalId>;
fn current_output(&self, cx: &AsyncApp) -> Result<acp::TerminalOutputResponse>;
fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>>;
}
pub trait ThreadEnvironment {
fn create_terminal(
&self,
command: String,
cwd: Option<PathBuf>,
output_byte_limit: Option<u64>,
cx: &mut AsyncApp,
) -> Task<Result<Rc<dyn TerminalHandle>>>;
}
#[derive(Debug)]
pub enum ThreadEvent {
UserMessage(UserMessage),
@@ -535,6 +552,14 @@ pub enum ThreadEvent {
Stop(acp::StopReason),
}
#[derive(Debug)]
pub struct NewTerminal {
pub command: String,
pub output_byte_limit: Option<u64>,
pub cwd: Option<PathBuf>,
pub response: oneshot::Sender<Result<Entity<acp_thread::Terminal>>>,
}
#[derive(Debug)]
pub struct ToolCallAuthorization {
pub tool_call: acp::ToolCallUpdate,
@@ -1024,7 +1049,11 @@ impl Thread {
}
}
pub fn add_default_tools(&mut self, cx: &mut Context<Self>) {
pub fn add_default_tools(
&mut self,
environment: Rc<dyn ThreadEnvironment>,
cx: &mut Context<Self>,
) {
let language_registry = self.project.read(cx).languages().clone();
self.add_tool(CopyPathTool::new(self.project.clone()));
self.add_tool(CreateDirectoryTool::new(self.project.clone()));
@@ -1045,7 +1074,7 @@ impl Thread {
self.project.clone(),
self.action_log.clone(),
));
self.add_tool(TerminalTool::new(self.project.clone(), cx));
self.add_tool(TerminalTool::new(self.project.clone(), environment));
self.add_tool(ThinkingTool);
self.add_tool(WebSearchTool);
}
@@ -2389,19 +2418,6 @@ impl ToolCallEventStream {
.ok();
}
pub fn update_terminal(&self, terminal: Entity<acp_thread::Terminal>) {
self.stream
.0
.unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
acp_thread::ToolCallUpdateTerminal {
id: acp::ToolCallId(self.tool_use_id.to_string().into()),
terminal,
}
.into(),
)))
.ok();
}
pub fn authorize(&self, title: impl Into<String>, cx: &mut App) -> Task<Result<()>> {
if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
return Task::ready(Ok(()));

View File

@@ -1,19 +1,19 @@
use agent_client_protocol as acp;
use anyhow::Result;
use futures::{FutureExt as _, future::Shared};
use gpui::{App, AppContext, Entity, SharedString, Task};
use gpui::{App, Entity, SharedString, Task};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
};
use util::{ResultExt, get_system_shell, markdown::MarkdownInlineCode};
use util::markdown::MarkdownInlineCode;
use crate::{AgentTool, ToolCallEventStream};
use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream};
const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024;
const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
/// Executes a shell one-liner and returns the combined output.
///
@@ -36,25 +36,14 @@ pub struct TerminalToolInput {
pub struct TerminalTool {
project: Entity<Project>,
determine_shell: Shared<Task<String>>,
environment: Rc<dyn ThreadEnvironment>,
}
impl TerminalTool {
pub fn new(project: Entity<Project>, cx: &mut App) -> Self {
let determine_shell = cx.background_spawn(async move {
if cfg!(windows) {
return get_system_shell();
}
if which::which("bash").is_ok() {
"bash".into()
} else {
get_system_shell()
}
});
pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self {
Self {
project,
determine_shell: determine_shell.shared(),
environment,
}
}
}
@@ -99,128 +88,49 @@ impl AgentTool for TerminalTool {
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output>> {
let language_registry = self.project.read(cx).languages().clone();
let working_dir = match working_dir(&input, &self.project, cx) {
Ok(dir) => dir,
Err(err) => return Task::ready(Err(err)),
};
let program = self.determine_shell.clone();
let command = if cfg!(windows) {
format!("$null | & {{{}}}", input.command.replace("\"", "'"))
} else if let Some(cwd) = working_dir
.as_ref()
.and_then(|cwd| cwd.as_os_str().to_str())
{
// Make sure once we're *inside* the shell, we cd into `cwd`
format!("(cd {cwd}; {}) </dev/null", input.command)
} else {
format!("({}) </dev/null", input.command)
};
let args = vec!["-c".into(), command];
let env = match &working_dir {
Some(dir) => self.project.update(cx, |project, cx| {
project.directory_environment(dir.as_path().into(), cx)
}),
None => Task::ready(None).shared(),
};
let env = cx.spawn(async move |_| {
let mut env = env.await.unwrap_or_default();
if cfg!(unix) {
env.insert("PAGER".into(), "cat".into());
}
env
});
let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())), cx);
cx.spawn(async move |cx| {
authorize.await?;
cx.spawn({
async move |cx| {
authorize.await?;
let terminal = self
.environment
.create_terminal(
input.command.clone(),
working_dir,
Some(COMMAND_OUTPUT_LIMIT),
cx,
)
.await?;
let program = program.await;
let env = env.await;
let terminal = self
.project
.update(cx, |project, cx| {
project.create_terminal_task(
task::SpawnInTerminal {
command: Some(program),
args,
cwd: working_dir.clone(),
env,
..Default::default()
},
cx,
)
})?
.await?;
let acp_terminal = cx.new(|cx| {
acp_thread::Terminal::new(
input.command.clone(),
working_dir.clone(),
terminal.clone(),
language_registry,
cx,
)
})?;
event_stream.update_terminal(acp_terminal.clone());
let terminal_id = terminal.id(cx)?;
event_stream.update_fields(acp::ToolCallUpdateFields {
content: Some(vec![acp::ToolCallContent::Terminal { terminal_id }]),
..Default::default()
});
let exit_status = terminal
.update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
.await;
let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {
(terminal.get_content(), terminal.total_lines())
})?;
let exit_status = terminal.wait_for_exit(cx)?.await;
let output = terminal.current_output(cx)?;
let (processed_content, finished_with_empty_output) = process_content(
&content,
&input.command,
exit_status.map(portable_pty::ExitStatus::from),
);
acp_terminal
.update(cx, |terminal, cx| {
terminal.finish(
exit_status,
content.len(),
processed_content.len(),
content_line_count,
finished_with_empty_output,
cx,
);
})
.log_err();
Ok(processed_content)
}
Ok(process_content(output, &input.command, exit_status))
})
}
}
fn process_content(
content: &str,
output: acp::TerminalOutputResponse,
command: &str,
exit_status: Option<portable_pty::ExitStatus>,
) -> (String, bool) {
let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
let content = if should_truncate {
let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
while !content.is_char_boundary(end_ix) {
end_ix -= 1;
}
// Don't truncate mid-line, clear the remainder of the last line
end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
&content[..end_ix]
} else {
content
};
let content = content.trim();
exit_status: acp::TerminalExitStatus,
) -> String {
let content = output.output.trim();
let is_empty = content.is_empty();
let content = format!("```\n{content}\n```");
let content = if should_truncate {
let content = if output.truncated {
format!(
"Command output too long. The first {} bytes:\n\n{content}",
content.len(),
@@ -229,24 +139,21 @@ fn process_content(
content
};
let content = match exit_status {
Some(exit_status) if exit_status.success() => {
let content = match exit_status.exit_code {
Some(0) => {
if is_empty {
"Command executed successfully.".to_string()
} else {
content
}
}
Some(exit_status) => {
Some(exit_code) => {
if is_empty {
format!(
"Command \"{command}\" failed with exit code {}.",
exit_status.exit_code()
)
format!("Command \"{command}\" failed with exit code {}.", exit_code)
} else {
format!(
"Command \"{command}\" failed with exit code {}.\n\n{content}",
exit_status.exit_code()
exit_code
)
}
}
@@ -257,7 +164,7 @@ fn process_content(
)
}
};
(content, is_empty)
content
}
fn working_dir(
@@ -300,169 +207,3 @@ fn working_dir(
anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
}
}
#[cfg(test)]
mod tests {
use agent_settings::AgentSettings;
use editor::EditorSettings;
use fs::RealFs;
use gpui::{BackgroundExecutor, TestAppContext};
use pretty_assertions::assert_eq;
use serde_json::json;
use settings::{Settings, SettingsStore};
use terminal::terminal_settings::TerminalSettings;
use theme::ThemeSettings;
use util::test::TempTree;
use crate::ThreadEvent;
use super::*;
fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
zlog::init_test();
executor.allow_parking();
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
Project::init_settings(cx);
ThemeSettings::register(cx);
TerminalSettings::register(cx);
EditorSettings::register(cx);
AgentSettings::register(cx);
});
}
#[gpui::test]
async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
if cfg!(windows) {
return;
}
init_test(&executor, cx);
let fs = Arc::new(RealFs::new(None, executor));
let tree = TempTree::new(json!({
"project": {},
}));
let project: Entity<Project> =
Project::test(fs, [tree.path().join("project").as_path()], cx).await;
let input = TerminalToolInput {
command: "cat".to_owned(),
cd: tree
.path()
.join("project")
.as_path()
.to_string_lossy()
.to_string(),
};
let (event_stream_tx, mut event_stream_rx) = ToolCallEventStream::test();
let result = cx
.update(|cx| Arc::new(TerminalTool::new(project, cx)).run(input, event_stream_tx, cx));
let auth = event_stream_rx.expect_authorization().await;
auth.response.send(auth.options[0].id.clone()).unwrap();
event_stream_rx.expect_terminal().await;
assert_eq!(result.await.unwrap(), "Command executed successfully.");
}
#[gpui::test]
async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
if cfg!(windows) {
return;
}
init_test(&executor, cx);
let fs = Arc::new(RealFs::new(None, executor));
let tree = TempTree::new(json!({
"project": {},
"other-project": {},
}));
let project: Entity<Project> =
Project::test(fs, [tree.path().join("project").as_path()], cx).await;
let check = |input, expected, cx: &mut TestAppContext| {
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
let result = cx.update(|cx| {
Arc::new(TerminalTool::new(project.clone(), cx)).run(input, stream_tx, cx)
});
cx.run_until_parked();
let event = stream_rx.try_next();
if let Ok(Some(Ok(ThreadEvent::ToolCallAuthorization(auth)))) = event {
auth.response.send(auth.options[0].id.clone()).unwrap();
}
cx.spawn(async move |_| {
let output = result.await;
assert_eq!(output.ok(), expected);
})
};
check(
TerminalToolInput {
command: "pwd".into(),
cd: ".".into(),
},
Some(format!(
"```\n{}\n```",
tree.path().join("project").display()
)),
cx,
)
.await;
check(
TerminalToolInput {
command: "pwd".into(),
cd: "other-project".into(),
},
None, // other-project is a dir, but *not* a worktree (yet)
cx,
)
.await;
// Absolute path above the worktree root
check(
TerminalToolInput {
command: "pwd".into(),
cd: tree.path().to_string_lossy().into(),
},
None,
cx,
)
.await;
project
.update(cx, |project, cx| {
project.create_worktree(tree.path().join("other-project"), true, cx)
})
.await
.unwrap();
check(
TerminalToolInput {
command: "pwd".into(),
cd: "other-project".into(),
},
Some(format!(
"```\n{}\n```",
tree.path().join("other-project").display()
)),
cx,
)
.await;
check(
TerminalToolInput {
command: "pwd".into(),
cd: ".".into(),
},
None,
cx,
)
.await;
}
}

View File

@@ -28,7 +28,7 @@ pub struct AcpConnection {
connection: Rc<acp::ClientSideConnection>,
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
auth_methods: Vec<acp::AuthMethod>,
prompt_capabilities: acp::PromptCapabilities,
agent_capabilities: acp::AgentCapabilities,
_io_task: Task<Result<()>>,
_wait_task: Task<Result<()>>,
_stderr_task: Task<Result<()>>,
@@ -134,6 +134,7 @@ impl AcpConnection {
read_text_file: true,
write_text_file: true,
},
terminal: true,
},
})
.await?;
@@ -147,7 +148,7 @@ impl AcpConnection {
connection,
server_name,
sessions,
prompt_capabilities: response.agent_capabilities.prompt_capabilities,
agent_capabilities: response.agent_capabilities,
_io_task: io_task,
_wait_task: wait_task,
_stderr_task: stderr_task,
@@ -155,7 +156,7 @@ impl AcpConnection {
}
pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities {
&self.prompt_capabilities
&self.agent_capabilities.prompt_capabilities
}
}
@@ -222,7 +223,8 @@ impl AgentConnection for AcpConnection {
action_log,
session_id.clone(),
// ACP doesn't currently support per-session prompt capabilities or changing capabilities dynamically.
watch::Receiver::constant(self.prompt_capabilities),
watch::Receiver::constant(self.agent_capabilities.prompt_capabilities),
response.available_commands,
cx,
)
})?;
@@ -344,11 +346,7 @@ impl acp::Client for ClientDelegate {
let cx = &mut self.cx.clone();
let task = self
.sessions
.borrow()
.get(&arguments.session_id)
.context("Failed to get session")?
.thread
.session_thread(&arguments.session_id)?
.update(cx, |thread, cx| {
thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx)
})??;
@@ -364,11 +362,7 @@ impl acp::Client for ClientDelegate {
) -> Result<(), acp::Error> {
let cx = &mut self.cx.clone();
let task = self
.sessions
.borrow()
.get(&arguments.session_id)
.context("Failed to get session")?
.thread
.session_thread(&arguments.session_id)?
.update(cx, |thread, cx| {
thread.write_text_file(arguments.path, arguments.content, cx)
})?;
@@ -382,16 +376,12 @@ impl acp::Client for ClientDelegate {
&self,
arguments: acp::ReadTextFileRequest,
) -> Result<acp::ReadTextFileResponse, acp::Error> {
let cx = &mut self.cx.clone();
let task = self
.sessions
.borrow()
.get(&arguments.session_id)
.context("Failed to get session")?
.thread
.update(cx, |thread, cx| {
let task = self.session_thread(&arguments.session_id)?.update(
&mut self.cx.clone(),
|thread, cx| {
thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx)
})?;
},
)?;
let content = task.await?;
@@ -402,16 +392,92 @@ impl acp::Client for ClientDelegate {
&self,
notification: acp::SessionNotification,
) -> Result<(), acp::Error> {
let cx = &mut self.cx.clone();
let sessions = self.sessions.borrow();
let session = sessions
.get(&notification.session_id)
.context("Failed to get session")?;
session.thread.update(cx, |thread, cx| {
thread.handle_session_update(notification.update, cx)
})??;
self.session_thread(&notification.session_id)?
.update(&mut self.cx.clone(), |thread, cx| {
thread.handle_session_update(notification.update, cx)
})??;
Ok(())
}
async fn create_terminal(
&self,
args: acp::CreateTerminalRequest,
) -> Result<acp::CreateTerminalResponse, acp::Error> {
let terminal = self
.session_thread(&args.session_id)?
.update(&mut self.cx.clone(), |thread, cx| {
thread.create_terminal(
args.command,
args.args,
args.env,
args.cwd,
args.output_byte_limit,
cx,
)
})?
.await?;
Ok(
terminal.read_with(&self.cx, |terminal, _| acp::CreateTerminalResponse {
terminal_id: terminal.id().clone(),
})?,
)
}
async fn kill_terminal(&self, args: acp::KillTerminalRequest) -> Result<(), acp::Error> {
self.session_thread(&args.session_id)?
.update(&mut self.cx.clone(), |thread, cx| {
thread.kill_terminal(args.terminal_id, cx)
})??;
Ok(())
}
async fn release_terminal(&self, args: acp::ReleaseTerminalRequest) -> Result<(), acp::Error> {
self.session_thread(&args.session_id)?
.update(&mut self.cx.clone(), |thread, cx| {
thread.release_terminal(args.terminal_id, cx)
})??;
Ok(())
}
async fn terminal_output(
&self,
args: acp::TerminalOutputRequest,
) -> Result<acp::TerminalOutputResponse, acp::Error> {
self.session_thread(&args.session_id)?
.read_with(&mut self.cx.clone(), |thread, cx| {
let out = thread
.terminal(args.terminal_id)?
.read(cx)
.current_output(cx);
Ok(out)
})?
}
async fn wait_for_terminal_exit(
&self,
args: acp::WaitForTerminalExitRequest,
) -> Result<acp::WaitForTerminalExitResponse, acp::Error> {
let exit_status = self
.session_thread(&args.session_id)?
.update(&mut self.cx.clone(), |thread, cx| {
anyhow::Ok(thread.terminal(args.terminal_id)?.read(cx).wait_for_exit())
})??
.await;
Ok(acp::WaitForTerminalExitResponse { exit_status })
}
}
impl ClientDelegate {
fn session_thread(&self, session_id: &acp::SessionId) -> Result<WeakEntity<AcpThread>> {
let sessions = self.sessions.borrow();
sessions
.get(session_id)
.context("Failed to get session")
.map(|session| session.thread.clone())
}
}

View File

@@ -45,11 +45,20 @@ pub fn init(cx: &mut App) {
pub struct AgentServerDelegate {
project: Entity<Project>,
status_tx: Option<watch::Sender<SharedString>>,
new_version_available: Option<watch::Sender<Option<String>>>,
}
impl AgentServerDelegate {
pub fn new(project: Entity<Project>, status_tx: Option<watch::Sender<SharedString>>) -> Self {
Self { project, status_tx }
pub fn new(
project: Entity<Project>,
status_tx: Option<watch::Sender<SharedString>>,
new_version_tx: Option<watch::Sender<Option<String>>>,
) -> Self {
Self {
project,
status_tx,
new_version_available: new_version_tx,
}
}
pub fn project(&self) -> &Entity<Project> {
@@ -73,6 +82,7 @@ impl AgentServerDelegate {
)));
};
let status_tx = self.status_tx;
let new_version_available = self.new_version_available;
cx.spawn(async move |cx| {
if !ignore_system_version {
@@ -101,9 +111,11 @@ impl AgentServerDelegate {
continue;
};
if let Some(version) = file_name
.to_str()
.and_then(|name| semver::Version::from_str(&name).ok())
if let Some(name) = file_name.to_str()
&& let Some(version) = semver::Version::from_str(name).ok()
&& fs
.is_file(&dir.join(file_name).join(&entrypoint_path))
.await
{
versions.push((version, file_name.to_owned()));
} else {
@@ -146,6 +158,7 @@ impl AgentServerDelegate {
cx.background_spawn({
let file_name = file_name.clone();
let dir = dir.clone();
let fs = fs.clone();
async move {
let latest_version =
node_runtime.npm_package_latest_version(&package_name).await;
@@ -160,6 +173,9 @@ impl AgentServerDelegate {
)
.await
.log_err();
if let Some(mut new_version_available) = new_version_available {
new_version_available.send(Some(latest_version)).ok();
}
}
}
})
@@ -171,7 +187,7 @@ impl AgentServerDelegate {
}
let dir = dir.clone();
cx.background_spawn(Self::download_latest_version(
fs,
fs.clone(),
dir.clone(),
node_runtime,
package_name,
@@ -179,14 +195,18 @@ impl AgentServerDelegate {
.await?
.into()
};
let agent_server_path = dir.join(version).join(entrypoint_path);
let agent_server_path_exists = fs.is_file(&agent_server_path).await;
anyhow::ensure!(
agent_server_path_exists,
"Missing entrypoint path {} after installation",
agent_server_path.to_string_lossy()
);
anyhow::Ok(AgentServerCommand {
path: node_path,
args: vec![
dir.join(version)
.join(entrypoint_path)
.to_string_lossy()
.to_string(),
],
args: vec![agent_server_path.to_string_lossy().to_string()],
env: Default::default(),
})
})

View File

@@ -76,6 +76,7 @@ impl AgentServer for ClaudeCode {
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
let root_dir = root_dir.to_path_buf();
let fs = delegate.project().read(cx).fs().clone();
let server_name = self.name();
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).claude.clone()
@@ -109,6 +110,13 @@ impl AgentServer for ClaudeCode {
.insert("ANTHROPIC_API_KEY".to_owned(), api_key.key);
}
let root_dir_exists = fs.is_dir(&root_dir).await;
anyhow::ensure!(
root_dir_exists,
"Session root {} does not exist or is not a directory",
root_dir.to_string_lossy()
);
crate::acp::connect(server_name, command.clone(), &root_dir, cx).await
})
}

View File

@@ -498,7 +498,7 @@ pub async fn new_test_thread(
current_dir: impl AsRef<Path>,
cx: &mut TestAppContext,
) -> Entity<AcpThread> {
let delegate = AgentServerDelegate::new(project.clone(), None);
let delegate = AgentServerDelegate::new(project.clone(), None, None);
let connection = cx
.update(|cx| server.connect(current_dir.as_ref(), delegate, cx))

View File

@@ -36,6 +36,7 @@ impl AgentServer for Gemini {
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
let root_dir = root_dir.to_path_buf();
let fs = delegate.project().read(cx).fs().clone();
let server_name = self.name();
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).gemini.clone()
@@ -74,6 +75,13 @@ impl AgentServer for Gemini {
.insert("GEMINI_API_KEY".to_owned(), api_key.key);
}
let root_dir_exists = fs.is_dir(&root_dir).await;
anyhow::ensure!(
root_dir_exists,
"Session root {} does not exist or is not a directory",
root_dir.to_string_lossy()
);
let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await;
match &result {
Ok(connection) => {
@@ -92,7 +100,7 @@ impl AgentServer for Gemini {
log::error!("connected to gemini, but missing prompt_capabilities.image (version is {current_version})");
return Err(LoadError::Unsupported {
current_version: current_version.into(),
command: command.path.to_string_lossy().to_string().into(),
command: (command.path.to_string_lossy().to_string() + " " + &command.args.join(" ")).into(),
minimum_version: Self::MINIMUM_VERSION.into(),
}
.into());
@@ -129,7 +137,7 @@ impl AgentServer for Gemini {
if !supported {
return Err(LoadError::Unsupported {
current_version: current_version.into(),
command: command.path.to_string_lossy().to_string().into(),
command: (command.path.to_string_lossy().to_string() + " " + &command.args.join(" ")).into(),
minimum_version: Self::MINIMUM_VERSION.into(),
}
.into());

View File

@@ -48,7 +48,7 @@ pub enum NotifyWhenAgentWaiting {
Never,
}
#[derive(Default, Clone, Debug, SettingsUi)]
#[derive(Default, Clone, Debug)]
pub struct AgentSettings {
pub enabled: bool,
pub button: bool,
@@ -223,7 +223,7 @@ impl AgentSettingsContent {
}
}
#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug, Default)]
#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug, Default, SettingsUi)]
pub struct AgentSettingsContent {
/// Whether the Agent is enabled.
///

View File

@@ -1,4 +1,4 @@
use std::cell::Cell;
use std::cell::{Cell, RefCell};
use std::ops::Range;
use std::rc::Rc;
use std::sync::Arc;
@@ -13,8 +13,10 @@ use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{App, Entity, Task, WeakEntity};
use language::{Buffer, CodeLabel, HighlightId};
use lsp::CompletionContext;
use project::lsp_store::CompletionDocumentation;
use project::{
Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, Symbol, WorktreeId,
Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, Project,
ProjectPath, Symbol, WorktreeId,
};
use prompt_store::PromptStore;
use rope::Point;
@@ -23,7 +25,7 @@ use ui::prelude::*;
use workspace::Workspace;
use crate::AgentPanel;
use crate::acp::message_editor::MessageEditor;
use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
use crate::context_picker::file_context_picker::{FileMatch, search_files};
use crate::context_picker::rules_context_picker::{RulesContextEntry, search_rules};
use crate::context_picker::symbol_context_picker::SymbolMatch;
@@ -67,6 +69,7 @@ pub struct ContextPickerCompletionProvider {
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
}
impl ContextPickerCompletionProvider {
@@ -76,6 +79,7 @@ impl ContextPickerCompletionProvider {
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
) -> Self {
Self {
message_editor,
@@ -83,6 +87,7 @@ impl ContextPickerCompletionProvider {
history_store,
prompt_store,
prompt_capabilities,
available_commands,
}
}
@@ -369,7 +374,42 @@ impl ContextPickerCompletionProvider {
})
}
fn search(
fn search_slash_commands(
&self,
query: String,
cx: &mut App,
) -> Task<Vec<acp::AvailableCommand>> {
let commands = self.available_commands.borrow().clone();
if commands.is_empty() {
return Task::ready(Vec::new());
}
cx.spawn(async move |cx| {
let candidates = commands
.iter()
.enumerate()
.map(|(id, command)| StringMatchCandidate::new(id, &command.name))
.collect::<Vec<_>>();
let matches = fuzzy::match_strings(
&candidates,
&query,
false,
true,
100,
&Arc::new(AtomicBool::default()),
cx.background_executor().clone(),
)
.await;
matches
.into_iter()
.map(|mat| commands[mat.candidate_id].clone())
.collect()
})
}
fn search_mentions(
&self,
mode: Option<ContextPickerMode>,
query: String,
@@ -651,10 +691,10 @@ impl CompletionProvider for ContextPickerCompletionProvider {
let offset_to_line = buffer.point_to_offset(line_start);
let mut lines = buffer.text_for_range(line_start..position).lines();
let line = lines.next()?;
MentionCompletion::try_parse(
self.prompt_capabilities.get().embedded_context,
ContextCompletion::try_parse(
line,
offset_to_line,
self.prompt_capabilities.get().embedded_context,
)
});
let Some(state) = state else {
@@ -667,97 +707,175 @@ impl CompletionProvider for ContextPickerCompletionProvider {
let project = workspace.read(cx).project().clone();
let snapshot = buffer.read(cx).snapshot();
let source_range = snapshot.anchor_before(state.source_range.start)
..snapshot.anchor_after(state.source_range.end);
let source_range = snapshot.anchor_before(state.source_range().start)
..snapshot.anchor_after(state.source_range().end);
let editor = self.message_editor.clone();
let MentionCompletion { mode, argument, .. } = state;
let query = argument.unwrap_or_else(|| "".to_string());
let search_task = self.search(mode, query, Arc::<AtomicBool>::default(), cx);
cx.spawn(async move |_, cx| {
let matches = search_task.await;
let completions = cx.update(|cx| {
matches
.into_iter()
.filter_map(|mat| match mat {
Match::File(FileMatch { mat, is_recent }) => {
let project_path = ProjectPath {
worktree_id: WorktreeId::from_usize(mat.worktree_id),
path: mat.path.clone(),
match state {
ContextCompletion::SlashCommand(SlashCommandCompletion {
command, argument, ..
}) => {
let search_task = self.search_slash_commands(command.unwrap_or_default(), cx);
cx.background_spawn(async move {
let completions = search_task
.await
.into_iter()
.map(|command| {
let new_text = if let Some(argument) = argument.as_ref() {
format!("/{} {}", command.name, argument)
} else {
format!("/{} ", command.name)
};
Self::completion_for_path(
project_path,
&mat.path_prefix,
is_recent,
mat.is_dir,
source_range.clone(),
editor.clone(),
project.clone(),
cx,
)
}
let is_missing_argument = argument.is_none() && command.input.is_some();
Completion {
replace_range: source_range.clone(),
new_text,
label: CodeLabel::plain(command.name.to_string(), None),
documentation: Some(CompletionDocumentation::MultiLinePlainText(
command.description.into(),
)),
source: project::CompletionSource::Custom,
icon_path: None,
insert_text_mode: None,
confirm: Some(Arc::new({
let editor = editor.clone();
move |intent, _window, cx| {
if !is_missing_argument {
cx.defer({
let editor = editor.clone();
move |cx| {
editor
.update(cx, |_editor, cx| {
match intent {
CompletionIntent::Complete
| CompletionIntent::CompleteWithInsert
| CompletionIntent::CompleteWithReplace => {
if !is_missing_argument {
cx.emit(MessageEditorEvent::Send);
}
}
CompletionIntent::Compose => {}
}
})
.ok();
}
});
}
is_missing_argument
}
})),
}
})
.collect();
Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol(
symbol,
source_range.clone(),
editor.clone(),
workspace.clone(),
cx,
),
Ok(vec![CompletionResponse {
completions,
display_options: CompletionDisplayOptions {
dynamic_width: true,
},
// Since this does its own filtering (see `filter_completions()` returns false),
// there is no benefit to computing whether this set of completions is incomplete.
is_incomplete: true,
}])
})
}
ContextCompletion::Mention(MentionCompletion { mode, argument, .. }) => {
let query = argument.unwrap_or_default();
let search_task =
self.search_mentions(mode, query, Arc::<AtomicBool>::default(), cx);
Match::Thread(thread) => Some(Self::completion_for_thread(
thread,
source_range.clone(),
false,
editor.clone(),
cx,
)),
cx.spawn(async move |_, cx| {
let matches = search_task.await;
Match::RecentThread(thread) => Some(Self::completion_for_thread(
thread,
source_range.clone(),
true,
editor.clone(),
cx,
)),
let completions = cx.update(|cx| {
matches
.into_iter()
.filter_map(|mat| match mat {
Match::File(FileMatch { mat, is_recent }) => {
let project_path = ProjectPath {
worktree_id: WorktreeId::from_usize(mat.worktree_id),
path: mat.path.clone(),
};
Match::Rules(user_rules) => Some(Self::completion_for_rules(
user_rules,
source_range.clone(),
editor.clone(),
cx,
)),
Self::completion_for_path(
project_path,
&mat.path_prefix,
is_recent,
mat.is_dir,
source_range.clone(),
editor.clone(),
project.clone(),
cx,
)
}
Match::Fetch(url) => Self::completion_for_fetch(
source_range.clone(),
url,
editor.clone(),
cx,
),
Match::Symbol(SymbolMatch { symbol, .. }) => {
Self::completion_for_symbol(
symbol,
source_range.clone(),
editor.clone(),
workspace.clone(),
cx,
)
}
Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry(
entry,
source_range.clone(),
editor.clone(),
&workspace,
cx,
),
})
.collect()
})?;
Match::Thread(thread) => Some(Self::completion_for_thread(
thread,
source_range.clone(),
false,
editor.clone(),
cx,
)),
Ok(vec![CompletionResponse {
completions,
// Since this does its own filtering (see `filter_completions()` returns false),
// there is no benefit to computing whether this set of completions is incomplete.
is_incomplete: true,
}])
})
Match::RecentThread(thread) => Some(Self::completion_for_thread(
thread,
source_range.clone(),
true,
editor.clone(),
cx,
)),
Match::Rules(user_rules) => Some(Self::completion_for_rules(
user_rules,
source_range.clone(),
editor.clone(),
cx,
)),
Match::Fetch(url) => Self::completion_for_fetch(
source_range.clone(),
url,
editor.clone(),
cx,
),
Match::Entry(EntryMatch { entry, .. }) => {
Self::completion_for_entry(
entry,
source_range.clone(),
editor.clone(),
&workspace,
cx,
)
}
})
.collect()
})?;
Ok(vec![CompletionResponse {
completions,
display_options: CompletionDisplayOptions {
dynamic_width: true,
},
// Since this does its own filtering (see `filter_completions()` returns false),
// there is no benefit to computing whether this set of completions is incomplete.
is_incomplete: true,
}])
})
}
}
}
fn is_completion_trigger(
@@ -775,14 +893,14 @@ impl CompletionProvider for ContextPickerCompletionProvider {
let offset_to_line = buffer.point_to_offset(line_start);
let mut lines = buffer.text_for_range(line_start..position).lines();
if let Some(line) = lines.next() {
MentionCompletion::try_parse(
self.prompt_capabilities.get().embedded_context,
ContextCompletion::try_parse(
line,
offset_to_line,
self.prompt_capabilities.get().embedded_context,
)
.map(|completion| {
completion.source_range.start <= offset_to_line + position.column as usize
&& completion.source_range.end >= offset_to_line + position.column as usize
completion.source_range().start <= offset_to_line + position.column as usize
&& completion.source_range().end >= offset_to_line + position.column as usize
})
.unwrap_or(false)
} else {
@@ -851,7 +969,7 @@ fn confirm_completion_callback(
.clone()
.update(cx, |message_editor, cx| {
message_editor
.confirm_completion(
.confirm_mention_completion(
crease_text,
start,
content_len,
@@ -867,6 +985,89 @@ fn confirm_completion_callback(
})
}
enum ContextCompletion {
SlashCommand(SlashCommandCompletion),
Mention(MentionCompletion),
}
impl ContextCompletion {
fn source_range(&self) -> Range<usize> {
match self {
Self::SlashCommand(completion) => completion.source_range.clone(),
Self::Mention(completion) => completion.source_range.clone(),
}
}
fn try_parse(line: &str, offset_to_line: usize, allow_non_file_mentions: bool) -> Option<Self> {
if let Some(command) = SlashCommandCompletion::try_parse(line, offset_to_line) {
Some(Self::SlashCommand(command))
} else if let Some(mention) =
MentionCompletion::try_parse(allow_non_file_mentions, line, offset_to_line)
{
Some(Self::Mention(mention))
} else {
None
}
}
}
#[derive(Debug, Default, PartialEq)]
pub struct SlashCommandCompletion {
pub source_range: Range<usize>,
pub command: Option<String>,
pub argument: Option<String>,
}
impl SlashCommandCompletion {
pub fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
// If we decide to support commands that are not at the beginning of the prompt, we can remove this check
if !line.starts_with('/') || offset_to_line != 0 {
return None;
}
let last_command_start = line.rfind('/')?;
if last_command_start >= line.len() {
return Some(Self::default());
}
if last_command_start > 0
&& line
.chars()
.nth(last_command_start - 1)
.is_some_and(|c| !c.is_whitespace())
{
return None;
}
let rest_of_line = &line[last_command_start + 1..];
let mut command = None;
let mut argument = None;
let mut end = last_command_start + 1;
if let Some(command_text) = rest_of_line.split_whitespace().next() {
command = Some(command_text.to_string());
end += command_text.len();
// Find the start of arguments after the command
if let Some(args_start) =
rest_of_line[command_text.len()..].find(|c: char| !c.is_whitespace())
{
let args = &rest_of_line[command_text.len() + args_start..].trim_end();
if !args.is_empty() {
argument = Some(args.to_string());
end += args.len() + 1;
}
}
}
Some(Self {
source_range: last_command_start + offset_to_line..end + offset_to_line,
command,
argument,
})
}
}
#[derive(Debug, Default, PartialEq)]
struct MentionCompletion {
source_range: Range<usize>,
@@ -932,6 +1133,62 @@ impl MentionCompletion {
mod tests {
use super::*;
#[test]
fn test_slash_command_completion_parse() {
assert_eq!(
SlashCommandCompletion::try_parse("/", 0),
Some(SlashCommandCompletion {
source_range: 0..1,
command: None,
argument: None,
})
);
assert_eq!(
SlashCommandCompletion::try_parse("/help", 0),
Some(SlashCommandCompletion {
source_range: 0..5,
command: Some("help".to_string()),
argument: None,
})
);
assert_eq!(
SlashCommandCompletion::try_parse("/help ", 0),
Some(SlashCommandCompletion {
source_range: 0..5,
command: Some("help".to_string()),
argument: None,
})
);
assert_eq!(
SlashCommandCompletion::try_parse("/help arg1", 0),
Some(SlashCommandCompletion {
source_range: 0..10,
command: Some("help".to_string()),
argument: Some("arg1".to_string()),
})
);
assert_eq!(
SlashCommandCompletion::try_parse("/help arg1 arg2", 0),
Some(SlashCommandCompletion {
source_range: 0..15,
command: Some("help".to_string()),
argument: Some("arg1 arg2".to_string()),
})
);
assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None);
assert_eq!(SlashCommandCompletion::try_parse("Lorem /", 0), None);
assert_eq!(SlashCommandCompletion::try_parse("Lorem /help", 0), None);
assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None);
}
#[test]
fn test_mention_completion_parse() {
assert_eq!(MentionCompletion::try_parse(true, "Lorem Ipsum", 0), None);

View File

@@ -1,13 +1,17 @@
use std::{cell::Cell, ops::Range, rc::Rc};
use std::{
cell::{Cell, RefCell},
ops::Range,
rc::Rc,
};
use acp_thread::{AcpThread, AgentThreadEntry};
use agent_client_protocol::{PromptCapabilities, ToolCallId};
use agent_client_protocol::{self as acp, ToolCallId};
use agent2::HistoryStore;
use collections::HashMap;
use editor::{Editor, EditorMode, MinimapVisibility};
use gpui::{
AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
ScrollHandle, TextStyleRefinement, WeakEntity, Window,
ScrollHandle, SharedString, TextStyleRefinement, WeakEntity, Window,
};
use language::language_settings::SoftWrap;
use project::Project;
@@ -26,8 +30,9 @@ pub struct EntryViewState {
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
entries: Vec<Entry>,
prevent_slash_commands: bool,
prompt_capabilities: Rc<Cell<PromptCapabilities>>,
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
agent_name: SharedString,
}
impl EntryViewState {
@@ -36,8 +41,9 @@ impl EntryViewState {
project: Entity<Project>,
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<Cell<PromptCapabilities>>,
prevent_slash_commands: bool,
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
agent_name: SharedString,
) -> Self {
Self {
workspace,
@@ -45,8 +51,9 @@ impl EntryViewState {
history_store,
prompt_store,
entries: Vec::new(),
prevent_slash_commands,
prompt_capabilities,
available_commands,
agent_name,
}
}
@@ -85,8 +92,9 @@ impl EntryViewState {
self.history_store.clone(),
self.prompt_store.clone(),
self.prompt_capabilities.clone(),
self.available_commands.clone(),
self.agent_name.clone(),
"Edit message @ to include context",
self.prevent_slash_commands,
editor::EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
@@ -125,22 +133,35 @@ impl EntryViewState {
views
};
let is_tool_call_completed =
matches!(tool_call.status, acp_thread::ToolCallStatus::Completed);
for terminal in terminals {
views.entry(terminal.entity_id()).or_insert_with(|| {
let element = create_terminal(
self.workspace.clone(),
self.project.clone(),
terminal.clone(),
window,
cx,
)
.into_any();
cx.emit(EntryViewEvent {
entry_index: index,
view_event: ViewEvent::NewTerminal(id.clone()),
});
element
});
match views.entry(terminal.entity_id()) {
collections::hash_map::Entry::Vacant(entry) => {
let element = create_terminal(
self.workspace.clone(),
self.project.clone(),
terminal.clone(),
window,
cx,
)
.into_any();
cx.emit(EntryViewEvent {
entry_index: index,
view_event: ViewEvent::NewTerminal(id.clone()),
});
entry.insert(element);
}
collections::hash_map::Entry::Occupied(_entry) => {
if is_tool_call_completed && terminal.read(cx).output().is_none() {
cx.emit(EntryViewEvent {
entry_index: index,
view_event: ViewEvent::TerminalMovedToBackground(id.clone()),
});
}
}
}
}
for diff in diffs {
@@ -217,6 +238,7 @@ pub struct EntryViewEvent {
pub enum ViewEvent {
NewDiff(ToolCallId),
NewTerminal(ToolCallId),
TerminalMovedToBackground(ToolCallId),
MessageEditorEvent(Entity<MessageEditor>, MessageEditorEvent),
}
@@ -457,7 +479,8 @@ mod tests {
history_store,
None,
Default::default(),
false,
Default::default(),
"Test Agent".into(),
)
});

View File

@@ -1,5 +1,5 @@
use crate::{
acp::completion_provider::ContextPickerCompletionProvider,
acp::completion_provider::{ContextPickerCompletionProvider, SlashCommandCompletion},
context_picker::{ContextPickerAction, fetch_context_picker::fetch_url_content},
};
use acp_thread::{MentionUri, selection_name};
@@ -11,10 +11,10 @@ use assistant_slash_commands::codeblock_fence_for_path;
use collections::{HashMap, HashSet};
use editor::{
Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer,
SemanticsProvider, ToOffset,
EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, InlayId,
MultiBuffer, ToOffset,
actions::Paste,
display_map::{Crease, CreaseId, FoldId},
display_map::{Crease, CreaseId, FoldId, Inlay},
};
use futures::{
FutureExt as _,
@@ -22,18 +22,20 @@ use futures::{
};
use gpui::{
Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId,
EventEmitter, FocusHandle, Focusable, HighlightStyle, Image, ImageFormat, Img, KeyContext,
Subscription, Task, TextStyle, UnderlineStyle, WeakEntity, pulsating_between,
EventEmitter, FocusHandle, Focusable, Image, ImageFormat, Img, KeyContext, SharedString,
Subscription, Task, TextStyle, WeakEntity, pulsating_between,
};
use language::{Buffer, Language};
use language::{Buffer, Language, language_settings::InlayHintKind};
use language_model::LanguageModelImage;
use postage::stream::Stream as _;
use project::{CompletionIntent, Project, ProjectItem, ProjectPath, Worktree};
use project::{
CompletionIntent, InlayHint, InlayHintLabel, Project, ProjectItem, ProjectPath, Worktree,
};
use prompt_store::{PromptId, PromptStore};
use rope::Point;
use settings::Settings;
use std::{
cell::Cell,
cell::{Cell, RefCell},
ffi::OsStr,
fmt::Write,
ops::{Range, RangeInclusive},
@@ -42,20 +44,18 @@ use std::{
sync::Arc,
time::Duration,
};
use text::{OffsetRangeExt, ToOffset as _};
use text::OffsetRangeExt;
use theme::ThemeSettings;
use ui::{
ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Element as _,
FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label,
LabelCommon, LabelSize, ParentElement, Render, SelectableButton, SharedString, Styled,
TextSize, TintColor, Toggleable, Window, div, h_flex, px,
LabelCommon, LabelSize, ParentElement, Render, SelectableButton, Styled, TextSize, TintColor,
Toggleable, Window, div, h_flex,
};
use util::{ResultExt, debug_panic};
use workspace::{Workspace, notifications::NotifyResultExt as _};
use zed_actions::agent::Chat;
const PARSE_SLASH_COMMAND_DEBOUNCE: Duration = Duration::from_millis(50);
pub struct MessageEditor {
mention_set: MentionSet,
editor: Entity<Editor>,
@@ -63,8 +63,9 @@ pub struct MessageEditor {
workspace: WeakEntity<Workspace>,
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
prevent_slash_commands: bool,
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
agent_name: SharedString,
_subscriptions: Vec<Subscription>,
_parse_slash_command_task: Task<()>,
}
@@ -79,6 +80,8 @@ pub enum MessageEditorEvent {
impl EventEmitter<MessageEditorEvent> for MessageEditor {}
const COMMAND_HINT_INLAY_ID: usize = 0;
impl MessageEditor {
pub fn new(
workspace: WeakEntity<Workspace>,
@@ -86,8 +89,9 @@ impl MessageEditor {
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
agent_name: SharedString,
placeholder: impl Into<Arc<str>>,
prevent_slash_commands: bool,
mode: EditorMode,
window: &mut Window,
cx: &mut Context<Self>,
@@ -99,16 +103,14 @@ impl MessageEditor {
},
None,
);
let completion_provider = ContextPickerCompletionProvider::new(
let completion_provider = Rc::new(ContextPickerCompletionProvider::new(
cx.weak_entity(),
workspace.clone(),
history_store.clone(),
prompt_store.clone(),
prompt_capabilities.clone(),
);
let semantics_provider = Rc::new(SlashCommandSemanticsProvider {
range: Cell::new(None),
});
available_commands.clone(),
));
let mention_set = MentionSet::default();
let editor = cx.new(|cx| {
let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
@@ -119,15 +121,12 @@ impl MessageEditor {
editor.set_show_indent_guides(false, cx);
editor.set_soft_wrap();
editor.set_use_modal_editing(true);
editor.set_completion_provider(Some(Rc::new(completion_provider)));
editor.set_completion_provider(Some(completion_provider.clone()));
editor.set_context_menu_options(ContextMenuOptions {
min_entries_visible: 12,
max_entries_visible: 12,
placement: Some(ContextMenuPlacement::Above),
});
if prevent_slash_commands {
editor.set_semantics_provider(Some(semantics_provider.clone()));
}
editor.register_addon(MessageEditorAddon::new());
editor
});
@@ -141,21 +140,33 @@ impl MessageEditor {
})
.detach();
let mut has_hint = false;
let mut subscriptions = Vec::new();
subscriptions.push(cx.subscribe_in(&editor, window, {
let semantics_provider = semantics_provider.clone();
move |this, editor, event, window, cx| {
if let EditorEvent::Edited { .. } = event {
if prevent_slash_commands {
this.highlight_slash_command(
semantics_provider.clone(),
editor.clone(),
window,
let snapshot = editor.update(cx, |editor, cx| {
let new_hints = this
.command_hint(editor.buffer(), cx)
.into_iter()
.collect::<Vec<_>>();
let has_new_hint = !new_hints.is_empty();
editor.splice_inlays(
if has_hint {
&[InlayId::Hint(COMMAND_HINT_INLAY_ID)]
} else {
&[]
},
new_hints,
cx,
);
}
let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
has_hint = has_new_hint;
editor.snapshot(window, cx)
});
this.mention_set.remove_invalid(snapshot);
cx.notify();
}
}
@@ -168,13 +179,57 @@ impl MessageEditor {
workspace,
history_store,
prompt_store,
prevent_slash_commands,
prompt_capabilities,
available_commands,
agent_name,
_subscriptions: subscriptions,
_parse_slash_command_task: Task::ready(()),
}
}
fn command_hint(&self, buffer: &Entity<MultiBuffer>, cx: &App) -> Option<Inlay> {
let available_commands = self.available_commands.borrow();
if available_commands.is_empty() {
return None;
}
let snapshot = buffer.read(cx).snapshot(cx);
let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?;
if parsed_command.argument.is_some() {
return None;
}
let command_name = parsed_command.command?;
let available_command = available_commands
.iter()
.find(|command| command.name == command_name)?;
let acp::AvailableCommandInput::Unstructured { mut hint } =
available_command.input.clone()?;
let mut hint_pos = parsed_command.source_range.end + 1;
if hint_pos > snapshot.len() {
hint_pos = snapshot.len();
hint.insert(0, ' ');
}
let hint_pos = snapshot.anchor_after(hint_pos);
Some(Inlay::hint(
COMMAND_HINT_INLAY_ID,
hint_pos,
&InlayHint {
position: hint_pos.text_anchor,
label: InlayHintLabel::String(hint),
kind: Some(InlayHintKind::Parameter),
padding_left: false,
padding_right: false,
tooltip: None,
resolve_state: project::ResolveState::Resolved,
},
))
}
pub fn insert_thread_summary(
&mut self,
thread: agent2::DbThreadMetadata,
@@ -191,7 +246,7 @@ impl MessageEditor {
.text_anchor
});
self.confirm_completion(
self.confirm_mention_completion(
thread.title.clone(),
start,
thread.title.len(),
@@ -227,7 +282,7 @@ impl MessageEditor {
.collect()
}
pub fn confirm_completion(
pub fn confirm_mention_completion(
&mut self,
crease_text: SharedString,
start: text::Anchor,
@@ -645,7 +700,7 @@ impl MessageEditor {
self.project.read(cx).fs().clone(),
self.history_store.clone(),
));
let delegate = AgentServerDelegate::new(self.project.clone(), None);
let delegate = AgentServerDelegate::new(self.project.clone(), None, None);
let connection = server.connect(Path::new(""), delegate, cx);
cx.spawn(async move |_, cx| {
let agent = connection.await?;
@@ -679,21 +734,62 @@ impl MessageEditor {
})
}
fn validate_slash_commands(
text: &str,
available_commands: &[acp::AvailableCommand],
agent_name: &str,
) -> Result<()> {
if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
if let Some(command_name) = parsed_command.command {
// Check if this command is in the list of available commands from the server
let is_supported = available_commands
.iter()
.any(|cmd| cmd.name == command_name);
if !is_supported {
return Err(anyhow!(
"The /{} command is not supported by {}.\n\nAvailable commands: {}",
command_name,
agent_name,
if available_commands.is_empty() {
"none".to_string()
} else {
available_commands
.iter()
.map(|cmd| format!("/{}", cmd.name))
.collect::<Vec<_>>()
.join(", ")
}
));
}
}
}
Ok(())
}
pub fn contents(
&self,
cx: &mut Context<Self>,
) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
// Check for unsupported slash commands before spawning async task
let text = self.editor.read(cx).text(cx);
let available_commands = self.available_commands.borrow().clone();
if let Err(err) =
Self::validate_slash_commands(&text, &available_commands, &self.agent_name)
{
return Task::ready(Err(err));
}
let contents = self
.mention_set
.contents(&self.prompt_capabilities.get(), cx);
let editor = self.editor.clone();
let prevent_slash_commands = self.prevent_slash_commands;
cx.spawn(async move |_, cx| {
let contents = contents.await?;
let mut all_tracked_buffers = Vec::new();
editor.update(cx, |editor, cx| {
let result = editor.update(cx, |editor, cx| {
let mut ix = 0;
let mut chunks: Vec<acp::ContentBlock> = Vec::new();
let text = editor.text(cx);
@@ -706,14 +802,16 @@ impl MessageEditor {
let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
if crease_range.start > ix {
let chunk = if prevent_slash_commands
&& ix == 0
&& parse_slash_command(&text[ix..]).is_some()
{
format!(" {}", &text[ix..crease_range.start]).into()
} else {
text[ix..crease_range.start].into()
};
//todo(): Custom slash command ContentBlock?
// let chunk = if prevent_slash_commands
// && ix == 0
// && parse_slash_command(&text[ix..]).is_some()
// {
// format!(" {}", &text[ix..crease_range.start]).into()
// } else {
// text[ix..crease_range.start].into()
// };
let chunk = text[ix..crease_range.start].into();
chunks.push(chunk);
}
let chunk = match mention {
@@ -769,22 +867,24 @@ impl MessageEditor {
}
if ix < text.len() {
let last_chunk = if prevent_slash_commands
&& ix == 0
&& parse_slash_command(&text[ix..]).is_some()
{
format!(" {}", text[ix..].trim_end())
} else {
text[ix..].trim_end().to_owned()
};
//todo(): Custom slash command ContentBlock?
// let last_chunk = if prevent_slash_commands
// && ix == 0
// && parse_slash_command(&text[ix..]).is_some()
// {
// format!(" {}", text[ix..].trim_end())
// } else {
// text[ix..].trim_end().to_owned()
// };
let last_chunk = text[ix..].trim_end().to_owned();
if !last_chunk.is_empty() {
chunks.push(last_chunk.into());
}
}
});
(chunks, all_tracked_buffers)
})
Ok((chunks, all_tracked_buffers))
})?;
result
})
}
@@ -971,7 +1071,14 @@ impl MessageEditor {
cx,
);
});
tasks.push(self.confirm_completion(file_name, anchor, content_len, uri, window, cx));
tasks.push(self.confirm_mention_completion(
file_name,
anchor,
content_len,
uri,
window,
cx,
));
}
cx.spawn(async move |_, _| {
join_all(tasks).await;
@@ -1133,48 +1240,6 @@ impl MessageEditor {
cx.notify();
}
fn highlight_slash_command(
&mut self,
semantics_provider: Rc<SlashCommandSemanticsProvider>,
editor: Entity<Editor>,
window: &mut Window,
cx: &mut Context<Self>,
) {
struct InvalidSlashCommand;
self._parse_slash_command_task = cx.spawn_in(window, async move |_, cx| {
cx.background_executor()
.timer(PARSE_SLASH_COMMAND_DEBOUNCE)
.await;
editor
.update_in(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let range = parse_slash_command(&editor.text(cx));
semantics_provider.range.set(range);
if let Some((start, end)) = range {
editor.highlight_text::<InvalidSlashCommand>(
vec![
snapshot.buffer_snapshot.anchor_after(start)
..snapshot.buffer_snapshot.anchor_before(end),
],
HighlightStyle {
underline: Some(UnderlineStyle {
thickness: px(1.),
color: Some(gpui::red()),
wavy: true,
}),
..Default::default()
},
cx,
);
} else {
editor.clear_highlights::<InvalidSlashCommand>(cx);
}
})
.ok();
})
}
pub fn text(&self, cx: &App) -> String {
self.editor.read(cx).text(cx)
}
@@ -1234,6 +1299,7 @@ impl Render for MessageEditor {
local_player: cx.theme().players().local(),
text: text_style,
syntax: cx.theme().syntax().clone(),
inlay_hints_style: editor::make_inlay_hints_style(cx),
..Default::default()
},
)
@@ -1264,7 +1330,7 @@ pub(crate) fn insert_crease_for_mention(
let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
let placeholder = FoldPlaceholder {
render: render_fold_icon_button(
render: render_mention_fold_button(
crease_label,
crease_icon,
start..end,
@@ -1294,7 +1360,7 @@ pub(crate) fn insert_crease_for_mention(
Some((crease_id, tx))
}
fn render_fold_icon_button(
fn render_mention_fold_button(
label: SharedString,
icon: SharedString,
range: Range<Anchor>,
@@ -1471,118 +1537,6 @@ impl MentionSet {
}
}
struct SlashCommandSemanticsProvider {
range: Cell<Option<(usize, usize)>>,
}
impl SemanticsProvider for SlashCommandSemanticsProvider {
fn hover(
&self,
buffer: &Entity<Buffer>,
position: text::Anchor,
cx: &mut App,
) -> Option<Task<Option<Vec<project::Hover>>>> {
let snapshot = buffer.read(cx).snapshot();
let offset = position.to_offset(&snapshot);
let (start, end) = self.range.get()?;
if !(start..end).contains(&offset) {
return None;
}
let range = snapshot.anchor_after(start)..snapshot.anchor_after(end);
Some(Task::ready(Some(vec![project::Hover {
contents: vec![project::HoverBlock {
text: "Slash commands are not supported".into(),
kind: project::HoverBlockKind::PlainText,
}],
range: Some(range),
language: None,
}])))
}
fn inline_values(
&self,
_buffer_handle: Entity<Buffer>,
_range: Range<text::Anchor>,
_cx: &mut App,
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
None
}
fn inlay_hints(
&self,
_buffer_handle: Entity<Buffer>,
_range: Range<text::Anchor>,
_cx: &mut App,
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
None
}
fn resolve_inlay_hint(
&self,
_hint: project::InlayHint,
_buffer_handle: Entity<Buffer>,
_server_id: lsp::LanguageServerId,
_cx: &mut App,
) -> Option<Task<anyhow::Result<project::InlayHint>>> {
None
}
fn supports_inlay_hints(&self, _buffer: &Entity<Buffer>, _cx: &mut App) -> bool {
false
}
fn document_highlights(
&self,
_buffer: &Entity<Buffer>,
_position: text::Anchor,
_cx: &mut App,
) -> Option<Task<Result<Vec<project::DocumentHighlight>>>> {
None
}
fn definitions(
&self,
_buffer: &Entity<Buffer>,
_position: text::Anchor,
_kind: editor::GotoDefinitionKind,
_cx: &mut App,
) -> Option<Task<Result<Option<Vec<project::LocationLink>>>>> {
None
}
fn range_for_rename(
&self,
_buffer: &Entity<Buffer>,
_position: text::Anchor,
_cx: &mut App,
) -> Option<Task<Result<Option<Range<text::Anchor>>>>> {
None
}
fn perform_rename(
&self,
_buffer: &Entity<Buffer>,
_position: text::Anchor,
_new_name: String,
_cx: &mut App,
) -> Option<Task<Result<project::ProjectTransaction>>> {
None
}
}
fn parse_slash_command(text: &str) -> Option<(usize, usize)> {
if let Some(remainder) = text.strip_prefix('/') {
let pos = remainder
.find(char::is_whitespace)
.unwrap_or(remainder.len());
let command = &remainder[..pos];
if !command.is_empty() && command.chars().all(char::is_alphanumeric) {
return Some((0, 1 + command.len()));
}
}
None
}
pub struct MessageEditorAddon {}
impl MessageEditorAddon {
@@ -1610,7 +1564,13 @@ impl Addon for MessageEditorAddon {
#[cfg(test)]
mod tests {
use std::{cell::Cell, ops::Range, path::Path, rc::Rc, sync::Arc};
use std::{
cell::{Cell, RefCell},
ops::Range,
path::Path,
rc::Rc,
sync::Arc,
};
use acp_thread::MentionUri;
use agent_client_protocol as acp;
@@ -1657,8 +1617,9 @@ mod tests {
history_store.clone(),
None,
Default::default(),
Default::default(),
"Test Agent".into(),
"Test",
false,
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
@@ -1735,6 +1696,140 @@ mod tests {
pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
}
#[gpui::test]
async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/test",
json!({
".zed": {
"tasks.json": r#"[{"label": "test", "command": "echo"}]"#
},
"src": {
"main.rs": "fn main() {}",
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
// Start with no available commands - simulating Claude which doesn't support slash commands
let available_commands = Rc::new(RefCell::new(vec![]));
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let workspace_handle = workspace.downgrade();
let message_editor = workspace.update_in(cx, |_, window, cx| {
cx.new(|cx| {
MessageEditor::new(
workspace_handle.clone(),
project.clone(),
history_store.clone(),
None,
prompt_capabilities.clone(),
available_commands.clone(),
"Claude Code".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
)
})
});
let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
// Test that slash commands fail when no available_commands are set (empty list means no commands supported)
editor.update_in(cx, |editor, window, cx| {
editor.set_text("/file test.txt", window, cx);
});
let contents_result = message_editor
.update(cx, |message_editor, cx| message_editor.contents(cx))
.await;
// Should fail because available_commands is empty (no commands supported)
assert!(contents_result.is_err());
let error_message = contents_result.unwrap_err().to_string();
assert!(error_message.contains("not supported by Claude Code"));
assert!(error_message.contains("Available commands: none"));
// Now simulate Claude providing its list of available commands (which doesn't include file)
available_commands.replace(vec![acp::AvailableCommand {
name: "help".to_string(),
description: "Get help".to_string(),
input: None,
}]);
// Test that unsupported slash commands trigger an error when we have a list of available commands
editor.update_in(cx, |editor, window, cx| {
editor.set_text("/file test.txt", window, cx);
});
let contents_result = message_editor
.update(cx, |message_editor, cx| message_editor.contents(cx))
.await;
assert!(contents_result.is_err());
let error_message = contents_result.unwrap_err().to_string();
assert!(error_message.contains("not supported by Claude Code"));
assert!(error_message.contains("/file"));
assert!(error_message.contains("Available commands: /help"));
// Test that supported commands work fine
editor.update_in(cx, |editor, window, cx| {
editor.set_text("/help", window, cx);
});
let contents_result = message_editor
.update(cx, |message_editor, cx| message_editor.contents(cx))
.await;
// Should succeed because /help is in available_commands
assert!(contents_result.is_ok());
// Test that regular text works fine
editor.update_in(cx, |editor, window, cx| {
editor.set_text("Hello Claude!", window, cx);
});
let (content, _) = message_editor
.update(cx, |message_editor, cx| message_editor.contents(cx))
.await
.unwrap();
assert_eq!(content.len(), 1);
if let acp::ContentBlock::Text(text) = &content[0] {
assert_eq!(text.text, "Hello Claude!");
} else {
panic!("Expected ContentBlock::Text");
}
// Test that @ mentions still work
editor.update_in(cx, |editor, window, cx| {
editor.set_text("Check this @", window, cx);
});
// The @ mention functionality should not be affected
let (content, _) = message_editor
.update(cx, |message_editor, cx| message_editor.contents(cx))
.await
.unwrap();
assert_eq!(content.len(), 1);
if let acp::ContentBlock::Text(text) = &content[0] {
assert_eq!(text.text, "Check this @");
} else {
panic!("Expected ContentBlock::Text");
}
}
struct MessageEditorItem(Entity<MessageEditor>);
impl Item for MessageEditorItem {
@@ -1764,7 +1859,192 @@ mod tests {
}
#[gpui::test]
async fn test_context_completion_provider(cx: &mut TestAppContext) {
async fn test_completion_provider_commands(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
cx.update(|cx| {
language::init(cx);
editor::init(cx);
workspace::init(app_state.clone(), cx);
Project::init_settings(cx);
});
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let workspace = window.root(cx).unwrap();
let mut cx = VisualTestContext::from_window(*window, cx);
let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
let available_commands = Rc::new(RefCell::new(vec![
acp::AvailableCommand {
name: "quick-math".to_string(),
description: "2 + 2 = 4 - 1 = 3".to_string(),
input: None,
},
acp::AvailableCommand {
name: "say-hello".to_string(),
description: "Say hello to whoever you want".to_string(),
input: Some(acp::AvailableCommandInput::Unstructured {
hint: "<name>".to_string(),
}),
},
]));
let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.clone(),
history_store.clone(),
None,
prompt_capabilities.clone(),
available_commands.clone(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
},
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window);
message_editor.read(cx).editor().clone()
});
cx.simulate_input("/");
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "/");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels_with_documentation(editor),
&[
("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
("say-hello".into(), "Say hello to whoever you want".into())
]
);
editor.set_text("", window, cx);
});
cx.simulate_input("/qui");
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "/qui");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels_with_documentation(editor),
&[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
);
editor.set_text("", window, cx);
});
editor.update_in(&mut cx, |editor, window, cx| {
assert!(editor.has_visible_completions_menu());
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.display_text(cx), "/quick-math ");
assert!(!editor.has_visible_completions_menu());
editor.set_text("", window, cx);
});
cx.simulate_input("/say");
editor.update_in(&mut cx, |editor, _window, cx| {
assert_eq!(editor.display_text(cx), "/say");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels_with_documentation(editor),
&[("say-hello".into(), "Say hello to whoever you want".into())]
);
});
editor.update_in(&mut cx, |editor, window, cx| {
assert!(editor.has_visible_completions_menu());
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, _window, cx| {
assert_eq!(editor.text(cx), "/say-hello ");
assert_eq!(editor.display_text(cx), "/say-hello <name>");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels_with_documentation(editor),
&[("say-hello".into(), "Say hello to whoever you want".into())]
);
});
cx.simulate_input("GPT5");
editor.update_in(&mut cx, |editor, window, cx| {
assert!(editor.has_visible_completions_menu());
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "/say-hello GPT5");
assert_eq!(editor.display_text(cx), "/say-hello GPT5");
assert!(!editor.has_visible_completions_menu());
// Delete argument
for _ in 0..4 {
editor.backspace(&editor::actions::Backspace, window, cx);
}
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "/say-hello ");
// Hint is visible because argument was deleted
assert_eq!(editor.display_text(cx), "/say-hello <name>");
// Delete last command letter
editor.backspace(&editor::actions::Backspace, window, cx);
editor.backspace(&editor::actions::Backspace, window, cx);
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, _window, cx| {
// Hint goes away once command no longer matches an available one
assert_eq!(editor.text(cx), "/say-hell");
assert_eq!(editor.display_text(cx), "/say-hell");
assert!(!editor.has_visible_completions_menu());
});
}
#[gpui::test]
async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
@@ -1857,8 +2137,9 @@ mod tests {
history_store.clone(),
None,
prompt_capabilities.clone(),
Default::default(),
"Test Agent".into(),
"Test",
false,
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
@@ -1888,7 +2169,6 @@ mod tests {
assert_eq!(editor.text(cx), "Lorem @");
assert!(editor.has_visible_completions_menu());
// Only files since we have default capabilities
assert_eq!(
current_completion_labels(editor),
&[
@@ -2284,4 +2564,20 @@ mod tests {
.map(|completion| completion.label.text)
.collect::<Vec<_>>()
}
fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
let completions = editor.current_completions().expect("Missing completions");
completions
.into_iter()
.map(|completion| {
(
completion.label.text,
completion
.documentation
.map(|d| d.text().to_string())
.unwrap_or_default(),
)
})
.collect::<Vec<_>>()
}
}

View File

@@ -36,6 +36,14 @@ impl AcpModelSelectorPopover {
pub fn toggle(&self, window: &mut Window, cx: &mut Context<Self>) {
self.menu_handle.toggle(window, cx);
}
pub fn active_model_name(&self, cx: &App) -> Option<SharedString> {
self.selector
.read(cx)
.delegate
.active_model()
.map(|model| model.name.clone())
}
}
impl Render for AcpModelSelectorPopover {

View File

@@ -8,7 +8,7 @@ use action_log::ActionLog;
use agent_client_protocol::{self as acp, PromptCapabilities};
use agent_servers::{AgentServer, AgentServerDelegate, ClaudeCode};
use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore};
use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer};
use anyhow::{Context as _, Result, anyhow, bail};
use audio::{Audio, Sound};
use buffer_diff::BufferDiff;
@@ -23,9 +23,9 @@ use gpui::{
Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length,
ListOffset, ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement,
Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity,
Window, WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage,
point, prelude::*, pulsating_between,
Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, Window,
WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, point, prelude::*,
pulsating_between,
};
use language::Buffer;
@@ -35,7 +35,7 @@ use project::{Project, ProjectEntryId};
use prompt_store::{PromptId, PromptStore};
use rope::Point;
use settings::{Settings as _, SettingsStore};
use std::cell::Cell;
use std::cell::{Cell, RefCell};
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
@@ -45,8 +45,8 @@ use terminal_view::terminal_panel::TerminalPanel;
use text::Anchor;
use theme::ThemeSettings;
use ui::{
Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle,
Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
Callout, CommonAnimationExt, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding,
PopoverMenuHandle, Scrollbar, ScrollbarState, SpinnerLabel, TintColor, Tooltip, prelude::*,
};
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
use workspace::{CollaboratorId, Workspace};
@@ -78,10 +78,12 @@ enum ThreadFeedback {
Negative,
}
#[derive(Debug)]
enum ThreadError {
PaymentRequired,
ModelRequestLimitReached(cloud_llm_client::Plan),
ToolUseLimitReached,
Refusal,
AuthenticationRequired(SharedString),
Other(SharedString),
}
@@ -284,7 +286,9 @@ pub struct AcpThreadView {
should_be_following: bool,
editing_message: Option<usize>,
prompt_capabilities: Rc<Cell<PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
is_loading_contents: bool,
new_server_version_available: Option<SharedString>,
_cancel_task: Option<Task<()>>,
_subscriptions: [Subscription; 3],
}
@@ -325,10 +329,15 @@ impl AcpThreadView {
cx: &mut Context<Self>,
) -> Self {
let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
let prevent_slash_commands = agent.clone().downcast::<ClaudeCode>().is_some();
let available_commands = Rc::new(RefCell::new(vec![]));
let placeholder = if agent.name() == "Zed Agent" {
format!("Message the {} — @ to include context", agent.name())
} else if agent.name() == "Claude Code" || !available_commands.borrow().is_empty() {
format!(
"Message {} — @ to include context, / for commands",
agent.name()
)
} else {
format!("Message {} — @ to include context", agent.name())
};
@@ -340,8 +349,9 @@ impl AcpThreadView {
history_store.clone(),
prompt_store.clone(),
prompt_capabilities.clone(),
available_commands.clone(),
agent.name(),
placeholder,
prevent_slash_commands,
editor::EditorMode::AutoHeight {
min_lines: MIN_EDITOR_LINES,
max_lines: Some(MAX_EDITOR_LINES),
@@ -364,7 +374,8 @@ impl AcpThreadView {
history_store.clone(),
prompt_store.clone(),
prompt_capabilities.clone(),
prevent_slash_commands,
available_commands.clone(),
agent.name(),
)
});
@@ -396,18 +407,33 @@ impl AcpThreadView {
editing_message: None,
edits_expanded: false,
plan_expanded: false,
prompt_capabilities,
available_commands,
editor_expanded: false,
should_be_following: false,
history_store,
hovered_recent_history_item: None,
prompt_capabilities,
is_loading_contents: false,
_subscriptions: subscriptions,
_cancel_task: None,
focus_handle: cx.focus_handle(),
new_server_version_available: None,
}
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.thread_state = Self::initial_state(
self.agent.clone(),
None,
self.workspace.clone(),
self.project.clone(),
window,
cx,
);
self.new_server_version_available.take();
cx.notify();
}
fn initial_state(
agent: Rc<dyn AgentServer>,
resume_thread: Option<DbThreadMetadata>,
@@ -416,14 +442,37 @@ impl AcpThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) -> ThreadState {
let root_dir = project
.read(cx)
.visible_worktrees(cx)
if !project.read(cx).is_local() && agent.clone().downcast::<NativeAgentServer>().is_none() {
return ThreadState::LoadError(LoadError::Other(
"External agents are not yet supported for remote projects.".into(),
));
}
let mut worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
// Pick the first non-single-file worktree for the root directory if there are any,
// and otherwise the parent of a single-file worktree, falling back to $HOME if there are no visible worktrees.
worktrees.sort_by(|l, r| {
l.read(cx)
.is_single_file()
.cmp(&r.read(cx).is_single_file())
});
let root_dir = worktrees
.into_iter()
.filter_map(|worktree| {
if worktree.read(cx).is_single_file() {
Some(worktree.read(cx).abs_path().parent()?.into())
} else {
Some(worktree.read(cx).abs_path())
}
})
.next()
.map(|worktree| worktree.read(cx).abs_path())
.unwrap_or_else(|| paths::home_dir().as_path().into());
let (tx, mut rx) = watch::channel("Loading…".into());
let delegate = AgentServerDelegate::new(project.clone(), Some(tx));
let (status_tx, mut status_rx) = watch::channel("Loading…".into());
let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None);
let delegate = AgentServerDelegate::new(
project.clone(),
Some(status_tx),
Some(new_version_available_tx),
);
let connect_task = agent.connect(&root_dir, delegate, cx);
let load_task = cx.spawn_in(window, async move |this, cx| {
@@ -486,6 +535,26 @@ impl AcpThreadView {
Ok(thread) => {
let action_log = thread.read(cx).action_log().clone();
let mut available_commands = thread.read(cx).available_commands();
if connection
.auth_methods()
.iter()
.any(|method| method.id.0.as_ref() == "claude-login")
{
available_commands.push(acp::AvailableCommand {
name: "login".to_owned(),
description: "Authenticate".to_owned(),
input: None,
});
available_commands.push(acp::AvailableCommand {
name: "logout".to_owned(),
description: "Authenticate".to_owned(),
input: None,
});
}
this.available_commands.replace(available_commands);
this.prompt_capabilities
.set(thread.read(cx).prompt_capabilities());
@@ -578,10 +647,23 @@ impl AcpThreadView {
.log_err();
});
cx.spawn(async move |this, cx| {
while let Ok(new_version) = new_version_available_rx.recv().await {
if let Some(new_version) = new_version {
this.update(cx, |this, cx| {
this.new_server_version_available = Some(new_version.into());
cx.notify();
})
.log_err();
}
}
})
.detach();
let loading_view = cx.new(|cx| {
let update_title_task = cx.spawn(async move |this, cx| {
loop {
let status = rx.recv().await?;
let status = status_rx.recv().await?;
this.update(cx, |this: &mut LoadingView, cx| {
this.title = status;
cx.notify();
@@ -617,17 +699,13 @@ impl AcpThreadView {
move |_, ev, window, cx| {
if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev
&& &provider_id == updated_provider_id
&& LanguageModelRegistry::global(cx)
.read(cx)
.provider(&provider_id)
.map_or(false, |provider| provider.is_authenticated(cx))
{
this.update(cx, |this, cx| {
this.thread_state = Self::initial_state(
agent.clone(),
None,
this.workspace.clone(),
this.project.clone(),
window,
cx,
);
cx.notify();
this.reset(window, cx);
})
.ok();
}
@@ -827,6 +905,9 @@ impl AcpThreadView {
self.expanded_tool_calls.insert(tool_call_id.clone());
}
}
ViewEvent::TerminalMovedToBackground(tool_call_id) => {
self.expanded_tool_calls.remove(tool_call_id);
}
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
if let Some(thread) = self.thread()
&& let Some(AgentThreadEntry::UserMessage(user_message)) =
@@ -899,6 +980,40 @@ impl AcpThreadView {
return;
}
let text = self.message_editor.read(cx).text(cx);
let text = text.trim();
if text == "/login" || text == "/logout" {
let ThreadState::Ready { thread, .. } = &self.thread_state else {
return;
};
let connection = thread.read(cx).connection().clone();
if !connection
.auth_methods()
.iter()
.any(|method| method.id.0.as_ref() == "claude-login")
{
return;
};
let this = cx.weak_entity();
let agent = self.agent.clone();
window.defer(cx, |window, cx| {
Self::handle_auth_required(
this,
AuthRequired {
description: None,
provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID),
},
agent,
connection,
window,
cx,
);
});
cx.notify();
return;
}
let contents = self
.message_editor
.update(cx, |message_editor, cx| message_editor.contents(cx));
@@ -1189,6 +1304,14 @@ impl AcpThreadView {
cx,
);
}
AcpThreadEvent::Refusal => {
self.thread_retry_status.take();
self.thread_error = Some(ThreadError::Refusal);
let model_or_agent_name = self.get_current_model_name(cx);
let notification_message =
format!("{} refused to respond to this request", model_or_agent_name);
self.notify_with_sound(&notification_message, IconName::Warning, window, cx);
}
AcpThreadEvent::Error => {
self.thread_retry_status.take();
self.notify_with_sound(
@@ -1271,11 +1394,11 @@ impl AcpThreadView {
.read(cx)
.provider(&language_model::ANTHROPIC_PROVIDER_ID)
.unwrap();
if !provider.is_authenticated(cx) {
let this = cx.weak_entity();
let agent = self.agent.clone();
let connection = connection.clone();
window.defer(cx, |window, cx| {
let this = cx.weak_entity();
let agent = self.agent.clone();
let connection = connection.clone();
window.defer(cx, move |window, cx| {
if !provider.is_authenticated(cx) {
Self::handle_auth_required(
this,
AuthRequired {
@@ -1287,9 +1410,21 @@ impl AcpThreadView {
window,
cx,
);
});
return;
}
} else {
this.update(cx, |this, cx| {
this.thread_state = Self::initial_state(
agent,
None,
this.workspace.clone(),
this.project.clone(),
window,
cx,
)
})
.ok();
}
});
return;
} else if method.0.as_ref() == "vertex-ai"
&& std::env::var("GOOGLE_API_KEY").is_err()
&& (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
@@ -1333,7 +1468,6 @@ impl AcpThreadView {
cx.notify();
self.auth_task =
Some(cx.spawn_in(window, {
let project = self.project.clone();
let agent = self.agent.clone();
async move |this, cx| {
let result = authenticate.await;
@@ -1362,14 +1496,7 @@ impl AcpThreadView {
}
this.handle_thread_error(err, cx);
} else {
this.thread_state = Self::initial_state(
agent,
None,
this.workspace.clone(),
project.clone(),
window,
cx,
)
this.reset(window, cx);
}
this.auth_task.take()
})
@@ -1391,7 +1518,7 @@ impl AcpThreadView {
let cwd = project.first_project_directory(cx);
let shell = project.terminal_settings(&cwd, cx).shell.clone();
let delegate = AgentServerDelegate::new(project_entity.clone(), None);
let delegate = AgentServerDelegate::new(project_entity.clone(), None, None);
let command = ClaudeCode::login_command(delegate, cx);
window.spawn(cx, async move |cx| {
@@ -2418,7 +2545,8 @@ impl AcpThreadView {
let output = terminal_data.output();
let command_finished = output.is_some();
let truncated_output = output.is_some_and(|output| output.was_content_truncated);
let truncated_output =
output.is_some_and(|output| output.original_content_len > output.content.len());
let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
let command_failed = command_finished
@@ -2506,48 +2634,20 @@ impl AcpThreadView {
Icon::new(IconName::ArrowCircle)
.size(IconSize::XSmall)
.color(Color::Info)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(percentage(delta)))
},
),
.with_rotate_animation(2)
)
})
.child(
Disclosure::new(
SharedString::from(format!(
"terminal-tool-disclosure-{}",
terminal.entity_id()
)),
is_expanded,
)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
.visible_on_hover(&header_group)
.on_click(cx.listener({
let id = tool_call.id.clone();
move |this, _event, _window, _cx| {
if is_expanded {
this.expanded_tool_calls.remove(&id);
} else {
this.expanded_tool_calls.insert(id.clone());
}
}
})),
)
.when(truncated_output, |header| {
let tooltip = if let Some(output) = output {
if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
"Output exceeded terminal max lines and was \
truncated, the model received the first 16 KB."
.to_string()
format!("Output exceeded terminal max lines and was \
truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true))
} else {
format!(
"Output is {} long, and to avoid unexpected token usage, \
only 16 KB was sent back to the model.",
only {} was sent back to the agent.",
format_file_size(output.original_content_len as u64, true),
format_file_size(output.content.len() as u64, true)
)
}
} else {
@@ -2595,7 +2695,29 @@ impl AcpThreadView {
)))
}),
)
});
})
.child(
Disclosure::new(
SharedString::from(format!(
"terminal-tool-disclosure-{}",
terminal.entity_id()
)),
is_expanded,
)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
.visible_on_hover(&header_group)
.on_click(cx.listener({
let id = tool_call.id.clone();
move |this, _event, _window, _cx| {
if is_expanded {
this.expanded_tool_calls.remove(&id);
} else {
this.expanded_tool_calls.insert(id.clone());
}
}
})),
);
let terminal_view = self
.entry_view_state
@@ -2646,7 +2768,18 @@ impl AcpThreadView {
.bg(cx.theme().colors().editor_background)
.rounded_b_md()
.text_ui_sm(cx)
.children(terminal_view.clone()),
.h_full()
.children(terminal_view.map(|terminal_view| {
if terminal_view
.read(cx)
.content_mode(window, cx)
.is_scrollable()
{
div().h_72().child(terminal_view).into_any_element()
} else {
terminal_view.into_any_element()
}
})),
)
})
.into_any()
@@ -2928,16 +3061,7 @@ impl AcpThreadView {
Icon::new(IconName::ArrowCircle)
.size(IconSize::Small)
.color(Color::Muted)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(percentage(
delta,
)))
},
)
.into_any_element(),
.with_rotate_animation(2)
)
.child(Label::new("Authenticating…").size(LabelSize::Small)),
)
@@ -3250,13 +3374,7 @@ impl AcpThreadView {
acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
.size(IconSize::Small)
.color(Color::Accent)
.with_animation(
"running",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(percentage(delta)))
},
)
.with_rotate_animation(2)
.into_any_element(),
acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
.size(IconSize::Small)
@@ -4683,6 +4801,7 @@ impl AcpThreadView {
fn render_thread_error(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
let content = match self.thread_error.as_ref()? {
ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
ThreadError::Refusal => self.render_refusal_error(cx),
ThreadError::AuthenticationRequired(error) => {
self.render_authentication_required_error(error.clone(), cx)
}
@@ -4698,6 +4817,75 @@ impl AcpThreadView {
Some(div().child(content))
}
fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context<Self>) -> Div {
v_flex().w_full().justify_end().child(
h_flex()
.p_2()
.pr_3()
.w_full()
.gap_1p5()
.border_t_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().element_background)
.child(
h_flex()
.flex_1()
.gap_1p5()
.child(
Icon::new(IconName::Download)
.color(Color::Accent)
.size(IconSize::Small),
)
.child(Label::new("New version available").size(LabelSize::Small)),
)
.child(
Button::new("update-button", format!("Update to v{}", version))
.label_size(LabelSize::Small)
.style(ButtonStyle::Tinted(TintColor::Accent))
.on_click(cx.listener(|this, _, window, cx| {
this.reset(window, cx);
})),
),
)
}
fn get_current_model_name(&self, cx: &App) -> SharedString {
// For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
// For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI")
// This provides better clarity about what refused the request
if self
.agent
.clone()
.downcast::<agent2::NativeAgentServer>()
.is_some()
{
// Native agent - use the model name
self.model_selector
.as_ref()
.and_then(|selector| selector.read(cx).active_model_name(cx))
.unwrap_or_else(|| SharedString::from("The model"))
} else {
// ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI")
self.agent.name()
}
}
fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout {
let model_or_agent_name = self.get_current_model_name(cx);
let refusal_message = format!(
"{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.",
model_or_agent_name
);
Callout::new()
.severity(Severity::Error)
.title("Request Refused")
.icon(IconName::XCircle)
.description(refusal_message.clone())
.actions_slot(self.create_copy_button(&refusal_message))
.dismiss_action(self.dismiss_error_button(cx))
}
fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
let can_resume = self
.thread()
@@ -4980,11 +5168,7 @@ fn loading_contents_spinner(size: IconSize) -> AnyElement {
Icon::new(IconName::LoadCircle)
.size(size)
.color(Color::Accent)
.with_animation(
"load_context_circle",
Animation::new(Duration::from_secs(3)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
)
.with_rotate_animation(3)
.into_any_element()
}
@@ -5075,6 +5259,12 @@ impl Render for AcpThreadView {
})
.children(self.render_thread_retry_status_callout(window, cx))
.children(self.render_thread_error(window, cx))
.when_some(
self.new_server_version_available.as_ref().filter(|_| {
!has_messages || !matches!(self.thread_state, ThreadState::Ready { .. })
}),
|this, version| this.child(self.render_new_version_callout(&version, cx)),
)
.children(
if let Some(usage_callout) = self.render_usage_callout(line_height, cx) {
Some(usage_callout.into_any_element())
@@ -5329,6 +5519,33 @@ pub(crate) mod tests {
);
}
#[gpui::test]
async fn test_refusal_handling(cx: &mut TestAppContext) {
init_test(cx);
let (thread_view, cx) =
setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
message_editor.update_in(cx, |editor, window, cx| {
editor.set_text("Do something harmful", window, cx);
});
thread_view.update_in(cx, |thread_view, window, cx| {
thread_view.send(window, cx);
});
cx.run_until_parked();
// Check that the refusal error is set
thread_view.read_with(cx, |thread_view, _cx| {
assert!(
matches!(thread_view.thread_error, Some(ThreadError::Refusal)),
"Expected refusal error to be set"
);
});
}
#[gpui::test]
async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
init_test(cx);
@@ -5528,6 +5745,7 @@ pub(crate) mod tests {
audio: true,
embedded_context: true,
}),
vec![],
cx,
)
})))
@@ -5563,6 +5781,68 @@ pub(crate) mod tests {
}
}
/// Simulates a model which always returns a refusal response
#[derive(Clone)]
struct RefusalAgentConnection;
impl AgentConnection for RefusalAgentConnection {
fn new_thread(
self: Rc<Self>,
project: Entity<Project>,
_cwd: &Path,
cx: &mut gpui::App,
) -> Task<gpui::Result<Entity<AcpThread>>> {
Task::ready(Ok(cx.new(|cx| {
let action_log = cx.new(|_| ActionLog::new(project.clone()));
AcpThread::new(
"RefusalAgentConnection",
self,
project,
action_log,
SessionId("test".into()),
watch::Receiver::constant(acp::PromptCapabilities {
image: true,
audio: true,
embedded_context: true,
}),
Vec::new(),
cx,
)
})))
}
fn auth_methods(&self) -> &[acp::AuthMethod] {
&[]
}
fn authenticate(
&self,
_method_id: acp::AuthMethodId,
_cx: &mut App,
) -> Task<gpui::Result<()>> {
unimplemented!()
}
fn prompt(
&self,
_id: Option<acp_thread::UserMessageId>,
_params: acp::PromptRequest,
_cx: &mut App,
) -> Task<gpui::Result<acp::PromptResponse>> {
Task::ready(Ok(acp::PromptResponse {
stop_reason: acp::StopReason::Refusal,
}))
}
fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
unimplemented!()
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
self
}
}
pub(crate) fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);

View File

@@ -23,9 +23,8 @@ use gpui::{
AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, ClipboardEntry,
ClipboardItem, DefiniteLength, EdgesRefinement, Empty, Entity, EventEmitter, Focusable, Hsla,
ListAlignment, ListOffset, ListState, MouseButton, PlatformDisplay, ScrollHandle, Stateful,
StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, Transformation,
UnderlineStyle, WeakEntity, WindowHandle, linear_color_stop, linear_gradient, list, percentage,
pulsating_between,
StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle,
WeakEntity, WindowHandle, linear_color_stop, linear_gradient, list, pulsating_between,
};
use language::{Buffer, Language, LanguageRegistry};
use language_model::{
@@ -46,8 +45,8 @@ use std::time::Duration;
use text::ToPoint;
use theme::ThemeSettings;
use ui::{
Banner, Disclosure, KeyBinding, PopoverMenuHandle, Scrollbar, ScrollbarState, TextSize,
Tooltip, prelude::*,
Banner, CommonAnimationExt, Disclosure, KeyBinding, PopoverMenuHandle, Scrollbar,
ScrollbarState, TextSize, Tooltip, prelude::*,
};
use util::ResultExt as _;
use util::markdown::MarkdownCodeBlock;
@@ -1002,8 +1001,22 @@ impl ActiveThread {
// Don't notify for intermediate tool use
}
Ok(StopReason::Refusal) => {
let model_name = self
.thread
.read(cx)
.configured_model()
.map(|configured| configured.model.name().0.to_string())
.unwrap_or_else(|| "The model".to_string());
let refusal_message = format!(
"{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.",
model_name
);
self.last_error = Some(ThreadError::Message {
header: SharedString::from("Request Refused"),
message: SharedString::from(refusal_message),
});
self.notify_with_sound(
"Language model refused to respond",
format!("{} refused to respond", model_name),
IconName::Warning,
window,
cx,
@@ -2647,15 +2660,7 @@ impl ActiveThread {
Icon::new(IconName::ArrowCircle)
.color(Color::Accent)
.size(IconSize::Small)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(
percentage(delta),
))
},
)
.with_rotate_animation(2)
}),
),
)
@@ -2831,17 +2836,11 @@ impl ActiveThread {
}
ToolUseStatus::Pending
| ToolUseStatus::InputStillStreaming
| ToolUseStatus::Running => {
let icon = Icon::new(IconName::ArrowCircle)
.color(Color::Accent)
.size(IconSize::Small);
icon.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
)
.into_any_element()
}
| ToolUseStatus::Running => Icon::new(IconName::ArrowCircle)
.color(Color::Accent)
.size(IconSize::Small)
.with_rotate_animation(2)
.into_any_element(),
ToolUseStatus::Finished(_) => div().w_0().into_any_element(),
ToolUseStatus::Error(_) => {
let icon = Icon::new(IconName::Close)
@@ -2930,15 +2929,7 @@ impl ActiveThread {
Icon::new(IconName::ArrowCircle)
.size(IconSize::Small)
.color(Color::Accent)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(percentage(
delta,
)))
},
),
.with_rotate_animation(2),
)
.child(
Label::new("Running…")

View File

@@ -3,7 +3,7 @@ mod configure_context_server_modal;
mod manage_profiles_modal;
mod tool_picker;
use std::{ops::Range, sync::Arc, time::Duration};
use std::{ops::Range, sync::Arc};
use agent_servers::{AgentServerCommand, AllAgentServersSettings, CustomAgentServerSettings};
use agent_settings::AgentSettings;
@@ -17,9 +17,8 @@ use extension::ExtensionManifest;
use extension_host::ExtensionStore;
use fs::Fs;
use gpui::{
Action, Animation, AnimationExt as _, AnyView, App, AsyncWindowContext, Corner, Entity,
EventEmitter, FocusHandle, Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation,
WeakEntity, percentage,
Action, AnyView, App, AsyncWindowContext, Corner, Entity, EventEmitter, FocusHandle, Focusable,
Hsla, ScrollHandle, Subscription, Task, WeakEntity,
};
use language::LanguageRegistry;
use language_model::{
@@ -32,8 +31,9 @@ use project::{
};
use settings::{Settings, SettingsStore, update_settings_file};
use ui::{
Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*,
Chip, CommonAnimationExt, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex,
Indicator, PopoverMenu, Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip,
prelude::*,
};
use util::ResultExt as _;
use workspace::{Workspace, create_and_open_local_file};
@@ -670,10 +670,9 @@ impl AgentConfiguration {
Icon::new(IconName::LoadCircle)
.size(IconSize::XSmall)
.color(Color::Accent)
.with_animation(
SharedString::from(format!("{}-starting", context_server_id.0,)),
Animation::new(Duration::from_secs(3)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
.with_keyed_rotate_animation(
SharedString::from(format!("{}-starting", context_server_id.0)),
3,
)
.into_any_element(),
"Server is starting.",

View File

@@ -1,16 +1,14 @@
use std::{
path::PathBuf,
sync::{Arc, Mutex},
time::Duration,
};
use anyhow::{Context as _, Result};
use context_server::{ContextServerCommand, ContextServerId};
use editor::{Editor, EditorElement, EditorStyle};
use gpui::{
Animation, AnimationExt as _, AsyncWindowContext, DismissEvent, Entity, EventEmitter,
FocusHandle, Focusable, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle,
WeakEntity, percentage, prelude::*,
AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task,
TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
};
use language::{Language, LanguageRegistry};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
@@ -24,7 +22,9 @@ use project::{
};
use settings::{Settings as _, update_settings_file};
use theme::ThemeSettings;
use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*};
use ui::{
CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*,
};
use util::ResultExt as _;
use workspace::{ModalView, Workspace};
@@ -638,11 +638,7 @@ impl ConfigureContextServerModal {
Icon::new(IconName::ArrowCircle)
.size(IconSize::XSmall)
.color(Color::Info)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
)
.with_rotate_animation(2)
.into_any_element(),
)
.child(

View File

@@ -14,9 +14,8 @@ use editor::{
scroll::Autoscroll,
};
use gpui::{
Action, Animation, AnimationExt, AnyElement, AnyView, App, AppContext, Empty, Entity,
EventEmitter, FocusHandle, Focusable, Global, SharedString, Subscription, Task, Transformation,
WeakEntity, Window, percentage, prelude::*,
Action, AnyElement, AnyView, App, AppContext, Empty, Entity, EventEmitter, FocusHandle,
Focusable, Global, SharedString, Subscription, Task, WeakEntity, Window, prelude::*,
};
use language::{Buffer, Capability, DiskState, OffsetRangeExt, Point};
@@ -29,9 +28,8 @@ use std::{
collections::hash_map::Entry,
ops::Range,
sync::Arc,
time::Duration,
};
use ui::{IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider};
use ui::{CommonAnimationExt, IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider};
use util::ResultExt;
use workspace::{
Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
@@ -1084,11 +1082,7 @@ impl Render for AgentDiffToolbar {
Icon::new(IconName::LoadCircle)
.size(IconSize::Small)
.color(Color::Accent)
.with_animation(
"load_circle",
Animation::new(Duration::from_secs(3)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
),
.with_rotate_animation(3),
)
.into_any();
@@ -1523,7 +1517,10 @@ impl AgentDiff {
self.update_reviewing_editors(workspace, window, cx);
}
}
AcpThreadEvent::Stopped | AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) => {
AcpThreadEvent::Stopped
| AcpThreadEvent::Error
| AcpThreadEvent::LoadError(_)
| AcpThreadEvent::Refusal => {
self.update_reviewing_editors(workspace, window, cx);
}
AcpThreadEvent::TitleUpdated

View File

@@ -10,11 +10,11 @@ use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize};
use zed_actions::OpenBrowser;
use zed_actions::agent::ReauthenticateAgent;
use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent};
use crate::acp::{AcpThreadHistory, ThreadHistoryEvent};
use crate::agent_diff::AgentDiffThread;
use crate::ui::AcpOnboardingModal;
use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
use crate::{
AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
@@ -207,6 +207,9 @@ pub fn init(cx: &mut App) {
.register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
AcpOnboardingModal::toggle(workspace, window, cx)
})
.register_action(|workspace, _: &OpenClaudeCodeOnboardingModal, window, cx| {
ClaudeCodeOnboardingModal::toggle(workspace, window, cx)
})
.register_action(|_workspace, _: &ResetOnboarding, window, cx| {
window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
window.refresh();
@@ -1091,6 +1094,7 @@ impl AgentPanel {
let workspace = self.workspace.clone();
let project = self.project.clone();
let fs = self.fs.clone();
let is_not_local = !self.project.read(cx).is_local();
const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
@@ -1122,17 +1126,21 @@ impl AgentPanel {
agent
}
None => {
cx.background_spawn(async move {
KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
})
.await
.log_err()
.flatten()
.and_then(|value| {
serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
})
.unwrap_or_default()
.agent
if is_not_local {
ExternalAgent::NativeAgent
} else {
cx.background_spawn(async move {
KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
})
.await
.log_err()
.flatten()
.and_then(|value| {
serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
})
.unwrap_or_default()
.agent
}
}
};
@@ -1911,13 +1919,17 @@ impl AgentPanel {
AgentType::Gemini => {
self.external_thread(Some(crate::ExternalAgent::Gemini), None, None, window, cx)
}
AgentType::ClaudeCode => self.external_thread(
Some(crate::ExternalAgent::ClaudeCode),
None,
None,
window,
cx,
),
AgentType::ClaudeCode => {
self.selected_agent = AgentType::ClaudeCode;
self.serialize(cx);
self.external_thread(
Some(crate::ExternalAgent::ClaudeCode),
None,
None,
window,
cx,
)
}
AgentType::Custom { name, command } => self.external_thread(
Some(crate::ExternalAgent::Custom { name, command }),
None,
@@ -2523,6 +2535,9 @@ impl AgentPanel {
.with_handle(self.new_thread_menu_handle.clone())
.menu({
let workspace = self.workspace.clone();
let is_not_local = workspace
.update(cx, |workspace, cx| !workspace.project().read(cx).is_local())
.unwrap_or_default();
move |window, cx| {
telemetry::event!("New Thread Clicked");
@@ -2613,6 +2628,7 @@ impl AgentPanel {
ContextMenuEntry::new("New Gemini CLI Thread")
.icon(IconName::AiGemini)
.icon_color(Color::Muted)
.disabled(is_not_local)
.handler({
let workspace = workspace.clone();
move |window, cx| {
@@ -2639,6 +2655,7 @@ impl AgentPanel {
menu.item(
ContextMenuEntry::new("New Claude Code Thread")
.icon(IconName::AiClaude)
.disabled(is_not_local)
.icon_color(Color::Muted)
.handler({
let workspace = workspace.clone();
@@ -2671,6 +2688,7 @@ impl AgentPanel {
ContextMenuEntry::new(format!("New {} Thread", agent_name))
.icon(IconName::Terminal)
.icon_color(Color::Muted)
.disabled(is_not_local)
.handler({
let workspace = workspace.clone();
let agent_name = agent_name.clone();
@@ -2949,6 +2967,20 @@ impl AgentPanel {
return false;
}
let user_store = self.user_store.read(cx);
if user_store
.plan()
.is_some_and(|plan| matches!(plan, Plan::ZedPro))
&& user_store
.subscription_period()
.and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
.is_some_and(|date| date < chrono::Utc::now())
{
OnboardingUpsell::set_dismissed(true, cx);
return false;
}
match &self.active_view {
ActiveView::History | ActiveView::Configuration => false,
ActiveView::ExternalAgentThread { thread_view, .. }
@@ -3517,6 +3549,11 @@ impl AgentPanel {
) -> AnyElement {
let message_with_header = format!("{}\n{}", header, message);
// Don't show Retry button for refusals
let is_refusal = header == "Request Refused";
let retry_button = self.render_retry_button(thread);
let copy_button = self.create_copy_button(message_with_header);
Callout::new()
.severity(Severity::Error)
.icon(IconName::XCircle)
@@ -3525,8 +3562,8 @@ impl AgentPanel {
.actions_slot(
h_flex()
.gap_0p5()
.child(self.render_retry_button(thread))
.child(self.create_copy_button(message_with_header)),
.when(!is_refusal, |this| this.child(retry_button))
.child(copy_button),
)
.dismiss_action(self.dismiss_error_button(thread, cx))
.into_any_element()

View File

@@ -13,7 +13,10 @@ use http_client::HttpClientWithUrl;
use itertools::Itertools;
use language::{Buffer, CodeLabel, HighlightId};
use lsp::CompletionContext;
use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, Symbol, WorktreeId};
use project::{
Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, ProjectPath,
Symbol, WorktreeId,
};
use prompt_store::PromptStore;
use rope::Point;
use text::{Anchor, OffsetRangeExt, ToPoint};
@@ -897,6 +900,7 @@ impl CompletionProvider for ContextPickerCompletionProvider {
Ok(vec![CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
// Since this does its own filtering (see `filter_completions()` returns false),
// there is no benefit to computing whether this set of completions is incomplete.
is_incomplete: true,

View File

@@ -144,7 +144,8 @@ impl InlineAssistant {
let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
return;
};
let enabled = AgentSettings::get_global(cx).enabled;
let enabled = !DisableAiSettings::get_global(cx).disable_ai
&& AgentSettings::get_global(cx).enabled;
terminal_panel.update(cx, |terminal_panel, cx| {
terminal_panel.set_assistant_enabled(enabled, cx)
});

View File

@@ -93,8 +93,8 @@ impl<T: 'static> Render for PromptEditor<T> {
};
let bottom_padding = match &self.mode {
PromptEditorMode::Buffer { .. } => Pixels::from(0.),
PromptEditorMode::Terminal { .. } => Pixels::from(8.0),
PromptEditorMode::Buffer { .. } => rems_from_px(2.0),
PromptEditorMode::Terminal { .. } => rems_from_px(8.0),
};
buttons.extend(self.render_buttons(window, cx));
@@ -762,20 +762,22 @@ impl<T: 'static> PromptEditor<T> {
)
}
fn render_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let font_size = TextSize::Default.rems(cx);
let line_height = font_size.to_pixels(window.rem_size()) * 1.3;
fn render_editor(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let colors = cx.theme().colors();
div()
.key_context("InlineAssistEditor")
.size_full()
.p_2()
.pl_1()
.bg(cx.theme().colors().editor_background)
.bg(colors.editor_background)
.child({
let settings = ThemeSettings::get_global(cx);
let font_size = settings.buffer_font_size(cx);
let line_height = font_size * 1.2;
let text_style = TextStyle {
color: cx.theme().colors().editor_foreground,
color: colors.editor_foreground,
font_family: settings.buffer_font.family.clone(),
font_features: settings.buffer_font.features.clone(),
font_size: font_size.into(),
@@ -786,7 +788,7 @@ impl<T: 'static> PromptEditor<T> {
EditorElement::new(
&self.editor,
EditorStyle {
background: cx.theme().colors().editor_background,
background: colors.editor_background,
local_player: cx.theme().players().local(),
text: text_style,
..Default::default()

View File

@@ -7,7 +7,10 @@ use fuzzy::{StringMatchCandidate, match_strings};
use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity, Window};
use language::{Anchor, Buffer, ToPoint};
use parking_lot::Mutex;
use project::{CompletionIntent, CompletionSource, lsp_store::CompletionDocumentation};
use project::{
CompletionDisplayOptions, CompletionIntent, CompletionSource,
lsp_store::CompletionDocumentation,
};
use rope::Point;
use std::{
ops::Range,
@@ -133,6 +136,7 @@ impl SlashCommandCompletionProvider {
vec![project::CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
is_incomplete: false,
}]
})
@@ -237,6 +241,7 @@ impl SlashCommandCompletionProvider {
Ok(vec![project::CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
// TODO: Could have slash commands indicate whether their completions are incomplete.
is_incomplete: true,
}])
@@ -244,6 +249,7 @@ impl SlashCommandCompletionProvider {
} else {
Task::ready(Ok(vec![project::CompletionResponse {
completions: Vec::new(),
display_options: CompletionDisplayOptions::default(),
is_incomplete: true,
}]))
}
@@ -305,6 +311,7 @@ impl CompletionProvider for SlashCommandCompletionProvider {
else {
return Task::ready(Ok(vec![project::CompletionResponse {
completions: Vec::new(),
display_options: CompletionDisplayOptions::default(),
is_incomplete: false,
}]));
};

View File

@@ -25,8 +25,8 @@ use gpui::{
Action, Animation, AnimationExt, AnyElement, AnyView, App, ClipboardEntry, ClipboardItem,
Empty, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, InteractiveElement,
IntoElement, ParentElement, Pixels, Render, RenderImage, SharedString, Size,
StatefulInteractiveElement, Styled, Subscription, Task, Transformation, WeakEntity, actions,
div, img, percentage, point, prelude::*, pulsating_between, size,
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, actions, div, img, point,
prelude::*, pulsating_between, size,
};
use language::{
BufferSnapshot, LspAdapterDelegate, ToOffset,
@@ -53,8 +53,8 @@ use std::{
};
use text::SelectionGoal;
use ui::{
ButtonLike, Disclosure, ElevationIndex, KeyBinding, PopoverMenuHandle, TintColor, Tooltip,
prelude::*,
ButtonLike, CommonAnimationExt, Disclosure, ElevationIndex, KeyBinding, PopoverMenuHandle,
TintColor, Tooltip, prelude::*,
};
use util::{ResultExt, maybe};
use workspace::{
@@ -1061,15 +1061,7 @@ impl TextThreadEditor {
Icon::new(IconName::ArrowCircle)
.size(IconSize::XSmall)
.color(Color::Info)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(
percentage(delta),
))
},
)
.with_rotate_animation(2)
.into_any_element(),
);
note = Some(Self::esc_kbd(cx).into_any_element());
@@ -2790,11 +2782,7 @@ fn invoked_slash_command_fold_placeholder(
.child(Label::new(format!("/{}", command.name)))
.map(|parent| match &command.status {
InvokedSlashCommandStatus::Running(_) => {
parent.child(Icon::new(IconName::ArrowCircle).with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(4)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
))
parent.child(Icon::new(IconName::ArrowCircle).with_rotate_animation(4))
}
InvokedSlashCommandStatus::Error(message) => parent.child(
Label::new(format!("error: {message}"))

View File

@@ -1,6 +1,7 @@
mod acp_onboarding_modal;
mod agent_notification;
mod burn_mode_tooltip;
mod claude_code_onboarding_modal;
mod context_pill;
mod end_trial_upsell;
mod onboarding_modal;
@@ -10,6 +11,7 @@ mod unavailable_editing_tooltip;
pub use acp_onboarding_modal::*;
pub use agent_notification::*;
pub use burn_mode_tooltip::*;
pub use claude_code_onboarding_modal::*;
pub use context_pill::*;
pub use end_trial_upsell::*;
pub use onboarding_modal::*;

View File

@@ -141,20 +141,12 @@ impl Render for AcpOnboardingModal {
.bg(gpui::black().opacity(0.15)),
)
.child(
h_flex()
.gap_4()
.child(
Vector::new(VectorName::AcpLogo, rems_from_px(106.), rems_from_px(40.))
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
)
.child(
Vector::new(
VectorName::AcpLogoSerif,
rems_from_px(111.),
rems_from_px(41.),
)
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
),
Vector::new(
VectorName::AcpLogoSerif,
rems_from_px(257.),
rems_from_px(47.),
)
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
)
.child(
v_flex()

View File

@@ -0,0 +1,254 @@
use client::zed_urls;
use gpui::{
ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
linear_color_stop, linear_gradient,
};
use ui::{TintColor, Vector, VectorName, prelude::*};
use workspace::{ModalView, Workspace};
use crate::agent_panel::{AgentPanel, AgentType};
macro_rules! claude_code_onboarding_event {
($name:expr) => {
telemetry::event!($name, source = "ACP Claude Code Onboarding");
};
($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => {
telemetry::event!($name, source = "ACP Claude Code Onboarding", $($key $(= $value)?),+);
};
}
pub struct ClaudeCodeOnboardingModal {
focus_handle: FocusHandle,
workspace: Entity<Workspace>,
}
impl ClaudeCodeOnboardingModal {
pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
let workspace_entity = cx.entity();
workspace.toggle_modal(window, cx, |_window, cx| Self {
workspace: workspace_entity,
focus_handle: cx.focus_handle(),
});
}
fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
self.workspace.update(cx, |workspace, cx| {
workspace.focus_panel::<AgentPanel>(window, cx);
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.new_agent_thread(AgentType::ClaudeCode, window, cx);
});
}
});
cx.emit(DismissEvent);
claude_code_onboarding_event!("Open Panel Clicked");
}
fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
cx.open_url(&zed_urls::external_agents_docs(cx));
cx.notify();
claude_code_onboarding_event!("Documentation Link Clicked");
}
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
}
impl EventEmitter<DismissEvent> for ClaudeCodeOnboardingModal {}
impl Focusable for ClaudeCodeOnboardingModal {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl ModalView for ClaudeCodeOnboardingModal {}
impl Render for ClaudeCodeOnboardingModal {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let illustration_element = |icon: IconName, label: Option<SharedString>, opacity: f32| {
h_flex()
.px_1()
.py_0p5()
.gap_1()
.rounded_sm()
.bg(cx.theme().colors().element_active.opacity(0.05))
.border_1()
.border_color(cx.theme().colors().border)
.border_dashed()
.child(
Icon::new(icon)
.size(IconSize::Small)
.color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))),
)
.map(|this| {
if let Some(label_text) = label {
this.child(
Label::new(label_text)
.size(LabelSize::Small)
.color(Color::Muted),
)
} else {
this.child(
div().w_16().h_1().rounded_full().bg(cx
.theme()
.colors()
.element_active
.opacity(0.6)),
)
}
})
.opacity(opacity)
};
let illustration = h_flex()
.relative()
.h(rems_from_px(126.))
.bg(cx.theme().colors().editor_background)
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.justify_center()
.gap_8()
.rounded_t_md()
.overflow_hidden()
.child(
div().absolute().inset_0().w(px(515.)).h(px(126.)).child(
Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.))
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))),
),
)
.child(div().absolute().inset_0().size_full().bg(linear_gradient(
0.,
linear_color_stop(
cx.theme().colors().elevated_surface_background.opacity(0.1),
0.9,
),
linear_color_stop(
cx.theme().colors().elevated_surface_background.opacity(0.),
0.,
),
)))
.child(
div()
.absolute()
.inset_0()
.size_full()
.bg(gpui::black().opacity(0.15)),
)
.child(
Vector::new(
VectorName::AcpLogoSerif,
rems_from_px(257.),
rems_from_px(47.),
)
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
)
.child(
v_flex()
.gap_1p5()
.child(illustration_element(IconName::Stop, None, 0.15))
.child(illustration_element(
IconName::AiGemini,
Some("New Gemini CLI Thread".into()),
0.3,
))
.child(
h_flex()
.pl_1()
.pr_2()
.py_0p5()
.gap_1()
.rounded_sm()
.bg(cx.theme().colors().element_active.opacity(0.2))
.border_1()
.border_color(cx.theme().colors().border)
.child(
Icon::new(IconName::AiClaude)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(Label::new("New Claude Code Thread").size(LabelSize::Small)),
)
.child(illustration_element(
IconName::Stop,
Some("Your Agent Here".into()),
0.3,
))
.child(illustration_element(IconName::Stop, None, 0.15)),
);
let heading = v_flex()
.w_full()
.gap_1()
.child(
Label::new("Beta Release")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(Headline::new("Claude Code: Natively in Zed").size(HeadlineSize::Large));
let copy = "Powered by the Agent Client Protocol, you can now run Claude Code as\na first-class citizen in Zed's agent panel.";
let open_panel_button = Button::new("open-panel", "Start with Claude Code")
.icon_size(IconSize::Indicator)
.style(ButtonStyle::Tinted(TintColor::Accent))
.full_width()
.on_click(cx.listener(Self::open_panel));
let docs_button = Button::new("add-other-agents", "Add Other Agents")
.icon(IconName::ArrowUpRight)
.icon_size(IconSize::Indicator)
.icon_color(Color::Muted)
.full_width()
.on_click(cx.listener(Self::view_docs));
let close_button = h_flex().absolute().top_2().right_2().child(
IconButton::new("cancel", IconName::Close).on_click(cx.listener(
|_, _: &ClickEvent, _window, cx| {
claude_code_onboarding_event!("Canceled", trigger = "X click");
cx.emit(DismissEvent);
},
)),
);
v_flex()
.id("acp-onboarding")
.key_context("AcpOnboardingModal")
.relative()
.w(rems(34.))
.h_full()
.elevation_3(cx)
.track_focus(&self.focus_handle(cx))
.overflow_hidden()
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
claude_code_onboarding_event!("Canceled", trigger = "Action");
cx.emit(DismissEvent);
}))
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
this.focus_handle.focus(window);
}))
.child(illustration)
.child(
v_flex()
.p_4()
.gap_2()
.child(heading)
.child(Label::new(copy).color(Color::Muted))
.child(
v_flex()
.w_full()
.mt_2()
.gap_1()
.child(open_panel_button)
.child(docs_button),
),
)
.child(close_button)
}
}

View File

@@ -1,12 +1,9 @@
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
use client::{Client, UserStore, zed_urls};
use cloud_llm_client::Plan;
use gpui::{
Animation, AnimationExt, AnyElement, App, Entity, IntoElement, RenderOnce, Transformation,
Window, percentage,
};
use ui::{Divider, Vector, VectorName, prelude::*};
use gpui::{AnyElement, App, Entity, IntoElement, RenderOnce, Window};
use ui::{CommonAnimationExt, Divider, Vector, VectorName, prelude::*};
use crate::{SignInStatus, YoungAccountBanner, plan_definitions::PlanDefinitions};
@@ -147,11 +144,7 @@ impl RenderOnce for AiUpsellCard {
rems_from_px(72.),
)
.color(Color::Custom(cx.theme().colors().text_accent.alpha(0.3)))
.with_animation(
"loading_stamp",
Animation::new(Duration::from_secs(10)).repeat(),
|this, delta| this.transform(Transformation::rotate(percentage(delta))),
),
.with_rotate_animation(10),
);
let pro_trial_stamp = div()

View File

@@ -1,6 +1,7 @@
use std::sync::Arc;
use client::{Client, UserStore};
use cloud_llm_client::Plan;
use gpui::{Entity, IntoElement, ParentElement};
use ui::prelude::*;
@@ -35,6 +36,8 @@ impl EditPredictionOnboarding {
impl Render for EditPredictionOnboarding {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_free_plan = self.user_store.read(cx).plan() == Some(Plan::ZedFree);
let github_copilot = v_flex()
.gap_1()
.child(Label::new(if self.copilot_is_configured {
@@ -67,7 +70,8 @@ impl Render for EditPredictionOnboarding {
self.continue_with_zed_ai.clone(),
cx,
))
.child(ui::Divider::horizontal())
.child(github_copilot)
.when(is_free_plan, |this| {
this.child(ui::Divider::horizontal()).child(github_copilot)
})
}
}

View File

@@ -6,7 +6,7 @@ pub struct YoungAccountBanner;
impl RenderOnce for YoungAccountBanner {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
const YOUNG_ACCOUNT_DISCLAIMER: &str = "To prevent abuse of our service, we cannot offer plans to GitHub accounts created fewer than 30 days ago. To request an exception, reach out to billing-support@zed.dev.";
const YOUNG_ACCOUNT_DISCLAIMER: &str = "To prevent abuse of our service, GitHub accounts created fewer than 30 days ago are not eligible for free plan usage or Pro plan free trial. To request an exception, reach out to billing-support@zed.dev.";
let label = div()
.w_full()

View File

@@ -35,7 +35,7 @@ impl Tool for DeletePathTool {
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
true
}
fn may_perform_edits(&self) -> bool {

View File

@@ -17,7 +17,7 @@ use editor::{
use futures::StreamExt;
use gpui::{
Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
TextStyleRefinement, Transformation, WeakEntity, percentage, pulsating_between, px,
TextStyleRefinement, WeakEntity, pulsating_between, px,
};
use indoc::formatdoc;
use language::{
@@ -44,7 +44,7 @@ use std::{
time::Duration,
};
use theme::ThemeSettings;
use ui::{Disclosure, Tooltip, prelude::*};
use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*};
use util::ResultExt;
use workspace::Workspace;
@@ -636,8 +636,11 @@ impl EditFileToolCard {
// Create a buffer diff with the current text as the base
let buffer_diff = cx.new(|cx| {
let mut diff = BufferDiff::new(&text_snapshot, cx);
let base_text = buffer_snapshot.text();
let language = buffer_snapshot.language().cloned();
let _ = diff.set_base_text(
buffer_snapshot.clone(),
Some(Arc::new(base_text)),
language,
language_registry,
text_snapshot,
cx,
@@ -939,11 +942,7 @@ impl ToolCard for EditFileToolCard {
Icon::new(IconName::ArrowCircle)
.size(IconSize::XSmall)
.color(Color::Info)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
),
.with_rotate_animation(2),
)
})
.when_some(error_message, |header, error_message| {

View File

@@ -8,8 +8,8 @@ use anyhow::{Context as _, Result, anyhow};
use assistant_tool::{Tool, ToolCard, ToolResult, ToolUseStatus};
use futures::{FutureExt as _, future::Shared};
use gpui::{
Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task,
TextStyleRefinement, Transformation, WeakEntity, Window, percentage,
AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, TextStyleRefinement,
WeakEntity, Window,
};
use language::LineEnding;
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
@@ -28,7 +28,7 @@ use std::{
};
use terminal_view::TerminalView;
use theme::ThemeSettings;
use ui::{Disclosure, Tooltip, prelude::*};
use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*};
use util::{
ResultExt, get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
time::duration_alt_display,
@@ -522,11 +522,7 @@ impl ToolCard for TerminalToolCard {
Icon::new(IconName::ArrowCircle)
.size(IconSize::XSmall)
.color(Color::Info)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
),
.with_rotate_animation(2),
)
})
.when(tool_failed || command_failed, |header| {

View File

@@ -4,7 +4,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources, SettingsUi};
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
pub struct AudioSettings {
/// Opt into the new audio system.
#[serde(rename = "experimental.rodio_audio", default)]
@@ -12,7 +12,7 @@ pub struct AudioSettings {
}
/// Configuration of audio in Zed.
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
#[serde(default)]
pub struct AudioSettingsContent {
/// Whether to use the experimental audio system

View File

@@ -113,20 +113,19 @@ impl Drop for MacOsUnmounter {
}
}
#[derive(SettingsUi)]
struct AutoUpdateSetting(bool);
/// Whether or not to automatically check for updates.
///
/// Default: true
#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize)]
#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize, SettingsUi)]
#[serde(transparent)]
struct AutoUpdateSettingContent(bool);
impl Settings for AutoUpdateSetting {
const KEY: Option<&'static str> = Some("auto_update");
type FileContent = Option<AutoUpdateSettingContent>;
type FileContent = AutoUpdateSettingContent;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
let auto_update = [
@@ -136,17 +135,19 @@ impl Settings for AutoUpdateSetting {
sources.user,
]
.into_iter()
.find_map(|value| value.copied().flatten())
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
.find_map(|value| value.copied())
.unwrap_or(*sources.default);
Ok(Self(auto_update.0))
}
fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) {
vscode.enum_setting("update.mode", current, |s| match s {
let mut cur = &mut Some(*current);
vscode.enum_setting("update.mode", &mut cur, |s| match s {
"none" | "manual" => Some(AutoUpdateSettingContent(false)),
_ => Some(AutoUpdateSettingContent(true)),
});
*current = cur.unwrap();
}
}

View File

@@ -1158,34 +1158,22 @@ impl BufferDiff {
self.hunks_intersecting_range(start..end, buffer, cx)
}
pub fn set_base_text_buffer(
&mut self,
base_buffer: Entity<language::Buffer>,
buffer: text::BufferSnapshot,
cx: &mut Context<Self>,
) -> oneshot::Receiver<()> {
let base_buffer = base_buffer.read(cx);
let language_registry = base_buffer.language_registry();
let base_buffer = base_buffer.snapshot();
self.set_base_text(base_buffer, language_registry, buffer, cx)
}
/// Used in cases where the change set isn't derived from git.
pub fn set_base_text(
&mut self,
base_buffer: language::BufferSnapshot,
base_text: Option<Arc<String>>,
language: Option<Arc<Language>>,
language_registry: Option<Arc<LanguageRegistry>>,
buffer: text::BufferSnapshot,
cx: &mut Context<Self>,
) -> oneshot::Receiver<()> {
let (tx, rx) = oneshot::channel();
let this = cx.weak_entity();
let base_text = Arc::new(base_buffer.text());
let snapshot = BufferDiffSnapshot::new_with_base_text(
buffer.clone(),
Some(base_text),
base_buffer.language().cloned(),
base_text,
language,
language_registry,
cx,
);

View File

@@ -4,14 +4,14 @@ use schemars::JsonSchema;
use serde_derive::{Deserialize, Serialize};
use settings::{Settings, SettingsSources, SettingsUi};
#[derive(Deserialize, Debug, SettingsUi)]
#[derive(Deserialize, Debug)]
pub struct CallSettings {
pub mute_on_join: bool,
pub share_on_join: bool,
}
/// Configuration of voice calls in Zed.
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
pub struct CallSettingsContent {
/// Whether the microphone should be muted when joining a channel or a call.
///

View File

@@ -84,13 +84,16 @@ struct Args {
/// Run zed in dev-server mode
#[arg(long)]
dev_server_token: Option<String>,
/// The username and WSL distribution to use when opening paths. ,If not specified,
/// The username and WSL distribution to use when opening paths. If not specified,
/// Zed will attempt to open the paths directly.
///
/// The username is optional, and if not specified, the default user for the distribution
/// will be used.
///
/// Example: `me@Ubuntu` or `Ubuntu` for default distribution.
/// Example: `me@Ubuntu` or `Ubuntu`.
///
/// WARN: You should not fill in this field by hand.
#[cfg(target_os = "windows")]
#[arg(long, value_name = "USER@DISTRO")]
wsl: Option<String>,
/// Not supported in Zed CLI, only supported on Zed binary
@@ -301,6 +304,11 @@ fn main() -> Result<()> {
]);
}
#[cfg(target_os = "windows")]
let wsl = args.wsl.as_ref();
#[cfg(not(target_os = "windows"))]
let wsl = None;
for path in args.paths_with_position.iter() {
if path.starts_with("zed://")
|| path.starts_with("http://")
@@ -319,7 +327,7 @@ fn main() -> Result<()> {
paths.push(tmp_file.path().to_string_lossy().to_string());
let (tmp_file, _) = tmp_file.keep()?;
anonymous_fd_tmp_files.push((file, tmp_file));
} else if let Some(wsl) = &args.wsl {
} else if let Some(wsl) = wsl {
urls.push(format!("file://{}", parse_path_in_wsl(path, wsl)?));
} else {
paths.push(parse_path_with_position(path)?);
@@ -338,11 +346,16 @@ fn main() -> Result<()> {
let (_, handshake) = server.accept().context("Handshake after Zed spawn")?;
let (tx, rx) = (handshake.requests, handshake.responses);
#[cfg(target_os = "windows")]
let wsl = args.wsl;
#[cfg(not(target_os = "windows"))]
let wsl = None;
tx.send(CliRequest::Open {
paths,
urls,
diff_paths,
wsl: args.wsl,
wsl,
wait: args.wait,
open_new_workspace,
env,

View File

@@ -96,12 +96,12 @@ actions!(
]
);
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, SettingsUi)]
pub struct ClientSettingsContent {
server_url: Option<String>,
}
#[derive(Deserialize, SettingsUi)]
#[derive(Deserialize)]
pub struct ClientSettings {
pub server_url: String,
}
@@ -122,12 +122,12 @@ impl Settings for ClientSettings {
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
}
#[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
#[derive(Default, Clone, Serialize, Deserialize, JsonSchema, SettingsUi)]
pub struct ProxySettingsContent {
proxy: Option<String>,
}
#[derive(Deserialize, Default, SettingsUi)]
#[derive(Deserialize, Default)]
pub struct ProxySettings {
pub proxy: Option<String>,
}
@@ -520,14 +520,14 @@ impl<T: 'static> Drop for PendingEntitySubscription<T> {
}
}
#[derive(Copy, Clone, Deserialize, Debug, SettingsUi)]
#[derive(Copy, Clone, Deserialize, Debug)]
pub struct TelemetrySettings {
pub diagnostics: bool,
pub metrics: bool,
}
/// Control what info is collected by Zed.
#[derive(Default, Clone, Serialize, Deserialize, JsonSchema, Debug)]
#[derive(Default, Clone, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
pub struct TelemetrySettingsContent {
/// Send debug info like crash reports.
///

View File

@@ -3425,16 +3425,16 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
assert_eq!(
entries,
vec![
Some(blame_entry("1b1b1b", 0..1)),
Some(blame_entry("0d0d0d", 1..2)),
Some(blame_entry("3a3a3a", 2..3)),
Some(blame_entry("4c4c4c", 3..4)),
Some((buffer_id_b, blame_entry("1b1b1b", 0..1))),
Some((buffer_id_b, blame_entry("0d0d0d", 1..2))),
Some((buffer_id_b, blame_entry("3a3a3a", 2..3))),
Some((buffer_id_b, blame_entry("4c4c4c", 3..4))),
]
);
blame.update(cx, |blame, _| {
for (idx, entry) in entries.iter().flatten().enumerate() {
let details = blame.details_for_entry(entry).unwrap();
for (idx, (buffer, entry)) in entries.iter().flatten().enumerate() {
let details = blame.details_for_entry(*buffer, entry).unwrap();
assert_eq!(details.message, format!("message for idx-{}", idx));
assert_eq!(
details.permalink.unwrap().to_string(),
@@ -3474,9 +3474,9 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
entries,
vec![
None,
Some(blame_entry("0d0d0d", 1..2)),
Some(blame_entry("3a3a3a", 2..3)),
Some(blame_entry("4c4c4c", 3..4)),
Some((buffer_id_b, blame_entry("0d0d0d", 1..2))),
Some((buffer_id_b, blame_entry("3a3a3a", 2..3))),
Some((buffer_id_b, blame_entry("4c4c4c", 3..4))),
]
);
});
@@ -3511,8 +3511,8 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
vec![
None,
None,
Some(blame_entry("3a3a3a", 2..3)),
Some(blame_entry("4c4c4c", 3..4)),
Some((buffer_id_b, blame_entry("3a3a3a", 2..3))),
Some((buffer_id_b, blame_entry("4c4c4c", 3..4))),
]
);
});

View File

@@ -12,7 +12,9 @@ use language::{
Anchor, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry, ToOffset,
language_settings::SoftWrap,
};
use project::{Completion, CompletionResponse, CompletionSource, search::SearchQuery};
use project::{
Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, search::SearchQuery,
};
use settings::Settings;
use std::{
ops::Range,
@@ -275,6 +277,7 @@ impl MessageEditor {
Task::ready(Ok(vec![CompletionResponse {
completions: Vec::new(),
display_options: CompletionDisplayOptions::default(),
is_incomplete: false,
}]))
}
@@ -317,6 +320,7 @@ impl MessageEditor {
CompletionResponse {
is_incomplete: completions.len() >= LIMIT,
display_options: CompletionDisplayOptions::default(),
completions,
}
}

View File

@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources, SettingsUi};
use workspace::dock::DockPosition;
#[derive(Deserialize, Debug, SettingsUi)]
#[derive(Deserialize, Debug)]
pub struct CollaborationPanelSettings {
pub button: bool,
pub dock: DockPosition,
@@ -20,14 +20,14 @@ pub enum ChatPanelButton {
WhenInCall,
}
#[derive(Deserialize, Debug, SettingsUi)]
#[derive(Deserialize, Debug)]
pub struct ChatPanelSettings {
pub button: ChatPanelButton,
pub dock: DockPosition,
pub default_width: Pixels,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
pub struct ChatPanelSettingsContent {
/// When to show the panel button in the status bar.
///
@@ -43,14 +43,14 @@ pub struct ChatPanelSettingsContent {
pub default_width: Option<f32>,
}
#[derive(Deserialize, Debug, SettingsUi)]
#[derive(Deserialize, Debug)]
pub struct NotificationPanelSettings {
pub button: bool,
pub dock: DockPosition,
pub default_width: Pixels,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
pub struct PanelSettingsContent {
/// Whether to show the panel button in the status bar.
///

View File

@@ -25,7 +25,7 @@ use crate::{
};
const JSON_RPC_VERSION: &str = "2.0";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
// Standard JSON-RPC error codes
pub const PARSE_ERROR: i32 = -32700;
@@ -60,6 +60,7 @@ pub(crate) struct Client {
executor: BackgroundExecutor,
#[allow(dead_code)]
transport: Arc<dyn Transport>,
request_timeout: Option<Duration>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -143,6 +144,7 @@ pub struct ModelContextServerBinary {
pub executable: PathBuf,
pub args: Vec<String>,
pub env: Option<HashMap<String, String>>,
pub timeout: Option<u64>,
}
impl Client {
@@ -169,8 +171,9 @@ impl Client {
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(String::new);
let timeout = binary.timeout.map(Duration::from_millis);
let transport = Arc::new(StdioTransport::new(binary, working_directory, &cx)?);
Self::new(server_id, server_name.into(), transport, cx)
Self::new(server_id, server_name.into(), transport, timeout, cx)
}
/// Creates a new Client instance for a context server.
@@ -178,6 +181,7 @@ impl Client {
server_id: ContextServerId,
server_name: Arc<str>,
transport: Arc<dyn Transport>,
request_timeout: Option<Duration>,
cx: AsyncApp,
) -> Result<Self> {
let (outbound_tx, outbound_rx) = channel::unbounded::<String>();
@@ -237,6 +241,7 @@ impl Client {
io_tasks: Mutex::new(Some((input_task, output_task))),
output_done_rx: Mutex::new(Some(output_done_rx)),
transport,
request_timeout,
})
}
@@ -327,8 +332,13 @@ impl Client {
method: &str,
params: impl Serialize,
) -> Result<T> {
self.request_with(method, params, None, Some(REQUEST_TIMEOUT))
.await
self.request_with(
method,
params,
None,
self.request_timeout.or(Some(DEFAULT_REQUEST_TIMEOUT)),
)
.await
}
pub async fn request_with<T: DeserializeOwned>(

View File

@@ -34,6 +34,8 @@ pub struct ContextServerCommand {
pub path: PathBuf,
pub args: Vec<String>,
pub env: Option<HashMap<String, String>>,
/// Timeout for tool calls in milliseconds. Defaults to 60000 (60 seconds) if not specified.
pub timeout: Option<u64>,
}
impl std::fmt::Debug for ContextServerCommand {
@@ -123,6 +125,7 @@ impl ContextServer {
executable: Path::new(&command.path).to_path_buf(),
args: command.args.clone(),
env: command.env.clone(),
timeout: command.timeout,
},
working_directory,
cx.clone(),
@@ -131,6 +134,7 @@ impl ContextServer {
client::ContextServerId(self.id.0.clone()),
self.id().0,
transport.clone(),
None,
cx.clone(),
)?,
})

View File

@@ -164,6 +164,8 @@ pub enum ModelVendor {
OpenAI,
Google,
Anthropic,
#[serde(rename = "xAI")]
XAI,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]

View File

@@ -14,6 +14,8 @@ pub enum DebugPanelDockPosition {
#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy, SettingsUi)]
#[serde(default)]
// todo(settings_ui) @ben: I'm pretty sure not having the fields be optional here is a bug,
// it means the defaults will override previously set values if a single key is missing
#[settings_ui(group = "Debugger", path = "debugger")]
pub struct DebuggerSettings {
/// Determines the stepping granularity.

View File

@@ -1,9 +1,9 @@
use std::{rc::Rc, time::Duration};
use std::rc::Rc;
use collections::HashMap;
use gpui::{Animation, AnimationExt as _, Entity, Transformation, WeakEntity, percentage};
use gpui::{Entity, WeakEntity};
use project::debugger::session::{ThreadId, ThreadStatus};
use ui::{ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*};
use ui::{CommonAnimationExt, ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*};
use util::{maybe, truncate_and_trailoff};
use crate::{
@@ -152,11 +152,7 @@ impl DebugPanel {
Icon::new(IconName::ArrowCircle)
.size(IconSize::Small)
.color(Color::Muted)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
)
.with_rotate_animation(2)
.into_any_element()
} else {
match running_state.thread_status(cx).unwrap_or_default() {

View File

@@ -15,7 +15,7 @@ use gpui::{
use language::{Anchor, Buffer, CodeLabel, TextBufferSnapshot, ToOffset};
use menu::{Confirm, SelectNext, SelectPrevious};
use project::{
Completion, CompletionResponse,
Completion, CompletionDisplayOptions, CompletionResponse,
debugger::session::{CompletionsQuery, OutputToken, Session},
lsp_store::CompletionDocumentation,
search_history::{SearchHistory, SearchHistoryCursor},
@@ -685,6 +685,7 @@ impl ConsoleQueryBarCompletionProvider {
Ok(vec![project::CompletionResponse {
is_incomplete: completions.len() >= LIMIT,
display_options: CompletionDisplayOptions::default(),
completions,
}])
})
@@ -797,6 +798,7 @@ impl ConsoleQueryBarCompletionProvider {
Ok(vec![project::CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
is_incomplete: false,
}])
})

View File

@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::convert::TryFrom;
pub const DEEPSEEK_API_URL: &str = "https://api.deepseek.com";
pub const DEEPSEEK_API_URL: &str = "https://api.deepseek.com/v1";
#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
@@ -263,7 +263,7 @@ pub async fn stream_completion(
api_key: &str,
request: Request,
) -> Result<BoxStream<'static, Result<StreamResponse>>> {
let uri = format!("{api_url}/v1/chat/completions");
let uri = format!("{api_url}/chat/completions");
let request_builder = HttpRequest::builder()
.method(Method::POST)
.uri(uri)

View File

@@ -228,21 +228,29 @@ pub struct ShowCompletions {
pub struct HandleInput(pub String);
/// Deletes from the cursor to the end of the next word.
/// Stops before the end of the next word, if whitespace sequences of length >= 2 are encountered.
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
#[action(namespace = editor)]
#[serde(deny_unknown_fields)]
pub struct DeleteToNextWordEnd {
#[serde(default)]
pub ignore_newlines: bool,
// Whether to stop before the end of the next word, if language-defined bracket is encountered.
#[serde(default)]
pub ignore_brackets: bool,
}
/// Deletes from the cursor to the start of the previous word.
/// Stops before the start of the previous word, if whitespace sequences of length >= 2 are encountered.
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
#[action(namespace = editor)]
#[serde(deny_unknown_fields)]
pub struct DeleteToPreviousWordStart {
#[serde(default)]
pub ignore_newlines: bool,
// Whether to stop before the start of the previous word, if language-defined bracket is encountered.
#[serde(default)]
pub ignore_brackets: bool,
}
/// Folds all code blocks at the specified indentation level.

View File

@@ -11,9 +11,9 @@ use language::{Buffer, LanguageName, LanguageRegistry};
use markdown::{Markdown, MarkdownElement};
use multi_buffer::{Anchor, ExcerptId};
use ordered_float::OrderedFloat;
use project::CompletionSource;
use project::lsp_store::CompletionDocumentation;
use project::{CodeAction, Completion, TaskSourceKind};
use project::{CompletionDisplayOptions, CompletionSource};
use task::DebugScenario;
use task::TaskContext;
@@ -232,6 +232,7 @@ pub struct CompletionsMenu {
markdown_cache: Rc<RefCell<VecDeque<(MarkdownCacheKey, Entity<Markdown>)>>>,
language_registry: Option<Arc<LanguageRegistry>>,
language: Option<LanguageName>,
display_options: CompletionDisplayOptions,
snippet_sort_order: SnippetSortOrder,
}
@@ -271,6 +272,7 @@ impl CompletionsMenu {
is_incomplete: bool,
buffer: Entity<Buffer>,
completions: Box<[Completion]>,
display_options: CompletionDisplayOptions,
snippet_sort_order: SnippetSortOrder,
language_registry: Option<Arc<LanguageRegistry>>,
language: Option<LanguageName>,
@@ -304,6 +306,7 @@ impl CompletionsMenu {
markdown_cache: RefCell::new(VecDeque::new()).into(),
language_registry,
language,
display_options,
snippet_sort_order,
};
@@ -375,6 +378,7 @@ impl CompletionsMenu {
markdown_cache: RefCell::new(VecDeque::new()).into(),
language_registry: None,
language: None,
display_options: CompletionDisplayOptions::default(),
snippet_sort_order,
}
}
@@ -737,6 +741,33 @@ impl CompletionsMenu {
cx: &mut Context<Editor>,
) -> AnyElement {
let show_completion_documentation = self.show_completion_documentation;
let widest_completion_ix = if self.display_options.dynamic_width {
let completions = self.completions.borrow();
let widest_completion_ix = self
.entries
.borrow()
.iter()
.enumerate()
.max_by_key(|(_, mat)| {
let completion = &completions[mat.candidate_id];
let documentation = &completion.documentation;
let mut len = completion.label.text.chars().count();
if let Some(CompletionDocumentation::SingleLine(text)) = documentation {
if show_completion_documentation {
len += text.chars().count();
}
}
len
})
.map(|(ix, _)| ix);
drop(completions);
widest_completion_ix
} else {
None
};
let selected_item = self.selected_item;
let completions = self.completions.clone();
let entries = self.entries.clone();
@@ -863,7 +894,13 @@ impl CompletionsMenu {
.max_h(max_height_in_lines as f32 * window.line_height())
.track_scroll(self.scroll_handle.clone())
.with_sizing_behavior(ListSizingBehavior::Infer)
.w(rems(34.));
.map(|this| {
if self.display_options.dynamic_width {
this.with_width_from_item(widest_completion_ix)
} else {
this.w(rems(34.))
}
});
Popover::new().child(list).into_any_element()
}

View File

@@ -34,7 +34,6 @@ mod lsp_ext;
mod mouse_context_menu;
pub mod movement;
mod persistence;
mod proposed_changes_editor;
mod rust_analyzer_ext;
pub mod scroll;
mod selections_collection;
@@ -70,9 +69,7 @@ pub use multi_buffer::{
Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, PathKey,
RowInfo, ToOffset, ToPoint,
};
pub use proposed_changes_editor::{
ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
};
pub use text::Bias;
use ::git::{
@@ -147,21 +144,22 @@ use multi_buffer::{
use parking_lot::Mutex;
use persistence::DB;
use project::{
BreakpointWithPosition, CodeAction, Completion, CompletionIntent, CompletionResponse,
CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, Location, LocationLink,
PrepareRenameResponse, Project, ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind,
debugger::breakpoint_store::Breakpoint,
BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent,
CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint,
Location, LocationLink, PrepareRenameResponse, Project, ProjectItem, ProjectPath,
ProjectTransaction, TaskSourceKind,
debugger::{
breakpoint_store::{
BreakpointEditAction, BreakpointSessionState, BreakpointState, BreakpointStore,
BreakpointStoreEvent,
Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState,
BreakpointStore, BreakpointStoreEvent,
},
session::{Session, SessionEvent},
},
git_store::{GitStoreEvent, RepositoryEvent},
lsp_store::{CompletionDocumentation, FormatTrigger, LspFormatTarget, OpenLspBufferHandle},
project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter},
project_settings::{GitGutterSetting, ProjectSettings},
project_settings::{
DiagnosticSeverity, GitGutterSetting, GoToDiagnosticSeverityFilter, ProjectSettings,
},
};
use rand::{seq::SliceRandom, thread_rng};
use rpc::{ErrorCode, ErrorExt, proto::PeerId};
@@ -189,7 +187,6 @@ use std::{
sync::Arc,
time::{Duration, Instant},
};
use sum_tree::TreeMap;
use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
use text::{BufferId, FromAnchor, OffsetUtf16, Rope};
use theme::{
@@ -226,7 +223,7 @@ const MAX_SELECTION_HISTORY_LEN: usize = 1024;
pub(crate) const CURSORS_VISIBLE_FOR: Duration = Duration::from_millis(2000);
#[doc(hidden)]
pub const CODE_ACTIONS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
const SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
pub const SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
pub(crate) const CODE_ACTION_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) const FORMAT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -1059,8 +1056,8 @@ pub struct Editor {
placeholder_text: Option<Arc<str>>,
highlight_order: usize,
highlighted_rows: HashMap<TypeId, Vec<RowHighlight>>,
background_highlights: TreeMap<HighlightKey, BackgroundHighlight>,
gutter_highlights: TreeMap<TypeId, GutterHighlight>,
background_highlights: HashMap<HighlightKey, BackgroundHighlight>,
gutter_highlights: HashMap<TypeId, GutterHighlight>,
scrollbar_marker_state: ScrollbarMarkerState,
active_indent_guides_state: ActiveIndentGuidesState,
nav_history: Option<ItemNavHistory>,
@@ -2111,8 +2108,8 @@ impl Editor {
placeholder_text: None,
highlight_order: 0,
highlighted_rows: HashMap::default(),
background_highlights: TreeMap::default(),
gutter_highlights: TreeMap::default(),
background_highlights: HashMap::default(),
gutter_highlights: HashMap::default(),
scrollbar_marker_state: ScrollbarMarkerState::default(),
active_indent_guides_state: ActiveIndentGuidesState::default(),
nav_history: None,
@@ -5635,17 +5632,25 @@ impl Editor {
// that having one source with `is_incomplete: true` doesn't cause all to be re-queried.
let mut completions = Vec::new();
let mut is_incomplete = false;
let mut display_options: Option<CompletionDisplayOptions> = None;
if let Some(provider_responses) = provider_responses.await.log_err()
&& !provider_responses.is_empty()
{
for response in provider_responses {
completions.extend(response.completions);
is_incomplete = is_incomplete || response.is_incomplete;
match display_options.as_mut() {
None => {
display_options = Some(response.display_options);
}
Some(options) => options.merge(&response.display_options),
}
}
if completion_settings.words == WordsCompletionMode::Fallback {
words = Task::ready(BTreeMap::default());
}
}
let display_options = display_options.unwrap_or_default();
let mut words = words.await;
if let Some(word_to_exclude) = &word_to_exclude {
@@ -5687,6 +5692,7 @@ impl Editor {
is_incomplete,
buffer.clone(),
completions.into(),
display_options,
snippet_sort_order,
languages,
language,
@@ -6620,7 +6626,7 @@ impl Editor {
buffer_row: Some(point.row),
..Default::default()
};
let Some(blame_entry) = blame
let Some((buffer, blame_entry)) = blame
.update(cx, |blame, cx| blame.blame_for_rows(&[row_info], cx).next())
.flatten()
else {
@@ -6630,12 +6636,19 @@ impl Editor {
let anchor = self.selections.newest_anchor().head();
let position = self.to_pixel_point(anchor, &snapshot, window);
if let (Some(position), Some(last_bounds)) = (position, self.last_bounds) {
self.show_blame_popover(&blame_entry, position + last_bounds.origin, true, cx);
self.show_blame_popover(
buffer,
&blame_entry,
position + last_bounds.origin,
true,
cx,
);
};
}
fn show_blame_popover(
&mut self,
buffer: BufferId,
blame_entry: &BlameEntry,
position: gpui::Point<Pixels>,
ignore_timeout: bool,
@@ -6659,7 +6672,7 @@ impl Editor {
return;
};
let blame = blame.read(cx);
let details = blame.details_for_entry(&blame_entry);
let details = blame.details_for_entry(buffer, &blame_entry);
let markdown = cx.new(|cx| {
Markdown::new(
details
@@ -7744,6 +7757,11 @@ impl Editor {
return None;
}
if self.ime_transaction.is_some() {
self.discard_edit_prediction(false, cx);
return None;
}
let selection = self.selections.newest_anchor();
let cursor = selection.head();
let multibuffer = self.buffer.read(cx).snapshot(cx);
@@ -11816,6 +11834,18 @@ impl Editor {
let buffer = self.buffer.read(cx).snapshot(cx);
let selections = self.selections.all::<Point>(cx);
#[derive(Clone, Debug, PartialEq)]
enum CommentFormat {
/// single line comment, with prefix for line
Line(String),
/// single line within a block comment, with prefix for line
BlockLine(String),
/// a single line of a block comment that includes the initial delimiter
BlockCommentWithStart(BlockCommentConfig),
/// a single line of a block comment that includes the ending delimiter
BlockCommentWithEnd(BlockCommentConfig),
}
// Split selections to respect paragraph, indent, and comment prefix boundaries.
let wrap_ranges = selections.into_iter().flat_map(|selection| {
let mut non_blank_rows_iter = (selection.start.row..=selection.end.row)
@@ -11832,37 +11862,75 @@ impl Editor {
let language_scope = buffer.language_scope_at(selection.head());
let indent_and_prefix_for_row =
|row: u32| -> (IndentSize, Option<String>, Option<String>) {
|row: u32| -> (IndentSize, Option<CommentFormat>, Option<String>) {
let indent = buffer.indent_size_for_line(MultiBufferRow(row));
let (comment_prefix, rewrap_prefix) =
if let Some(language_scope) = &language_scope {
let indent_end = Point::new(row, indent.len);
let comment_prefix = language_scope
let (comment_prefix, rewrap_prefix) = if let Some(language_scope) =
&language_scope
{
let indent_end = Point::new(row, indent.len);
let line_end = Point::new(row, buffer.line_len(MultiBufferRow(row)));
let line_text_after_indent = buffer
.text_for_range(indent_end..line_end)
.collect::<String>();
let is_within_comment_override = buffer
.language_scope_at(indent_end)
.is_some_and(|scope| scope.override_name() == Some("comment"));
let comment_delimiters = if is_within_comment_override {
// we are within a comment syntax node, but we don't
// yet know what kind of comment: block, doc or line
match (
language_scope.documentation_comment(),
language_scope.block_comment(),
) {
(Some(config), _) | (_, Some(config))
if buffer.contains_str_at(indent_end, &config.start) =>
{
Some(CommentFormat::BlockCommentWithStart(config.clone()))
}
(Some(config), _) | (_, Some(config))
if line_text_after_indent.ends_with(config.end.as_ref()) =>
{
Some(CommentFormat::BlockCommentWithEnd(config.clone()))
}
(Some(config), _) | (_, Some(config))
if buffer.contains_str_at(indent_end, &config.prefix) =>
{
Some(CommentFormat::BlockLine(config.prefix.to_string()))
}
(_, _) => language_scope
.line_comment_prefixes()
.iter()
.find(|prefix| buffer.contains_str_at(indent_end, prefix))
.map(|prefix| CommentFormat::Line(prefix.to_string())),
}
} else {
// we not in an overridden comment node, but we may
// be within a non-overridden line comment node
language_scope
.line_comment_prefixes()
.iter()
.find(|prefix| buffer.contains_str_at(indent_end, prefix))
.map(|prefix| prefix.to_string());
let line_end = Point::new(row, buffer.line_len(MultiBufferRow(row)));
let line_text_after_indent = buffer
.text_for_range(indent_end..line_end)
.collect::<String>();
let rewrap_prefix = language_scope
.rewrap_prefixes()
.iter()
.find_map(|prefix_regex| {
prefix_regex.find(&line_text_after_indent).map(|mat| {
if mat.start() == 0 {
Some(mat.as_str().to_string())
} else {
None
}
})
})
.flatten();
(comment_prefix, rewrap_prefix)
} else {
(None, None)
.map(|prefix| CommentFormat::Line(prefix.to_string()))
};
let rewrap_prefix = language_scope
.rewrap_prefixes()
.iter()
.find_map(|prefix_regex| {
prefix_regex.find(&line_text_after_indent).map(|mat| {
if mat.start() == 0 {
Some(mat.as_str().to_string())
} else {
None
}
})
})
.flatten();
(comment_delimiters, rewrap_prefix)
} else {
(None, None)
};
(indent, comment_prefix, rewrap_prefix)
};
@@ -11873,22 +11941,22 @@ impl Editor {
let mut prev_row = first_row;
let (
mut current_range_indent,
mut current_range_comment_prefix,
mut current_range_comment_delimiters,
mut current_range_rewrap_prefix,
) = indent_and_prefix_for_row(first_row);
for row in non_blank_rows_iter.skip(1) {
let has_paragraph_break = row > prev_row + 1;
let (row_indent, row_comment_prefix, row_rewrap_prefix) =
let (row_indent, row_comment_delimiters, row_rewrap_prefix) =
indent_and_prefix_for_row(row);
let has_indent_change = row_indent != current_range_indent;
let has_comment_change = row_comment_prefix != current_range_comment_prefix;
let has_comment_change = row_comment_delimiters != current_range_comment_delimiters;
let has_boundary_change = has_comment_change
|| row_rewrap_prefix.is_some()
|| (has_indent_change && current_range_comment_prefix.is_some());
|| (has_indent_change && current_range_comment_delimiters.is_some());
if has_paragraph_break || has_boundary_change {
ranges.push((
@@ -11896,13 +11964,13 @@ impl Editor {
Point::new(current_range_start, 0)
..Point::new(prev_row, buffer.line_len(MultiBufferRow(prev_row))),
current_range_indent,
current_range_comment_prefix.clone(),
current_range_comment_delimiters.clone(),
current_range_rewrap_prefix.clone(),
from_empty_selection,
));
current_range_start = row;
current_range_indent = row_indent;
current_range_comment_prefix = row_comment_prefix;
current_range_comment_delimiters = row_comment_delimiters;
current_range_rewrap_prefix = row_rewrap_prefix;
}
prev_row = row;
@@ -11913,7 +11981,7 @@ impl Editor {
Point::new(current_range_start, 0)
..Point::new(prev_row, buffer.line_len(MultiBufferRow(prev_row))),
current_range_indent,
current_range_comment_prefix,
current_range_comment_delimiters,
current_range_rewrap_prefix,
from_empty_selection,
));
@@ -11927,7 +11995,7 @@ impl Editor {
for (
language_settings,
wrap_range,
indent_size,
mut indent_size,
comment_prefix,
rewrap_prefix,
from_empty_selection,
@@ -11947,16 +12015,26 @@ impl Editor {
let tab_size = language_settings.tab_size;
let (line_prefix, inside_comment) = match &comment_prefix {
Some(CommentFormat::Line(prefix) | CommentFormat::BlockLine(prefix)) => {
(Some(prefix.as_str()), true)
}
Some(CommentFormat::BlockCommentWithEnd(BlockCommentConfig { prefix, .. })) => {
(Some(prefix.as_ref()), true)
}
Some(CommentFormat::BlockCommentWithStart(BlockCommentConfig {
start: _,
end: _,
prefix,
tab_size,
})) => {
indent_size.len += tab_size;
(Some(prefix.as_ref()), true)
}
None => (None, false),
};
let indent_prefix = indent_size.chars().collect::<String>();
let mut line_prefix = indent_prefix.clone();
let mut inside_comment = false;
if let Some(prefix) = &comment_prefix {
line_prefix.push_str(prefix);
inside_comment = true;
}
if let Some(prefix) = &rewrap_prefix {
line_prefix.push_str(prefix);
}
let line_prefix = format!("{indent_prefix}{}", line_prefix.unwrap_or(""));
let allow_rewrap_based_on_language = match language_settings.allow_rewrap {
RewrapBehavior::InComments => inside_comment,
@@ -12001,6 +12079,8 @@ impl Editor {
let start_offset = start.to_offset(&buffer);
let end = Point::new(end_row, buffer.line_len(MultiBufferRow(end_row)));
let selection_text = buffer.text_for_range(start..end).collect::<String>();
let mut first_line_delimiter = None;
let mut last_line_delimiter = None;
let Some(lines_without_prefixes) = selection_text
.lines()
.enumerate()
@@ -12008,6 +12088,46 @@ impl Editor {
let line_trimmed = line.trim_start();
if rewrap_prefix.is_some() && ix > 0 {
Ok(line_trimmed)
} else if let Some(
CommentFormat::BlockCommentWithStart(BlockCommentConfig {
start,
prefix,
end,
tab_size,
})
| CommentFormat::BlockCommentWithEnd(BlockCommentConfig {
start,
prefix,
end,
tab_size,
}),
) = &comment_prefix
{
let line_trimmed = line_trimmed
.strip_prefix(start.as_ref())
.map(|s| {
let mut indent_size = indent_size;
indent_size.len -= tab_size;
let indent_prefix: String = indent_size.chars().collect();
first_line_delimiter = Some((indent_prefix, start));
s.trim_start()
})
.unwrap_or(line_trimmed);
let line_trimmed = line_trimmed
.strip_suffix(end.as_ref())
.map(|s| {
last_line_delimiter = Some(end);
s.trim_end()
})
.unwrap_or(line_trimmed);
let line_trimmed = line_trimmed
.strip_prefix(prefix.as_ref())
.unwrap_or(line_trimmed);
Ok(line_trimmed)
} else if let Some(CommentFormat::BlockLine(prefix)) = &comment_prefix {
line_trimmed.strip_prefix(prefix).with_context(|| {
format!("line did not start with prefix {prefix:?}: {line:?}")
})
} else {
line_trimmed
.strip_prefix(&line_prefix.trim_start())
@@ -12034,14 +12154,25 @@ impl Editor {
line_prefix.clone()
};
let wrapped_text = wrap_with_prefix(
line_prefix,
subsequent_lines_prefix,
lines_without_prefixes.join("\n"),
wrap_column,
tab_size,
options.preserve_existing_whitespace,
);
let wrapped_text = {
let mut wrapped_text = wrap_with_prefix(
line_prefix,
subsequent_lines_prefix,
lines_without_prefixes.join("\n"),
wrap_column,
tab_size,
options.preserve_existing_whitespace,
);
if let Some((indent, delimiter)) = first_line_delimiter {
wrapped_text = format!("{indent}{delimiter}\n{wrapped_text}");
}
if let Some(last_line) = last_line_delimiter {
wrapped_text = format!("{wrapped_text}\n{indent_prefix}{last_line}");
}
wrapped_text
};
// TODO: should always use char-based diff while still supporting cursor behavior that
// matches vim.
@@ -13019,11 +13150,17 @@ impl Editor {
this.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
if selection.is_empty() {
let cursor = if action.ignore_newlines {
let mut cursor = if action.ignore_newlines {
movement::previous_word_start(map, selection.head())
} else {
movement::previous_word_start_or_newline(map, selection.head())
};
cursor = movement::adjust_greedy_deletion(
map,
selection.head(),
cursor,
action.ignore_brackets,
);
selection.set_head(cursor, SelectionGoal::None);
}
});
@@ -13044,7 +13181,9 @@ impl Editor {
this.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
if selection.is_empty() {
let cursor = movement::previous_subword_start(map, selection.head());
let mut cursor = movement::previous_subword_start(map, selection.head());
cursor =
movement::adjust_greedy_deletion(map, selection.head(), cursor, false);
selection.set_head(cursor, SelectionGoal::None);
}
});
@@ -13120,11 +13259,17 @@ impl Editor {
this.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
if selection.is_empty() {
let cursor = if action.ignore_newlines {
let mut cursor = if action.ignore_newlines {
movement::next_word_end(map, selection.head())
} else {
movement::next_word_end_or_newline(map, selection.head())
};
cursor = movement::adjust_greedy_deletion(
map,
selection.head(),
cursor,
action.ignore_brackets,
);
selection.set_head(cursor, SelectionGoal::None);
}
});
@@ -13144,7 +13289,9 @@ impl Editor {
this.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
if selection.is_empty() {
let cursor = movement::next_subword_end(map, selection.head());
let mut cursor = movement::next_subword_end(map, selection.head());
cursor =
movement::adjust_greedy_deletion(map, selection.head(), cursor, false);
selection.set_head(cursor, SelectionGoal::None);
}
});
@@ -18943,7 +19090,7 @@ impl Editor {
let snapshot = self.snapshot(window, cx);
let cursor = self.selections.newest::<Point>(cx).head();
let (buffer, point, _) = snapshot.buffer_snapshot.point_to_buffer_point(cursor)?;
let blame_entry = blame
let (_, blame_entry) = blame
.update(cx, |blame, cx| {
blame
.blame_for_rows(
@@ -18958,7 +19105,7 @@ impl Editor {
})
.flatten()?;
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
let repo = blame.read(cx).repository(cx)?;
let repo = blame.read(cx).repository(cx, buffer.remote_id())?;
let workspace = self.workspace()?.downgrade();
renderer.open_blame_commit(blame_entry, repo, workspace, window, cx);
None
@@ -18994,18 +19141,17 @@ impl Editor {
cx: &mut Context<Self>,
) {
if let Some(project) = self.project() {
let Some(buffer) = self.buffer().read(cx).as_singleton() else {
return;
};
if buffer.read(cx).file().is_none() {
if let Some(buffer) = self.buffer().read(cx).as_singleton()
&& buffer.read(cx).file().is_none()
{
return;
}
let focused = self.focus_handle(cx).contains_focused(window, cx);
let project = project.clone();
let blame = cx.new(|cx| GitBlame::new(buffer, project, user_triggered, focused, cx));
let blame = cx
.new(|cx| GitBlame::new(self.buffer.clone(), project, user_triggered, focused, cx));
self.blame_subscription =
Some(cx.observe_in(&blame, window, |_, _, _, cx| cx.notify()));
self.blame = Some(blame);
@@ -19655,7 +19801,24 @@ impl Editor {
let buffer = &snapshot.buffer_snapshot;
let start = buffer.anchor_before(0);
let end = buffer.anchor_after(buffer.len());
self.background_highlights_in_range(start..end, &snapshot, cx.theme())
self.sorted_background_highlights_in_range(start..end, &snapshot, cx.theme())
}
#[cfg(any(test, feature = "test-support"))]
pub fn sorted_background_highlights_in_range(
&self,
search_range: Range<Anchor>,
display_snapshot: &DisplaySnapshot,
theme: &Theme,
) -> Vec<(Range<DisplayPoint>, Hsla)> {
let mut res = self.background_highlights_in_range(search_range, display_snapshot, theme);
res.sort_by(|a, b| {
a.0.start
.cmp(&b.0.start)
.then_with(|| a.0.end.cmp(&b.0.end))
.then_with(|| a.1.cmp(&b.1))
});
res
}
#[cfg(feature = "test-support")]
@@ -19720,6 +19883,9 @@ impl Editor {
.is_some_and(|(_, highlights)| !highlights.is_empty())
}
/// Returns all background highlights for a given range.
///
/// The order of highlights is not deterministic, do sort the ranges if needed for the logic.
pub fn background_highlights_in_range(
&self,
search_range: Range<Anchor>,
@@ -19758,84 +19924,6 @@ impl Editor {
results
}
pub fn background_highlight_row_ranges<T: 'static>(
&self,
search_range: Range<Anchor>,
display_snapshot: &DisplaySnapshot,
count: usize,
) -> Vec<RangeInclusive<DisplayPoint>> {
let mut results = Vec::new();
let Some((_, ranges)) = self
.background_highlights
.get(&HighlightKey::Type(TypeId::of::<T>()))
else {
return vec![];
};
let start_ix = match ranges.binary_search_by(|probe| {
let cmp = probe
.end
.cmp(&search_range.start, &display_snapshot.buffer_snapshot);
if cmp.is_gt() {
Ordering::Greater
} else {
Ordering::Less
}
}) {
Ok(i) | Err(i) => i,
};
let mut push_region = |start: Option<Point>, end: Option<Point>| {
if let (Some(start_display), Some(end_display)) = (start, end) {
results.push(
start_display.to_display_point(display_snapshot)
..=end_display.to_display_point(display_snapshot),
);
}
};
let mut start_row: Option<Point> = None;
let mut end_row: Option<Point> = None;
if ranges.len() > count {
return Vec::new();
}
for range in &ranges[start_ix..] {
if range
.start
.cmp(&search_range.end, &display_snapshot.buffer_snapshot)
.is_ge()
{
break;
}
let end = range.end.to_point(&display_snapshot.buffer_snapshot);
if let Some(current_row) = &end_row
&& end.row == current_row.row
{
continue;
}
let start = range.start.to_point(&display_snapshot.buffer_snapshot);
if start_row.is_none() {
assert_eq!(end_row, None);
start_row = Some(start);
end_row = Some(end);
continue;
}
if let Some(current_end) = end_row.as_mut() {
if start.row > current_end.row + 1 {
push_region(start_row, end_row);
start_row = Some(start);
end_row = Some(end);
} else {
// Merge two hunks.
*current_end = end;
}
} else {
unreachable!();
}
}
// We might still have a hunk that was not rendered (if there was a search hit on the last line)
push_region(start_row, end_row);
results
}
pub fn gutter_highlights_in_range(
&self,
search_range: Range<Anchor>,
@@ -20381,65 +20469,6 @@ impl Editor {
self.searchable
}
fn open_proposed_changes_editor(
&mut self,
_: &OpenProposedChangesEditor,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace() else {
cx.propagate();
return;
};
let selections = self.selections.all::<usize>(cx);
let multi_buffer = self.buffer.read(cx);
let multi_buffer_snapshot = multi_buffer.snapshot(cx);
let mut new_selections_by_buffer = HashMap::default();
for selection in selections {
for (buffer, range, _) in
multi_buffer_snapshot.range_to_buffer_ranges(selection.start..selection.end)
{
let mut range = range.to_point(buffer);
range.start.column = 0;
range.end.column = buffer.line_len(range.end.row);
new_selections_by_buffer
.entry(multi_buffer.buffer(buffer.remote_id()).unwrap())
.or_insert(Vec::new())
.push(range)
}
}
let proposed_changes_buffers = new_selections_by_buffer
.into_iter()
.map(|(buffer, ranges)| ProposedChangeLocation { buffer, ranges })
.collect::<Vec<_>>();
let proposed_changes_editor = cx.new(|cx| {
ProposedChangesEditor::new(
"Proposed changes",
proposed_changes_buffers,
self.project.clone(),
window,
cx,
)
});
window.defer(cx, move |window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(proposed_changes_editor),
true,
true,
None,
window,
cx,
);
});
});
});
}
pub fn open_excerpts_in_split(
&mut self,
_: &OpenExcerptsSplit,
@@ -22142,6 +22171,7 @@ fn snippet_completions(
if scopes.is_empty() {
return Task::ready(Ok(CompletionResponse {
completions: vec![],
display_options: CompletionDisplayOptions::default(),
is_incomplete: false,
}));
}
@@ -22166,6 +22196,7 @@ fn snippet_completions(
if last_word.is_empty() {
return Ok(CompletionResponse {
completions: vec![],
display_options: CompletionDisplayOptions::default(),
is_incomplete: true,
});
}
@@ -22287,6 +22318,7 @@ fn snippet_completions(
Ok(CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
is_incomplete,
})
})

View File

@@ -11,12 +11,13 @@ use util::serde::default_true;
/// Imports from the VSCode settings at
/// https://code.visualstudio.com/docs/reference/default-settings
#[derive(Deserialize, Clone, SettingsUi)]
#[derive(Deserialize, Clone)]
pub struct EditorSettings {
pub cursor_blink: bool,
pub cursor_shape: Option<CursorShape>,
pub current_line_highlight: CurrentLineHighlight,
pub selection_highlight: bool,
pub rounded_selection: bool,
pub lsp_highlight_debounce: u64,
pub hover_popover_enabled: bool,
pub hover_popover_delay: u64,
@@ -60,7 +61,9 @@ pub struct EditorSettings {
}
/// How to render LSP `textDocument/documentColor` colors in the editor.
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[derive(
Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi,
)]
#[serde(rename_all = "snake_case")]
pub enum DocumentColorsRenderMode {
/// Do not query and render document colors.
@@ -74,7 +77,7 @@ pub enum DocumentColorsRenderMode {
Background,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi)]
#[serde(rename_all = "snake_case")]
pub enum CurrentLineHighlight {
// Don't highlight the current line.
@@ -88,7 +91,7 @@ pub enum CurrentLineHighlight {
}
/// When to populate a new search's query based on the text under the cursor.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi)]
#[serde(rename_all = "snake_case")]
pub enum SeedQuerySetting {
/// Always populate the search query with the word under the cursor.
@@ -100,7 +103,9 @@ pub enum SeedQuerySetting {
}
/// What to do when multibuffer is double clicked in some of its excerpts (parts of singleton buffers).
#[derive(Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[derive(
Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi,
)]
#[serde(rename_all = "snake_case")]
pub enum DoubleClickInMultibuffer {
/// Behave as a regular buffer and select the whole word.
@@ -119,7 +124,9 @@ pub struct Jupyter {
pub enabled: bool,
}
#[derive(Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[derive(
Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi,
)]
#[serde(rename_all = "snake_case")]
pub struct JupyterContent {
/// Whether the Jupyter feature is enabled.
@@ -291,7 +298,9 @@ pub struct ScrollbarAxes {
}
/// Whether to allow drag and drop text selection in buffer.
#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[derive(
Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi,
)]
pub struct DragAndDropSelection {
/// When true, enables drag and drop text selection in buffer.
///
@@ -331,7 +340,7 @@ pub enum ScrollbarDiagnostics {
/// The key to use for adding multiple cursors
///
/// Default: alt
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi)]
#[serde(rename_all = "snake_case")]
pub enum MultiCursorModifier {
Alt,
@@ -342,7 +351,7 @@ pub enum MultiCursorModifier {
/// Whether the editor will scroll beyond the last line.
///
/// Default: one_page
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi)]
#[serde(rename_all = "snake_case")]
pub enum ScrollBeyondLastLine {
/// The editor will not scroll beyond the last line.
@@ -356,7 +365,9 @@ pub enum ScrollBeyondLastLine {
}
/// Default options for buffer and project search items.
#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[derive(
Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi,
)]
pub struct SearchSettings {
/// Whether to show the project search button in the status bar.
#[serde(default = "default_true")]
@@ -372,7 +383,9 @@ pub struct SearchSettings {
}
/// What to do when go to definition yields no results.
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[derive(
Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi,
)]
#[serde(rename_all = "snake_case")]
pub enum GoToDefinitionFallback {
/// Disables the fallback.
@@ -385,7 +398,9 @@ pub enum GoToDefinitionFallback {
/// Determines when the mouse cursor should be hidden in an editor or input box.
///
/// Default: on_typing_and_movement
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[derive(
Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi,
)]
#[serde(rename_all = "snake_case")]
pub enum HideMouseMode {
/// Never hide the mouse cursor
@@ -400,7 +415,9 @@ pub enum HideMouseMode {
/// Determines how snippets are sorted relative to other completion items.
///
/// Default: inline
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[derive(
Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi,
)]
#[serde(rename_all = "snake_case")]
pub enum SnippetSortOrder {
/// Place snippets at the top of the completion list
@@ -414,7 +431,8 @@ pub enum SnippetSortOrder {
None,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, SettingsUi)]
#[settings_ui(group = "Editor")]
pub struct EditorSettingsContent {
/// Whether the cursor blinks in the editor.
///
@@ -423,7 +441,7 @@ pub struct EditorSettingsContent {
/// Cursor shape for the default editor.
/// Can be "bar", "block", "underline", or "hollow".
///
/// Default: None
/// Default: bar
pub cursor_shape: Option<CursorShape>,
/// Determines when the mouse cursor should be hidden in an editor or input box.
///
@@ -441,6 +459,10 @@ pub struct EditorSettingsContent {
///
/// Default: true
pub selection_highlight: Option<bool>,
/// Whether the text selection should have rounded corners.
///
/// Default: true
pub rounded_selection: Option<bool>,
/// The debounce delay before querying highlights from the language
/// server based on the current cursor location.
///
@@ -596,7 +618,7 @@ pub struct EditorSettingsContent {
}
// Status bar related settings
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi)]
pub struct StatusBarContent {
/// Whether to display the active language button in the status bar.
///
@@ -609,7 +631,7 @@ pub struct StatusBarContent {
}
// Toolbar related settings
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi)]
pub struct ToolbarContent {
/// Whether to display breadcrumbs in the editor toolbar.
///
@@ -635,7 +657,9 @@ pub struct ToolbarContent {
}
/// Scrollbar related settings
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
#[derive(
Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default, SettingsUi,
)]
pub struct ScrollbarContent {
/// When to show the scrollbar in the editor.
///
@@ -670,7 +694,9 @@ pub struct ScrollbarContent {
}
/// Minimap related settings
#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
#[derive(
Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, SettingsUi,
)]
pub struct MinimapContent {
/// When to show the minimap in the editor.
///
@@ -718,7 +744,9 @@ pub struct ScrollbarAxesContent {
}
/// Gutter related settings
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[derive(
Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi,
)]
pub struct GutterContent {
/// Whether to show line numbers in the gutter.
///
@@ -794,6 +822,7 @@ impl Settings for EditorSettings {
"editor.selectionHighlight",
&mut current.selection_highlight,
);
vscode.bool_setting("editor.roundedSelection", &mut current.rounded_selection);
vscode.bool_setting("editor.hover.enabled", &mut current.hover_popover_enabled);
vscode.u64_setting("editor.hover.delay", &mut current.hover_popover_delay);

View File

@@ -2476,51 +2476,379 @@ async fn test_delete_to_beginning_of_line(cx: &mut TestAppContext) {
}
#[gpui::test]
fn test_delete_to_word_boundary(cx: &mut TestAppContext) {
async fn test_delete_to_word_boundary(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let editor = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple("one two three four", cx);
build_editor(buffer, window, cx)
});
let mut cx = EditorTestContext::new(cx).await;
_ = editor.update(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
// an empty selection - the preceding word fragment is deleted
DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2),
// characters selected - they are deleted
DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 12),
])
});
// For an empty selection, the preceding word fragment is deleted.
// For non-empty selections, only selected characters are deleted.
cx.set_state("onˇe two t«hreˇ»e four");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: false,
ignore_brackets: false,
},
window,
cx,
);
assert_eq!(editor.buffer.read(cx).read(cx).text(), "e two te four");
});
cx.assert_editor_state("ˇe two tˇe four");
_ = editor.update(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
// an empty selection - the following word fragment is deleted
DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3),
// characters selected - they are deleted
DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 10),
])
});
cx.set_state("e tˇwo te «fˇ»our");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: false,
ignore_brackets: false,
},
window,
cx,
);
assert_eq!(editor.buffer.read(cx).read(cx).text(), "e t te our");
});
cx.assert_editor_state("e tˇ te ˇour");
}
#[gpui::test]
async fn test_delete_whitespaces(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
cx.set_state("here is some text ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: false,
ignore_brackets: true,
},
window,
cx,
);
});
// Continuous whitespace sequences are removed entirely, words behind them are not affected by the deletion action.
cx.assert_editor_state("here is some textˇwith a space");
cx.set_state("here is some text ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: false,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("here is some textˇwith a space");
cx.set_state("here is some textˇ with a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: false,
ignore_brackets: true,
},
window,
cx,
);
});
// Same happens in the other direction.
cx.assert_editor_state("here is some textˇwith a space");
cx.set_state("here is some textˇ with a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: false,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("here is some textˇwith a space");
cx.set_state("here is some textˇ with a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("here is some textˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("here is some ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
// Single whitespaces are removed with the word behind them.
cx.assert_editor_state("here is ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("here ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
// Same happens in the other direction.
cx.assert_editor_state("ˇ a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("ˇ space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("ˇ");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("ˇ");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("ˇ");
}
#[gpui::test]
async fn test_delete_to_bracket(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let language = Arc::new(
Language::new(
LanguageConfig {
brackets: BracketPairConfig {
pairs: vec![
BracketPair {
start: "\"".to_string(),
end: "\"".to_string(),
close: true,
surround: true,
newline: false,
},
BracketPair {
start: "(".to_string(),
end: ")".to_string(),
close: true,
surround: true,
newline: true,
},
],
..BracketPairConfig::default()
},
..LanguageConfig::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
)
.with_brackets_query(
r#"
("(" @open ")" @close)
("\"" @open "\"" @close)
"#,
)
.unwrap(),
);
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
cx.set_state(r#"macro!("// ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
// Deletion stops before brackets if asked to not ignore them.
cx.assert_editor_state(r#"macro!("ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
// Deletion has to remove a single bracket and then stop again.
cx.assert_editor_state(r#"macro!(ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state(r#"macro!ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state(r#"ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state(r#"ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
// Brackets on the right are not paired anymore, hence deletion does not stop at them
cx.assert_editor_state(r#"ˇ");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state(r#"ˇ"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state(r#"ˇ"#);
cx.set_state(r#"macro!("// ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: true,
},
window,
cx,
);
});
cx.assert_editor_state(r#"macroˇCOMMENT");"#);
}
#[gpui::test]
@@ -2533,9 +2861,11 @@ fn test_delete_to_previous_word_start_or_newline(cx: &mut TestAppContext) {
});
let del_to_prev_word_start = DeleteToPreviousWordStart {
ignore_newlines: false,
ignore_brackets: false,
};
let del_to_prev_word_start_ignore_newlines = DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
};
_ = editor.update(cx, |editor, window, cx| {
@@ -2569,9 +2899,11 @@ fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) {
});
let del_to_next_word_end = DeleteToNextWordEnd {
ignore_newlines: false,
ignore_brackets: false,
};
let del_to_next_word_end_ignore_newlines = DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
};
_ = editor.update(cx, |editor, window, cx| {
@@ -2600,6 +2932,8 @@ fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) {
editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx);
assert_eq!(editor.buffer.read(cx).read(cx).text(), "\n four");
editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx);
assert_eq!(editor.buffer.read(cx).read(cx).text(), "four");
editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx);
assert_eq!(editor.buffer.read(cx).read(cx).text(), "");
});
}
@@ -5561,14 +5895,18 @@ async fn test_rewrap(cx: &mut TestAppContext) {
},
None,
));
let rust_language = Arc::new(Language::new(
LanguageConfig {
name: "Rust".into(),
line_comments: vec!["// ".into(), "/// ".into()],
..LanguageConfig::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
));
let rust_language = Arc::new(
Language::new(
LanguageConfig {
name: "Rust".into(),
line_comments: vec!["// ".into(), "/// ".into()],
..LanguageConfig::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
)
.with_override_query("[(line_comment)(block_comment)] @comment.inclusive")
.unwrap(),
);
let plaintext_language = Arc::new(Language::new(
LanguageConfig {
@@ -5884,6 +6222,411 @@ async fn test_rewrap(cx: &mut TestAppContext) {
}
}
#[gpui::test]
async fn test_rewrap_block_comments(cx: &mut TestAppContext) {
init_test(cx, |settings| {
settings.languages.0.extend([(
"Rust".into(),
LanguageSettingsContent {
allow_rewrap: Some(language_settings::RewrapBehavior::InComments),
preferred_line_length: Some(40),
..Default::default()
},
)])
});
let mut cx = EditorTestContext::new(cx).await;
let rust_lang = Arc::new(
Language::new(
LanguageConfig {
name: "Rust".into(),
line_comments: vec!["// ".into()],
block_comment: Some(BlockCommentConfig {
start: "/*".into(),
end: "*/".into(),
prefix: "* ".into(),
tab_size: 1,
}),
documentation_comment: Some(BlockCommentConfig {
start: "/**".into(),
end: "*/".into(),
prefix: "* ".into(),
tab_size: 1,
}),
..LanguageConfig::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
)
.with_override_query("[(line_comment) (block_comment)] @comment.inclusive")
.unwrap(),
);
// regular block comment
assert_rewrap(
indoc! {"
/*
*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
*/
/*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
"},
indoc! {"
/*
*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
/*
*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
"},
rust_lang.clone(),
&mut cx,
);
// indent is respected
assert_rewrap(
indoc! {"
{}
/*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
"},
indoc! {"
{}
/*
*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
"},
rust_lang.clone(),
&mut cx,
);
// short block comments with inline delimiters
assert_rewrap(
indoc! {"
/*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
/*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
*/
/*
*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
"},
indoc! {"
/*
*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
/*
*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
/*
*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
"},
rust_lang.clone(),
&mut cx,
);
// multiline block comment with inline start/end delimiters
assert_rewrap(
indoc! {"
/*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit. */
"},
indoc! {"
/*
*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
"},
rust_lang.clone(),
&mut cx,
);
// block comment rewrap still respects paragraph bounds
assert_rewrap(
indoc! {"
/*
*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
*
* Lorem ipsum dolor sit amet, consectetur adipiscing elit.
*/
"},
indoc! {"
/*
*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*
* Lorem ipsum dolor sit amet, consectetur adipiscing elit.
*/
"},
rust_lang.clone(),
&mut cx,
);
// documentation comments
assert_rewrap(
indoc! {"
/**ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
/**
*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
*/
"},
indoc! {"
/**
*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
/**
*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
"},
rust_lang.clone(),
&mut cx,
);
// different, adjacent comments
assert_rewrap(
indoc! {"
/**
*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
*/
/*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
//ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
"},
indoc! {"
/**
*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
/*
*ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
//ˇ Lorem ipsum dolor sit amet,
// consectetur adipiscing elit.
"},
rust_lang.clone(),
&mut cx,
);
// selection w/ single short block comment
assert_rewrap(
indoc! {"
«/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ»
"},
indoc! {"
«/*
* Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/ˇ»
"},
rust_lang.clone(),
&mut cx,
);
// rewrapping a single comment w/ abutting comments
assert_rewrap(
indoc! {"
/* ˇLorem ipsum dolor sit amet, consectetur adipiscing elit. */
/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
"},
indoc! {"
/*
* ˇLorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
"},
rust_lang.clone(),
&mut cx,
);
// selection w/ non-abutting short block comments
assert_rewrap(
indoc! {"
«/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ»
"},
indoc! {"
«/*
* Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
/*
* Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/ˇ»
"},
rust_lang.clone(),
&mut cx,
);
// selection of multiline block comments
assert_rewrap(
indoc! {"
«/* Lorem ipsum dolor sit amet,
* consectetur adipiscing elit. */ˇ»
"},
indoc! {"
«/*
* Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/ˇ»
"},
rust_lang.clone(),
&mut cx,
);
// partial selection of multiline block comments
assert_rewrap(
indoc! {"
«/* Lorem ipsum dolor sit amet,ˇ»
* consectetur adipiscing elit. */
/* Lorem ipsum dolor sit amet,
«* consectetur adipiscing elit. */ˇ»
"},
indoc! {"
«/*
* Lorem ipsum dolor sit amet,ˇ»
* consectetur adipiscing elit. */
/* Lorem ipsum dolor sit amet,
«* consectetur adipiscing elit.
*/ˇ»
"},
rust_lang.clone(),
&mut cx,
);
// selection w/ abutting short block comments
// TODO: should not be combined; should rewrap as 2 comments
assert_rewrap(
indoc! {"
«/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ»
"},
// desired behavior:
// indoc! {"
// «/*
// * Lorem ipsum dolor sit amet,
// * consectetur adipiscing elit.
// */
// /*
// * Lorem ipsum dolor sit amet,
// * consectetur adipiscing elit.
// */ˇ»
// "},
// actual behaviour:
indoc! {"
«/*
* Lorem ipsum dolor sit amet,
* consectetur adipiscing elit. Lorem
* ipsum dolor sit amet, consectetur
* adipiscing elit.
*/ˇ»
"},
rust_lang.clone(),
&mut cx,
);
// TODO: same as above, but with delimiters on separate line
// assert_rewrap(
// indoc! {"
// «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit.
// */
// /*
// * Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ»
// "},
// // desired:
// // indoc! {"
// // «/*
// // * Lorem ipsum dolor sit amet,
// // * consectetur adipiscing elit.
// // */
// // /*
// // * Lorem ipsum dolor sit amet,
// // * consectetur adipiscing elit.
// // */ˇ»
// // "},
// // actual: (but with trailing w/s on the empty lines)
// indoc! {"
// «/*
// * Lorem ipsum dolor sit amet,
// * consectetur adipiscing elit.
// *
// */
// /*
// *
// * Lorem ipsum dolor sit amet,
// * consectetur adipiscing elit.
// */ˇ»
// "},
// rust_lang.clone(),
// &mut cx,
// );
// TODO these are unhandled edge cases; not correct, just documenting known issues
assert_rewrap(
indoc! {"
/*
//ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
*/
/*
//ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
/*ˇ Lorem ipsum dolor sit amet */ /* consectetur adipiscing elit. */
"},
// desired:
// indoc! {"
// /*
// *ˇ Lorem ipsum dolor sit amet,
// * consectetur adipiscing elit.
// */
// /*
// *ˇ Lorem ipsum dolor sit amet,
// * consectetur adipiscing elit.
// */
// /*
// *ˇ Lorem ipsum dolor sit amet
// */ /* consectetur adipiscing elit. */
// "},
// actual:
indoc! {"
/*
//ˇ Lorem ipsum dolor sit amet,
// consectetur adipiscing elit.
*/
/*
* //ˇ Lorem ipsum dolor sit amet,
* consectetur adipiscing elit.
*/
/*
*ˇ Lorem ipsum dolor sit amet */ /*
* consectetur adipiscing elit.
*/
"},
rust_lang,
&mut cx,
);
#[track_caller]
fn assert_rewrap(
unwrapped_text: &str,
wrapped_text: &str,
language: Arc<Language>,
cx: &mut EditorTestContext,
) {
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
cx.set_state(unwrapped_text);
cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx));
cx.assert_editor_state(wrapped_text);
}
}
#[gpui::test]
async fn test_hard_wrap(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -15044,37 +15787,34 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) {
);
let snapshot = editor.snapshot(window, cx);
let mut highlighted_ranges = editor.background_highlights_in_range(
let highlighted_ranges = editor.sorted_background_highlights_in_range(
anchor_range(Point::new(3, 4)..Point::new(7, 4)),
&snapshot,
cx.theme(),
);
// Enforce a consistent ordering based on color without relying on the ordering of the
// highlight's `TypeId` which is non-executor.
highlighted_ranges.sort_unstable_by_key(|(_, color)| *color);
assert_eq!(
highlighted_ranges,
&[
(
DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 4),
Hsla::red(),
),
(
DisplayPoint::new(DisplayRow(6), 3)..DisplayPoint::new(DisplayRow(6), 5),
Hsla::red(),
),
(
DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 5),
Hsla::green(),
),
(
DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 4),
Hsla::red(),
),
(
DisplayPoint::new(DisplayRow(5), 3)..DisplayPoint::new(DisplayRow(5), 6),
Hsla::green(),
),
(
DisplayPoint::new(DisplayRow(6), 3)..DisplayPoint::new(DisplayRow(6), 5),
Hsla::red(),
),
]
);
assert_eq!(
editor.background_highlights_in_range(
editor.sorted_background_highlights_in_range(
anchor_range(Point::new(5, 6)..Point::new(6, 4)),
&snapshot,
cx.theme(),

View File

@@ -117,6 +117,7 @@ struct SelectionLayout {
struct InlineBlameLayout {
element: AnyElement,
bounds: Bounds<Pixels>,
buffer_id: BufferId,
entry: BlameEntry,
}
@@ -439,7 +440,6 @@ impl EditorElement {
register_action(editor, window, Editor::toggle_code_actions);
register_action(editor, window, Editor::open_excerpts);
register_action(editor, window, Editor::open_excerpts_in_split);
register_action(editor, window, Editor::open_proposed_changes_editor);
register_action(editor, window, Editor::toggle_soft_wrap);
register_action(editor, window, Editor::toggle_tab_bar);
register_action(editor, window, Editor::toggle_line_numbers);
@@ -1157,7 +1157,7 @@ impl EditorElement {
cx.notify();
}
if let Some((bounds, blame_entry)) = &position_map.inline_blame_bounds {
if let Some((bounds, buffer_id, blame_entry)) = &position_map.inline_blame_bounds {
let mouse_over_inline_blame = bounds.contains(&event.position);
let mouse_over_popover = editor
.inline_blame_popover
@@ -1170,7 +1170,7 @@ impl EditorElement {
.is_some_and(|state| state.keyboard_grace);
if mouse_over_inline_blame || mouse_over_popover {
editor.show_blame_popover(blame_entry, event.position, false, cx);
editor.show_blame_popover(*buffer_id, blame_entry, event.position, false, cx);
} else if !keyboard_grace {
editor.hide_blame_popover(cx);
}
@@ -2454,7 +2454,7 @@ impl EditorElement {
padding * em_width
};
let entry = blame
let (buffer_id, entry) = blame
.update(cx, |blame, cx| {
blame.blame_for_rows(&[*row_info], cx).next()
})
@@ -2489,13 +2489,22 @@ impl EditorElement {
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
let bounds = Bounds::new(absolute_offset, size);
self.layout_blame_entry_popover(entry.clone(), blame, line_height, text_hitbox, window, cx);
self.layout_blame_entry_popover(
entry.clone(),
blame,
line_height,
text_hitbox,
row_info.buffer_id?,
window,
cx,
);
element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), window, cx);
Some(InlineBlameLayout {
element,
bounds,
buffer_id,
entry,
})
}
@@ -2506,6 +2515,7 @@ impl EditorElement {
blame: Entity<GitBlame>,
line_height: Pixels,
text_hitbox: &Hitbox,
buffer: BufferId,
window: &mut Window,
cx: &mut App,
) {
@@ -2530,6 +2540,7 @@ impl EditorElement {
popover_state.markdown,
workspace,
&blame,
buffer,
window,
cx,
)
@@ -2604,14 +2615,16 @@ impl EditorElement {
.into_iter()
.enumerate()
.flat_map(|(ix, blame_entry)| {
let (buffer_id, blame_entry) = blame_entry?;
let mut element = render_blame_entry(
ix,
&blame,
blame_entry?,
blame_entry,
&self.style,
&mut last_used_color,
self.editor.clone(),
workspace.clone(),
buffer_id,
blame_renderer.clone(),
cx,
)?;
@@ -3270,6 +3283,10 @@ impl EditorElement {
if rows.start >= rows.end {
return Vec::new();
}
if !base_background.is_opaque() {
// We don't actually know what color is behind this editor.
return Vec::new();
}
let highlight_iter = highlight_ranges.iter().cloned();
let selection_iter = selections.iter().flat_map(|(player_color, layouts)| {
let color = player_color.selection;
@@ -6063,7 +6080,7 @@ impl EditorElement {
};
self.paint_lines_background(layout, window, cx);
let invisible_display_ranges = self.paint_highlights(layout, window);
let invisible_display_ranges = self.paint_highlights(layout, window, cx);
self.paint_document_colors(layout, window);
self.paint_lines(&invisible_display_ranges, layout, window, cx);
self.paint_redactions(layout, window);
@@ -6085,6 +6102,7 @@ impl EditorElement {
&mut self,
layout: &mut EditorLayout,
window: &mut Window,
cx: &mut App,
) -> SmallVec<[Range<DisplayPoint>; 32]> {
window.paint_layer(layout.position_map.text_hitbox.bounds, |window| {
let mut invisible_display_ranges = SmallVec::<[Range<DisplayPoint>; 32]>::new();
@@ -6101,7 +6119,11 @@ impl EditorElement {
);
}
let corner_radius = 0.15 * layout.position_map.line_height;
let corner_radius = if EditorSettings::get_global(cx).rounded_selection {
0.15 * layout.position_map.line_height
} else {
Pixels::ZERO
};
for (player_color, selections) in &layout.selections {
for selection in selections.iter() {
@@ -7389,12 +7411,13 @@ fn render_blame_entry_popover(
markdown: Entity<Markdown>,
workspace: WeakEntity<Workspace>,
blame: &Entity<GitBlame>,
buffer: BufferId,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
let blame = blame.read(cx);
let repository = blame.repository(cx)?;
let repository = blame.repository(cx, buffer)?;
renderer.render_blame_entry_popover(
blame_entry,
scroll_handle,
@@ -7415,6 +7438,7 @@ fn render_blame_entry(
last_used_color: &mut Option<(PlayerColor, Oid)>,
editor: Entity<Editor>,
workspace: Entity<Workspace>,
buffer: BufferId,
renderer: Arc<dyn BlameRenderer>,
cx: &mut App,
) -> Option<AnyElement> {
@@ -7435,8 +7459,8 @@ fn render_blame_entry(
last_used_color.replace((sha_color, blame_entry.sha));
let blame = blame.read(cx);
let details = blame.details_for_entry(&blame_entry);
let repository = blame.repository(cx)?;
let details = blame.details_for_entry(buffer, &blame_entry);
let repository = blame.repository(cx, buffer)?;
renderer.render_blame_entry(
&style.text,
blame_entry,
@@ -8739,7 +8763,7 @@ impl Element for EditorElement {
return None;
}
let blame = editor.blame.as_ref()?;
let blame_entry = blame
let (_, blame_entry) = blame
.update(cx, |blame, cx| {
let row_infos =
snapshot.row_infos(snapshot.longest_row()).next()?;
@@ -9289,7 +9313,7 @@ impl Element for EditorElement {
text_hitbox: text_hitbox.clone(),
inline_blame_bounds: inline_blame_layout
.as_ref()
.map(|layout| (layout.bounds, layout.entry.clone())),
.map(|layout| (layout.bounds, layout.buffer_id, layout.entry.clone())),
display_hunks: display_hunks.clone(),
diff_hunk_control_bounds,
});
@@ -9949,7 +9973,7 @@ pub(crate) struct PositionMap {
pub snapshot: EditorSnapshot,
pub text_hitbox: Hitbox,
pub gutter_hitbox: Hitbox,
pub inline_blame_bounds: Option<(Bounds<Pixels>, BlameEntry)>,
pub inline_blame_bounds: Option<(Bounds<Pixels>, BufferId, BlameEntry)>,
pub display_hunks: Vec<(DisplayDiffHunk, Option<Hitbox>)>,
pub diff_hunk_control_bounds: Vec<(DisplayRow, Bounds<Pixels>)>,
}
@@ -10969,7 +10993,7 @@ mod tests {
#[gpui::test]
fn test_merge_overlapping_ranges() {
let base_bg = Hsla::default();
let base_bg = Hsla::white();
let color1 = Hsla {
h: 0.0,
s: 0.5,
@@ -11039,7 +11063,7 @@ mod tests {
#[gpui::test]
fn test_bg_segments_per_row() {
let base_bg = Hsla::default();
let base_bg = Hsla::white();
// Case A: selection spans three display rows: row 1 [5, end), full row 2, row 3 [0, 7)
{

View File

@@ -10,16 +10,18 @@ use gpui::{
AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
TextStyle, WeakEntity, Window,
};
use language::{Bias, Buffer, BufferSnapshot, Edit};
use itertools::Itertools;
use language::{Bias, BufferSnapshot, Edit};
use markdown::Markdown;
use multi_buffer::RowInfo;
use multi_buffer::{MultiBuffer, RowInfo};
use project::{
Project, ProjectItem,
Project, ProjectItem as _,
git_store::{GitStoreEvent, Repository, RepositoryEvent},
};
use smallvec::SmallVec;
use std::{sync::Arc, time::Duration};
use sum_tree::SumTree;
use text::BufferId;
use workspace::Workspace;
#[derive(Clone, Debug, Default)]
@@ -63,16 +65,19 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
}
}
pub struct GitBlame {
project: Entity<Project>,
buffer: Entity<Buffer>,
struct GitBlameBuffer {
entries: SumTree<GitBlameEntry>,
commit_details: HashMap<Oid, ParsedCommitMessage>,
buffer_snapshot: BufferSnapshot,
buffer_edits: text::Subscription,
commit_details: HashMap<Oid, ParsedCommitMessage>,
}
pub struct GitBlame {
project: Entity<Project>,
multi_buffer: WeakEntity<MultiBuffer>,
buffers: HashMap<BufferId, GitBlameBuffer>,
task: Task<Result<()>>,
focused: bool,
generated: bool,
changed_while_blurred: bool,
user_triggered: bool,
regenerate_on_edit_task: Task<Result<()>>,
@@ -184,44 +189,44 @@ impl gpui::Global for GlobalBlameRenderer {}
impl GitBlame {
pub fn new(
buffer: Entity<Buffer>,
multi_buffer: Entity<MultiBuffer>,
project: Entity<Project>,
user_triggered: bool,
focused: bool,
cx: &mut Context<Self>,
) -> Self {
let entries = SumTree::from_item(
GitBlameEntry {
rows: buffer.read(cx).max_point().row + 1,
blame: None,
let multi_buffer_subscription = cx.subscribe(
&multi_buffer,
|git_blame, multi_buffer, event, cx| match event {
multi_buffer::Event::DirtyChanged => {
if !multi_buffer.read(cx).is_dirty(cx) {
git_blame.generate(cx);
}
}
multi_buffer::Event::ExcerptsAdded { .. }
| multi_buffer::Event::ExcerptsEdited { .. } => git_blame.regenerate_on_edit(cx),
_ => {}
},
&(),
);
let buffer_subscriptions = cx.subscribe(&buffer, |this, buffer, event, cx| match event {
language::BufferEvent::DirtyChanged => {
if !buffer.read(cx).is_dirty() {
this.generate(cx);
}
}
language::BufferEvent::Edited => {
this.regenerate_on_edit(cx);
}
_ => {}
});
let project_subscription = cx.subscribe(&project, {
let buffer = buffer.clone();
let multi_buffer = multi_buffer.downgrade();
move |this, _, event, cx| {
move |git_blame, _, event, cx| {
if let project::Event::WorktreeUpdatedEntries(_, updated) = event {
let project_entry_id = buffer.read(cx).entry_id(cx);
let Some(multi_buffer) = multi_buffer.upgrade() else {
return;
};
let project_entry_id = multi_buffer
.read(cx)
.as_singleton()
.and_then(|it| it.read(cx).entry_id(cx));
if updated
.iter()
.any(|(_, entry_id, _)| project_entry_id == Some(*entry_id))
{
log::debug!("Updated buffers. Regenerating blame data...",);
this.generate(cx);
git_blame.generate(cx);
}
}
}
@@ -239,24 +244,17 @@ impl GitBlame {
_ => {}
});
let buffer_snapshot = buffer.read(cx).snapshot();
let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
let mut this = Self {
project,
buffer,
buffer_snapshot,
entries,
buffer_edits,
multi_buffer: multi_buffer.downgrade(),
buffers: HashMap::default(),
user_triggered,
focused,
changed_while_blurred: false,
commit_details: HashMap::default(),
task: Task::ready(Ok(())),
generated: false,
regenerate_on_edit_task: Task::ready(Ok(())),
_regenerate_subscriptions: vec![
buffer_subscriptions,
multi_buffer_subscription,
project_subscription,
git_store_subscription,
],
@@ -265,56 +263,63 @@ impl GitBlame {
this
}
pub fn repository(&self, cx: &App) -> Option<Entity<Repository>> {
pub fn repository(&self, cx: &App, id: BufferId) -> Option<Entity<Repository>> {
self.project
.read(cx)
.git_store()
.read(cx)
.repository_and_path_for_buffer_id(self.buffer.read(cx).remote_id(), cx)
.repository_and_path_for_buffer_id(id, cx)
.map(|(repo, _)| repo)
}
pub fn has_generated_entries(&self) -> bool {
self.generated
!self.buffers.is_empty()
}
pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<ParsedCommitMessage> {
self.commit_details.get(&entry.sha).cloned()
pub fn details_for_entry(
&self,
buffer: BufferId,
entry: &BlameEntry,
) -> Option<ParsedCommitMessage> {
self.buffers
.get(&buffer)?
.commit_details
.get(&entry.sha)
.cloned()
}
pub fn blame_for_rows<'a>(
&'a mut self,
rows: &'a [RowInfo],
cx: &App,
) -> impl 'a + Iterator<Item = Option<BlameEntry>> {
self.sync(cx);
let buffer_id = self.buffer_snapshot.remote_id();
let mut cursor = self.entries.cursor::<u32>(&());
cx: &'a mut App,
) -> impl Iterator<Item = Option<(BufferId, BlameEntry)>> + use<'a> {
rows.iter().map(move |info| {
let row = info
.buffer_row
.filter(|_| info.buffer_id == Some(buffer_id))?;
cursor.seek_forward(&row, Bias::Right);
cursor.item()?.blame.clone()
let buffer_id = info.buffer_id?;
self.sync(cx, buffer_id);
let buffer_row = info.buffer_row?;
let mut cursor = self.buffers.get(&buffer_id)?.entries.cursor::<u32>(&());
cursor.seek_forward(&buffer_row, Bias::Right);
Some((buffer_id, cursor.item()?.blame.clone()?))
})
}
pub fn max_author_length(&mut self, cx: &App) -> usize {
self.sync(cx);
pub fn max_author_length(&mut self, cx: &mut App) -> usize {
let mut max_author_length = 0;
self.sync_all(cx);
for entry in self.entries.iter() {
let author_len = entry
.blame
.as_ref()
.and_then(|entry| entry.author.as_ref())
.map(|author| author.len());
if let Some(author_len) = author_len
&& author_len > max_author_length
{
max_author_length = author_len;
for buffer in self.buffers.values() {
for entry in buffer.entries.iter() {
let author_len = entry
.blame
.as_ref()
.and_then(|entry| entry.author.as_ref())
.map(|author| author.len());
if let Some(author_len) = author_len
&& author_len > max_author_length
{
max_author_length = author_len;
}
}
}
@@ -336,22 +341,48 @@ impl GitBlame {
}
}
fn sync(&mut self, cx: &App) {
let edits = self.buffer_edits.consume();
let new_snapshot = self.buffer.read(cx).snapshot();
fn sync_all(&mut self, cx: &mut App) {
let Some(multi_buffer) = self.multi_buffer.upgrade() else {
return;
};
multi_buffer
.read(cx)
.excerpt_buffer_ids()
.into_iter()
.for_each(|id| self.sync(cx, id));
}
fn sync(&mut self, cx: &mut App, buffer_id: BufferId) {
let Some(blame_buffer) = self.buffers.get_mut(&buffer_id) else {
return;
};
let Some(buffer) = self
.multi_buffer
.upgrade()
.and_then(|multi_buffer| multi_buffer.read(cx).buffer(buffer_id))
else {
return;
};
let edits = blame_buffer.buffer_edits.consume();
let new_snapshot = buffer.read(cx).snapshot();
let mut row_edits = edits
.into_iter()
.map(|edit| {
let old_point_range = self.buffer_snapshot.offset_to_point(edit.old.start)
..self.buffer_snapshot.offset_to_point(edit.old.end);
let old_point_range = blame_buffer.buffer_snapshot.offset_to_point(edit.old.start)
..blame_buffer.buffer_snapshot.offset_to_point(edit.old.end);
let new_point_range = new_snapshot.offset_to_point(edit.new.start)
..new_snapshot.offset_to_point(edit.new.end);
if old_point_range.start.column
== self.buffer_snapshot.line_len(old_point_range.start.row)
== blame_buffer
.buffer_snapshot
.line_len(old_point_range.start.row)
&& (new_snapshot.chars_at(edit.new.start).next() == Some('\n')
|| self.buffer_snapshot.line_len(old_point_range.end.row) == 0)
|| blame_buffer
.buffer_snapshot
.line_len(old_point_range.end.row)
== 0)
{
Edit {
old: old_point_range.start.row + 1..old_point_range.end.row + 1,
@@ -375,7 +406,7 @@ impl GitBlame {
.peekable();
let mut new_entries = SumTree::default();
let mut cursor = self.entries.cursor::<u32>(&());
let mut cursor = blame_buffer.entries.cursor::<u32>(&());
while let Some(mut edit) = row_edits.next() {
while let Some(next_edit) = row_edits.peek() {
@@ -433,17 +464,28 @@ impl GitBlame {
new_entries.append(cursor.suffix(), &());
drop(cursor);
self.buffer_snapshot = new_snapshot;
self.entries = new_entries;
blame_buffer.buffer_snapshot = new_snapshot;
blame_buffer.entries = new_entries;
}
#[cfg(test)]
fn check_invariants(&mut self, cx: &mut Context<Self>) {
self.sync(cx);
assert_eq!(
self.entries.summary().rows,
self.buffer.read(cx).max_point().row + 1
);
self.sync_all(cx);
for (&id, buffer) in &self.buffers {
assert_eq!(
buffer.entries.summary().rows,
self.multi_buffer
.upgrade()
.unwrap()
.read(cx)
.buffer(id)
.unwrap()
.read(cx)
.max_point()
.row
+ 1
);
}
}
fn generate(&mut self, cx: &mut Context<Self>) {
@@ -451,62 +493,105 @@ impl GitBlame {
self.changed_while_blurred = true;
return;
}
let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());
let snapshot = self.buffer.read(cx).snapshot();
let blame = self.project.update(cx, |project, cx| {
project.blame_buffer(&self.buffer, None, cx)
let Some(multi_buffer) = self.multi_buffer.upgrade() else {
return Vec::new();
};
multi_buffer
.read(cx)
.all_buffer_ids()
.into_iter()
.filter_map(|id| {
let buffer = multi_buffer.read(cx).buffer(id)?;
let snapshot = buffer.read(cx).snapshot();
let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
let blame_buffer = project.blame_buffer(&buffer, None, cx);
Some((id, snapshot, buffer_edits, blame_buffer))
})
.collect::<Vec<_>>()
});
let provider_registry = GitHostingProviderRegistry::default_global(cx);
self.task = cx.spawn(async move |this, cx| {
let result = cx
let (result, errors) = cx
.background_spawn({
let snapshot = snapshot.clone();
async move {
let Some(Blame {
entries,
messages,
remote_url,
}) = blame.await?
else {
return Ok(None);
};
let mut res = vec![];
let mut errors = vec![];
for (id, snapshot, buffer_edits, blame) in blame {
match blame.await {
Ok(Some(Blame {
entries,
messages,
remote_url,
})) => {
let entries = build_blame_entry_sum_tree(
entries,
snapshot.max_point().row,
);
let commit_details = parse_commit_messages(
messages,
remote_url,
provider_registry.clone(),
)
.await;
let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
let commit_details =
parse_commit_messages(messages, remote_url, provider_registry).await;
anyhow::Ok(Some((entries, commit_details)))
res.push((
id,
snapshot,
buffer_edits,
Some(entries),
commit_details,
));
}
Ok(None) => {
res.push((id, snapshot, buffer_edits, None, Default::default()))
}
Err(e) => errors.push(e),
}
}
(res, errors)
}
})
.await;
this.update(cx, |this, cx| match result {
Ok(None) => {
// Nothing to do, e.g. no repository found
this.update(cx, |this, cx| {
this.buffers.clear();
for (id, snapshot, buffer_edits, entries, commit_details) in result {
let Some(entries) = entries else {
continue;
};
this.buffers.insert(
id,
GitBlameBuffer {
buffer_edits,
buffer_snapshot: snapshot,
entries,
commit_details,
},
);
}
Ok(Some((entries, commit_details))) => {
this.buffer_edits = buffer_edits;
this.buffer_snapshot = snapshot;
this.entries = entries;
this.commit_details = commit_details;
this.generated = true;
cx.notify();
cx.notify();
if !errors.is_empty() {
this.project.update(cx, |_, cx| {
if this.user_triggered {
log::error!("failed to get git blame data: {errors:?}");
let notification = errors
.into_iter()
.format_with(",", |e, f| f(&format_args!("{:#}", e)))
.to_string();
cx.emit(project::Event::Toast {
notification_id: "git-blame".into(),
message: notification,
});
} else {
// If we weren't triggered by a user, we just log errors in the background, instead of sending
// notifications.
log::debug!("failed to get git blame data: {errors:?}");
}
})
}
Err(error) => this.project.update(cx, |_, cx| {
if this.user_triggered {
log::error!("failed to get git blame data: {error:?}");
let notification = format!("{:#}", error).trim().to_string();
cx.emit(project::Event::Toast {
notification_id: "git-blame".into(),
message: notification,
});
} else {
// If we weren't triggered by a user, we just log errors in the background, instead of sending
// notifications.
log::debug!("failed to get git blame data: {error:?}");
}
}),
})
});
}
@@ -520,7 +605,7 @@ impl GitBlame {
this.update(cx, |this, cx| {
this.generate(cx);
})
})
});
}
}
@@ -659,6 +744,9 @@ mod tests {
)
.collect::<Vec<_>>(),
expected
.into_iter()
.map(|it| Some((buffer_id, it?)))
.collect::<Vec<_>>()
);
}
@@ -705,6 +793,7 @@ mod tests {
})
.await
.unwrap();
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let blame = cx.new(|cx| GitBlame::new(buffer.clone(), project.clone(), true, true, cx));
@@ -785,6 +874,7 @@ mod tests {
.await
.unwrap();
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
@@ -806,14 +896,14 @@ mod tests {
)
.collect::<Vec<_>>(),
vec![
Some(blame_entry("1b1b1b", 0..1)),
Some(blame_entry("0d0d0d", 1..2)),
Some(blame_entry("3a3a3a", 2..3)),
Some((buffer_id, blame_entry("1b1b1b", 0..1))),
Some((buffer_id, blame_entry("0d0d0d", 1..2))),
Some((buffer_id, blame_entry("3a3a3a", 2..3))),
None,
None,
Some(blame_entry("3a3a3a", 5..6)),
Some(blame_entry("0d0d0d", 6..7)),
Some(blame_entry("3a3a3a", 7..8)),
Some((buffer_id, blame_entry("3a3a3a", 5..6))),
Some((buffer_id, blame_entry("0d0d0d", 6..7))),
Some((buffer_id, blame_entry("3a3a3a", 7..8))),
]
);
// Subset of lines
@@ -831,8 +921,8 @@ mod tests {
)
.collect::<Vec<_>>(),
vec![
Some(blame_entry("0d0d0d", 1..2)),
Some(blame_entry("3a3a3a", 2..3)),
Some((buffer_id, blame_entry("0d0d0d", 1..2))),
Some((buffer_id, blame_entry("3a3a3a", 2..3))),
None
]
);
@@ -852,7 +942,7 @@ mod tests {
cx
)
.collect::<Vec<_>>(),
vec![Some(blame_entry("0d0d0d", 1..2)), None, None]
vec![Some((buffer_id, blame_entry("0d0d0d", 1..2))), None, None]
);
});
}
@@ -895,6 +985,7 @@ mod tests {
.await
.unwrap();
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
@@ -1061,8 +1152,9 @@ mod tests {
})
.await
.unwrap();
let mbuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
let git_blame = cx.new(|cx| GitBlame::new(mbuffer.clone(), project, false, true, cx));
cx.executor().run_until_parked();
git_blame.update(cx, |blame, cx| blame.check_invariants(cx));

View File

@@ -289,12 +289,114 @@ pub fn previous_word_start_or_newline(map: &DisplaySnapshot, point: DisplayPoint
let classifier = map.buffer_snapshot.char_classifier_at(raw_point);
find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| {
(classifier.kind(left) != classifier.kind(right) && !right.is_whitespace())
(classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(right))
|| left == '\n'
|| right == '\n'
})
}
/// Text movements are too greedy, making deletions too greedy too.
/// Makes deletions more ergonomic by potentially reducing the deletion range based on its text contents:
/// * whitespace sequences with length >= 2 stop the deletion after removal (despite movement jumping over the word behind the whitespaces)
/// * brackets stop the deletion after removal (despite movement currently not accounting for these and jumping over)
pub fn adjust_greedy_deletion(
map: &DisplaySnapshot,
delete_from: DisplayPoint,
delete_until: DisplayPoint,
ignore_brackets: bool,
) -> DisplayPoint {
if delete_from == delete_until {
return delete_until;
}
let is_backward = delete_from > delete_until;
let delete_range = if is_backward {
map.display_point_to_point(delete_until, Bias::Left)
.to_offset(&map.buffer_snapshot)
..map
.display_point_to_point(delete_from, Bias::Right)
.to_offset(&map.buffer_snapshot)
} else {
map.display_point_to_point(delete_from, Bias::Left)
.to_offset(&map.buffer_snapshot)
..map
.display_point_to_point(delete_until, Bias::Right)
.to_offset(&map.buffer_snapshot)
};
let trimmed_delete_range = if ignore_brackets {
delete_range
} else {
let brackets_in_delete_range = map
.buffer_snapshot
.bracket_ranges(delete_range.clone())
.into_iter()
.flatten()
.flat_map(|(left_bracket, right_bracket)| {
[
left_bracket.start,
left_bracket.end,
right_bracket.start,
right_bracket.end,
]
})
.filter(|&bracket| delete_range.start < bracket && bracket < delete_range.end);
let closest_bracket = if is_backward {
brackets_in_delete_range.max()
} else {
brackets_in_delete_range.min()
};
if is_backward {
closest_bracket.unwrap_or(delete_range.start)..delete_range.end
} else {
delete_range.start..closest_bracket.unwrap_or(delete_range.end)
}
};
let mut whitespace_sequences = Vec::new();
let mut current_offset = trimmed_delete_range.start;
let mut whitespace_sequence_length = 0;
let mut whitespace_sequence_start = 0;
for ch in map
.buffer_snapshot
.text_for_range(trimmed_delete_range.clone())
.flat_map(str::chars)
{
if ch.is_whitespace() {
if whitespace_sequence_length == 0 {
whitespace_sequence_start = current_offset;
}
whitespace_sequence_length += 1;
} else {
if whitespace_sequence_length >= 2 {
whitespace_sequences.push((whitespace_sequence_start, current_offset));
}
whitespace_sequence_start = 0;
whitespace_sequence_length = 0;
}
current_offset += ch.len_utf8();
}
if whitespace_sequence_length >= 2 {
whitespace_sequences.push((whitespace_sequence_start, current_offset));
}
let closest_whitespace_end = if is_backward {
whitespace_sequences.last().map(|&(start, _)| start)
} else {
whitespace_sequences.first().map(|&(_, end)| end)
};
closest_whitespace_end
.unwrap_or_else(|| {
if is_backward {
trimmed_delete_range.start
} else {
trimmed_delete_range.end
}
})
.to_display_point(map)
}
/// Returns a position of the previous subword boundary, where a subword is defined as a run of
/// word characters of the same "subkind" - where subcharacter kinds are '_' character,
/// lowerspace characters and uppercase characters.

View File

@@ -1,516 +0,0 @@
use crate::{ApplyAllDiffHunks, Editor, EditorEvent, SelectionEffects, SemanticsProvider};
use buffer_diff::BufferDiff;
use collections::HashSet;
use futures::{channel::mpsc, future::join_all};
use gpui::{App, Entity, EventEmitter, Focusable, Render, Subscription, Task};
use language::{Buffer, BufferEvent, Capability};
use multi_buffer::{ExcerptRange, MultiBuffer};
use project::Project;
use smol::stream::StreamExt;
use std::{any::TypeId, ops::Range, rc::Rc, time::Duration};
use text::ToOffset;
use ui::{ButtonLike, KeyBinding, prelude::*};
use workspace::{
Item, ItemHandle as _, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
item::SaveOptions, searchable::SearchableItemHandle,
};
pub struct ProposedChangesEditor {
editor: Entity<Editor>,
multibuffer: Entity<MultiBuffer>,
title: SharedString,
buffer_entries: Vec<BufferEntry>,
_recalculate_diffs_task: Task<Option<()>>,
recalculate_diffs_tx: mpsc::UnboundedSender<RecalculateDiff>,
}
pub struct ProposedChangeLocation<T> {
pub buffer: Entity<Buffer>,
pub ranges: Vec<Range<T>>,
}
struct BufferEntry {
base: Entity<Buffer>,
branch: Entity<Buffer>,
_subscription: Subscription,
}
pub struct ProposedChangesEditorToolbar {
current_editor: Option<Entity<ProposedChangesEditor>>,
}
struct RecalculateDiff {
buffer: Entity<Buffer>,
debounce: bool,
}
/// A provider of code semantics for branch buffers.
///
/// Requests in edited regions will return nothing, but requests in unchanged
/// regions will be translated into the base buffer's coordinates.
struct BranchBufferSemanticsProvider(Rc<dyn SemanticsProvider>);
impl ProposedChangesEditor {
pub fn new<T: Clone + ToOffset>(
title: impl Into<SharedString>,
locations: Vec<ProposedChangeLocation<T>>,
project: Option<Entity<Project>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let (recalculate_diffs_tx, mut recalculate_diffs_rx) = mpsc::unbounded();
let mut this = Self {
editor: cx.new(|cx| {
let mut editor = Editor::for_multibuffer(multibuffer.clone(), project, window, cx);
editor.set_expand_all_diff_hunks(cx);
editor.set_completion_provider(None);
editor.clear_code_action_providers();
editor.set_semantics_provider(
editor
.semantics_provider()
.map(|provider| Rc::new(BranchBufferSemanticsProvider(provider)) as _),
);
editor
}),
multibuffer,
title: title.into(),
buffer_entries: Vec::new(),
recalculate_diffs_tx,
_recalculate_diffs_task: cx.spawn_in(window, async move |this, cx| {
let mut buffers_to_diff = HashSet::default();
while let Some(mut recalculate_diff) = recalculate_diffs_rx.next().await {
buffers_to_diff.insert(recalculate_diff.buffer);
while recalculate_diff.debounce {
cx.background_executor()
.timer(Duration::from_millis(50))
.await;
let mut had_further_changes = false;
while let Ok(next_recalculate_diff) = recalculate_diffs_rx.try_next() {
let next_recalculate_diff = next_recalculate_diff?;
recalculate_diff.debounce &= next_recalculate_diff.debounce;
buffers_to_diff.insert(next_recalculate_diff.buffer);
had_further_changes = true;
}
if !had_further_changes {
break;
}
}
let recalculate_diff_futures = this
.update(cx, |this, cx| {
buffers_to_diff
.drain()
.filter_map(|buffer| {
let buffer = buffer.read(cx);
let base_buffer = buffer.base_buffer()?;
let buffer = buffer.text_snapshot();
let diff =
this.multibuffer.read(cx).diff_for(buffer.remote_id())?;
Some(diff.update(cx, |diff, cx| {
diff.set_base_text_buffer(base_buffer.clone(), buffer, cx)
}))
})
.collect::<Vec<_>>()
})
.ok()?;
join_all(recalculate_diff_futures).await;
}
None
}),
};
this.reset_locations(locations, window, cx);
this
}
pub fn branch_buffer_for_base(&self, base_buffer: &Entity<Buffer>) -> Option<Entity<Buffer>> {
self.buffer_entries.iter().find_map(|entry| {
if &entry.base == base_buffer {
Some(entry.branch.clone())
} else {
None
}
})
}
pub fn set_title(&mut self, title: SharedString, cx: &mut Context<Self>) {
self.title = title;
cx.notify();
}
pub fn reset_locations<T: Clone + ToOffset>(
&mut self,
locations: Vec<ProposedChangeLocation<T>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Undo all branch changes
for entry in &self.buffer_entries {
let base_version = entry.base.read(cx).version();
entry.branch.update(cx, |buffer, cx| {
let undo_counts = buffer
.operations()
.iter()
.filter_map(|(timestamp, _)| {
if !base_version.observed(*timestamp) {
Some((*timestamp, u32::MAX))
} else {
None
}
})
.collect();
buffer.undo_operations(undo_counts, cx);
});
}
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.clear(cx);
});
let mut buffer_entries = Vec::new();
let mut new_diffs = Vec::new();
for location in locations {
let branch_buffer;
if let Some(ix) = self
.buffer_entries
.iter()
.position(|entry| entry.base == location.buffer)
{
let entry = self.buffer_entries.remove(ix);
branch_buffer = entry.branch.clone();
buffer_entries.push(entry);
} else {
branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx));
new_diffs.push(cx.new(|cx| {
let mut diff = BufferDiff::new(&branch_buffer.read(cx).snapshot(), cx);
let _ = diff.set_base_text_buffer(
location.buffer.clone(),
branch_buffer.read(cx).text_snapshot(),
cx,
);
diff
}));
buffer_entries.push(BufferEntry {
branch: branch_buffer.clone(),
base: location.buffer.clone(),
_subscription: cx.subscribe(&branch_buffer, Self::on_buffer_event),
});
}
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.push_excerpts(
branch_buffer,
location
.ranges
.into_iter()
.map(|range| ExcerptRange::new(range)),
cx,
);
});
}
self.buffer_entries = buffer_entries;
self.editor.update(cx, |editor, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
selections.refresh()
});
editor.buffer.update(cx, |buffer, cx| {
for diff in new_diffs {
buffer.add_diff(diff, cx)
}
})
});
}
pub fn recalculate_all_buffer_diffs(&self) {
for (ix, entry) in self.buffer_entries.iter().enumerate().rev() {
self.recalculate_diffs_tx
.unbounded_send(RecalculateDiff {
buffer: entry.branch.clone(),
debounce: ix > 0,
})
.ok();
}
}
fn on_buffer_event(
&mut self,
buffer: Entity<Buffer>,
event: &BufferEvent,
_cx: &mut Context<Self>,
) {
if let BufferEvent::Operation { .. } = event {
self.recalculate_diffs_tx
.unbounded_send(RecalculateDiff {
buffer,
debounce: true,
})
.ok();
}
}
}
impl Render for ProposedChangesEditor {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.key_context("ProposedChangesEditor")
.child(self.editor.clone())
}
}
impl Focusable for ProposedChangesEditor {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
self.editor.focus_handle(cx)
}
}
impl EventEmitter<EditorEvent> for ProposedChangesEditor {}
impl Item for ProposedChangesEditor {
type Event = EditorEvent;
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::Diff))
}
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
self.title.clone()
}
fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
) -> Option<gpui::AnyView> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.to_any())
} else if type_id == TypeId::of::<Editor>() {
Some(self.editor.to_any())
} else {
None
}
}
fn added_to_workspace(
&mut self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
Item::added_to_workspace(editor, workspace, window, cx)
});
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor
.update(cx, |editor, cx| editor.deactivated(window, cx));
}
fn navigate(
&mut self,
data: Box<dyn std::any::Any>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.editor
.update(cx, |editor, cx| Item::navigate(editor, data, window, cx))
}
fn set_nav_history(
&mut self,
nav_history: workspace::ItemNavHistory,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
Item::set_nav_history(editor, nav_history, window, cx)
});
}
fn can_save(&self, cx: &App) -> bool {
self.editor.read(cx).can_save(cx)
}
fn save(
&mut self,
options: SaveOptions,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<anyhow::Result<()>> {
self.editor.update(cx, |editor, cx| {
Item::save(editor, options, project, window, cx)
})
}
}
impl ProposedChangesEditorToolbar {
pub fn new() -> Self {
Self {
current_editor: None,
}
}
fn get_toolbar_item_location(&self) -> ToolbarItemLocation {
if self.current_editor.is_some() {
ToolbarItemLocation::PrimaryRight
} else {
ToolbarItemLocation::Hidden
}
}
}
impl Render for ProposedChangesEditorToolbar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let button_like = ButtonLike::new("apply-changes").child(Label::new("Apply All"));
match &self.current_editor {
Some(editor) => {
let focus_handle = editor.focus_handle(cx);
let keybinding =
KeyBinding::for_action_in(&ApplyAllDiffHunks, &focus_handle, window, cx)
.map(|binding| binding.into_any_element());
button_like.children(keybinding).on_click({
move |_event, window, cx| {
focus_handle.dispatch_action(&ApplyAllDiffHunks, window, cx)
}
})
}
None => button_like.disabled(true),
}
}
}
impl EventEmitter<ToolbarItemEvent> for ProposedChangesEditorToolbar {}
impl ToolbarItemView for ProposedChangesEditorToolbar {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn workspace::ItemHandle>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> workspace::ToolbarItemLocation {
self.current_editor =
active_pane_item.and_then(|item| item.downcast::<ProposedChangesEditor>());
self.get_toolbar_item_location()
}
}
impl BranchBufferSemanticsProvider {
fn to_base(
&self,
buffer: &Entity<Buffer>,
positions: &[text::Anchor],
cx: &App,
) -> Option<Entity<Buffer>> {
let base_buffer = buffer.read(cx).base_buffer()?;
let version = base_buffer.read(cx).version();
if positions
.iter()
.any(|position| !version.observed(position.timestamp))
{
return None;
}
Some(base_buffer)
}
}
impl SemanticsProvider for BranchBufferSemanticsProvider {
fn hover(
&self,
buffer: &Entity<Buffer>,
position: text::Anchor,
cx: &mut App,
) -> Option<Task<Option<Vec<project::Hover>>>> {
let buffer = self.to_base(buffer, &[position], cx)?;
self.0.hover(&buffer, position, cx)
}
fn inlay_hints(
&self,
buffer: Entity<Buffer>,
range: Range<text::Anchor>,
cx: &mut App,
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
let buffer = self.to_base(&buffer, &[range.start, range.end], cx)?;
self.0.inlay_hints(buffer, range, cx)
}
fn inline_values(
&self,
_: Entity<Buffer>,
_: Range<text::Anchor>,
_: &mut App,
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
None
}
fn resolve_inlay_hint(
&self,
hint: project::InlayHint,
buffer: Entity<Buffer>,
server_id: lsp::LanguageServerId,
cx: &mut App,
) -> Option<Task<anyhow::Result<project::InlayHint>>> {
let buffer = self.to_base(&buffer, &[], cx)?;
self.0.resolve_inlay_hint(hint, buffer, server_id, cx)
}
fn supports_inlay_hints(&self, buffer: &Entity<Buffer>, cx: &mut App) -> bool {
if let Some(buffer) = self.to_base(buffer, &[], cx) {
self.0.supports_inlay_hints(&buffer, cx)
} else {
false
}
}
fn document_highlights(
&self,
buffer: &Entity<Buffer>,
position: text::Anchor,
cx: &mut App,
) -> Option<Task<anyhow::Result<Vec<project::DocumentHighlight>>>> {
let buffer = self.to_base(buffer, &[position], cx)?;
self.0.document_highlights(&buffer, position, cx)
}
fn definitions(
&self,
buffer: &Entity<Buffer>,
position: text::Anchor,
kind: crate::GotoDefinitionKind,
cx: &mut App,
) -> Option<Task<anyhow::Result<Option<Vec<project::LocationLink>>>>> {
let buffer = self.to_base(buffer, &[position], cx)?;
self.0.definitions(&buffer, position, kind, cx)
}
fn range_for_rename(
&self,
_: &Entity<Buffer>,
_: text::Anchor,
_: &mut App,
) -> Option<Task<anyhow::Result<Option<Range<text::Anchor>>>>> {
None
}
fn perform_rename(
&self,
_: &Entity<Buffer>,
_: text::Anchor,
_: String,
_: &mut App,
) -> Option<Task<anyhow::Result<project::ProjectTransaction>>> {
None
}
}

View File

@@ -108,6 +108,10 @@ pub struct ClaudeCodeFeatureFlag;
impl FeatureFlag for ClaudeCodeFeatureFlag {
const NAME: &'static str = "claude-code";
fn enabled_for_all() -> bool {
true
}
}
pub trait FeatureFlagViewExt<V: 'static> {

View File

@@ -3,7 +3,7 @@ use schemars::JsonSchema;
use serde_derive::{Deserialize, Serialize};
use settings::{Settings, SettingsSources, SettingsUi};
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, SettingsUi)]
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
pub struct FileFinderSettings {
pub file_icons: bool,
pub modal_max_width: Option<FileFinderWidth>,
@@ -11,7 +11,7 @@ pub struct FileFinderSettings {
pub include_ignored: Option<bool>,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
pub struct FileFinderSettingsContent {
/// Whether to show file icons in the file finder.
///

View File

@@ -107,6 +107,18 @@ impl GitRepository for FakeGitRepository {
.boxed()
}
fn diff_to_commit(
&self,
_commit: String,
_cx: AsyncApp,
) -> BoxFuture<'_, Result<git::repository::CommitDiff>> {
unimplemented!()
}
fn merge_base(&self, _commit_a: String, _commit_b: String) -> BoxFuture<'_, Option<String>> {
unimplemented!()
}
fn load_commit(
&self,
_commit: String,

View File

@@ -309,6 +309,8 @@ pub trait GitRepository: Send + Sync {
/// Also returns `None` for symlinks.
fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
fn merge_base(&self, commit_a: String, commit_b: String) -> BoxFuture<'_, Option<String>>;
fn set_index_text(
&self,
path: RepoPath,
@@ -360,6 +362,7 @@ pub trait GitRepository: Send + Sync {
fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
fn diff_to_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
/// Returns the absolute path to the repository. For worktrees, this will be the path to the
@@ -614,6 +617,115 @@ impl GitRepository for RealGitRepository {
.boxed()
}
fn merge_base(&self, commit_a: String, commit_b: String) -> BoxFuture<'_, Option<String>> {
let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
else {
return future::ready(None).boxed();
};
let git = GitBinary::new(
self.git_binary_path.clone(),
working_directory,
self.executor.clone(),
);
async move {
let merge_base = git
.run(&["merge-base", &commit_a, &commit_b])
.await
.log_err()?;
Some(merge_base.to_string())
}
.boxed()
}
fn diff_to_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
else {
return future::ready(Err(anyhow!("no working directory"))).boxed();
};
cx.background_spawn(async move {
let diff_output = util::command::new_std_command("git")
.current_dir(&working_directory)
.args([
"--no-optional-locks",
"diff",
"--format=%P",
"-z",
"--no-renames",
"--name-status",
])
.arg(&commit)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.context("starting git show process")?;
let diff_stdout = String::from_utf8_lossy(&diff_output.stdout);
dbg!(&diff_stdout);
let changes = parse_git_diff_name_status(&diff_stdout);
let mut cat_file_process = util::command::new_std_command("git")
.current_dir(&working_directory)
.args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("starting git cat-file process")?;
use std::io::Write as _;
let mut files = Vec::<CommitFile>::new();
let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
let mut info_line = String::new();
let mut newline = [b'\0'];
for (path, status_code) in changes {
match status_code {
StatusCode::Modified => {
writeln!(&mut stdin, "{commit}:{}", path.display())?;
}
StatusCode::Added => {
files.push(CommitFile {
path: path.into(),
old_text: None,
new_text: None,
});
continue;
}
StatusCode::Deleted => {
writeln!(&mut stdin, "{commit}:{}", path.display())?;
}
_ => continue,
}
stdin.flush()?;
info_line.clear();
stdout.read_line(&mut info_line)?;
dbg!(&info_line);
let len = info_line.trim_end().parse().with_context(|| {
format!("invalid object size output from cat-file {info_line}")
})?;
let mut text = vec![0; len];
stdout.read_exact(&mut text)?;
stdout.read_exact(&mut newline)?;
let text = String::from_utf8_lossy(&text).to_string();
dbg!(&text);
files.push(CommitFile {
path: path.into(),
old_text: Some(text),
new_text: None,
})
}
Ok(CommitDiff { files })
})
.boxed()
}
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
else {

View File

@@ -341,7 +341,6 @@ impl PickerDelegate for BranchListDelegate {
};
picker
.update(cx, |picker, _| {
#[allow(clippy::nonminimal_bool)]
if !query.is_empty()
&& !matches
.first()

View File

@@ -3,7 +3,7 @@ use crate::commit_modal::CommitModal;
use crate::commit_tooltip::CommitTooltip;
use crate::commit_view::CommitView;
use crate::git_panel_settings::StatusStyle;
use crate::project_diff::{self, Diff, ProjectDiff};
use crate::project_diff::{self, Diff, DiffBaseKind, ProjectDiff};
use crate::remote_output::{self, RemoteAction, SuccessMessage};
use crate::{branch_picker, picker_prompt, render_remote_button};
use crate::{
@@ -31,11 +31,11 @@ use git::{
UnstageAll,
};
use gpui::{
Action, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner,
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext,
ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, Point,
PromptLevel, ScrollStrategy, Subscription, Task, Transformation, UniformListScrollHandle,
WeakEntity, actions, anchored, deferred, percentage, uniform_list,
Action, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner, DismissEvent, Entity,
EventEmitter, FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior,
ListSizingBehavior, MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy,
Subscription, Task, UniformListScrollHandle, WeakEntity, actions, anchored, deferred,
uniform_list,
};
use itertools::Itertools;
use language::{Buffer, File};
@@ -63,8 +63,8 @@ use std::{collections::HashSet, sync::Arc, time::Duration, usize};
use strum::{IntoEnumIterator, VariantNames};
use time::OffsetDateTime;
use ui::{
Checkbox, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize, PopoverMenu, Scrollbar,
ScrollbarState, SplitButton, Tooltip, prelude::*,
Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize,
PopoverMenu, Scrollbar, ScrollbarState, SplitButton, Tooltip, prelude::*,
};
use util::{ResultExt, TryFutureExt, maybe};
use workspace::SERIALIZATION_THROTTLE_TIME;
@@ -937,7 +937,13 @@ impl GitPanel {
self.workspace
.update(cx, |workspace, cx| {
ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
ProjectDiff::deploy_at(
workspace,
DiffBaseKind::Head,
Some(entry.clone()),
window,
cx,
);
})
.ok();
self.focus_handle.focus(window);
@@ -3088,13 +3094,7 @@ impl GitPanel {
Icon::new(IconName::ArrowCircle)
.size(IconSize::XSmall)
.color(Color::Info)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(percentage(delta)))
},
),
.with_rotate_animation(2),
)
.child(
Label::new("Generating Commit...")

View File

@@ -36,7 +36,7 @@ pub enum StatusStyle {
LabelColor,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
pub struct GitPanelSettingsContent {
/// Whether to show the panel button in the status bar.
///
@@ -77,7 +77,7 @@ pub struct GitPanelSettingsContent {
pub collapse_untracked_diff: Option<bool>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, SettingsUi)]
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct GitPanelSettings {
pub button: bool,
pub dock: DockPosition,

View File

@@ -5,7 +5,7 @@ use crate::{
remote_button::{render_publish_button, render_push_button},
};
use anyhow::Result;
use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
use buffer_diff::{BufferDiff, BufferDiffSnapshot, DiffHunkSecondaryStatus};
use collections::HashSet;
use editor::{
Editor, EditorEvent, SelectionEffects,
@@ -17,7 +17,7 @@ use futures::StreamExt;
use git::{
Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
status::FileStatus,
status::{FileStatus, TrackedStatus},
};
use gpui::{
Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
@@ -30,8 +30,11 @@ use project::{
git_store::{GitStore, GitStoreEvent, RepositoryEvent},
};
use settings::{Settings, SettingsStore};
use std::any::{Any, TypeId};
use std::ops::Range;
use std::{
any::{Any, TypeId},
sync::Arc,
};
use theme::ActiveTheme;
use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider};
use util::ResultExt as _;
@@ -48,7 +51,9 @@ actions!(
/// Shows the diff between the working directory and the index.
Diff,
/// Adds files to the git staging area.
Add
Add,
/// Shows the diff between the working directory and the default branch.
DiffToDefaultBranch,
]
);
@@ -61,10 +66,17 @@ pub struct ProjectDiff {
focus_handle: FocusHandle,
update_needed: postage::watch::Sender<()>,
pending_scroll: Option<PathKey>,
diff_base_kind: DiffBaseKind,
_task: Task<Result<()>>,
_subscription: Subscription,
}
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum DiffBaseKind {
Head,
MergeBaseOfDefaultBranch,
}
#[derive(Debug)]
struct DiffBuffer {
path_key: PathKey,
@@ -80,6 +92,7 @@ const NEW_NAMESPACE: u32 = 3;
impl ProjectDiff {
pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
workspace.register_action(Self::deploy);
workspace.register_action(Self::diff_to_default_branch);
workspace.register_action(|workspace, _: &Add, window, cx| {
Self::deploy(workspace, &Diff, window, cx);
});
@@ -92,39 +105,66 @@ impl ProjectDiff {
window: &mut Window,
cx: &mut Context<Workspace>,
) {
Self::deploy_at(workspace, None, window, cx)
Self::deploy_at(workspace, DiffBaseKind::Head, None, window, cx)
}
fn diff_to_default_branch(
workspace: &mut Workspace,
_: &DiffToDefaultBranch,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
Self::deploy_at(
workspace,
DiffBaseKind::MergeBaseOfDefaultBranch,
None,
window,
cx,
);
}
pub fn deploy_at(
workspace: &mut Workspace,
diff_base_kind: DiffBaseKind,
entry: Option<GitStatusEntry>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
telemetry::event!(
"Git Diff Opened",
match diff_base_kind {
DiffBaseKind::MergeBaseOfDefaultBranch => "Git Branch Diff Opened",
DiffBaseKind::Head => "Git Diff Opened",
},
source = if entry.is_some() {
"Git Panel"
} else {
"Action"
}
);
let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
workspace.activate_item(&existing, true, true, window, cx);
existing
} else {
let workspace_handle = cx.entity();
let project_diff =
cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
workspace.add_item_to_active_pane(
Box::new(project_diff.clone()),
None,
true,
window,
cx,
);
project_diff
};
let project_diff =
if let Some(existing) = Self::existing_project_diff(workspace, diff_base_kind, cx) {
workspace.activate_item(&existing, true, true, window, cx);
existing
} else {
let workspace_handle = cx.entity();
let project_diff = cx.new(|cx| {
Self::new(
workspace.project().clone(),
workspace_handle,
diff_base_kind,
window,
cx,
)
});
workspace.add_item_to_active_pane(
Box::new(project_diff.clone()),
None,
true,
window,
cx,
);
project_diff
};
if let Some(entry) = entry {
project_diff.update(cx, |project_diff, cx| {
project_diff.move_to_entry(entry, window, cx);
@@ -132,6 +172,16 @@ impl ProjectDiff {
}
}
pub fn existing_project_diff(
workspace: &mut Workspace,
diff_base_kind: DiffBaseKind,
cx: &mut Context<Workspace>,
) -> Option<Entity<Self>> {
workspace
.items_of_type::<Self>(cx)
.find(|item| item.read(cx).diff_base_kind == diff_base_kind)
}
pub fn autoscroll(&self, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.request_autoscroll(Autoscroll::fit(), cx);
@@ -141,6 +191,7 @@ impl ProjectDiff {
fn new(
project: Entity<Project>,
workspace: Entity<Workspace>,
diff_base_kind: DiffBaseKind,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -152,9 +203,21 @@ impl ProjectDiff {
Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
diff_display_editor.disable_diagnostics(cx);
diff_display_editor.set_expand_all_diff_hunks(cx);
diff_display_editor.register_addon(GitPanelAddon {
workspace: workspace.downgrade(),
});
match diff_base_kind {
DiffBaseKind::Head => {
diff_display_editor.register_addon(GitPanelAddon {
workspace: workspace.downgrade(),
});
}
DiffBaseKind::MergeBaseOfDefaultBranch => {
diff_display_editor.start_temporary_diff_override();
diff_display_editor.set_render_diff_hunk_controls(
Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
cx,
);
//
}
}
diff_display_editor
});
window.defer(cx, {
@@ -205,7 +268,7 @@ impl ProjectDiff {
let (mut send, recv) = postage::watch::channel::<()>();
let worker = window.spawn(cx, {
let this = cx.weak_entity();
async |cx| Self::handle_status_updates(this, recv, cx).await
async move |cx| Self::handle_status_updates(this, diff_base_kind, recv, cx).await
});
// Kick off a refresh immediately
*send.borrow_mut() = ();
@@ -214,6 +277,7 @@ impl ProjectDiff {
project,
git_store: git_store.clone(),
workspace: workspace.downgrade(),
diff_base_kind,
focus_handle,
editor,
multibuffer,
@@ -351,6 +415,9 @@ impl ProjectDiff {
let Some(project_path) = self.active_path(cx) else {
return;
};
if self.diff_base_kind != DiffBaseKind::Head {
return;
}
self.workspace
.update(cx, |workspace, cx| {
if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
@@ -506,21 +573,149 @@ impl ProjectDiff {
}
}
fn refresh_merge_base_of_default_branch(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let project = self.project.clone();
let language_registry = project.read(cx).languages().clone();
let Some(repo) = self.git_store.read(cx).active_repository() else {
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.clear(cx);
});
return Task::ready(Ok(()));
};
let default_branch = repo.update(cx, |repo, _| repo.default_branch());
cx.spawn_in(window, async move |this, cx| {
let Some(default_branch) = default_branch.await?? else {
return Ok(());
};
let Some(merge_base) = repo
.update(cx, |repo, _| {
repo.merge_base("HEAD".to_string(), default_branch.into())
})?
.await?
else {
return Ok(());
};
let diff = repo
.update(cx, |repo, _| repo.diff_to_commit(merge_base))?
.await??;
for file in diff.files {
let Some(path) = repo.update(cx, |repo, cx| {
repo.repo_path_to_project_path(&file.path, cx)
})?
else {
continue;
};
let open_buffer = project
.update(cx, |project, cx| project.open_buffer(path.clone(), cx))?
.await;
let mut status = FileStatus::Tracked(TrackedStatus {
index_status: git::status::StatusCode::Unmodified,
worktree_status: git::status::StatusCode::Modified,
});
let buffer = match open_buffer {
Ok(buffer) => buffer,
Err(err) => {
let exists = project.read_with(cx, |project, cx| {
project.entry_for_path(&path, cx).is_some()
})?;
if exists {
return Err(err);
}
status = FileStatus::Tracked(TrackedStatus {
index_status: git::status::StatusCode::Unmodified,
worktree_status: git::status::StatusCode::Deleted,
});
cx.new(|cx| Buffer::local("", cx))?
}
};
let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
let namespace = if file.old_text.is_none() {
NEW_NAMESPACE
} else {
TRACKED_NAMESPACE
};
let buffer_diff = cx.new(|cx| BufferDiff::new(&buffer_snapshot, cx))?;
buffer_diff
.update(cx, |buffer_diff, cx| {
buffer_diff.set_base_text(
file.old_text.map(Arc::new),
buffer_snapshot.language().cloned(),
Some(language_registry.clone()),
buffer_snapshot.text,
cx,
)
})?
.await?;
this.read_with(cx, |this, cx| {
BufferDiffSnapshot::new_with_base_buffer(
buffer.clone(),
base_text,
this.base_text().clone(),
cx,
)
})?
.await;
this.update_in(cx, |this, window, cx| {
this.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.add_diff(buffer_diff.clone(), cx);
});
this.register_buffer(
DiffBuffer {
path_key: PathKey::namespaced(namespace, file.path.0),
buffer,
diff: buffer_diff,
file_status: status,
},
window,
cx,
);
})?;
}
Ok(())
})
}
pub async fn handle_status_updates(
this: WeakEntity<Self>,
diff_base_kind: DiffBaseKind,
mut recv: postage::watch::Receiver<()>,
cx: &mut AsyncWindowContext,
) -> Result<()> {
while (recv.next().await).is_some() {
let buffers_to_load = this.update(cx, |this, cx| this.load_buffers(cx))?;
for buffer_to_load in buffers_to_load {
if let Some(buffer) = buffer_to_load.await.log_err() {
cx.update(|window, cx| {
this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
.ok();
})?;
match diff_base_kind {
DiffBaseKind::Head => {
let buffers_to_load = this.update(cx, |this, cx| this.load_buffers(cx))?;
for buffer_to_load in buffers_to_load {
if let Some(buffer) = buffer_to_load.await.log_err() {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.register_buffer(buffer, window, cx)
})
.ok();
})?;
}
}
}
}
DiffBaseKind::MergeBaseOfDefaultBranch => {
this.update_in(cx, |this, window, cx| {
this.refresh_merge_base_of_default_branch(window, cx)
})?
.await
.log_err();
}
};
this.update(cx, |this, cx| {
this.pending_scroll.take();
cx.notify();
@@ -637,7 +832,15 @@ impl Item for ProjectDiff {
Self: Sized,
{
let workspace = self.workspace.upgrade()?;
Some(cx.new(|cx| ProjectDiff::new(self.project.clone(), workspace, window, cx)))
Some(cx.new(|cx| {
ProjectDiff::new(
self.project.clone(),
workspace,
self.diff_base_kind,
window,
cx,
)
}))
}
fn is_dirty(&self, cx: &App) -> bool {
@@ -805,7 +1008,16 @@ impl SerializableItem for ProjectDiff {
window.spawn(cx, async move |cx| {
workspace.update_in(cx, |workspace, window, cx| {
let workspace_handle = cx.entity();
cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx))
// todo!()
cx.new(|cx| {
Self::new(
workspace.project().clone(),
workspace_handle,
DiffBaseKind::Head,
window,
cx,
)
})
})
})
}
@@ -1399,7 +1611,7 @@ mod tests {
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let diff = cx.new_window_entity(|window, cx| {
ProjectDiff::new(project.clone(), workspace, window, cx)
ProjectDiff::new(project.clone(), workspace, DiffBaseKind::Head, window, cx)
});
cx.run_until_parked();
@@ -1454,7 +1666,7 @@ mod tests {
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let diff = cx.new_window_entity(|window, cx| {
ProjectDiff::new(project.clone(), workspace, window, cx)
ProjectDiff::new(project.clone(), workspace, DiffBaseKind::Head, window, cx)
});
cx.run_until_parked();
@@ -1536,7 +1748,7 @@ mod tests {
Editor::for_buffer(buffer, Some(project.clone()), window, cx)
});
let diff = cx.new_window_entity(|window, cx| {
ProjectDiff::new(project.clone(), workspace, window, cx)
ProjectDiff::new(project.clone(), workspace, DiffBaseKind::Head, window, cx)
});
cx.run_until_parked();
@@ -1827,7 +2039,7 @@ mod tests {
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let diff = cx.new_window_entity(|window, cx| {
ProjectDiff::new(project.clone(), workspace, window, cx)
ProjectDiff::new(project.clone(), workspace, DiffBaseKind::Head, window, cx)
});
cx.run_until_parked();

View File

@@ -293,7 +293,7 @@ impl StatusItemView for CursorPosition {
}
}
#[derive(Clone, Copy, Default, PartialEq, JsonSchema, Deserialize, Serialize, SettingsUi)]
#[derive(Clone, Copy, Default, PartialEq, JsonSchema, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum LineIndicatorFormat {
Short,
@@ -301,14 +301,14 @@ pub(crate) enum LineIndicatorFormat {
Long,
}
#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize)]
#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize, SettingsUi)]
#[serde(transparent)]
pub(crate) struct LineIndicatorFormatContent(LineIndicatorFormat);
impl Settings for LineIndicatorFormat {
const KEY: Option<&'static str> = Some("line_indicator_format");
type FileContent = Option<LineIndicatorFormatContent>;
type FileContent = LineIndicatorFormatContent;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> anyhow::Result<Self> {
let format = [
@@ -317,8 +317,8 @@ impl Settings for LineIndicatorFormat {
sources.user,
]
.into_iter()
.find_map(|value| value.copied().flatten())
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
.find_map(|value| value.copied())
.unwrap_or(*sources.default);
Ok(format.0)
}

View File

@@ -12,13 +12,13 @@ license = "Apache-2.0"
workspace = true
[features]
default = ["http_client", "font-kit", "wayland", "x11", "windows-manifest"]
default = ["font-kit", "wayland", "x11", "windows-manifest"]
test-support = [
"leak-detection",
"collections/test-support",
"rand",
"util/test-support",
"http_client?/test-support",
"http_client/test-support",
"wayland",
"x11",
]
@@ -91,7 +91,7 @@ derive_more.workspace = true
etagere = "0.2"
futures.workspace = true
gpui_macros.workspace = true
http_client = { optional = true, workspace = true }
http_client.workspace = true
image.workspace = true
inventory.workspace = true
itertools.workspace = true

View File

@@ -23,7 +23,7 @@ On macOS, GPUI uses Metal for rendering. In order to use Metal, you need to do t
- Install [Xcode](https://apps.apple.com/us/app/xcode/id497799835?mt=12) from the macOS App Store, or from the [Apple Developer](https://developer.apple.com/download/all/) website. Note this requires a developer account.
> Ensure you launch XCode after installing, and install the macOS components, which is the default option.
> Ensure you launch Xcode after installing, and install the macOS components, which is the default option. If you are on macOS 26 (Tahoe) you will need to use `--features gpui/runtime_shaders` or add the feature in the root `Cargo.toml`
- Install [Xcode command line tools](https://developer.apple.com/xcode/resources/)

View File

@@ -2313,7 +2313,7 @@ pub struct AnyDrag {
}
/// Contains state associated with a tooltip. You'll only need this struct if you're implementing
/// tooltip behavior on a custom element. Otherwise, use [Div::tooltip].
/// tooltip behavior on a custom element. Otherwise, use [Div::tooltip](crate::Interactivity::tooltip).
#[derive(Clone)]
pub struct AnyTooltip {
/// The view used to display the tooltip

View File

@@ -218,7 +218,7 @@ impl AsyncApp {
Some(read(app.try_global()?, &app))
}
/// A convenience method for [App::update_global]
/// A convenience method for [`App::update_global`](BorrowAppContext::update_global)
/// for updating the global state of the specified type.
pub fn update_global<G: Global, R>(
&self,
@@ -293,7 +293,7 @@ impl AsyncWindowContext {
.update(self, |_, window, cx| read(cx.global(), window, cx))
}
/// A convenience method for [`App::update_global`].
/// A convenience method for [`App::update_global`](BorrowAppContext::update_global).
/// for updating the global state of the specified type.
pub fn update_global<G, R>(
&mut self,

View File

@@ -473,6 +473,11 @@ impl Hsla {
self.a == 0.0
}
/// Returns true if the HSLA color is fully opaque, false otherwise.
pub fn is_opaque(&self) -> bool {
self.a == 1.0
}
/// Blends `other` on top of `self` based on `other`'s alpha value. The resulting color is a combination of `self`'s and `other`'s colors.
///
/// If `other`'s alpha value is 1.0 or greater, `other` color is fully opaque, thus `other` is returned as the output color.

View File

@@ -88,9 +88,9 @@ impl Deref for GlobalColors {
impl Global for GlobalColors {}
/// Implement this trait to allow global [Color] access via `cx.default_colors()`.
/// Implement this trait to allow global [Colors] access via `cx.default_colors()`.
pub trait DefaultColors {
/// Returns the default [`gpui::Colors`]
/// Returns the default [`Colors`]
fn default_colors(&self) -> &Arc<Colors>;
}

View File

@@ -14,13 +14,13 @@
//! tree and any callbacks they have registered with GPUI are dropped and the process repeats.
//!
//! But some state is too simple and voluminous to store in every view that needs it, e.g.
//! whether a hover has been started or not. For this, GPUI provides the [`Element::State`], associated type.
//! whether a hover has been started or not. For this, GPUI provides the [`Element::PrepaintState`], associated type.
//!
//! # Implementing your own elements
//!
//! Elements are intended to be the low level, imperative API to GPUI. They are responsible for upholding,
//! or breaking, GPUI's features as they deem necessary. As an example, most GPUI elements are expected
//! to stay in the bounds that their parent element gives them. But with [`WindowContext::break_content_mask`],
//! to stay in the bounds that their parent element gives them. But with [`Window::with_content_mask`],
//! you can ignore this restriction and paint anywhere inside of the window's bounds. This is useful for overlays
//! and popups and anything else that shows up 'on top' of other elements.
//! With great power, comes great responsibility.

View File

@@ -87,7 +87,7 @@ pub trait AnimationExt {
}
}
impl<E> AnimationExt for E {}
impl<E: IntoElement + 'static> AnimationExt for E {}
/// A GPUI element that applies an animation to another element
pub struct AnimationElement<E> {

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