Compare commits

...

136 Commits

Author SHA1 Message Date
Conrad Irwin
051c4ce3c7 bump fontconfig-parser 2025-06-02 13:21:35 -06:00
Conrad Irwin
320154bc72 Bump 2025-06-02 13:21:03 -06:00
Conrad Irwin
6c139ed8c2 Bump font-kit
https://github.com/zed-industries/font-kit/pull/5

Updates #20026
2025-06-02 13:21:03 -06:00
Bennet Bo Fenner
ec69b68e72 indent guides: Fix issue with entirely-whitespace lines (#31916)
Closes #26957

Release Notes:

- Fix an edge case where indent guides would be rendered incorrectly if
lines consisted of entirely whitespace

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-06-02 17:35:00 +00:00
Piotr Osiewicz
9dd18e5ee1 python: Re-land usage of source file path in toolchain picker (#31893)
This reverts commit 1e55e88c18.

Closes #ISSUE

Release Notes:

- Python toolchain selector now uses path to the closest pyproject.toml
as a basis for picking a toolchain. All files under the same
pyproject.toml (in filesystem hierarchy) will share a single virtual
environment. It is possible to have multiple Python virtual environments
selected for disjoint parts of the same project.
2025-06-02 16:29:06 +00:00
Smit Barmase
2ebe16a52f workspace: Fix empty pane becomes unresponsive to keybindings after commit via terminal (#31905)
Closes #27579

This PR fixes issue where keybinding wouldn’t work in a pane after
focusing it from dock using the `ActivatePaneInDirection` action in
certain cases.


https://github.com/user-attachments/assets/9ceca580-a63f-4807-acff-29b61819f424

Release Notes:

- Fixed the issue where keybinding wouldn’t work in a pane after
focusing it from dock using the `ActivatePaneInDirection` action in
certain cases.
2025-06-02 21:52:35 +05:30
Joseph T. Lyons
1ed4647203 Add test for pane: toggle pin tab (#31906)
Also adds the optimization to not move a tab being pinned when its
destination index is the same as its index.

Release Notes:

- N/A
2025-06-02 15:58:10 +00:00
Dino
ebed567adb vim: Handle paste in visual line mode when cursor is at newline (#30791)
This Pull Request fixes the current paste behavior in vim mode, when in
visual mode, and the cursor is at a newline character. Currently this
joins the pasted contents with the line right below it, but in vim this
does not happen, so these changes make it so that Zed's vim mode behaves
the same as vim for this specific case.

Closes #29270 

Release Notes:

- Fixed pasting in vim's visual line mode when cursor is on a newline
character
2025-06-02 09:50:13 -06:00
5brian
a6544c70c5 vim: Fix add empty line (#30987)
Fixes: 

`] space` does not consume counts, and it gets applied to the next
action.

`] space` on an empty line causes cursor to move to the next line.

Release Notes:

- N/A
2025-06-02 09:49:31 -06:00
AidanV
b363e1a482 vim: Add support for :e[dit] {file} command to open files (#31227)
Closes #17786

Release Notes:

- Adds `:e[dit] {file}` command to open files
2025-06-02 09:47:40 -06:00
Umesh Yadav
65e3e84cbc language_models: Add thinking support for ollama (#31665)
This PR updates how we handle Ollama responses, leveraging the new
[v0.9.0](https://github.com/ollama/ollama/releases/tag/v0.9.0) release.
Previously, thinking text was embedded within the model's main content,
leading to it appearing directly in the agent's response. Now, thinking
content is provided as a separate parameter, allowing us to display it
correctly within the agent panel, similar to other providers. I have
tested this with qwen3:8b and works nicely. ~~We can release this once
the ollama is release is stable.~~ It's released now as stable.

<img width="433" alt="image"
src="https://github.com/user-attachments/assets/2983ef06-6679-4033-82c2-231ea9cd6434"
/>


Release Notes:

- Add thinking support for ollama

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-06-02 15:12:41 +00:00
morgankrey
1e1d4430c2 Fixing 404 in AI Configuration Docs (#31899)
Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-06-02 09:09:00 -05:00
Oleksiy Syvokon
c874f1fa9d agent: Migrate thread storage to SQLite with zstd compression (#31741)
Previously, LMDB was used for storing threads, but it consumed excessive
disk space and was capped at 1GB.

This change migrates thread storage to an SQLite database. Thread JSON
objects are now compressed using zstd.

I considered training a custom zstd dictionary and storing it in a
separate table. However, the additional complexity outweighed the modest
space savings (up to 20%). I ended up using the default dictionary
stored with data.

Threads can be exported relatively easily from outside the application:

```
$ sqlite3 threads.db "SELECT hex(data) FROM threads LIMIT 5;" |
    xxd -r -p |
    zstd -d |
    fx
```

Benchmarks:
- Original heed database: 200MB
- Sqlite uncompressed: 51MB
- sqlite compressed (this PR): 4.0MB
- sqlite compressed with a trained dictionary: 3.8MB


Release Notes:

- Migrated thread storage to SQLite with compression
2025-06-02 17:01:34 +03:00
Alisina Bahadori
9a9e96ed5a Increase terminal inline assistant block height (#31807)
Closes #31806
Closes #28969

Not sure if a static value is best. Maybe it is better to somehow use
`count_lines` function here too.

### Before
<img width="871" alt="449463234-ab1a33a0-2331-4605-aaee-cae60ddd0f9d"
src="https://github.com/user-attachments/assets/1e3bec86-4cad-426c-9f59-5ad3d14fc9d7"
/>


### After
<img width="861" alt="Screenshot 2025-05-31 at 1 12 33 AM"
src="https://github.com/user-attachments/assets/0c8219a9-0812-45af-8125-1f4294fe2142"
/>

Release Notes:

- Fixed terminal inline assistant clipping when cursor is at bottom of
terminal.
2025-06-02 10:55:40 -03:00
Danilo Leal
8c46e290df docs: Add more details to the agent checkpoint section (#31898)
Figured this was worth highlighting as part of the "Restore Checkpoint"
feature behavior.

Release Notes:

- N/A
2025-06-02 10:55:03 -03:00
Thiago Pacheco
aacbb9c2f4 python: Respect picked toolchain (when it's not at the root) when running tests (#31150)
# Fix Python venv Detection for Test Runner
## Problem
Zed’s Python test runner was not reliably detecting and activating the
project’s Python virtual environment (.venv or venv), causing it to
default to the system Python. This led to issues such as missing
dependencies (e.g., pytest) when running tests.
## Solution
Project Root Awareness: The Python context provider now receives the
project root path, ensuring venv detection always starts from the
project root rather than the test file’s directory.
Robust venv Activation: The test runner now correctly detects and
activates the Python interpreter from .venv or venv in the project root,
setting VIRTUAL_ENV and updating PATH as needed.
Minimal Impact: The change is limited in scope, affecting only the
necessary code paths for Python test runner venv detection. No broad
architectural changes were made.
## Additional Improvements
Updated trait and function signatures to thread the project root path
where needed.
Cleaned up linter warnings and unused code.
## Result
Python tests now reliably run using the project’s virtual environment,
matching the behavior of other IDEs and ensuring all dependencies are
available.

Release Notes:

- Fixed Python tasks always running with a toolchain selected for the
root of a workspace.

---------

Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
2025-06-02 15:29:34 +02:00
Ben Kunkle
f90333f92e zlog: Check crate name against filters if scope empty (#31892)
Fixes
https://github.com/zed-industries/zed/discussions/29541#discussioncomment-13243073

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-06-02 13:22:32 +00:00
Ben Kunkle
b24f614ca3 docs: Improve documentation around Vulkan/GPU issues on Linux (#31895)
Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-06-02 13:16:49 +00:00
Danilo Leal
cefa0cbed8 Improve the file finder picker footer design (#31777)
The current filter icon button in the file finder picker footer confused
me because it is a really a toggleable button that adds a specific
filter. From the icon used, I was expecting more configuration options
rather than just one. Also, I was really wanting a way to trigger it
with the keyboard, even if I need my mouse to initially learn about the
keybinding.

So, this PR transforms that icon button into an actual popover trigger,
in which (for now) there's only one filter option. However, being a menu
is cool because it allows to accomodate more items like, for example,
"Include Git Submodule Files" and others, in the future. Also, there's
now a keybinding that you can hit to open that popover, as well as an
indicator that pops in to communicate that a certain item inside it has
been toggled.

Lastly, also added a keybinding to the "Split" menu in the spirit of
making everything more keyboard accessible!

| Before | After |
|--------|--------|
| ![CleanShot 2025-05-30 at 4  29
57@2x](https://github.com/user-attachments/assets/88a30588-289d-4d76-bb50-0a4e7f72ef84)
| ![CleanShot 2025-05-30 at 4  24
31@2x](https://github.com/user-attachments/assets/30b8f3eb-4d5c-43e1-abad-59d32ed7c89f)
|

Release Notes:

- Improved the keyboard navigability of the file finder filtering
options.
2025-06-02 09:54:15 -03:00
Smit Barmase
3fb1023667 editor: Fix columnar selection incorrectly uses cursor to start selection instead of mouse position (#31888)
Closes #13905

This PR fixes columnar selection to originate from mouse position
instead of current cursor position. Now columnar selection behaves as
same as Sublime Text.

1. Columnar selection from click-and-drag on text (New):


https://github.com/user-attachments/assets/f2e721f4-109f-4d81-a25b-8534065bfb37

2. Columnar selection from click-and-drag on empty space (New): 


https://github.com/user-attachments/assets/c2bb02e9-c006-4193-8d76-097233a47a3c

3. Multi cursors at end of line when no interecting text found (New): 


https://github.com/user-attachments/assets/e47d5ab3-0b5f-4e55-81b3-dfe450f149b5

4. Converting normal selection to columnar selection (Existing):


https://github.com/user-attachments/assets/e5715679-ebae-4f5a-ad17-d29864e14e1e


Release Notes:

- Fixed the issue where the columnar selection (`opt+shift`) incorrectly
used the cursor to start the selection instead of the mouse position.
2025-06-02 16:37:36 +05:30
Umesh Yadav
9c715b470e agent: Show actual file name and icon in context pill (#31813)
Previously in the agent context pill if we added images it showed
generic Image tag on the image context pill. This PR make sure if we
have a path available for a image context show the filename which is in
line with other context pills.


Before | After
--- | ---
![Screenshot 2025-05-31 at 3 14 07
PM](https://github.com/user-attachments/assets/b342f046-2c1c-4c18-bb26-2926933d5d34)
| ![Screenshot 2025-05-31 at 3 14 07
PM](https://github.com/user-attachments/assets/90ad4062-cdc6-4274-b9cd-834b76e8e11b)





Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-06-02 10:35:22 +00:00
Oleksiy Syvokon
ae219e9e99 agent: Fix bug with double-counting tokens in Gemini (#31885)
We report the total number of input tokens by summing the numbers of
1. Prompt tokens
2. Cached tokens

But Google API returns prompt tokens (1) that already include cached
tokens (2), so we were double counting tokens in some cases.

Release Notes:

- Fixed bug with double-counting tokens in Gemini
2025-06-02 10:18:44 +00:00
Bennet Bo Fenner
6d99c12796 assistant_context_editor: Fix copy paste regression (#31882)
Closes #31166

Release Notes:

- Fixed an issue where copying and pasting an assistant response in text
threads would result in duplicate text
2025-06-02 11:52:47 +02:00
Kirill Bulatov
8fb7fa941a Suppress log blade_graphics -related logs by default (#31881)
Release Notes:

- N/A
2025-06-02 08:59:57 +00:00
Joseph T. Lyons
22d75b798e Notify when pinning a tab even if the tab isn't moved (#31880)
The optimization to not move a tab being pinned (when the destination
index is the same as its index) in
https://github.com/zed-industries/zed/pull/31871 caused a regression, as
we were no longer calling `cx.notify()` indirectly through `move_item`.
Thanks for catching this, @smitbarmase.

Release Notes:

- N/A
2025-06-02 06:58:57 +00:00
Smit Barmase
06a199da4d editor: Fix completion accept for optional chaining in Typescript (#31878)
Closes #31662

Currently, we assume `insert_range` will always end at the cursor and
`replace_range` will also always end after the cursor for calculating
range to replace. This is a particular case for the rust-analyzer, but
not widely true for other language servers.

This PR fixes this assumption, and now `insert_range` and
`replace_range` both can end before cursor.

In this particular case:
```ts
let x: string | undefined;

x.tostˇ // here insert as well as replace range is just "." while new_text is "?.toString()"
```

This change makes it such that if final range to replace ends before
cursor, we extend it till the cursor.

Bonus:
- Improves suffix and subsequence matching to use `label` over
`new_text` as `new_text` can contain end characters like `()` or `$`
which is not visible while accepting the completion.
- Make suffix and subsequence check case insensitive.
- Fixes broken subsequence matching which was not considering the order
of characters while matching subsequence.

Release Notes:

- Fixed an issue where autocompleting optional chaining methods in
TypeScript, such as `x.tostr`, would result in `x?.toString()tostr`
instead of `x?.toString()`.
2025-06-02 10:45:40 +05:30
Joseph T. Lyons
ab6125ddde Fix bugs around pinned tabs (#31871)
Closes https://github.com/zed-industries/zed/issues/31870

Release Notes:

- Allowed opening 1 more item if `n` tabs are pinned, where `n` equals
`max_tabs` count.
- Fixed a bug where pinned tabs would eventually be closed out when
exceeding the `max_tabs` count.
- Fixed a bug where a tab could be lost when pinning a tab while at the
`max_tabs` count.
- Fixed a bug where pinning a tab when already at the `max_tabs` limit
could cause other tabs to be incorrectly closed.
2025-06-01 19:50:06 -04:00
Joseph T. Lyons
d3bc561f26 Disable close clean menu item when all are dirty (#31859)
This PR disables the "Close Clean" tab context menu action if all items
are dirty.

<img width="595" alt="SCR-20250601-kaev"
src="https://github.com/user-attachments/assets/add30762-b483-4701-9053-141d2dfe9b05"
/>

<img width="573" alt="SCR-20250601-kahl"
src="https://github.com/user-attachments/assets/24f260e4-01d6-48d6-a6f4-a13ae59c246e"
/>

Also did a bit more general refactoring.

Release Notes:

- N/A
2025-06-01 15:15:33 +00:00
Joseph T. Lyons
f13f2dfb70 Ensure item-closing actions do not panic when no items are present (#31845)
This PR adds a comprehensive test that ensures that no item-closing
action will panic when no items are present. A test already existed
(`test_remove_active_empty `) that ensured `CloseActiveItem` didn't
panic, but the new test covers:

- `CloseActiveItem`
- `CloseInactiveItems`
- `CloseAllItems`
- `CloseCleanItems`
- `CloseItemsToTheRight`
- `CloseItemsToTheLeft`

I plan to do a bit more clean up in `pane.rs` and this feels like a good
thing to add before that.

Release Notes:

- N/A
2025-06-01 03:31:38 +00:00
Joseph T. Lyons
24e4446cd3 Refactor item-closing actions (#31838)
While working on 

- https://github.com/zed-industries/zed/pull/31783
- https://github.com/zed-industries/zed/pull/31786

... I noticed some areas that could be improved through refactoring. The
bug in https://github.com/zed-industries/zed/pull/31783 came from having
duplicate code. The fix had been applied to one version, but not the
duplicated code.

This PR attempts to do some initial clean up, through some refactoring.

Release Notes:

- N/A
2025-05-31 19:38:32 -04:00
Aleksei Gusev
cc536655a1 Fix slowness in Terminal when vi-mode is enabled (#31824)
It seems alacritty handles vi-mode motions in a special way and it is up
to the client to decide when redraw is necessary. With this change,
`TerminalView` notifies the context if a keystroke is processed and vi
mode is enabled.

Fixes #31447

Before:


https://github.com/user-attachments/assets/a78d4ba0-23a3-4660-a834-2f92948f586c

After:


https://github.com/user-attachments/assets/cabbb0f4-a1f9-4f1c-87d8-a56a10e35cc8

Release Notes:

- Fixed sluggish cursor motions in Terminal when Vi Mode is enabled
[#31447]
2025-05-31 20:02:56 +03:00
Kartik Vashistha
2a9e73c65d docs: Update Ansible docs (#31817)
Release Notes:

- N/A
2025-05-31 10:30:29 -04:00
Kirill Bulatov
4f1728e5ee Do not unwrap when updating inline diagnostics (#31814)
Also do not hold the strong editor reference while debounced.

Release Notes:

- N/A
2025-05-31 10:10:15 +00:00
Christian Bergschneider
40c91d5df0 gpui: Implement window_handle and display_handle for wayland platform (#28152)
This PR implements the previously unimplemented window_handle and
display_handle methods in the wayland platform. It also exposes the
display_handle method through the Window struct.

Release Notes:

- N/A
2025-05-30 15:45:03 -07:00
Kirill Bulatov
fe1b36671d Do not error on debugger server connection close (#31795)
Start to show islands of logs in a successful debugger runs for Rust:

<img width="1728" alt="1"
src="https://github.com/user-attachments/assets/7400201b-b900-4b20-8adf-21850ae59671"
/>

<img width="1728" alt="2"
src="https://github.com/user-attachments/assets/e75cc5f1-1f74-41d6-b7aa-697a4b2f055b"
/>

Release Notes:

- N/A
2025-05-30 22:37:40 +00:00
Agus Zubiaga
bb9e2b0403 Fix existing CompletionMode deserialization (#31790)
https://github.com/zed-industries/zed/pull/31668 renamed
`CompletionMode::Max` to `CompletionMode::Burn` which is a good change,
but this broke the deserialization for threads whose completion mode was
stored in LMDB. This adds a deserialization alias so that both values
work.

We could make a full new `SerializedThread` version which migrates this
value, but that seems overkill for this single change, we can batch that
with more changes later. Also, people in nightly already have some v1
threads with `burn` stored, so it wouldn't quite work for everybody.

Release Notes:

- N/A
2025-05-30 19:06:08 -03:00
Yaroslav Pietukhov
4f8d7f0a6b Disallow running Zed with root privileges (#31331)
This will fix a lot of weird problems that are based on file access
issues.

As discussed in
https://github.com/zed-industries/zed/pull/31219#issuecomment-2905371710,
for now it's better to just prevent running Zed with root privileges.

Release Notes:

- Explicitly disallow running Zed with root privileges

---------

Co-authored-by: Peter Tripp <peter@zed.dev>
2025-05-30 21:22:52 +00:00
Danilo Leal
caf3d30bf6 Harmonize quick action icons (#31784)
Just ensuring they're more harmonized in size, stroke width, and overall
dimensions. Adding here two new "play" icons, too, that will be used in
the context of the debugger.

<img
src="https://github.com/user-attachments/assets/79bcf0b0-e995-4c8e-9c78-0aba32f42f2d"
width="400" />

Release Notes:

- N/A
2025-05-30 18:18:23 -03:00
Peter Tripp
df0cf22347 Add powershell language docs (#31787)
Release Notes:

- N/A
2025-05-30 17:09:55 -04:00
Piotr Osiewicz
a305eda8d1 debugger: Relax implementation of validate_config to not run validation (#31785)
When we moved to schema-based debug configs, we've added validate_config
- a trait method
that is supposed to both validate the configuration and determine
whether it is a launch configuration
or an attach configuration.

The validation bit is a bit problematic though - we received reports on
Discords about
scenarios not starting up properly; it turned out that Javascript's
implementation was overly strict.
Thus, I got rid of any code that tries to validate the config - let's
let the debug adapter itself
decide whether it can digest the configuration or not. validate_config
is now left unimplemented for most
DebugAdapter implementations (except for PHP), because all adapters use
`request`: 'launch'/'attach' for that.
Let's leave the trait method in place though, as nothing guarantees this
to be true for all adapters.

cc @Anthony-Eid

Release Notes:

- debugger: Improved error messages when the debug scenario is not
valid.
- debugger: Fixed cases where valid configs were rejected.
2025-05-30 23:08:41 +02:00
Joseph T. Lyons
ba7b1db054 Mark items as pinned via ! in tests (#31786)
Release Notes:

- N/A
2025-05-30 21:03:31 +00:00
Joseph T. Lyons
019c8ded77 Fix bug where pinned tabs were closed when closing others (#31783)
Closes https://github.com/zed-industries/zed/issues/28166

Release Notes:

- Fixed a bug where pinned tabs were closed when running `Close Others`
from the tab context menu
2025-05-30 20:48:33 +00:00
Fernando Carletti
1704dbea7e keymap: Add subword navigation and selection to Sublime Text keymap (#30268)
For reference, this is what is set in Sublime Text's default-keymap
files for both MacOS and Linux:

```json
{ "keys": ["ctrl+left"], "command": "move", "args": {"by": "words", "forward": false} },
{ "keys": ["ctrl+right"], "command": "move", "args": {"by": "word_ends", "forward": true} },
{ "keys": ["ctrl+shift+left"], "command": "move", "args": {"by": "words", "forward": false, "extend": true} },
{ "keys": ["ctrl+shift+right"], "command": "move", "args": {"by": "word_ends", "forward": true, "extend": true} },
```

Release Notes:

- Add subword navigation and selection to Sublime keymap

Co-authored-by: Peter Tripp <peter@zed.dev>
2025-05-30 20:38:21 +00:00
Hendrik Sollich
eefa6c4882 Icon theme selector: Don't select last list item when fuzzy searching (#29560)
Adds manual icon-theme selection persistence

Store manually selected icon-themes to maintain selection when query
changes. This allows the theme selector to remember the user's choice
rather than resetting selection when filtering results.

mentioned in #28081 and #28278

Release Notes:

- Improved persistence when selecting themes and icon themes.

---------

Co-authored-by: Peter Tripp <peter@zed.dev>
2025-05-30 20:37:38 +00:00
Alex
1f17df7fb0 debugger: Add support for go tests (#31772)
In the https://github.com/zed-industries/zed/pull/31559 I did not
introduce ability to debug test invocations.
Adding it here. E.g:
![Kapture 2025-05-30 at 19 59
13](https://github.com/user-attachments/assets/1111d4a5-8b0a-42e6-aa98-2d797f61ffe3)

Release Notes:
- Added support for debugging single tests written in go
2025-05-30 22:18:32 +02:00
tidely
6d687a2c2c ollama: Change default context size to 4096 (#31682)
Ollama increased their default context size from 2048 to 4096 tokens in
version v0.6.7, which released over a month ago.

https://github.com/ollama/ollama/releases/tag/v0.6.7

Release Notes:

- ollama: Update default model context to 4096 (matching upstream)
2025-05-30 16:12:39 -04:00
Umesh Yadav
32214abb64 Improve TypeScript shebang detection (#31437)
Closes #13981

Release Notes:

- Improved TypeScript shebang detection
2025-05-30 16:11:13 -04:00
Peter Tripp
a78563b80b ci: Prevent "Tests Pass" job from running on forks (#31778)
Example fork [actions run](https://github.com/alphaArgon/zed/actions/runs/15349715488/job/43194591563)
which would be suppressed in the future.

(Sorry @alphaArgon)

Release Notes:

- N/A
2025-05-30 16:09:22 -04:00
Kirill Bulatov
f881cacd8a Use both language and LSP icons for LSP tasks (#31773)
Make more explicit which language LSP tasks are used.

Before:

![image](https://github.com/user-attachments/assets/27f93c5f-942e-47a0-9b74-2c6d4d6248de)

After:

![image
(1)](https://github.com/user-attachments/assets/5a29fb0a-2e16-4c35-9dda-ae7925eaa034)


![image](https://github.com/user-attachments/assets/d1bf518e-63d1-4ebf-af3d-3c9d464c6532)


Release Notes:

- N/A
2025-05-30 19:28:56 +00:00
Umesh Yadav
a539a38f13 Revert "copilot: Fix vision request detection for follow-up messages" (#31776)
Reverts zed-industries/zed#31760

see this comment for context:
https://github.com/zed-industries/zed/pull/31760#issuecomment-2923158611.

Release Notes:

- N/A
2025-05-30 21:28:31 +02:00
Piotr Osiewicz
ca6fd101c1 debugger: Change console text color, add tooltips (#31765)
- Improved legibility of console text:

| Theme | Dark | Light |
|--------|--------|--------|
| Before |
![image](https://github.com/user-attachments/assets/756da36d-9ef4-495a-9cf9-7249c25d106a)
|
![image](https://github.com/user-attachments/assets/42558ec2-ee08-4973-8f7d-d7f4feb38cf8)
|
| After |
![image](https://github.com/user-attachments/assets/4469f000-b34f-4cbb-819d-4ae1f2f58a4a)
|
![image](https://github.com/user-attachments/assets/3b862114-0fd3-427c-9c76-f030d3442090)
|

Release Notes:

- debugger: Improved legibility of console text
- debugger: Added tooltips to all debugger items.
2025-05-30 19:21:28 +02:00
Aldo Funes
f8097c7c98 Improve compatibility with Wayland clipboard (#30251)
Closes #26672, #20984

Release Notes:

- Fixed issue where some applications won't receive the clipboard
contents from Zed

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
2025-05-30 17:21:00 +00:00
Marshall Bowers
c1427ea802 collab: Remove POST /billing/subscriptions/migrate endpoint (#31770)
This PR removes the `POST /billing/subscriptions/migrate` endpoint, as
it is no longer needed.

Release Notes:

- N/A
2025-05-30 17:16:20 +00:00
Kirill Bulatov
1e83022f03 Add a JS/TS debug locator (#31769)
With this, a semi-working debug session is possible from the JS/TS
gutter tasks:


https://github.com/user-attachments/assets/8db6ed29-b44a-4314-ae8b-a8213291bffc

For now, available in debug builds only as a base to improve on later on
the DAP front.

Release Notes:

- N/A

---------

Co-authored-by: Piotr Osiewicz <peterosiewicz@gmail.com>
2025-05-30 17:15:42 +00:00
Chung Wei Leong
0ee900e8fb Support macOS Sequoia titlebar double-click action (#30468)
Closes #16527

Release Notes:

- Added MacOS titlebar double-click action

---

Unfortunately, Apple doesn't seem to make the "Fill" API public or
documented anywhere.

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
2025-05-30 17:13:50 +00:00
Marshall Bowers
f9f4be1fc4 collab: Use StripeClient in POST /billing/subscriptions/sync endpoint (#31764)
This PR updates the `POST /billing/subscriptions/sync` endpoint to use
the `StripeClient` trait instead of using `stripe::Client` directly.

Release Notes:

- N/A
2025-05-30 16:37:12 +00:00
Stephen Murray
a00b07371a copilot: Fix vision request detection for follow-up messages (#31760)
Previously, the vision request header was only set if the last message
in a thread contained an image. This caused 400 errors from the Copilot
API when sending follow-up messages in a thread that contained images in
earlier messages.

Modified the `is_vision_request` check to scan all messages in a thread
for image content instead of just the last one, ensuring the proper
header is set for the entire conversation.

Added a unit test to verify all cases function correctly.

Release Notes:

- Fix GitHub Copilot chat provider error when sending follow-up messages
in threads containing images
2025-05-30 16:32:49 +00:00
Marshall Bowers
f725b5e248 collab: Use StripeClient in sync_subscription (#31761)
This PR updates the `sync_subscription` function to use the
`StripeClient` trait instead of using `stripe::Client` directly.

Release Notes:

- N/A

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-05-30 16:08:58 +00:00
Vivek Pothina
07436b4284 breadcrumbs: Stylize filename in breadcrumbs when tab-bar is off and file is dirty (#30507)
Closes [#18870](https://github.com/zed-industries/zed/issues/18870)


- I like to use Zed with tab_bar off
- when the file is modified there is no indicator when tab_bar is off
- this PR aims to fix that

Thanks to @Qkessler for initial PR - #22418 

This is style decided in this discussion - #22418 
@iamnbutler @mikayla-maki [subtle style
decided](https://github.com/zed-industries/zed/pull/22418#issuecomment-2605253667)

Release Notes:
- When tab_bar is off, filename in the breadcrumbs will be the indicator
when file is unsaved.


#### Changes
- when tab_bar is off and file is dirty (unsaved)
<img width="834" alt="image"
src="https://github.com/user-attachments/assets/f205731b-c8e3-4d7a-9214-cbe706e372bf"
/>


- when tab_bar is off and file is not dirty (saved)
<img width="846" alt="image"
src="https://github.com/user-attachments/assets/88ea96eb-16a2-48e8-900d-64a921f0b5c3"
/>


- when tab_bar is on
<img width="741" alt="image"
src="https://github.com/user-attachments/assets/cc543544-9949-46ed-8e09-cdcbe2f47ab8"
/>

<img width="740" alt="image"
src="https://github.com/user-attachments/assets/8d347258-26f7-4bd7-82d4-8f23dbe63d61"
/>

Release Notes:

- Changed the highlighting of the current file to represent the current
saved state, when the tab bar is turned off.
2025-05-30 08:32:54 -07:00
tidely
8bec4cbecb assistant_tools: Reduce allocations (#30776)
Another batch of allocation savings. Noteworthy ones are
`find_path_tool.rs` where one clone of *all* found matches was saved and
`web_tool_search.rs` where the tooltip no longer clones the entire url
on every hover.

I'd also like to propose using `std::borrow::Cow` a lot more around the
codebase instead of Strings. There are hundreds if not 1000+ clones that
can be saved pretty regularly simply by switching to Cow. ´Cow´'s are
likely not used because they aren't compatible with futures and because
it could cause lifetime bloat. However if we use `Cow<'static, str>`
(static lifetime) for when we need to pass them into futures, we could
save a TON of allocations for `&'static str`. Additionally I often see
structs being created using `String`'s just to be deserialized
afterwards, which only requires a reference.

Release Notes:

- N/A
2025-05-30 08:28:22 -07:00
Jason Lee
047e7eacec gpui: Improve window.prompt to support ESC with non-English cancel text on macOS (#29538)
Release Notes:

- N/A

----

The before version GPUI used `Cancel` for cancel text, if we use
non-English text (e.g.: "取消" in Chinese), then the press `Esc` to cancel
will not work.

So this PR to change it by use `PromptButton` to instead the `&str`,
then we can use `PromptButton::cancel("取消")` for the `Cancel` button.

Run `cargo run -p gpui --example window` to test.

---

Platform Test:

- [x] macOS
- [x] Windows
- [x] Linux (x11 and Wayland)

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
2025-05-30 15:26:27 +00:00
laizy
1d5d3de85c gpui: Optimize the ordering update in the BoundsTree (#31025)
Release Notes:

- N/A
2025-05-30 08:23:27 -07:00
laizy
c4dbaa91f0 gpui: Fix race condition when upgrading a weak reference (#30952)
Release Notes:

- N/A
2025-05-30 08:18:25 -07:00
Ben Brandt
97c01c6720 Fix model deduplication to use provider ID and model ID (#31750)
Previously only used model ID for deduplication, which incorrectly
filtered models with the same name from different providers.

Release Notes:

- Fix to make sure all provider models are shown in the model picker
2025-05-30 13:49:09 +00:00
Marshall Bowers
310ea43048 danger: Check for changes in prompt files (#31744)
This PR adds a Danger check to remind engineers that any changes to our
various prompts need to be verified against the LLM Worker.

When changes to the prompt files are detected, we will fail the PR with
a message:

<img width="929" alt="Screenshot 2025-05-30 at 8 40 58 AM"
src="https://github.com/user-attachments/assets/79afab4e-e799-45f1-a90e-0fd7c9a73706"
/>

Once the corresponding changes have been made (or no changes to the LLM
Worker have been determined to be necessary), including the indicated
attestation message will convert the errors into informational messages:

<img width="926" alt="Screenshot 2025-05-30 at 8 41 52 AM"
src="https://github.com/user-attachments/assets/ff51c17a-7a76-46a7-b468-a7d864d480c3"
/>

Release Notes:

- N/A
2025-05-30 13:46:41 +00:00
Piotr Osiewicz
6bb4b5fa64 Revert "debugger beta: Fix bug where debug Rust main running action f… (#31743)
…ailed (#31291)"

This reverts commit aab76208b5.

Closes #31737

I cannot repro the original issue that this commit was trying to solve
anymore.

Release Notes:

- N/A
2025-05-30 14:32:59 +02:00
Alejandro Fernández Gómez
e0fa3032ec docs: Properly nest the docs for the inline git blame settings (#31739)
From a report on
[discord](https://discord.com/channels/869392257814519848/873292398204170290/1377943171320774656):
reorders the docs for the inline git blame feature so they are properly
nested.

<img width="1719" alt="Screenshot_2025-05-30_at_11 32 17"
src="https://github.com/user-attachments/assets/3c20bda3-de81-4ac3-b8e2-e1d4eac0ce88"
/>


Release Notes:

- N/A
2025-05-30 08:18:15 -03:00
Danilo Leal
9cf6be2057 agent: Add Burn Mode setting migrator (#31718)
Follow-up https://github.com/zed-industries/zed/pull/31470.

Release Notes:

- N/A
2025-05-30 08:10:12 -03:00
Piotr Osiewicz
5462e199fb debugger: Fix wrong path to the downloaded delve-shim-dap (#31738)
Closes #ISSUE

Release Notes:

- N/A
2025-05-30 10:43:29 +00:00
Piotr Osiewicz
3a60420b41 debugger: Fix delve-dap-shim path on Windows (#31735)
Closes #ISSUE

Release Notes:

- debugger: Fixed wrong path being picked up for delve on Windows
- debugger: Fixed delve not respecting the user-provided binary path
from settings.
2025-05-30 08:53:19 +00:00
Simon Pham
89c184a26f markdown_preview: Fix release notes title being overridden (#31703)
Closes: #31701

Screenshot:

<img width="383" alt="image"
src="https://github.com/user-attachments/assets/7fd8ce70-2208-4aca-bc70-860d6c649765"
/>



Release Notes:

- Fixed in-app release notes having an incorrect title

---------

Co-authored-by: Gilles Peiffer <gilles.peiffer.yt@gmail.com>
2025-05-30 08:29:52 +00:00
Michael Sloan
d7f0241d7b editor: Defer the effects of change_selections to end of transact (#31731)
In quite a few places the selection is changed multiple times in a
transaction. For example, `backspace` might do it 3 times:

* `select_autoclose_pair`
* selection of the ranges to delete
* `insert` of empty string also updates selection

Before this change, each of these selection changes appended to
selection history and did a bunch of work that's only relevant to
selections the user actually sees. So for each backspace,
`editor::UndoSelection` would need to be invoked 3-4 times before the
cursor actually moves. It still needs to be run twice after this change,
but that is a separate issue.

Signature help even had a `backspace_pressed: bool` as an incomplete
workaround, to avoid it flickering due to the selection switching
between being a range and being cursor-like.

The original motivation for this change is work I'm doing on not
re-querying completions when the language server provides a response
that has `is_incomplete: false`. Whether the menu is still visible is
determined by the cursor position, and this was complicated by it seeing
`backspace` temporarily moving the head of the selection 1 character to
the left.

This change also removes some redundant uses of
`push_to_selection_history`.

Not super stoked with the name `DeferredSelectionEffectsState`. Naming
is hard.

Release Notes:

- N/A
2025-05-30 01:53:02 -06:00
Cole Miller
1445af559b Unify the tasks modal and the new session modal (#31646)
Release Notes:

- Debugger Beta: added a button to the quick action bar to start a debug
session or spawn a task, depending on which of these actions was taken
most recently.
- Debugger Beta: incorporated the tasks modal into the new session modal
as an additional tab.

---------

Co-authored-by: Julia Ryan <juliaryan3.14@gmail.com>
Co-authored-by: Julia Ryan <p1n3appl3@users.noreply.github.com>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Mikayla <mikayla@zed.dev>
2025-05-29 21:33:52 -04:00
Anthony Eid
804de3316e debugger: Update docs with more examples (#31597)
This PR also shows more completion items when defining a debug config in
a `debug.json` file. Mainly when using a pre build task argument.

### Follow ups
- Add docs for Go, JS, PHP
- Add attach docs

Release Notes:

- debugger beta: Show build task completions when editing a debug.json
configuration with a pre build task
- debugger beta: Add Python and Native Code debug config
[examples](https://zed.dev/docs/debugger)
2025-05-30 04:22:16 +03:00
Smit Barmase
a387bf5f54 zed: Fix migration banner not hiding after migration has been carried out (#31723)
- https://github.com/zed-industries/zed/pull/30444

This PR broke migration notification to only emit event when content is
migrated. This resulted in the migration banner not going away after
clicking "Backup and Migrate". It should also emit event when it's not
migrated which removes the banner.

Future: I think we should have better tests in place for banner
visibility.

Release Notes:

- Fixed an issue where migration banner wouldn't go away after clicking
"Backup and Migrate".
2025-05-30 06:00:37 +05:30
Marshall Bowers
c7047d5f0a collab: Fully move StripeBilling over to using StripeClient (#31722)
This PR moves over the last method on `StripeBilling` to use the
`StripeClient` trait, allowing us to fully mock out Stripe behaviors for
`StripeBilling` in tests.

Release Notes:

- N/A
2025-05-29 23:49:14 +00:00
Kirill Bulatov
406d975f39 Cleanup corresponding task history on task file update (#31720)
Closes https://github.com/zed-industries/zed/issues/31715

Release Notes:

- Fixed old task history not erased after task file update
2025-05-29 23:02:59 +00:00
Finn Evers
cbed580db0 workspace: Ensure pane handle hitbox blocks mouse events (#31719)
Follow-up to #31712

Pane handle hitboxes were opaque prior to the linked PR. This was the
case because pane handles have an intentionally larger hitbox than the
pane dividers size to allow for easier dragging. The cursor style is
also updated for that hitbox to indicate that resizing is possible:


9086784038/crates/workspace/src/pane_group.rs (L1297-L1301)

Not blocking the mouse events here causes mouse events to bleed through
this hitbox whilst actually any clicks will only cause a pane resize to
happen. Hence, this hitbox should continue to block mouse events to
avoid any confusion when resizing panes.

I considered using `HitboxBehavior::BlockMouseExceptScroll` here,
however, due to the reasons mentioned above, I decided against it. The
cursor will not indicate that scrolling should be possible. Since all
other mouse events on underlying elements (like hovers) are blocked, it
felt more reasonable to just go with `HitboxBehavior::BlockMouse`.

Release Notes:

- N/A
2025-05-29 16:35:22 -06:00
Michael Sloan
8aef64bbfa Remove block_mouse_down in favor of stop_mouse_events_except_scroll (#30401)
This method was added in #20649 to be an alternative of `occlude` which
allows scroll events. It seems a bit arbitrary to only stop left mouse
downs, so this seems like it's probably an improvement.

Release Notes:

- N/A
2025-05-29 22:07:34 +00:00
Michael Sloan
9086784038 gpui: Support hitbox blocking mouse interaction except scrolling (#31712)
tl;dr: This adds `.block_mouse_except_scroll()` which should typically
be used instead of `.occlude()` for cases when the mouse shouldn't
interact with elements drawn below an element. The rationale for
treating scroll events differently:

* Mouse move / click / styles / tooltips are for elements the user is
interacting with directly.
* Mouse scroll events are about finding the current outer scroll
container.

Most use of `occlude` should probably be switched to this, but I figured
I'd derisk this change by minimizing behavior changes to just the 3 uses
of `block_mouse_except_scroll`.

GPUI changes:

* Added `InteractiveElement::block_mouse_except_scroll()`, and removes
`stop_mouse_events_except_scroll()`

* Added `Hitbox::should_handle_scroll()` to be used when handling scroll
wheel events.

* `Window::insert_hitbox` now takes `HitboxBehavior` instead of
`occlude: bool`.

    - `false` for that bool is now `HitboxBehavior::Normal`.

    - `true` for that bool is now `HitboxBehavior::BlockMouse`.
    
    - The new mode is `HitboxBehavior::BlockMouseExceptScroll`.

* Removes `Default` impl for `HitboxId` since applications should not
manually create `HitboxId(0)`.

Release Notes:

- N/A
2025-05-29 21:41:15 +00:00
Kirill Bulatov
2abc5893c1 Improve TypeScript task detection (#31711)
Parses project's package.json to better detect Jasmine, Jest, Vitest and
Mocha and `test`, `build` scripts presence.
Also tries to detect `pnpm` and `npx` as test runners, falls back to
`npm`.


https://github.com/user-attachments/assets/112d3d8b-8daa-4ba5-8cb5-2f483036bd98

Release Notes:

- Improved TypeScript task detection
2025-05-29 20:51:20 +00:00
Marshall Bowers
a23ee61a4b Pass up intent with completion requests (#31710)
This PR adds a new `intent` field to completion requests to assist in
categorizing them correctly.

Release Notes:

- N/A

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-05-29 20:43:12 +00:00
Simon Pham
38e45e828b Add View Release Notes to Help menu (#31704)
<img width="891" alt="image"
src="https://github.com/user-attachments/assets/59e98fdb-c1b5-4948-8d69-661561d838f1"
/>



Release Notes:

- Added `View Release Notes` to `Help` menu
2025-05-29 19:39:54 +00:00
Danilo Leal
181bf78b7d agent: Change the navigation menu keybinding (#31709)
As much as I enjoyed the previous keybinding, it was causing a conflict
with the editor where it wouldn't open on text threads. To not get into
a rabbit hole and complicate the fix too much, I figured simply changing
it to something non-conflictual would be a good move.

Release Notes:

- agent: Fixed a bug where the panel navigation menu wouldn't open with
the keybinding on text threads.
2025-05-29 19:31:57 +00:00
Piotr Osiewicz
c42d060509 Update debug.json in Zed repo to run the build on session startup (#31707)
Closes #ISSUE

Release Notes:

- N/A
2025-05-29 21:29:18 +02:00
Peter Tripp
6ea9abdc1b Cursor keymap (#31702)
To use this, spawn `weclome: toggle base keymap selector` from the
command palette.

<img width="589" alt="Screenshot 2025-05-29 at 14 07 35"
src="https://github.com/user-attachments/assets/0d4c4eff-6a3b-40f4-9032-5d8ca7664d20"
/>

MacOS is well tested to match Cursor. The [curors keymap
documentation](https://docs.cursor.com/kbd) is does not explicitly state
windows/linux keymap entries only "All Cmd keys can be replaced with
Ctrl on Windows." so that is what we've done. We welcome feedback /
refinements.

Note, because this provides a mapping for `cmd-k` (macos) and `ctrl-k`
(linux/windows) using this keymap will disable all of the default
chorded keymap entries which have `cmd-k` / `ctrl-k` as a prefix. For
example `cmd-k cmd-s` for open keymap will no longer function.

Release Notes:

- Added Cursor compatibility keymap

---------

Co-authored-by: Joseph Lyons <joseph@zed.dev>
2025-05-29 15:20:58 -04:00
Piotr Osiewicz
070eac28e3 go: Use delve-dap-shim for spawning delve (#31700)
This allows us to support terminal with go sessions

Closes #ISSUE

Release Notes:

- debugger: Add support for terminal when debugging Go programs
2025-05-29 21:19:56 +02:00
Danilo Leal
05692e298a agent: Fix panel "go back" button (#31706)
Closes https://github.com/zed-industries/zed/issues/31652.

Release Notes:

- agent: Fixed a bug where the "go back" button wouldn't go back to the
Text Thread after visiting another view from it.
2025-05-29 16:00:37 -03:00
Richard Feldman
ccb049bd97 Format streamed edits on save (#31623)
Re-enables format on save for agent changes (when the user has that
enabled in settings), except differently from before:
- Now we do the format-on-save in the separate buffer the edit tool
uses, *before* the diff
- This means it never triggers separate staleness
- It has the downside that edits are now blocked on the formatter
completing, but that's true of saving in general.

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
2025-05-29 14:33:41 -04:00
Danilo Leal
fe57eedb44 agent: Rename PromptEditor to TextThread in the panel's ActiveView (#31705)
Was touching this part of the Agent Panel and thought it could be a
quick name consistency win here, so it is aligned with the terminology
we're currently actively using in the product/docs.

Release Notes:

- N/A
2025-05-29 15:31:35 -03:00
5brian
c57e6bc784 tab_switcher: Add placeholder text (#31697)
| Before | After |
|---|---|
|<img width="478" alt="image"
src="https://github.com/user-attachments/assets/5baba783-ee31-42cd-9760-7ee19edb1123"
/>|<img width="478" alt="image"
src="https://github.com/user-attachments/assets/1b149500-4a97-4085-80e5-fd628c92471a"
/>|

Release Notes:

- N/A
2025-05-29 16:09:07 +00:00
Piotr Osiewicz
83135e98e6 Introduce $ZED_CUSTOM_PYTHON_ACTIVE_ZED_TOOLCHAIN_RAW to work around (#31685)
Follow up to #31674 

Release Notes:

- N/A

Co-authored-by: Kirill Bulatov <mail4score@gmail.com>
2025-05-29 13:44:55 +00:00
Umesh Yadav
703ee29658 Rename Max Mode to Burn Mode throughout code and docs (#31668)
Follow up to https://github.com/zed-industries/zed/pull/31470.

I started looking at config and changed preferred_completion_mode to
burn to only find its max so made changes to align it better with
rebrand. As this is in preview build now.

This doesn't touch zed_llm_client. Only the Zed changes the code and doc
to match the new UI of burn mode. There are still more things to be
renamed, though.

Release Notes:

- N/A

---------

Signed-off-by: Umesh Yadav <git@umesh.dev>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-05-29 13:12:42 +00:00
Kirill Bulatov
f792827a01 Allow to reuse PickerPopoverMenu outside of the model selector (#31684)
LSP button preparation step: move out the component that will be used to
build the button's context menu.

Release Notes:

- N/A
2025-05-29 12:55:47 +00:00
Danilo Leal
45f9edcbb9 docs: Add small refinements to CSS adjacent pages (#31683)
Follow up to https://github.com/zed-industries/zed/pull/31681. Was
visiting some of these pages and noticed these somewhat small formatting
and copywriting improvement opportunities. The docs for Svelte in
particular felt somewhat unorganized.

Release Notes:

- N/A
2025-05-29 08:43:54 -03:00
Danilo Leal
e3354543c0 docs: Improve the Tailwind CSS page (#31681)
Namely, ensuring we mention the support for their Prettier plugins.

Release Notes:

- N/A
2025-05-29 08:15:59 -03:00
Oleksiy Syvokon
cb187b0b4d evals: Configurable number of max dialog turns (#31680)
Release Notes:

- N/A
2025-05-29 10:35:29 +00:00
Kirill Bulatov
d989b2260b Do not react on settings change for disabled minimaps (#31677)
Turning minimap on during debug sessions would cause the console editor
to gain the minimap, despite it being explicitly disabled in the code.

Release Notes:

- N/A
2025-05-29 10:04:27 +00:00
Dhruvin Gandhi
ae076fa415 task: Add ZED_RELATIVE_DIR task variable (#31657)
This is my first contribution to zed, let me know if I missed anything.

There is no corresponding issue/discussion.

`$ZED_RELATIVE_DIR` can be used in cases where a task's command's
filesystem namespace (e.g. inside a container) is different than the
host, where absolute paths cannot work.

I modified `relative_path` to `relative_file` after the addition of
`relative_dir`.

For top-level files, where `relative_file.parent() == Some("")`, I use
`"."` for `$ZED_RELATIVE_DIR`, which is a valid relative path in both
*nix and windows.

Thank you for building zed, and open-sourcing it. I hope to contribute
more as I use it as my primary editor.

Release Notes:

- Added ZED_RELATIVE_DIR (path to current file's directory relative to
worktree root) task variable.
2025-05-29 11:50:36 +02:00
Kirill Bulatov
b4af61edfe Revert "task: Wrap programs in ""s (#31537)" (#31674)
That commit broke a lot, as our one-off tasks (alt-enter in the tasks
modal), npm, jest tasks are all not real commands, but a composition of
commands and arguments.

This reverts commit 5db14d315b.

Closes https://github.com/zed-industries/zed/issues/31554

Release Notes:

- N/A

Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
2025-05-29 09:19:23 +00:00
Smit Barmase
ea8a3be91b recent_projects: Move SSH server entry to initialize once instead of every render (#31650)
Currently, `RemoteEntry::SshConfig` for `ssh_config_servers` initializes
on every render. This leads to side effects like a new focus handle
being created on every render, which leads to breaking navigating
up/down for `ssh_config_servers` items.

This PR fixes it by moving the logic of remote entry
for`ssh_config_servers` into `default_mode`, and only rebuilding it when
`ssh_config_servers` actually changes.

Before:


https://github.com/user-attachments/assets/8c7187d3-16b5-4f96-aa73-fe4f8227b7d0

After:


https://github.com/user-attachments/assets/21588628-8b1c-43fb-bcb8-0b93c70a1e2b

Release Notes:

- Fixed issue navigating SSH config servers in Remote Projects with
keyboard.
2025-05-29 09:24:39 +05:30
Smit Barmase
5173a1a968 recent_projects: Fix remote projects not regaining focus after SSH server connect (#31651)
Closes #28071

Release Notes:

- Fixed issue preventing remote projects modal from regaining focus
after a successful SSH server connection.
2025-05-29 08:55:29 +05:30
Smit Barmase
87f097a0ab terminal_view: Fix terminal stealing focus on editor selection (#31639)
Closes #28234

Release Notes:

- Fixed the issue where the terminal focused when the mouse hovered over
it after selecting text in the editor.
2025-05-29 08:55:12 +05:30
Cole Miller
f9407db7d6 debugger: Add spinners while session is starting up (#31548)
Release Notes:

- Debugger Beta: Added a spinner to the debug panel when a session is
starting up.

---------

Co-authored-by: Remco Smits <djsmits12@gmail.com>
Co-authored-by: Julia <julia@zed.dev>
2025-05-29 01:58:40 +00:00
Cole Miller
384b11392a debugger: Disambiguate child session labels (#31526)
Add `(child)` instead of using the same label.

Release Notes:

- Debugger Beta: Made child sessions appear distinct from their parents
in the session selector.
2025-05-28 21:44:15 -04:00
Cole Miller
f20596c33b debugger: Don't open non-absolute paths from stack frame list (#31534)
Follow-up to #31524 with a more general fix

Release Notes:

- N/A

---------

Co-authored-by: Piotr <piotr@zed.dev>
2025-05-28 21:44:00 -04:00
Marshall Bowers
eb863f8fd6 collab: Use StripeClient when creating Stripe Checkout sessions (#31644)
This PR updates the `StripeBilling::checkout_with_zed_pro` and
`StripeBilling::checkout_with_zed_pro_trial` methods to use the
`StripeClient` trait instead of using `stripe::Client` directly.

Release Notes:

- N/A
2025-05-29 00:57:04 +00:00
Max Brunsfeld
97579662e6 Fix editor rendering slowness with large folds (#31569)
Closes https://github.com/zed-industries/zed/issues/31565

* Looking up settings on every row was very slow in the case of large
folds, especially if there was an `.editorconfig` file with numerous
glob patterns
* Checking whether each indent guide was within a fold was very slow,
when a fold spanned many indent guides.

Release Notes:

- Fixed slowness that could happen when editing in the presence of large
folds.
2025-05-28 23:05:06 +00:00
Marshall Bowers
53849cf983 collab: Remove Zed Free as an option when initiating a checkout session (#31638)
This PR removes Zed Free as an option when initiating a checkout
session, as we manage this plan automatically now.

Release Notes:

- N/A
2025-05-28 23:00:54 +00:00
Danilo Leal
1e25249055 docs: Adjust the channels page a bit (#31636)
All the docs related to collaboration could use some deep revamp, but
this PR is just formatting tweaks so it doesn't look broken. The images
weren't showing at all!

Release Notes:

- N/A
2025-05-28 19:27:47 -03:00
Marshall Bowers
469824c350 collab: Use StripeClient for creating model usage meter events (#31633)
This PR updates the `StripeBilling::bill_model_request_usage` method to
use the `StripeClient` trait.

Release Notes:

- N/A
2025-05-28 22:19:43 +00:00
Danilo Leal
a1c645e57e docs: Improve footer button design (#31634)
Just touching up these a little bit. I think including the page
destination as a label here is a good move!

Release Notes:

- N/A
2025-05-28 19:16:40 -03:00
Danilo Leal
0791596cda docs: Hide "on this page" element when there are no headings (#31635)
We were still showing the "On this page" element even when the page
didn't contain any h2s or h3s.

Release Notes:

- N/A
2025-05-28 19:16:32 -03:00
Finn Evers
9cc1851be7 python: Improve docstring highlighting (#31628)
This PR broadens the highlighting for docstrings in Python. 

Previously, only the first docstring for e.g. type aliases was
highlighted in Python files. This happened as only the first occurrence
in the module was considered a docstring. With this change, now all
existing docstrings are actually highlighted as such.

| `main` | This PR | 
| --- | --- |
|
![main](https://github.com/user-attachments/assets/facc96a9-4e98-4063-8b93-d6e9884221ff)
|
![PR](https://github.com/user-attachments/assets/9da557a1-b327-466a-be87-65d6a811e24c)
|

Release Notes:

- Added more docstring highlights for Python.
2025-05-29 00:02:40 +02:00
Finn Evers
50bd8770bd file_finder: Reduce vertical padding in footer (#31632)
Follow-up to #31542

This PR reduces the vertical padding in the file finders footer. We can
remove this padding as we already apply it just above


a5a116439e/crates/file_finder/src/file_finder.rs (L1500)

This also ensures that the items on the right side have the same padding
to the border as the icon on the left side. Currently, due to the
padding being applied twice, the items on the right side have `pr_4` as
well as `py_4` in practice, which seems a little excessive.

| `main` | This PR |
| --- | --- |
|
![file_finder_main](https://github.com/user-attachments/assets/352d2ac9-04a9-487d-96ca-b009b797809b)
|
![file_finder_pr](https://github.com/user-attachments/assets/c0b44beb-ff2c-4e93-a5b1-2393652a2a58)
|


Release Notes:

- N/A
2025-05-28 21:29:51 +00:00
Marshall Bowers
00bdebc89d collab: Use StripeClient in StripeBilling::subscribe_to_price (#31631)
This PR updates the `StripeBilling::subscribe_to_price` method to use
the `StripeClient` trait.

Release Notes:

- N/A
2025-05-28 21:17:11 +00:00
Danilo Leal
d5134062ac agent: Add keybinding to toggle Burn Mode (#31630)
One caveat with this PR is that the keybinding still doesn't work for text threads. Will do that in a follow-up.

Release Notes:

- agent: Added a keybinding to toggle Burn Mode on and off.
2025-05-28 18:08:58 -03:00
Julia Ryan
0e9f6986cf nix: Add job names and garnix substitutor (#31625)
This should result in some additional cache hits as I personally use
garnix.

Also added `-v` cachix arg to try to figure out why CI jobs aren't
pushing any paths. Right now they just show ["Pushing is
disabled."](https://github.com/zed-industries/zed/actions/runs/15293723678/job/43018512167#step:13:3)
but I'm not sure if that's due to the `pushFilter` or misconfigured
secrets.

Release Notes:

- N/A
2025-05-28 13:32:12 -07:00
Finn Evers
1035c6aab5 editor: Fix horizontal scrollbar alignment if indent guides are disabled (#31621)
Follow-up to #24887
Follow-up to #31510

This PR ensures that [this misalignment of the horizontal
scrollbar](https://github.com/zed-industries/zed/pull/31510#issuecomment-2912842457)
does not occur. See the entire discussion in the first linked PR as to
why this gap is there in the first place.

I am also aware of the general stance towards comments. Yet, I felt for
this case it is better to just straight up explain how these two things
are connected, as I do believe this is not intuitively clear after all.

Might also be a good time to bring
https://github.com/zed-industries/zed/issues/25519 up again. The
horizontal scrollbar seems huge for the edit file tool card.
Furthermore, since we do not reserve space for the horizontal scrollbar
(yet), this will lead to the last line being not clickable.

Release Notes:

- N/A
2025-05-28 22:59:51 +03:00
Marshall Bowers
75e69a5ae9 collab: Use StripeClient to retrieve prices and meters from Stripe (#31624)
This PR updates `StripeBilling` to use the `StripeClient` trait to
retrieve prices and meters from Stripe instead of using the
`stripe::Client` directly.

Release Notes:

- N/A
2025-05-28 19:51:06 +00:00
Oleksiy Syvokon
05afe95539 agent: Fix bug in creating empty files (#31626)
Release Notes:

- NA
2025-05-28 19:31:54 +00:00
Oleksiy Syvokon
a5a116439e agent: Rejecting agent changes shouldn't discard user edits (#31617)
The fix prevents data loss, but it also results in a somewhat confusing
UX. Specifically, after the user has made changes to an AI-created file,
selecting "Reject" will leave AI changes in place.

This is because there's no trivial way to disentangle user edits from
the edits made by the AI.

A better solution might exist. In the meantime, this change should do.
    
Closes
* #30527 

Release Notes:

- Prevent data loss when reverting changes in an agent-created file
2025-05-28 18:44:49 +00:00
Marshall Bowers
361ceee72b collab: Introduce StripeClient trait to abstract over Stripe interactions (#31615)
This PR introduces a new `StripeClient` trait to abstract over
interacting with the Stripe API.

This will allow us to more easily test our billing code.

This initial cut is small and focuses just on making
`StripeBilling::find_or_create_customer_by_email` testable. I'll follow
up with using the `StripeClient` in more places.

Release Notes:

- N/A
2025-05-28 18:34:44 +00:00
Danilo Leal
68724ea99e agent: Make clicking on the backdrop to dismiss message editing more reliable (#31614)
Previously, the click on the backdrop to dismiss the message editing was
unreliable. You would click on it and sometimes it would work and others
it wouldn't. This PR fixes that now.

Release Notes:

- agent: Fixes the previous message dismissal by clicking on the
backdrop
2025-05-28 15:29:52 -03:00
Danilo Leal
e12106e025 agent: Move focus to the panel after dismissing a user message edit (#31611)
Previously, when you clicked on a previous message to edit it and then
dismissed it, your focus would jump to the buffer. This caught me
several times as the most obvious place to return to for me was the
agent panel main message editor, so I can continue prompting something
else. And this is what this PR changes.

Release Notes:

- agent: Improved previous message editing UX by returning focus to the
main panel's text area after dismissing it.
2025-05-28 15:24:58 -03:00
Umesh Yadav
77aa667bf3 docs: Update LM Studio docs to show tool use is supported (#31610)
As the lmstudio tool call support was added recently:
https://github.com/zed-industries/zed/pull/30589. This updates the doc
to reflect it.

Release Notes:

- N/A
2025-05-28 20:09:20 +02:00
Peter Tripp
8b47b40dc0 Improve AI GitHub Issue template (#31598)
Release Notes:

- N/A
2025-05-28 13:54:07 -04:00
Max Brunsfeld
01990c8375 Bump Tree-sitter to 0.25.5 for YAML-editing crash fix (#31603)
Closes https://github.com/zed-industries/zed/issues/31380

See https://github.com/tree-sitter/tree-sitter/pull/4472 for the fix

Release Notes:

- Fixed a crash that could occur when editing YAML files.
2025-05-28 10:12:27 -07:00
Umesh Yadav
4e7dc37f01 language_models: Remove handling of WrappedTextContent in tool result content (#31605)
Fixes ci pipeline

Release Notes:

- N/A
2025-05-28 16:43:08 +00:00
Richard Feldman
00fd045844 Make language model deserialization more resilient (#31311)
This expands our deserialization of JSON from models to be more tolerant
of different variations that the model may send, including
capitalization, wrapping things in objects vs. being plain strings, etc.

Also when deserialization fails, it reports the entire error in the JSON
so we can see what failed to deserialize. (Previously these errors were
very unhelpful at diagnosing the problem.)

Finally, also removes the `WrappedText` variant since the custom
deserializer just turns that style of JSON into a normal `Text` variant.

Release Notes:

- N/A
2025-05-28 12:06:07 -04:00
Joseph T. Lyons
7443fde4e9 Show version info when downloading and installing updates (#31568)
Follow up to #31179 

In addition to seeing the version when in the `Click to restart and
update Zed` status, this PR allows us to see the version when in
`Downloading Zed update…` or `Installing Zed update…` status, in a
tooltip, when hovering on the activity indicator.

Will merge after tomorrow's release.

Release Notes:

- Added version information, in a tooltip, when hovering on the activity
indicator for both the download and install status.
2025-05-28 11:51:21 -04:00
Joseph T. Lyons
d5ab42aeb8 Clean up some auto updater code (#31543)
This PR simply does a tiny bit of cleanup on some code, where I wasn't
quite happy with the naming and ordering of parameters of the now
`check_if_fetched_version_is_newer` function. There should be no
functional changes here, but I will wait until after tomorrow's release
to merge.

Release Notes:

- N/A
2025-05-28 11:46:41 -04:00
Kirill Bulatov
07403f0b08 Improve LSP tasks ergonomics (#31551)
* stopped fetching LSP tasks for too long (but still use the hardcoded
value for the time being — the LSP tasks settings part is a simple bool
key and it's not very simple to fit in another value there)

* introduced `prefer_lsp` language task settings value, to control
whether in the gutter/modal/both/none LSP tasks are shown exclusively,
if possible

Release Notes:

- Added a way to prefer LSP tasks over Zed tasks
2025-05-28 18:36:25 +03:00
Remco Smits
00bc154c46 debugger: Fix invalid schema for pathMappings (#31595)
See
https://github.com/xdebug/vscode-php-debug?tab=readme-ov-file#remote-host-debugging

Release Notes:

- Debugger Beta: Fixed invalid schema for `pathMappings`
2025-05-28 15:16:12 +00:00
Joseph T. Lyons
f627ac92ee Bump Zed to v0.190 (#31592)
Release Notes:

-N/A
2025-05-28 14:36:50 +00:00
Cole Miller
218e8d09c5 Revert "Fix text wrapping in commit message editors (#31030)" (#31587)
This reverts commit f2601ce52c.

Release Notes:

- N/A
2025-05-28 10:16:34 -04:00
261 changed files with 9861 additions and 3586 deletions

View File

@@ -14,7 +14,6 @@ body:
### Description
<!-- Describe with sufficient detail to reproduce from a clean Zed install. -->
<!-- Please include the LLM provider and model name you are using -->
Steps to trigger the problem:
1.
2.
@@ -22,6 +21,13 @@ body:
Actual Behavior:
Expected Behavior:
### Model Provider Details
- Provider: (Anthropic via ZedPro, Anthropic via API key, Copilot Chat, Mistral, OpenAI, etc)
- Model Name:
- Mode: (Agent Panel, Inline Assistant, Terminal Assistant or Text Threads)
- MCP Servers in-use:
- Other Details:
validations:
required: true

View File

@@ -482,7 +482,9 @@ jobs:
- macos_tests
- windows_clippy
- windows_tests
if: always()
if: |
github.repository_owner == 'zed-industries' &&
always()
steps:
- name: Check all tests passed
run: |
@@ -714,6 +716,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
nix-build:
name: Build with Nix
uses: ./.github/workflows/nix.yml
if: github.repository_owner == 'zed-industries' && contains(github.event.pull_request.labels.*.name, 'run-nix')
with:

View File

@@ -56,6 +56,7 @@ jobs:
name: zed
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
pushFilter: "${{ inputs.cachix-filter }}"
cachixArgs: '-v'
- run: nix build .#${{ inputs.flake-output }} -L --accept-flake-config

View File

@@ -168,6 +168,7 @@ jobs:
run: script/upload-nightly linux-targz
bundle-nix:
name: Build and cache Nix package
needs: tests
uses: ./.github/workflows/nix.yml

View File

@@ -2,16 +2,11 @@
{
"label": "Debug Zed (CodeLLDB)",
"adapter": "CodeLLDB",
"program": "$ZED_WORKTREE_ROOT/target/debug/zed",
"request": "launch"
"build": { "label": "Build Zed", "command": "cargo", "args": ["build"] }
},
{
"label": "Debug Zed (GDB)",
"adapter": "GDB",
"program": "$ZED_WORKTREE_ROOT/target/debug/zed",
"request": "launch",
"initialize_args": {
"stopAtBeginningOfMainSubprogram": true
}
"build": { "label": "Build Zed", "command": "cargo", "args": ["build"] }
}
]

35
Cargo.lock generated
View File

@@ -114,6 +114,7 @@ dependencies = [
"serde_json_lenient",
"settings",
"smol",
"sqlez",
"streaming_diff",
"telemetry",
"telemetry_events",
@@ -133,6 +134,7 @@ dependencies = [
"workspace-hack",
"zed_actions",
"zed_llm_client",
"zstd",
]
[[package]]
@@ -525,6 +527,7 @@ dependencies = [
"fuzzy",
"gpui",
"indexed_docs",
"indoc",
"language",
"language_model",
"languages",
@@ -559,6 +562,7 @@ dependencies = [
"workspace",
"workspace-hack",
"zed_actions",
"zed_llm_client",
]
[[package]]
@@ -683,6 +687,7 @@ dependencies = [
"language_model",
"language_models",
"log",
"lsp",
"markdown",
"open",
"paths",
@@ -2198,6 +2203,7 @@ dependencies = [
"editor",
"gpui",
"itertools 0.14.0",
"settings",
"theme",
"ui",
"workspace",
@@ -4732,6 +4738,7 @@ dependencies = [
"tree-sitter-rust",
"tree-sitter-typescript",
"ui",
"unicode-script",
"unicode-segmentation",
"unindent",
"url",
@@ -5045,6 +5052,7 @@ dependencies = [
"util",
"uuid",
"workspace-hack",
"zed_llm_client",
]
[[package]]
@@ -5507,15 +5515,15 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "font-kit"
version = "0.14.1"
source = "git+https://github.com/zed-industries/font-kit?rev=5474cfad4b719a72ec8ed2cb7327b2b01fd10568#5474cfad4b719a72ec8ed2cb7327b2b01fd10568"
version = "0.14.2"
source = "git+https://github.com/zed-industries/font-kit?rev=13dd2eae3b07045d0f51df9a3ff93070b92e2693#13dd2eae3b07045d0f51df9a3ff93070b92e2693"
dependencies = [
"bitflags 2.9.0",
"byteorder",
"core-foundation 0.10.0",
"core-graphics 0.24.0",
"core-text",
"dirs 5.0.1",
"dirs 6.0.0",
"dwrote",
"float-ord",
"freetype-sys",
@@ -5540,9 +5548,9 @@ dependencies = [
[[package]]
name = "fontconfig-parser"
version = "0.5.7"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1fcfcd44ca6e90c921fee9fa665d530b21ef1327a4c1a6c5250ea44b776ada7"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
]
@@ -6147,6 +6155,7 @@ dependencies = [
"workspace",
"workspace-hack",
"zed_actions",
"zed_llm_client",
"zlog",
]
@@ -7064,6 +7073,7 @@ dependencies = [
"image",
"inventory",
"itertools 0.14.0",
"libc",
"log",
"lyon",
"media",
@@ -8929,6 +8939,7 @@ dependencies = [
"async-compression",
"async-tar",
"async-trait",
"chrono",
"collections",
"dap",
"futures 0.3.31",
@@ -8982,6 +8993,7 @@ dependencies = [
"tree-sitter-yaml",
"unindent",
"util",
"which 6.0.3",
"workspace",
"workspace-hack",
]
@@ -15581,6 +15593,7 @@ dependencies = [
"futures 0.3.31",
"gpui",
"hex",
"log",
"parking_lot",
"pretty_assertions",
"proto",
@@ -16480,9 +16493,9 @@ dependencies = [
[[package]]
name = "tree-sitter"
version = "0.25.3"
version = "0.25.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ac5ea5e7f2f1700842ec071401010b9c59bf735295f6e9fa079c3dc035b167"
checksum = "ac5fff5c47490dfdf473b5228039bfacad9d765d9b6939d26bf7cc064c1c7822"
dependencies = [
"cc",
"regex",
@@ -17115,8 +17128,6 @@ dependencies = [
"tempfile",
"tendril",
"unicase",
"unicode-script",
"unicode-segmentation",
"util_macros",
"walkdir",
"workspace-hack",
@@ -19680,7 +19691,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.189.0"
version = "0.190.0"
dependencies = [
"activity_indicator",
"agent",
@@ -19876,9 +19887,9 @@ dependencies = [
[[package]]
name = "zed_llm_client"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a8b9575b215536ed8ad254ba07171e4e13bd029eda3b54cca4b184d2768050"
checksum = "de7d9523255f4e00ee3d0918e5407bd252d798a4a8e71f6d37f23317a1588203"
dependencies = [
"anyhow",
"serde",

View File

@@ -572,7 +572,7 @@ tokio = { version = "1" }
tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] }
toml = "0.8"
tower-http = "0.4.4"
tree-sitter = { version = "0.25.3", features = ["wasm"] }
tree-sitter = { version = "0.25.5", features = ["wasm"] }
tree-sitter-bash = "0.23"
tree-sitter-c = "0.23"
tree-sitter-cpp = "0.23"
@@ -617,7 +617,7 @@ wasmtime = { version = "29", default-features = false, features = [
wasmtime-wasi = "29"
which = "6.0.0"
workspace-hack = "0.1.0"
zed_llm_client = "0.8.3"
zed_llm_client = "0.8.4"
zstd = "0.11"
[workspace.dependencies.async-stripe]

View File

@@ -1,5 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 20H16C14.9391 20 13.9217 19.6629 13.1716 19.0627C12.4214 18.4626 12 17.6487 12 16.8V7.2C12 6.35131 12.4214 5.53737 13.1716 4.93726C13.9217 4.33714 14.9391 4 16 4H17" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 20H8C9.06087 20 10.0783 19.5786 10.8284 18.8284C11.5786 18.0783 12 17.0609 12 16V15" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 4H8C9.06087 4 10.0783 4.42143 10.8284 5.17157C11.5786 5.92172 12 6.93913 12 8V9" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 13H10.4C9.76346 13 9.15302 12.7893 8.70296 12.4142C8.25284 12.0391 8 11.5304 8 11V5C8 4.46957 8.25284 3.96086 8.70296 3.58579C9.15302 3.21071 9.76346 3 10.4 3H11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 13H5.6C6.23654 13 6.84698 12.7893 7.29704 12.4142C7.74716 12.0391 8 11.5304 8 11V5C8 4.46957 7.74716 3.96086 7.29704 3.58579C6.84698 3.21071 6.23654 3 5.6 3H5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 715 B

After

Width:  |  Height:  |  Size: 617 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 3L13 8L4 13V3Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 214 B

View File

@@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 12C2.35977 11.85 1 10.575 1 9" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.00875 15.2C1.00875 13.625 0.683456 12.275 4.00001 12.2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 9C7 10.575 5.62857 11.85 4 12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12.2C6.98117 12.2 7 13.625 7 15.2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="2.5" y="9" width="3" height="6" rx="1.5" fill="black"/>
<path d="M9 10L13 8L4 3V7.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 813 B

View File

@@ -1,3 +1,8 @@
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.36667 3.79167C5.53364 3.79167 4.85833 4.46697 4.85833 5.3C4.85833 6.13303 5.53364 6.80833 6.36667 6.80833C7.1997 6.80833 7.875 6.13303 7.875 5.3C7.875 4.46697 7.1997 3.79167 6.36667 3.79167ZM2.1 5.925H3.67944C3.9626 7.14732 5.05824 8.05833 6.36667 8.05833C7.67509 8.05833 8.77073 7.14732 9.05389 5.925H14.9C15.2452 5.925 15.525 5.64518 15.525 5.3C15.525 4.95482 15.2452 4.675 14.9 4.675H9.05389C8.77073 3.45268 7.67509 2.54167 6.36667 2.54167C5.05824 2.54167 3.9626 3.45268 3.67944 4.675H2.1C1.75482 4.675 1.475 4.95482 1.475 5.3C1.475 5.64518 1.75482 5.925 2.1 5.925ZM13.3206 12.325C13.0374 13.5473 11.9418 14.4583 10.6333 14.4583C9.32491 14.4583 8.22927 13.5473 7.94611 12.325H2.1C1.75482 12.325 1.475 12.0452 1.475 11.7C1.475 11.3548 1.75482 11.075 2.1 11.075H7.94611C8.22927 9.85268 9.32491 8.94167 10.6333 8.94167C11.9418 8.94167 13.0374 9.85268 13.3206 11.075H14.9C15.2452 11.075 15.525 11.3548 15.525 11.7C15.525 12.0452 15.2452 12.325 14.9 12.325H13.3206ZM9.125 11.7C9.125 10.867 9.8003 10.1917 10.6333 10.1917C11.4664 10.1917 12.1417 10.867 12.1417 11.7C12.1417 12.533 11.4664 13.2083 10.6333 13.2083C9.8003 13.2083 9.125 12.533 9.125 11.7Z" fill="black"/>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 5H4" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
<path d="M8 5L14 5" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
<path d="M12 11L14 11" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
<path d="M2 11H8" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="6" cy="5" r="2" fill="black" fill-opacity="0.1" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="10" cy="11" r="2" fill="black" fill-opacity="0.1" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 657 B

View File

@@ -1,5 +1,5 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 1.75L5.88467 5.14092C5.82759 5.31446 5.73055 5.47218 5.60136 5.60136C5.47218 5.73055 5.31446 5.82759 5.14092 5.88467L1.75 7L5.14092 8.11533C5.31446 8.17241 5.47218 8.26945 5.60136 8.39864C5.73055 8.52782 5.82759 8.68554 5.88467 8.85908L7 12.25L8.11533 8.85908C8.17241 8.68554 8.26945 8.52782 8.39864 8.39864C8.52782 8.26945 8.68554 8.17241 8.85908 8.11533L12.25 7L8.85908 5.88467C8.68554 5.82759 8.52782 5.73055 8.39864 5.60136C8.26945 5.47218 8.17241 5.31446 8.11533 5.14092L7 1.75Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.91667 1.75V4.08333M1.75 2.91667H4.08333" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.0833 9.91667V12.25M9.91667 11.0833H12.25" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2L6.72534 5.87534C6.6601 6.07367 6.5492 6.25392 6.40155 6.40155C6.25392 6.5492 6.07367 6.6601 5.87534 6.72534L2 8L5.87534 9.27466C6.07367 9.3399 6.25392 9.4508 6.40155 9.59845C6.5492 9.74608 6.6601 9.92633 6.72534 10.1247L8 14L9.27466 10.1247C9.3399 9.92633 9.4508 9.74608 9.59845 9.59845C9.74608 9.4508 9.92633 9.3399 10.1247 9.27466L14 8L10.1247 6.72534C9.92633 6.6601 9.74608 6.5492 9.59845 6.40155C9.4508 6.25392 9.3399 6.07367 9.27466 5.87534L8 2Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.33334 2V4.66666M2 3.33334H4.66666" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.6665 11.3333V14M11.3333 12.6666H13.9999" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 998 B

View File

@@ -127,9 +127,7 @@
"shift-f10": "editor::OpenContextMenu",
"ctrl-shift-e": "editor::ToggleEditPrediction",
"f9": "editor::ToggleBreakpoint",
"shift-f9": "editor::EditLogBreakpoint",
"ctrl-shift-backspace": "editor::GoToPreviousChange",
"ctrl-shift-alt-backspace": "editor::GoToNextChange"
"shift-f9": "editor::EditLogBreakpoint"
}
},
{
@@ -148,6 +146,8 @@
"ctrl->": "assistant::QuoteSelection",
"ctrl-<": "assistant::InsertIntoEditor",
"ctrl-alt-e": "editor::SelectEnclosingSymbol",
"ctrl-shift-backspace": "editor::GoToPreviousChange",
"ctrl-shift-alt-backspace": "editor::GoToNextChange",
"alt-enter": "editor::OpenSelectionsInMultibuffer"
}
},
@@ -244,13 +244,14 @@
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-alt-/": "agent::ToggleModelSelector",
"ctrl-shift-a": "agent::ToggleContextPicker",
"ctrl-shift-o": "agent::ToggleNavigationMenu",
"ctrl-shift-j": "agent::ToggleNavigationMenu",
"ctrl-shift-i": "agent::ToggleOptionsMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl-alt-e": "agent::RemoveAllContext",
"ctrl-shift-e": "project_panel::ToggleFocus",
"ctrl-shift-enter": "agent::ContinueThread",
"alt-enter": "agent::ContinueWithBurnMode"
"alt-enter": "agent::ContinueWithBurnMode",
"ctrl-alt-b": "agent::ToggleBurnMode"
}
},
{
@@ -927,6 +928,13 @@
"tab": "channel_modal::ToggleMode"
}
},
{
"context": "FileFinder",
"bindings": {
"ctrl-shift-a": "file_finder::ToggleSplitMenu",
"ctrl-shift-i": "file_finder::ToggleFilterMenu"
}
},
{
"context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)",
"bindings": {
@@ -1018,5 +1026,12 @@
"bindings": {
"enter": "menu::Confirm"
}
},
{
"context": "RunModal",
"bindings": {
"ctrl-tab": "pane::ActivateNextItem",
"ctrl-shift-tab": "pane::ActivatePreviousItem"
}
}
]

View File

@@ -279,13 +279,14 @@
"cmd-i": "agent::ToggleProfileSelector",
"cmd-alt-/": "agent::ToggleModelSelector",
"cmd-shift-a": "agent::ToggleContextPicker",
"cmd-shift-o": "agent::ToggleNavigationMenu",
"cmd-shift-j": "agent::ToggleNavigationMenu",
"cmd-shift-i": "agent::ToggleOptionsMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
"cmd-alt-e": "agent::RemoveAllContext",
"cmd-shift-e": "project_panel::ToggleFocus",
"cmd-shift-enter": "agent::ContinueThread",
"alt-enter": "agent::ContinueWithBurnMode"
"alt-enter": "agent::ContinueWithBurnMode",
"cmd-alt-b": "agent::ToggleBurnMode"
}
},
{
@@ -545,9 +546,7 @@
"cmd-\\": "pane::SplitRight",
"cmd-k v": "markdown::OpenPreviewToTheSide",
"cmd-shift-v": "markdown::OpenPreview",
"ctrl-cmd-c": "editor::DisplayCursorNames",
"cmd-shift-backspace": "editor::GoToPreviousChange",
"cmd-shift-alt-backspace": "editor::GoToNextChange"
"ctrl-cmd-c": "editor::DisplayCursorNames"
}
},
{
@@ -555,7 +554,9 @@
"use_key_equivalents": true,
"bindings": {
"cmd-shift-o": "outline::Toggle",
"ctrl-g": "go_to_line::Toggle"
"ctrl-g": "go_to_line::Toggle",
"cmd-shift-backspace": "editor::GoToPreviousChange",
"cmd-shift-alt-backspace": "editor::GoToNextChange"
}
},
{
@@ -986,6 +987,14 @@
"tab": "channel_modal::ToggleMode"
}
},
{
"context": "FileFinder",
"use_key_equivalents": true,
"bindings": {
"cmd-shift-a": "file_finder::ToggleSplitMenu",
"cmd-shift-i": "file_finder::ToggleFilterMenu"
}
},
{
"context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)",
"use_key_equivalents": true,
@@ -1108,5 +1117,13 @@
"bindings": {
"enter": "menu::Confirm"
}
},
{
"context": "RunModal",
"use_key_equivalents": true,
"bindings": {
"ctrl-tab": "pane::ActivateNextItem",
"ctrl-shift-tab": "pane::ActivatePreviousItem"
}
}
]

View File

@@ -0,0 +1,85 @@
[
// Cursor for MacOS. See: https://docs.cursor.com/kbd
{
"context": "Workspace",
"use_key_equivalents": true,
"bindings": {
"ctrl-i": "agent::ToggleFocus",
"ctrl-shift-i": "agent::ToggleFocus",
"ctrl-l": "agent::ToggleFocus",
"ctrl-shift-l": "agent::ToggleFocus",
"ctrl-alt-b": "agent::ToggleFocus",
"ctrl-shift-j": "agent::OpenConfiguration"
}
},
{
"context": "Editor && mode == full",
"use_key_equivalents": true,
"bindings": {
"ctrl-i": "agent::ToggleFocus",
"ctrl-shift-i": "agent::ToggleFocus",
"ctrl-shift-l": "assistant::QuoteSelection", // In cursor uses "Ask" mode
"ctrl-l": "assistant::QuoteSelection", // In cursor uses "Agent" mode
"ctrl-k": "assistant::InlineAssist",
"ctrl-shift-k": "assistant::InsertIntoEditor"
}
},
{
"context": "InlineAssistEditor",
"use_key_equivalents": true,
"bindings": {
"ctrl-shift-backspace": "editor::Cancel"
// "alt-enter": // Quick Question
// "ctrl-shift-enter": // Full File Context
// "ctrl-shift-k": // Toggle input focus (editor <> inline assist)
}
},
{
"context": "AgentPanel || ContextEditor || (MessageEditor > Editor)",
"use_key_equivalents": true,
"bindings": {
"ctrl-i": "workspace::ToggleRightDock",
"ctrl-shift-i": "workspace::ToggleRightDock",
"ctrl-l": "workspace::ToggleRightDock",
"ctrl-shift-l": "workspace::ToggleRightDock",
"ctrl-alt-b": "workspace::ToggleRightDock",
"ctrl-w": "workspace::ToggleRightDock", // technically should close chat
"ctrl-.": "agent::ToggleProfileSelector",
"ctrl-/": "agent::ToggleModelSelector",
"ctrl-shift-backspace": "editor::Cancel",
"ctrl-r": "agent::NewThread",
"ctrl-shift-v": "editor::Paste",
"ctrl-shift-k": "assistant::InsertIntoEditor"
// "escape": "agent::ToggleFocus"
///// Enable when Zed supports multiple thread tabs
// "ctrl-t": // new thread tab
// "ctrl-[": // next thread tab
// "ctrl-]": // next thread tab
///// Enable if Zed adds support for keyboard navigation of thread elements
// "tab": // cycle to next message
// "shift-tab": // cycle to previous message
}
},
{
"context": "Editor && editor_agent_diff",
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "agent::KeepAll",
"ctrl-backspace": "agent::RejectAll"
}
},
{
"context": "Editor && mode == full && edit_prediction",
"use_key_equivalents": true,
"bindings": {
"ctrl-right": "editor::AcceptPartialEditPrediction"
}
},
{
"context": "Terminal",
"use_key_equivalents": true,
"bindings": {
"ctrl-k": "assistant::InlineAssist"
}
}
]

View File

@@ -51,7 +51,11 @@
"ctrl-k ctrl-l": "editor::ConvertToLowerCase",
"shift-alt-m": "markdown::OpenPreviewToTheSide",
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
"ctrl-delete": "editor::DeleteToNextWordEnd"
"ctrl-delete": "editor::DeleteToNextWordEnd",
"ctrl-right": "editor::MoveToNextSubwordEnd",
"ctrl-left": "editor::MoveToPreviousSubwordStart",
"ctrl-shift-right": "editor::SelectToNextSubwordEnd",
"ctrl-shift-left": "editor::SelectToPreviousSubwordStart"
}
},
{

View File

@@ -0,0 +1,85 @@
[
// Cursor for MacOS. See: https://docs.cursor.com/kbd
{
"context": "Workspace",
"use_key_equivalents": true,
"bindings": {
"cmd-i": "agent::ToggleFocus",
"cmd-shift-i": "agent::ToggleFocus",
"cmd-l": "agent::ToggleFocus",
"cmd-shift-l": "agent::ToggleFocus",
"cmd-alt-b": "agent::ToggleFocus",
"cmd-shift-j": "agent::OpenConfiguration"
}
},
{
"context": "Editor && mode == full",
"use_key_equivalents": true,
"bindings": {
"cmd-i": "agent::ToggleFocus",
"cmd-shift-i": "agent::ToggleFocus",
"cmd-shift-l": "assistant::QuoteSelection", // In cursor uses "Ask" mode
"cmd-l": "assistant::QuoteSelection", // In cursor uses "Agent" mode
"cmd-k": "assistant::InlineAssist",
"cmd-shift-k": "assistant::InsertIntoEditor"
}
},
{
"context": "InlineAssistEditor",
"use_key_equivalents": true,
"bindings": {
"cmd-shift-backspace": "editor::Cancel"
// "alt-enter": // Quick Question
// "cmd-shift-enter": // Full File Context
// "cmd-shift-k": // Toggle input focus (editor <> inline assist)
}
},
{
"context": "AgentPanel || ContextEditor || (MessageEditor > Editor)",
"use_key_equivalents": true,
"bindings": {
"cmd-i": "workspace::ToggleRightDock",
"cmd-shift-i": "workspace::ToggleRightDock",
"cmd-l": "workspace::ToggleRightDock",
"cmd-shift-l": "workspace::ToggleRightDock",
"cmd-alt-b": "workspace::ToggleRightDock",
"cmd-w": "workspace::ToggleRightDock", // technically should close chat
"cmd-.": "agent::ToggleProfileSelector",
"cmd-/": "agent::ToggleModelSelector",
"cmd-shift-backspace": "editor::Cancel",
"cmd-r": "agent::NewThread",
"cmd-shift-v": "editor::Paste",
"cmd-shift-k": "assistant::InsertIntoEditor"
// "escape": "agent::ToggleFocus"
///// Enable when Zed supports multiple thread tabs
// "cmd-t": // new thread tab
// "cmd-[": // next thread tab
// "cmd-]": // next thread tab
///// Enable if Zed adds support for keyboard navigation of thread elements
// "tab": // cycle to next message
// "shift-tab": // cycle to previous message
}
},
{
"context": "Editor && editor_agent_diff",
"use_key_equivalents": true,
"bindings": {
"cmd-enter": "agent::KeepAll",
"cmd-backspace": "agent::RejectAll"
}
},
{
"context": "Editor && mode == full && edit_prediction",
"use_key_equivalents": true,
"bindings": {
"cmd-right": "editor::AcceptPartialEditPrediction"
}
},
{
"context": "Terminal",
"use_key_equivalents": true,
"bindings": {
"cmd-k": "assistant::InlineAssist"
}
}
]

View File

@@ -53,7 +53,11 @@
"cmd-shift-j": "editor::JoinLines",
"shift-alt-m": "markdown::OpenPreviewToTheSide",
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
"ctrl-delete": "editor::DeleteToNextWordEnd"
"ctrl-delete": "editor::DeleteToNextWordEnd",
"ctrl-right": "editor::MoveToNextSubwordEnd",
"ctrl-left": "editor::MoveToPreviousSubwordStart",
"ctrl-shift-right": "editor::SelectToNextSubwordEnd",
"ctrl-shift-left": "editor::SelectToPreviousSubwordStart"
}
},
{

View File

@@ -714,7 +714,7 @@
"version": "2",
// Whether the agent is enabled.
"enabled": true,
/// What completion mode to start new threads in, if available. Can be 'normal' or 'max'.
/// What completion mode to start new threads in, if available. Can be 'normal' or 'burn'.
"preferred_completion_mode": "normal",
// Whether to show the agent panel button in the status bar.
"button": true,
@@ -1314,7 +1314,17 @@
// Settings related to running tasks.
"tasks": {
"variables": {},
"enabled": true
"enabled": true,
// Use LSP tasks over Zed language extension ones.
// If no LSP tasks are returned due to error/timeout or regular execution,
// Zed language extension tasks will be used instead.
//
// Other Zed tasks will still be shown:
// * Zed task from either of the task config file
// * Zed task from history (e.g. one-off task was spawned before)
//
// Default: true
"prefer_lsp": true
},
// An object whose keys are language names, and whose values
// are arrays of filenames or extensions of files that should
@@ -1452,9 +1462,7 @@
"language_servers": ["erlang-ls", "!elp", "..."]
},
"Git Commit": {
"allow_rewrap": "anywhere",
"preferred_line_length": 72,
"soft_wrap": "bounded"
"allow_rewrap": "anywhere"
},
"Go": {
"code_actions_on_format": {

View File

@@ -311,6 +311,31 @@ impl ActivityIndicator {
});
}
if let Some(session) = self
.project
.read(cx)
.dap_store()
.read(cx)
.sessions()
.find(|s| !s.read(cx).is_started())
{
return Some(Content {
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))),
)
.into_any_element(),
),
message: format!("Debug: {}", session.read(cx).adapter()),
tooltip_message: Some(session.read(cx).label().to_string()),
on_click: None,
});
}
let current_job = self
.project
.read(cx)
@@ -472,7 +497,7 @@ impl ActivityIndicator {
})),
tooltip_message: None,
}),
AutoUpdateStatus::Downloading => Some(Content {
AutoUpdateStatus::Downloading { version } => Some(Content {
icon: Some(
Icon::new(IconName::Download)
.size(IconSize::Small)
@@ -482,9 +507,9 @@ impl ActivityIndicator {
on_click: Some(Arc::new(|this, window, cx| {
this.dismiss_error_message(&DismissErrorMessage, window, cx)
})),
tooltip_message: None,
tooltip_message: Some(Self::version_tooltip_message(&version)),
}),
AutoUpdateStatus::Installing => Some(Content {
AutoUpdateStatus::Installing { version } => Some(Content {
icon: Some(
Icon::new(IconName::Download)
.size(IconSize::Small)
@@ -494,7 +519,7 @@ impl ActivityIndicator {
on_click: Some(Arc::new(|this, window, cx| {
this.dismiss_error_message(&DismissErrorMessage, window, cx)
})),
tooltip_message: None,
tooltip_message: Some(Self::version_tooltip_message(&version)),
}),
AutoUpdateStatus::Updated {
binary_path,
@@ -508,7 +533,7 @@ impl ActivityIndicator {
};
move |_, _, cx| workspace::reload(&reload, cx)
})),
tooltip_message: Some(Self::install_version_tooltip_message(&version)),
tooltip_message: Some(Self::version_tooltip_message(&version)),
}),
AutoUpdateStatus::Errored => Some(Content {
icon: Some(
@@ -548,8 +573,8 @@ impl ActivityIndicator {
None
}
fn install_version_tooltip_message(version: &VersionCheckType) -> String {
format!("Install version: {}", {
fn version_tooltip_message(version: &VersionCheckType) -> String {
format!("Version: {}", {
match version {
auto_update::VersionCheckType::Sha(sha) => format!("{}", sha.short()),
auto_update::VersionCheckType::Semantic(semantic_version) => {
@@ -699,17 +724,17 @@ mod tests {
use super::*;
#[test]
fn test_install_version_tooltip_message() {
let message = ActivityIndicator::install_version_tooltip_message(
&VersionCheckType::Semantic(SemanticVersion::new(1, 0, 0)),
);
fn test_version_tooltip_message() {
let message = ActivityIndicator::version_tooltip_message(&VersionCheckType::Semantic(
SemanticVersion::new(1, 0, 0),
));
assert_eq!(message, "Install version: 1.0.0");
assert_eq!(message, "Version: 1.0.0");
let message = ActivityIndicator::install_version_tooltip_message(&VersionCheckType::Sha(
let message = ActivityIndicator::version_tooltip_message(&VersionCheckType::Sha(
AppCommitSha::new("14d9a4189f058d8736339b06ff2340101eaea5af".to_string()),
));
assert_eq!(message, "Install version: 14d9a41…");
assert_eq!(message, "Version: 14d9a41…");
}
}

View File

@@ -46,6 +46,7 @@ git.workspace = true
gpui.workspace = true
heed.workspace = true
html_to_markdown.workspace = true
indoc.workspace = true
http_client.workspace = true
indexed_docs.workspace = true
inventory.workspace = true
@@ -78,6 +79,7 @@ serde_json.workspace = true
serde_json_lenient.workspace = true
settings.workspace = true
smol.workspace = true
sqlez.workspace = true
streaming_diff.workspace = true
telemetry.workspace = true
telemetry_events.workspace = true
@@ -97,6 +99,7 @@ workspace-hack.workspace = true
workspace.workspace = true
zed_actions.workspace = true
zed_llm_client.workspace = true
zstd.workspace = true
[dev-dependencies]
buffer_diff = { workspace = true, features = ["test-support"] }

View File

@@ -55,6 +55,7 @@ use util::ResultExt as _;
use util::markdown::MarkdownCodeBlock;
use workspace::{CollaboratorId, Workspace};
use zed_actions::assistant::OpenRulesLibrary;
use zed_llm_client::CompletionIntent;
pub struct ActiveThread {
context_store: Entity<ContextStore>,
@@ -1436,6 +1437,7 @@ impl ActiveThread {
let request = language_model::LanguageModelRequest {
thread_id: None,
prompt_id: None,
intent: None,
mode: None,
messages: vec![request_message],
tools: vec![],
@@ -1533,9 +1535,22 @@ impl ActiveThread {
});
}
fn cancel_editing_message(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
fn cancel_editing_message(
&mut self,
_: &menu::Cancel,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editing_message.take();
cx.notify();
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.focus_handle(cx).focus(window);
}
});
}
}
fn confirm_editing_message(
@@ -1597,7 +1612,12 @@ impl ActiveThread {
this.thread.update(cx, |thread, cx| {
thread.advance_prompt_id();
thread.send_to_model(model.model, Some(window.window_handle()), cx);
thread.send_to_model(
model.model,
CompletionIntent::UserPrompt,
Some(window.window_handle()),
cx,
);
});
this._load_edited_message_context_task = None;
cx.notify();
@@ -1818,6 +1838,7 @@ impl ActiveThread {
let colors = cx.theme().colors();
let editor_bg_color = colors.editor_background;
let panel_bg = colors.panel_background;
let open_as_markdown = IconButton::new(("open-as-markdown", ix), IconName::DocumentText)
.icon_size(IconSize::XSmall)
@@ -1838,7 +1859,6 @@ impl ActiveThread {
const RESPONSE_PADDING_X: Pixels = px(19.);
let show_feedback = thread.is_turn_end(ix);
let feedback_container = h_flex()
.group("feedback_container")
.mt_1()
@@ -2135,16 +2155,14 @@ impl ActiveThread {
message_id > *editing_message_id
});
let panel_background = cx.theme().colors().panel_background;
let backdrop = div()
.id("backdrop")
.stop_mouse_events_except_scroll()
.id(("backdrop", ix))
.size_full()
.absolute()
.inset_0()
.size_full()
.bg(panel_background)
.bg(panel_bg)
.opacity(0.8)
.block_mouse_except_scroll()
.on_click(cx.listener(Self::handle_cancel_click));
v_flex()
@@ -3691,7 +3709,8 @@ mod tests {
// Stream response to user message
thread.update(cx, |thread, cx| {
let request = thread.to_completion_request(model.clone(), cx);
let request =
thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx);
thread.stream_completion(request, model, cx.active_window(), cx)
});
// Follow the agent

View File

@@ -89,6 +89,7 @@ actions!(
ResetTrialEndUpsell,
ContinueThread,
ContinueWithBurnMode,
ToggleBurnMode,
]
);

View File

@@ -699,7 +699,7 @@ fn render_diff_hunk_controls(
.rounded_b_md()
.bg(cx.theme().colors().editor_background)
.gap_1()
.stop_mouse_events_except_scroll()
.block_mouse_except_scroll()
.shadow_md()
.children(vec![
Button::new(("reject", row as u64), "Reject")

View File

@@ -1,10 +1,11 @@
use agent_settings::AgentSettings;
use fs::Fs;
use gpui::{Entity, FocusHandle, SharedString};
use picker::popover_menu::PickerPopoverMenu;
use crate::Thread;
use assistant_context_editor::language_model_selector::{
LanguageModelSelector, LanguageModelSelectorPopoverMenu, ToggleModelSelector,
LanguageModelSelector, ToggleModelSelector, language_model_selector,
};
use language_model::{ConfiguredModel, LanguageModelRegistry};
use settings::update_settings_file;
@@ -35,7 +36,7 @@ impl AgentModelSelector {
Self {
selector: cx.new(move |cx| {
let fs = fs.clone();
LanguageModelSelector::new(
language_model_selector(
{
let model_type = model_type.clone();
move |cx| match &model_type {
@@ -100,15 +101,14 @@ impl AgentModelSelector {
}
impl Render for AgentModelSelector {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle.clone();
let model = self.selector.read(cx).active_model(cx);
let model = self.selector.read(cx).delegate.active_model(cx);
let model_name = model
.map(|model| model.model.name().0)
.unwrap_or_else(|| SharedString::from("No model selected"));
LanguageModelSelectorPopoverMenu::new(
PickerPopoverMenu::new(
self.selector.clone(),
Button::new("active-model", model_name)
.label_size(LabelSize::Small)
@@ -127,7 +127,9 @@ impl Render for AgentModelSelector {
)
},
gpui::Corner::BottomRight,
cx,
)
.with_handle(self.menu_handle.clone())
.render(window, cx)
}
}

View File

@@ -52,7 +52,7 @@ use workspace::{
use zed_actions::agent::{OpenConfiguration, OpenOnboardingModal, ResetOnboarding};
use zed_actions::assistant::{OpenRulesLibrary, ToggleFocus};
use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize};
use zed_llm_client::UsageLimit;
use zed_llm_client::{CompletionIntent, UsageLimit};
use crate::active_thread::{self, ActiveThread, ActiveThreadEvent};
use crate::agent_configuration::{AgentConfiguration, AssistantConfigurationEvent};
@@ -67,8 +67,8 @@ use crate::{
AddContextServer, AgentDiffPane, ContextStore, ContinueThread, ContinueWithBurnMode,
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell,
ResetTrialUpsell, TextThreadStore, ThreadEvent, ToggleContextPicker, ToggleNavigationMenu,
ToggleOptionsMenu,
ResetTrialUpsell, TextThreadStore, ThreadEvent, ToggleBurnMode, ToggleContextPicker,
ToggleNavigationMenu, ToggleOptionsMenu,
};
const AGENT_PANEL_KEY: &str = "agent_panel";
@@ -174,7 +174,7 @@ enum ActiveView {
thread: WeakEntity<Thread>,
_subscriptions: Vec<gpui::Subscription>,
},
PromptEditor {
TextThread {
context_editor: Entity<ContextEditor>,
title_editor: Entity<Editor>,
buffer_search_bar: Entity<BufferSearchBar>,
@@ -194,7 +194,7 @@ impl ActiveView {
pub fn which_font_size_used(&self) -> WhichFontSize {
match self {
ActiveView::Thread { .. } | ActiveView::History => WhichFontSize::AgentFont,
ActiveView::PromptEditor { .. } => WhichFontSize::BufferFont,
ActiveView::TextThread { .. } => WhichFontSize::BufferFont,
ActiveView::Configuration => WhichFontSize::None,
}
}
@@ -333,7 +333,7 @@ impl ActiveView {
buffer_search_bar.set_active_pane_item(Some(&context_editor), window, cx)
});
Self::PromptEditor {
Self::TextThread {
context_editor,
title_editor: editor,
buffer_search_bar,
@@ -1084,9 +1084,23 @@ impl AgentPanel {
pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
match self.active_view {
ActiveView::Configuration | ActiveView::History => {
self.active_view =
ActiveView::thread(self.thread.read(cx).thread().clone(), window, cx);
self.message_editor.focus_handle(cx).focus(window);
if let Some(previous_view) = self.previous_view.take() {
self.active_view = previous_view;
match &self.active_view {
ActiveView::Thread { .. } => {
self.message_editor.focus_handle(cx).focus(window);
}
ActiveView::TextThread { context_editor, .. } => {
context_editor.focus_handle(cx).focus(window);
}
_ => {}
}
} else {
self.active_view =
ActiveView::thread(self.thread.read(cx).thread().clone(), window, cx);
self.message_editor.focus_handle(cx).focus(window);
}
cx.notify();
}
_ => {}
@@ -1296,7 +1310,12 @@ impl AgentPanel {
active_thread.thread().update(cx, |thread, cx| {
thread.insert_invisible_continue_message(cx);
thread.advance_prompt_id();
thread.send_to_model(model, Some(window.window_handle()), cx);
thread.send_to_model(
model,
CompletionIntent::UserPrompt,
Some(window.window_handle()),
cx,
);
});
});
} else {
@@ -1304,9 +1323,27 @@ impl AgentPanel {
}
}
fn toggle_burn_mode(
&mut self,
_: &ToggleBurnMode,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.thread.update(cx, |active_thread, cx| {
active_thread.thread().update(cx, |thread, _cx| {
let current_mode = thread.completion_mode();
thread.set_completion_mode(match current_mode {
CompletionMode::Burn => CompletionMode::Normal,
CompletionMode::Normal => CompletionMode::Burn,
});
});
});
}
pub(crate) fn active_context_editor(&self) -> Option<Entity<ContextEditor>> {
match &self.active_view {
ActiveView::PromptEditor { context_editor, .. } => Some(context_editor.clone()),
ActiveView::TextThread { context_editor, .. } => Some(context_editor.clone()),
_ => None,
}
}
@@ -1329,6 +1366,12 @@ impl AgentPanel {
let current_is_history = matches!(self.active_view, ActiveView::History);
let new_is_history = matches!(new_view, ActiveView::History);
let current_is_config = matches!(self.active_view, ActiveView::Configuration);
let new_is_config = matches!(new_view, ActiveView::Configuration);
let current_is_special = current_is_history || current_is_config;
let new_is_special = new_is_history || new_is_config;
match &self.active_view {
ActiveView::Thread { thread, .. } => {
if let Some(thread) = thread.upgrade() {
@@ -1340,7 +1383,7 @@ impl AgentPanel {
}
}
}
ActiveView::PromptEditor { context_editor, .. } => {
ActiveView::TextThread { context_editor, .. } => {
let context = context_editor.read(cx).context();
// When switching away from an unsaved text thread, delete its entry.
if context.read(cx).path().is_none() {
@@ -1360,7 +1403,7 @@ impl AgentPanel {
store.push_recently_opened_entry(RecentEntry::Thread(id, thread), cx);
}
}),
ActiveView::PromptEditor { context_editor, .. } => {
ActiveView::TextThread { context_editor, .. } => {
self.history_store.update(cx, |store, cx| {
let context = context_editor.read(cx).context().clone();
store.push_recently_opened_entry(RecentEntry::Context(context), cx)
@@ -1369,12 +1412,12 @@ impl AgentPanel {
_ => {}
}
if current_is_history && !new_is_history {
if current_is_special && !new_is_special {
self.active_view = new_view;
} else if !current_is_history && new_is_history {
} else if !current_is_special && new_is_special {
self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
} else {
if !new_is_history {
if !new_is_special {
self.previous_view = None;
}
self.active_view = new_view;
@@ -1389,7 +1432,7 @@ impl Focusable for AgentPanel {
match &self.active_view {
ActiveView::Thread { .. } => self.message_editor.focus_handle(cx),
ActiveView::History => self.history.focus_handle(cx),
ActiveView::PromptEditor { context_editor, .. } => context_editor.focus_handle(cx),
ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx),
ActiveView::Configuration => {
if let Some(configuration) = self.configuration.as_ref() {
configuration.focus_handle(cx)
@@ -1541,7 +1584,7 @@ impl AgentPanel {
.into_any_element(),
}
}
ActiveView::PromptEditor {
ActiveView::TextThread {
title_editor,
context_editor,
..
@@ -1633,7 +1676,7 @@ impl AgentPanel {
let show_token_count = match &self.active_view {
ActiveView::Thread { .. } => !is_empty || !editor_empty,
ActiveView::PromptEditor { .. } => true,
ActiveView::TextThread { .. } => true,
_ => false,
};
@@ -1949,7 +1992,7 @@ impl AgentPanel {
Some(token_count)
}
ActiveView::PromptEditor { context_editor, .. } => {
ActiveView::TextThread { context_editor, .. } => {
let element = render_remaining_tokens(context_editor, cx)?;
Some(element.into_any_element())
@@ -2663,7 +2706,7 @@ impl AgentPanel {
.on_click(cx.listener(|this, _, window, cx| {
this.thread.update(cx, |active_thread, cx| {
active_thread.thread().update(cx, |thread, _cx| {
thread.set_completion_mode(CompletionMode::Max);
thread.set_completion_mode(CompletionMode::Burn);
});
});
this.continue_conversation(window, cx);
@@ -2867,7 +2910,7 @@ impl AgentPanel {
) -> Div {
let mut registrar = buffer_search::DivRegistrar::new(
|this, _, _cx| match &this.active_view {
ActiveView::PromptEditor {
ActiveView::TextThread {
buffer_search_bar, ..
} => Some(buffer_search_bar.clone()),
_ => None,
@@ -2985,7 +3028,7 @@ impl AgentPanel {
.detach();
});
}
ActiveView::PromptEditor { context_editor, .. } => {
ActiveView::TextThread { context_editor, .. } => {
context_editor.update(cx, |context_editor, cx| {
ContextEditor::insert_dragged_files(
context_editor,
@@ -3012,7 +3055,7 @@ impl AgentPanel {
fn key_context(&self) -> KeyContext {
let mut key_context = KeyContext::new_with_defaults();
key_context.add("AgentPanel");
if matches!(self.active_view, ActiveView::PromptEditor { .. }) {
if matches!(self.active_view, ActiveView::TextThread { .. }) {
key_context.add("prompt_editor");
}
key_context
@@ -3060,11 +3103,12 @@ impl Render for AgentPanel {
.on_action(cx.listener(|this, _: &ContinueWithBurnMode, window, cx| {
this.thread.update(cx, |active_thread, cx| {
active_thread.thread().update(cx, |thread, _cx| {
thread.set_completion_mode(CompletionMode::Max);
thread.set_completion_mode(CompletionMode::Burn);
});
});
this.continue_conversation(window, cx);
}))
.on_action(cx.listener(Self::toggle_burn_mode))
.child(self.render_toolbar(window, cx))
.children(self.render_upsell(window, cx))
.children(self.render_trial_end_upsell(window, cx))
@@ -3077,7 +3121,7 @@ impl Render for AgentPanel {
.children(self.render_last_error(cx))
.child(self.render_drag_target(cx)),
ActiveView::History => parent.child(self.history.clone()),
ActiveView::PromptEditor {
ActiveView::TextThread {
context_editor,
buffer_search_bar,
..

View File

@@ -34,6 +34,7 @@ use std::{
};
use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff};
use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
use zed_llm_client::CompletionIntent;
pub struct BufferCodegen {
alternatives: Vec<Entity<CodegenAlternative>>,
@@ -464,6 +465,7 @@ impl CodegenAlternative {
LanguageModelRequest {
thread_id: None,
prompt_id: None,
intent: Some(CompletionIntent::InlineAssist),
mode: None,
tools: Vec::new(),
tool_choice: None,

View File

@@ -734,6 +734,7 @@ impl Display for RulesContext {
#[derive(Debug, Clone)]
pub struct ImageContext {
pub project_path: Option<ProjectPath>,
pub full_path: Option<Arc<Path>>,
pub original_image: Arc<gpui::Image>,
// TODO: handle this elsewhere and remove `ignore-interior-mutability` opt-out in clippy.toml
// needed due to a false positive of `clippy::mutable_key_type`.

View File

@@ -7,7 +7,7 @@ use assistant_context_editor::AssistantContext;
use collections::{HashSet, IndexSet};
use futures::{self, FutureExt};
use gpui::{App, Context, Entity, EventEmitter, Image, SharedString, Task, WeakEntity};
use language::Buffer;
use language::{Buffer, File as _};
use language_model::LanguageModelImage;
use project::image_store::is_image_file;
use project::{Project, ProjectItem, ProjectPath, Symbol};
@@ -304,11 +304,13 @@ impl ContextStore {
project.open_image(project_path.clone(), cx)
})?;
let image_item = open_image_task.await?;
let image = image_item.read_with(cx, |image_item, _| image_item.image.clone())?;
this.update(cx, |this, cx| {
let item = image_item.read(cx);
this.insert_image(
Some(image_item.read(cx).project_path(cx)),
image,
Some(item.project_path(cx)),
Some(item.file.full_path(cx).into()),
item.image.clone(),
remove_if_exists,
cx,
)
@@ -317,12 +319,13 @@ impl ContextStore {
}
pub fn add_image_instance(&mut self, image: Arc<Image>, cx: &mut Context<ContextStore>) {
self.insert_image(None, image, false, cx);
self.insert_image(None, None, image, false, cx);
}
fn insert_image(
&mut self,
project_path: Option<ProjectPath>,
full_path: Option<Arc<Path>>,
image: Arc<Image>,
remove_if_exists: bool,
cx: &mut Context<ContextStore>,
@@ -330,6 +333,7 @@ impl ContextStore {
let image_task = LanguageModelImage::from_image(image.clone(), cx).shared();
let context = AgentContextHandle::Image(ImageContext {
project_path,
full_path,
original_image: image,
image_task,
context_id: self.next_context_id.post_inc(),

View File

@@ -1445,7 +1445,7 @@ impl InlineAssistant {
style: BlockStyle::Flex,
render: Arc::new(move |cx| {
div()
.block_mouse_down()
.block_mouse_except_scroll()
.bg(cx.theme().status().deleted_background)
.size_full()
.h(height as f32 * cx.window.line_height())

View File

@@ -100,7 +100,7 @@ impl<T: 'static> Render for PromptEditor<T> {
v_flex()
.key_context("PromptEditor")
.bg(cx.theme().colors().editor_background)
.block_mouse_down()
.block_mouse_except_scroll()
.gap_0p5()
.border_y_1()
.border_color(cx.theme().status().info_border)

View File

@@ -42,6 +42,7 @@ use theme::ThemeSettings;
use ui::{Disclosure, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
use util::{ResultExt as _, maybe};
use workspace::{CollaboratorId, Workspace};
use zed_llm_client::CompletionIntent;
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention};
use crate::context_store::ContextStore;
@@ -51,7 +52,7 @@ use crate::thread::{MessageCrease, Thread, TokenUsageRatio};
use crate::thread_store::{TextThreadStore, ThreadStore};
use crate::{
ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, NewThread,
OpenAgentDiff, RemoveAllContext, ToggleContextPicker, ToggleProfileSelector,
OpenAgentDiff, RemoveAllContext, ToggleBurnMode, ToggleContextPicker, ToggleProfileSelector,
register_agent_preview,
};
@@ -375,7 +376,12 @@ impl MessageEditor {
thread
.update(cx, |thread, cx| {
thread.advance_prompt_id();
thread.send_to_model(model, Some(window_handle), cx);
thread.send_to_model(
model,
CompletionIntent::UserPrompt,
Some(window_handle),
cx,
);
})
.log_err();
})
@@ -471,6 +477,22 @@ impl MessageEditor {
}
}
pub fn toggle_burn_mode(
&mut self,
_: &ToggleBurnMode,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.thread.update(cx, |thread, _cx| {
let active_completion_mode = thread.completion_mode();
thread.set_completion_mode(match active_completion_mode {
CompletionMode::Burn => CompletionMode::Normal,
CompletionMode::Normal => CompletionMode::Burn,
});
});
}
fn render_max_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let thread = self.thread.read(cx);
let model = thread.configured_model();
@@ -479,8 +501,8 @@ impl MessageEditor {
}
let active_completion_mode = thread.completion_mode();
let max_mode_enabled = active_completion_mode == CompletionMode::Max;
let icon = if max_mode_enabled {
let burn_mode_enabled = active_completion_mode == CompletionMode::Burn;
let icon = if burn_mode_enabled {
IconName::ZedBurnModeOn
} else {
IconName::ZedBurnMode
@@ -490,18 +512,13 @@ impl MessageEditor {
IconButton::new("burn-mode", icon)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.toggle_state(max_mode_enabled)
.toggle_state(burn_mode_enabled)
.selected_icon_color(Color::Error)
.on_click(cx.listener(move |this, _event, _window, cx| {
this.thread.update(cx, |thread, _cx| {
thread.set_completion_mode(match active_completion_mode {
CompletionMode::Max => CompletionMode::Normal,
CompletionMode::Normal => CompletionMode::Max,
});
});
.on_click(cx.listener(|this, _event, window, cx| {
this.toggle_burn_mode(&ToggleBurnMode, window, cx);
}))
.tooltip(move |_window, cx| {
cx.new(|_| MaxModeTooltip::new().selected(max_mode_enabled))
cx.new(|_| MaxModeTooltip::new().selected(burn_mode_enabled))
.into()
})
.into_any_element(),
@@ -596,6 +613,7 @@ impl MessageEditor {
.on_action(cx.listener(Self::remove_all_context))
.on_action(cx.listener(Self::move_up))
.on_action(cx.listener(Self::expand_message_editor))
.on_action(cx.listener(Self::toggle_burn_mode))
.capture_action(cx.listener(Self::paste))
.gap_2()
.p_2()
@@ -1268,6 +1286,7 @@ impl MessageEditor {
let request = language_model::LanguageModelRequest {
thread_id: None,
prompt_id: None,
intent: None,
mode: None,
messages: vec![request_message],
tools: vec![],

View File

@@ -0,0 +1 @@
These files changed since last read:

View File

@@ -0,0 +1,6 @@
Generate a detailed summary of this conversation. Include:
1. A brief overview of what was discussed
2. Key facts or information discovered
3. Outcomes or conclusions reached
4. Any action items or next steps if any
Format it in Markdown with headings and bullet points.

View File

@@ -0,0 +1,4 @@
Generate a concise 3-7 word title for this conversation, omitting punctuation.
Go straight to the title, without any preamble and prefix like `Here's a concise suggestion:...` or `Title:`.
If the conversation is about a specific subject, include it in the title.
Be descriptive. DO NOT speak in the first person.

View File

@@ -25,6 +25,7 @@ use terminal_view::TerminalView;
use ui::prelude::*;
use util::ResultExt;
use workspace::{Toast, Workspace, notifications::NotificationId};
use zed_llm_client::CompletionIntent;
pub fn init(
fs: Arc<dyn Fs>,
@@ -105,7 +106,7 @@ impl TerminalInlineAssistant {
});
let prompt_editor_render = prompt_editor.clone();
let block = terminal_view::BlockProperties {
height: 2,
height: 4,
render: Box::new(move |_| prompt_editor_render.clone().into_any_element()),
};
terminal_view.update(cx, |terminal_view, cx| {
@@ -291,6 +292,7 @@ impl TerminalInlineAssistant {
thread_id: None,
prompt_id: None,
mode: None,
intent: Some(CompletionIntent::TerminalInlineAssist),
messages: vec![request_message],
tools: Vec::new(),
tool_choice: None,

View File

@@ -24,7 +24,7 @@ use language_model::{
LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult,
LanguageModelToolResultContent, LanguageModelToolUseId, MessageContent,
ModelRequestLimitReachedError, PaymentRequiredError, RequestUsage, Role, SelectedModel,
StopReason, TokenUsage, WrappedTextContent,
StopReason, TokenUsage,
};
use postage::stream::Stream as _;
use project::Project;
@@ -38,7 +38,7 @@ use thiserror::Error;
use ui::Window;
use util::{ResultExt as _, post_inc};
use uuid::Uuid;
use zed_llm_client::CompletionRequestStatus;
use zed_llm_client::{CompletionIntent, CompletionRequestStatus};
use crate::ThreadStore;
use crate::context::{AgentContext, AgentContextHandle, ContextLoadResult, LoadedContext};
@@ -891,10 +891,7 @@ impl Thread {
pub fn output_for_tool(&self, id: &LanguageModelToolUseId) -> Option<&Arc<str>> {
match &self.tool_use.tool_result(id)?.content {
LanguageModelToolResultContent::Text(text)
| LanguageModelToolResultContent::WrappedText(WrappedTextContent { text, .. }) => {
Some(text)
}
LanguageModelToolResultContent::Text(text) => Some(text),
LanguageModelToolResultContent::Image(_) => {
// TODO: We should display image
None
@@ -1187,6 +1184,7 @@ impl Thread {
pub fn send_to_model(
&mut self,
model: Arc<dyn LanguageModel>,
intent: CompletionIntent,
window: Option<AnyWindowHandle>,
cx: &mut Context<Self>,
) {
@@ -1196,7 +1194,7 @@ impl Thread {
self.remaining_turns -= 1;
let request = self.to_completion_request(model.clone(), cx);
let request = self.to_completion_request(model.clone(), intent, cx);
self.stream_completion(request, model, window, cx);
}
@@ -1216,11 +1214,13 @@ impl Thread {
pub fn to_completion_request(
&self,
model: Arc<dyn LanguageModel>,
intent: CompletionIntent,
cx: &mut Context<Self>,
) -> LanguageModelRequest {
let mut request = LanguageModelRequest {
thread_id: Some(self.id.to_string()),
prompt_id: Some(self.last_prompt_id.to_string()),
intent: Some(intent),
mode: None,
messages: vec![],
tools: Vec::new(),
@@ -1374,12 +1374,14 @@ impl Thread {
fn to_summarize_request(
&self,
model: &Arc<dyn LanguageModel>,
intent: CompletionIntent,
added_user_message: String,
cx: &App,
) -> LanguageModelRequest {
let mut request = LanguageModelRequest {
thread_id: None,
prompt_id: None,
intent: Some(intent),
mode: None,
messages: vec![],
tools: Vec::new(),
@@ -1426,7 +1428,7 @@ impl Thread {
messages: &mut Vec<LanguageModelRequestMessage>,
cx: &App,
) {
const STALE_FILES_HEADER: &str = "These files changed since last read:";
const STALE_FILES_HEADER: &str = include_str!("./prompts/stale_files_prompt_header.txt");
let mut stale_message = String::new();
@@ -1438,7 +1440,7 @@ impl Thread {
};
if stale_message.is_empty() {
write!(&mut stale_message, "{}\n", STALE_FILES_HEADER).ok();
write!(&mut stale_message, "{}\n", STALE_FILES_HEADER.trim()).ok();
}
writeln!(&mut stale_message, "- {}", file.path().display()).ok();
@@ -1852,12 +1854,14 @@ impl Thread {
return;
}
let added_user_message = "Generate a concise 3-7 word title for this conversation, omitting punctuation. \
Go straight to the title, without any preamble and prefix like `Here's a concise suggestion:...` or `Title:`. \
If the conversation is about a specific subject, include it in the title. \
Be descriptive. DO NOT speak in the first person.";
let added_user_message = include_str!("./prompts/summarize_thread_prompt.txt");
let request = self.to_summarize_request(&model.model, added_user_message.into(), cx);
let request = self.to_summarize_request(
&model.model,
CompletionIntent::ThreadSummarization,
added_user_message.into(),
cx,
);
self.summary = ThreadSummary::Generating;
@@ -1951,14 +1955,14 @@ impl Thread {
return;
}
let added_user_message = "Generate a detailed summary of this conversation. Include:\n\
1. A brief overview of what was discussed\n\
2. Key facts or information discovered\n\
3. Outcomes or conclusions reached\n\
4. Any action items or next steps if any\n\
Format it in Markdown with headings and bullet points.";
let added_user_message = include_str!("./prompts/summarize_thread_detailed_prompt.txt");
let request = self.to_summarize_request(&model, added_user_message.into(), cx);
let request = self.to_summarize_request(
&model,
CompletionIntent::ThreadContextSummarization,
added_user_message.into(),
cx,
);
*self.detailed_summary_tx.borrow_mut() = DetailedSummaryState::Generating {
message_id: last_message_id,
@@ -2050,7 +2054,8 @@ impl Thread {
model: Arc<dyn LanguageModel>,
) -> Vec<PendingToolUse> {
self.auto_capture_telemetry(cx);
let request = Arc::new(self.to_completion_request(model.clone(), cx));
let request =
Arc::new(self.to_completion_request(model.clone(), CompletionIntent::ToolResults, cx));
let pending_tool_uses = self
.tool_use
.pending_tool_uses()
@@ -2246,7 +2251,7 @@ impl Thread {
if self.all_tools_finished() {
if let Some(ConfiguredModel { model, .. }) = self.configured_model.as_ref() {
if !canceled {
self.send_to_model(model.clone(), window, cx);
self.send_to_model(model.clone(), CompletionIntent::ToolResults, window, cx);
}
self.auto_capture_telemetry(cx);
}
@@ -2593,11 +2598,7 @@ impl Thread {
writeln!(markdown, "**\n")?;
match &tool_result.content {
LanguageModelToolResultContent::Text(text)
| LanguageModelToolResultContent::WrappedText(WrappedTextContent {
text,
..
}) => {
LanguageModelToolResultContent::Text(text) => {
writeln!(markdown, "{text}")?;
}
LanguageModelToolResultContent::Image(image) => {
@@ -2941,7 +2942,7 @@ fn main() {{
// Check message in request
let request = thread.update(cx, |thread, cx| {
thread.to_completion_request(model.clone(), cx)
thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx)
});
assert_eq!(request.messages.len(), 2);
@@ -3036,7 +3037,7 @@ fn main() {{
// Check entire request to make sure all contexts are properly included
let request = thread.update(cx, |thread, cx| {
thread.to_completion_request(model.clone(), cx)
thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx)
});
// The request should contain all 3 messages
@@ -3143,7 +3144,7 @@ fn main() {{
// Check message in request
let request = thread.update(cx, |thread, cx| {
thread.to_completion_request(model.clone(), cx)
thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx)
});
assert_eq!(request.messages.len(), 2);
@@ -3169,7 +3170,7 @@ fn main() {{
// Check that both messages appear in the request
let request = thread.update(cx, |thread, cx| {
thread.to_completion_request(model.clone(), cx)
thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx)
});
assert_eq!(request.messages.len(), 3);
@@ -3214,7 +3215,7 @@ fn main() {{
// Create a request and check that it doesn't have a stale buffer warning yet
let initial_request = thread.update(cx, |thread, cx| {
thread.to_completion_request(model.clone(), cx)
thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx)
});
// Make sure we don't have a stale file warning yet
@@ -3250,7 +3251,7 @@ fn main() {{
// Create a new request and check for the stale buffer warning
let new_request = thread.update(cx, |thread, cx| {
thread.to_completion_request(model.clone(), cx)
thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx)
});
// We should have a stale file warning as the last message
@@ -3300,7 +3301,7 @@ fn main() {{
});
let request = thread.update(cx, |thread, cx| {
thread.to_completion_request(model.clone(), cx)
thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx)
});
assert_eq!(request.temperature, Some(0.66));
@@ -3320,7 +3321,7 @@ fn main() {{
});
let request = thread.update(cx, |thread, cx| {
thread.to_completion_request(model.clone(), cx)
thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx)
});
assert_eq!(request.temperature, Some(0.66));
@@ -3340,7 +3341,7 @@ fn main() {{
});
let request = thread.update(cx, |thread, cx| {
thread.to_completion_request(model.clone(), cx)
thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx)
});
assert_eq!(request.temperature, Some(0.66));
@@ -3360,7 +3361,7 @@ fn main() {{
});
let request = thread.update(cx, |thread, cx| {
thread.to_completion_request(model.clone(), cx)
thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx)
});
assert_eq!(request.temperature, None);
}
@@ -3392,7 +3393,12 @@ fn main() {{
// Send a message
thread.update(cx, |thread, cx| {
thread.insert_user_message("Hi!", ContextLoadResult::default(), None, vec![], cx);
thread.send_to_model(model.clone(), None, cx);
thread.send_to_model(
model.clone(),
CompletionIntent::ThreadSummarization,
None,
cx,
);
});
let fake_model = model.as_fake();
@@ -3487,7 +3493,7 @@ fn main() {{
vec![],
cx,
);
thread.send_to_model(model.clone(), None, cx);
thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx);
});
let fake_model = model.as_fake();
@@ -3525,7 +3531,12 @@ fn main() {{
) {
thread.update(cx, |thread, cx| {
thread.insert_user_message("Hi!", ContextLoadResult::default(), None, vec![], cx);
thread.send_to_model(model.clone(), None, cx);
thread.send_to_model(
model.clone(),
CompletionIntent::ThreadSummarization,
None,
cx,
);
});
let fake_model = model.as_fake();

View File

@@ -1,8 +1,7 @@
use std::borrow::Cow;
use std::cell::{Ref, RefCell};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use agent_settings::{AgentProfile, AgentProfileId, AgentSettings, CompletionMode};
use anyhow::{Context as _, Result, anyhow};
@@ -17,8 +16,7 @@ use gpui::{
App, BackgroundExecutor, Context, Entity, EventEmitter, Global, ReadGlobal, SharedString,
Subscription, Task, prelude::*,
};
use heed::Database;
use heed::types::SerdeBincode;
use language_model::{LanguageModelToolResultContent, LanguageModelToolUseId, Role, TokenUsage};
use project::context_server_store::{ContextServerStatus, ContextServerStore};
use project::{Project, ProjectItem, ProjectPath, Worktree};
@@ -35,6 +33,42 @@ use crate::context_server_tool::ContextServerTool;
use crate::thread::{
DetailedSummaryState, ExceededWindowError, MessageId, ProjectSnapshot, Thread, ThreadId,
};
use indoc::indoc;
use sqlez::{
bindable::{Bind, Column},
connection::Connection,
statement::Statement,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataType {
#[serde(rename = "json")]
Json,
#[serde(rename = "zstd")]
Zstd,
}
impl Bind for DataType {
fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
let value = match self {
DataType::Json => "json",
DataType::Zstd => "zstd",
};
value.bind(statement, start_index)
}
}
impl Column for DataType {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let (value, next_index) = String::column(statement, start_index)?;
let data_type = match value.as_str() {
"json" => DataType::Json,
"zstd" => DataType::Zstd,
_ => anyhow::bail!("Unknown data type: {}", value),
};
Ok((data_type, next_index))
}
}
const RULES_FILE_NAMES: [&'static str; 6] = [
".rules",
@@ -866,25 +900,27 @@ impl Global for GlobalThreadsDatabase {}
pub(crate) struct ThreadsDatabase {
executor: BackgroundExecutor,
env: heed::Env,
threads: Database<SerdeBincode<ThreadId>, SerializedThread>,
connection: Arc<Mutex<Connection>>,
}
impl heed::BytesEncode<'_> for SerializedThread {
type EItem = SerializedThread;
impl ThreadsDatabase {
fn connection(&self) -> Arc<Mutex<Connection>> {
self.connection.clone()
}
fn bytes_encode(item: &Self::EItem) -> Result<Cow<[u8]>, heed::BoxedError> {
serde_json::to_vec(item).map(Cow::Owned).map_err(Into::into)
const COMPRESSION_LEVEL: i32 = 3;
}
impl Bind for ThreadId {
fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
self.to_string().bind(statement, start_index)
}
}
impl<'a> heed::BytesDecode<'a> for SerializedThread {
type DItem = SerializedThread;
fn bytes_decode(bytes: &'a [u8]) -> Result<Self::DItem, heed::BoxedError> {
// We implement this type manually because we want to call `SerializedThread::from_json`,
// instead of the Deserialize trait implementation for `SerializedThread`.
SerializedThread::from_json(bytes).map_err(Into::into)
impl Column for ThreadId {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let (id_str, next_index) = String::column(statement, start_index)?;
Ok((ThreadId::from(id_str.as_str()), next_index))
}
}
@@ -900,8 +936,8 @@ impl ThreadsDatabase {
let database_future = executor
.spawn({
let executor = executor.clone();
let database_path = paths::data_dir().join("threads/threads-db.1.mdb");
async move { ThreadsDatabase::new(database_path, executor) }
let threads_dir = paths::data_dir().join("threads");
async move { ThreadsDatabase::new(threads_dir, executor) }
})
.then(|result| future::ready(result.map(Arc::new).map_err(Arc::new)))
.boxed()
@@ -910,41 +946,144 @@ impl ThreadsDatabase {
cx.set_global(GlobalThreadsDatabase(database_future));
}
pub fn new(path: PathBuf, executor: BackgroundExecutor) -> Result<Self> {
std::fs::create_dir_all(&path)?;
pub fn new(threads_dir: PathBuf, executor: BackgroundExecutor) -> Result<Self> {
std::fs::create_dir_all(&threads_dir)?;
let sqlite_path = threads_dir.join("threads.db");
let mdb_path = threads_dir.join("threads-db.1.mdb");
let needs_migration_from_heed = mdb_path.exists();
let connection = Connection::open_file(&sqlite_path.to_string_lossy());
connection.exec(indoc! {"
CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
updated_at TEXT NOT NULL,
data_type TEXT NOT NULL,
data BLOB NOT NULL
)
"})?()
.map_err(|e| anyhow!("Failed to create threads table: {}", e))?;
let db = Self {
executor: executor.clone(),
connection: Arc::new(Mutex::new(connection)),
};
if needs_migration_from_heed {
let db_connection = db.connection();
let executor_clone = executor.clone();
executor
.spawn(async move {
log::info!("Starting threads.db migration");
Self::migrate_from_heed(&mdb_path, db_connection, executor_clone)?;
std::fs::remove_dir_all(mdb_path)?;
log::info!("threads.db migrated to sqlite");
Ok::<(), anyhow::Error>(())
})
.detach();
}
Ok(db)
}
// Remove this migration after 2025-09-01
fn migrate_from_heed(
mdb_path: &Path,
connection: Arc<Mutex<Connection>>,
_executor: BackgroundExecutor,
) -> Result<()> {
use heed::types::SerdeBincode;
struct SerializedThreadHeed(SerializedThread);
impl heed::BytesEncode<'_> for SerializedThreadHeed {
type EItem = SerializedThreadHeed;
fn bytes_encode(
item: &Self::EItem,
) -> Result<std::borrow::Cow<[u8]>, heed::BoxedError> {
serde_json::to_vec(&item.0)
.map(std::borrow::Cow::Owned)
.map_err(Into::into)
}
}
impl<'a> heed::BytesDecode<'a> for SerializedThreadHeed {
type DItem = SerializedThreadHeed;
fn bytes_decode(bytes: &'a [u8]) -> Result<Self::DItem, heed::BoxedError> {
SerializedThread::from_json(bytes)
.map(SerializedThreadHeed)
.map_err(Into::into)
}
}
const ONE_GB_IN_BYTES: usize = 1024 * 1024 * 1024;
let env = unsafe {
heed::EnvOpenOptions::new()
.map_size(ONE_GB_IN_BYTES)
.max_dbs(1)
.open(path)?
.open(mdb_path)?
};
let mut txn = env.write_txn()?;
let threads = env.create_database(&mut txn, Some("threads"))?;
txn.commit()?;
let txn = env.write_txn()?;
let threads: heed::Database<SerdeBincode<ThreadId>, SerializedThreadHeed> = env
.open_database(&txn, Some("threads"))?
.ok_or_else(|| anyhow!("threads database not found"))?;
Ok(Self {
executor,
env,
threads,
})
for result in threads.iter(&txn)? {
let (thread_id, thread_heed) = result?;
Self::save_thread_sync(&connection, thread_id, thread_heed.0)?;
}
Ok(())
}
fn save_thread_sync(
connection: &Arc<Mutex<Connection>>,
id: ThreadId,
thread: SerializedThread,
) -> Result<()> {
let json_data = serde_json::to_string(&thread)?;
let summary = thread.summary.to_string();
let updated_at = thread.updated_at.to_rfc3339();
let connection = connection.lock().unwrap();
let compressed = zstd::encode_all(json_data.as_bytes(), Self::COMPRESSION_LEVEL)?;
let data_type = DataType::Zstd;
let data = compressed;
let mut insert = connection.exec_bound::<(ThreadId, String, String, DataType, Vec<u8>)>(indoc! {"
INSERT OR REPLACE INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?)
"})?;
insert((id, summary, updated_at, data_type, data))?;
Ok(())
}
pub fn list_threads(&self) -> Task<Result<Vec<SerializedThreadMetadata>>> {
let env = self.env.clone();
let threads = self.threads;
let connection = self.connection.clone();
self.executor.spawn(async move {
let txn = env.read_txn()?;
let mut iter = threads.iter(&txn)?;
let connection = connection.lock().unwrap();
let mut select =
connection.select_bound::<(), (ThreadId, String, String)>(indoc! {"
SELECT id, summary, updated_at FROM threads ORDER BY updated_at DESC
"})?;
let rows = select(())?;
let mut threads = Vec::new();
while let Some((key, value)) = iter.next().transpose()? {
for (id, summary, updated_at) in rows {
threads.push(SerializedThreadMetadata {
id: key,
summary: value.summary,
updated_at: value.updated_at,
id,
summary: summary.into(),
updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc),
});
}
@@ -953,36 +1092,51 @@ impl ThreadsDatabase {
}
pub fn try_find_thread(&self, id: ThreadId) -> Task<Result<Option<SerializedThread>>> {
let env = self.env.clone();
let threads = self.threads;
let connection = self.connection.clone();
self.executor.spawn(async move {
let txn = env.read_txn()?;
let thread = threads.get(&txn, &id)?;
Ok(thread)
let connection = connection.lock().unwrap();
let mut select = connection.select_bound::<ThreadId, (DataType, Vec<u8>)>(indoc! {"
SELECT data_type, data FROM threads WHERE id = ? LIMIT 1
"})?;
let rows = select(id)?;
if let Some((data_type, data)) = rows.into_iter().next() {
let json_data = match data_type {
DataType::Zstd => {
let decompressed = zstd::decode_all(&data[..])?;
String::from_utf8(decompressed)?
}
DataType::Json => String::from_utf8(data)?,
};
let thread = SerializedThread::from_json(json_data.as_bytes())?;
Ok(Some(thread))
} else {
Ok(None)
}
})
}
pub fn save_thread(&self, id: ThreadId, thread: SerializedThread) -> Task<Result<()>> {
let env = self.env.clone();
let threads = self.threads;
let connection = self.connection.clone();
self.executor.spawn(async move {
let mut txn = env.write_txn()?;
threads.put(&mut txn, &id, &thread)?;
txn.commit()?;
Ok(())
})
self.executor
.spawn(async move { Self::save_thread_sync(&connection, id, thread) })
}
pub fn delete_thread(&self, id: ThreadId) -> Task<Result<()>> {
let env = self.env.clone();
let threads = self.threads;
let connection = self.connection.clone();
self.executor.spawn(async move {
let mut txn = env.write_txn()?;
threads.delete(&mut txn, &id)?;
txn.commit()?;
let connection = connection.lock().unwrap();
let mut delete = connection.exec_bound::<ThreadId>(indoc! {"
DELETE FROM threads WHERE id = ?
"})?;
delete(id)?;
Ok(())
})
}

View File

@@ -304,7 +304,7 @@ impl AddedContext {
AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)),
AgentContextHandle::TextThread(handle) => Some(Self::pending_text_thread(handle, cx)),
AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx),
AgentContextHandle::Image(handle) => Some(Self::image(handle)),
AgentContextHandle::Image(handle) => Some(Self::image(handle, cx)),
}
}
@@ -318,7 +318,7 @@ impl AddedContext {
AgentContext::Thread(context) => Self::attached_thread(context),
AgentContext::TextThread(context) => Self::attached_text_thread(context),
AgentContext::Rules(context) => Self::attached_rules(context),
AgentContext::Image(context) => Self::image(context.clone()),
AgentContext::Image(context) => Self::image(context.clone(), cx),
}
}
@@ -333,14 +333,8 @@ impl AddedContext {
fn file(handle: FileContextHandle, full_path: &Path, cx: &App) -> AddedContext {
let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
let name = full_path
.file_name()
.map(|n| n.to_string_lossy().into_owned().into())
.unwrap_or_else(|| full_path_string.clone());
let parent = full_path
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned().into());
let (name, parent) =
extract_file_name_and_directory_from_full_path(full_path, &full_path_string);
AddedContext {
kind: ContextKind::File,
name,
@@ -370,14 +364,8 @@ impl AddedContext {
fn directory(handle: DirectoryContextHandle, full_path: &Path) -> AddedContext {
let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
let name = full_path
.file_name()
.map(|n| n.to_string_lossy().into_owned().into())
.unwrap_or_else(|| full_path_string.clone());
let parent = full_path
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned().into());
let (name, parent) =
extract_file_name_and_directory_from_full_path(full_path, &full_path_string);
AddedContext {
kind: ContextKind::Directory,
name,
@@ -605,13 +593,23 @@ impl AddedContext {
}
}
fn image(context: ImageContext) -> AddedContext {
fn image(context: ImageContext, cx: &App) -> AddedContext {
let (name, parent, icon_path) = if let Some(full_path) = context.full_path.as_ref() {
let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
let (name, parent) =
extract_file_name_and_directory_from_full_path(full_path, &full_path_string);
let icon_path = FileIcons::get_icon(&full_path, cx);
(name, parent, icon_path)
} else {
("Image".into(), None, None)
};
AddedContext {
kind: ContextKind::Image,
name: "Image".into(),
parent: None,
name,
parent,
tooltip: None,
icon_path: None,
icon_path,
status: match context.status() {
ImageStatus::Loading => ContextStatus::Loading {
message: "Loading…".into(),
@@ -639,6 +637,22 @@ impl AddedContext {
}
}
fn extract_file_name_and_directory_from_full_path(
path: &Path,
name_fallback: &SharedString,
) -> (SharedString, Option<SharedString>) {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned().into())
.unwrap_or_else(|| name_fallback.clone());
let parent = path
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned().into());
(name, parent)
}
#[derive(Debug, Clone)]
struct ContextFileExcerpt {
pub file_name_and_range: SharedString,
@@ -765,37 +779,49 @@ impl Component for AddedContext {
let mut next_context_id = ContextId::zero();
let image_ready = (
"Ready",
AddedContext::image(ImageContext {
context_id: next_context_id.post_inc(),
project_path: None,
original_image: Arc::new(Image::empty()),
image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
}),
AddedContext::image(
ImageContext {
context_id: next_context_id.post_inc(),
project_path: None,
full_path: None,
original_image: Arc::new(Image::empty()),
image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
},
cx,
),
);
let image_loading = (
"Loading",
AddedContext::image(ImageContext {
context_id: next_context_id.post_inc(),
project_path: None,
original_image: Arc::new(Image::empty()),
image_task: cx
.background_spawn(async move {
smol::Timer::after(Duration::from_secs(60 * 5)).await;
Some(LanguageModelImage::empty())
})
.shared(),
}),
AddedContext::image(
ImageContext {
context_id: next_context_id.post_inc(),
project_path: None,
full_path: None,
original_image: Arc::new(Image::empty()),
image_task: cx
.background_spawn(async move {
smol::Timer::after(Duration::from_secs(60 * 5)).await;
Some(LanguageModelImage::empty())
})
.shared(),
},
cx,
),
);
let image_error = (
"Error",
AddedContext::image(ImageContext {
context_id: next_context_id.post_inc(),
project_path: None,
original_image: Arc::new(Image::empty()),
image_task: Task::ready(None).shared(),
}),
AddedContext::image(
ImageContext {
context_id: next_context_id.post_inc(),
project_path: None,
full_path: None,
original_image: Arc::new(Image::empty()),
image_task: Task::ready(None).shared(),
},
cx,
),
);
Some(

View File

@@ -1,5 +1,6 @@
use gpui::{Context, IntoElement, Render, Window};
use ui::{prelude::*, tooltip_container};
use crate::ToggleBurnMode;
use gpui::{Context, FontWeight, IntoElement, Render, Window};
use ui::{KeyBinding, prelude::*, tooltip_container};
pub struct MaxModeTooltip {
selected: bool,
@@ -18,39 +19,48 @@ impl MaxModeTooltip {
impl Render for MaxModeTooltip {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let icon = if self.selected {
IconName::ZedBurnModeOn
let (icon, color) = if self.selected {
(IconName::ZedBurnModeOn, Color::Error)
} else {
IconName::ZedBurnMode
(IconName::ZedBurnMode, Color::Default)
};
let turned_on = h_flex()
.h_4()
.px_1()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().text_accent.opacity(0.1))
.rounded_sm()
.child(
Label::new("ON")
.size(LabelSize::XSmall)
.weight(FontWeight::SEMIBOLD)
.color(Color::Accent),
);
let title = h_flex()
.gap_1()
.child(Icon::new(icon).size(IconSize::Small))
.child(Label::new("Burn Mode"));
.gap_1p5()
.child(Icon::new(icon).size(IconSize::Small).color(color))
.child(Label::new("Burn Mode"))
.when(self.selected, |title| title.child(turned_on));
let keybinding = KeyBinding::for_action(&ToggleBurnMode, window, cx)
.map(|kb| kb.size(rems_from_px(12.)));
tooltip_container(window, cx, |this, _, _| {
this.gap_0p5()
.map(|header| if self.selected {
header.child(
h_flex()
.justify_between()
.child(title)
.child(
h_flex()
.gap_0p5()
.child(Icon::new(IconName::Check).size(IconSize::XSmall).color(Color::Accent))
.child(Label::new("Turned On").size(LabelSize::XSmall).color(Color::Accent))
)
)
} else {
header.child(title)
})
this
.child(
h_flex()
.justify_between()
.child(title)
.children(keybinding)
)
.child(
div()
.max_w_72()
.max_w_64()
.child(
Label::new("Enables models to use large context windows, unlimited tool calls, and other capabilities for expanded reasoning, offering an unfettered agentic experience.")
Label::new("Enables models to use large context windows, unlimited tool calls, and other capabilities for expanded reasoning.")
.size(LabelSize::Small)
.color(Color::Muted)
)

View File

@@ -372,6 +372,7 @@ impl AgentSettingsContent {
None,
None,
Some(language_model.supports_tools()),
None,
)),
api_url,
});
@@ -689,14 +690,15 @@ pub struct AgentSettingsContentV2 {
pub enum CompletionMode {
#[default]
Normal,
Max,
#[serde(alias = "max")]
Burn,
}
impl From<CompletionMode> for zed_llm_client::CompletionMode {
fn from(value: CompletionMode) -> Self {
match value {
CompletionMode::Normal => zed_llm_client::CompletionMode::Normal,
CompletionMode::Max => zed_llm_client::CompletionMode::Max,
CompletionMode::Burn => zed_llm_client::CompletionMode::Max,
}
}
}

View File

@@ -57,8 +57,10 @@ uuid.workspace = true
workspace-hack.workspace = true
workspace.workspace = true
zed_actions.workspace = true
zed_llm_client.workspace = true
[dev-dependencies]
indoc.workspace = true
language_model = { workspace = true, features = ["test-support"] }
languages = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true

View File

@@ -45,6 +45,7 @@ use text::{BufferSnapshot, ToPoint};
use ui::IconName;
use util::{ResultExt, TryFutureExt, post_inc};
use uuid::Uuid;
use zed_llm_client::CompletionIntent;
#[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ContextId(String);
@@ -2272,6 +2273,7 @@ impl AssistantContext {
let mut completion_request = LanguageModelRequest {
thread_id: None,
prompt_id: None,
intent: Some(CompletionIntent::UserPrompt),
mode: None,
messages: Vec::new(),
tools: Vec::new(),

View File

@@ -1,6 +1,6 @@
use crate::{
language_model_selector::{
LanguageModelSelector, LanguageModelSelectorPopoverMenu, ToggleModelSelector,
LanguageModelSelector, ToggleModelSelector, language_model_selector,
},
max_mode_tooltip::MaxModeTooltip,
};
@@ -43,7 +43,7 @@ use language_model::{
Role,
};
use multi_buffer::MultiBufferRow;
use picker::Picker;
use picker::{Picker, popover_menu::PickerPopoverMenu};
use project::{Project, Worktree};
use project::{ProjectPath, lsp_store::LocalLspAdapterDelegate};
use rope::Point;
@@ -283,7 +283,7 @@ impl ContextEditor {
slash_menu_handle: Default::default(),
dragged_file_worktrees: Vec::new(),
language_model_selector: cx.new(|cx| {
LanguageModelSelector::new(
language_model_selector(
|cx| LanguageModelRegistry::read_global(cx).default_model(),
move |model, cx| {
update_settings_file::<AgentSettings>(
@@ -1646,34 +1646,35 @@ impl ContextEditor {
let context = self.context.read(cx);
let mut text = String::new();
for message in context.messages(cx) {
if message.offset_range.start >= selection.range().end {
break;
} else if message.offset_range.end >= selection.range().start {
let range = cmp::max(message.offset_range.start, selection.range().start)
..cmp::min(message.offset_range.end, selection.range().end);
if range.is_empty() {
let snapshot = context.buffer().read(cx).snapshot();
let point = snapshot.offset_to_point(range.start);
selection.start = snapshot.point_to_offset(Point::new(point.row, 0));
selection.end = snapshot.point_to_offset(cmp::min(
Point::new(point.row + 1, 0),
snapshot.max_point(),
));
for chunk in context.buffer().read(cx).text_for_range(selection.range()) {
text.push_str(chunk);
}
} else {
for chunk in context.buffer().read(cx).text_for_range(range) {
text.push_str(chunk);
}
if message.offset_range.end < selection.range().end {
text.push('\n');
// If selection is empty, we want to copy the entire line
if selection.range().is_empty() {
let snapshot = context.buffer().read(cx).snapshot();
let point = snapshot.offset_to_point(selection.range().start);
selection.start = snapshot.point_to_offset(Point::new(point.row, 0));
selection.end = snapshot
.point_to_offset(cmp::min(Point::new(point.row + 1, 0), snapshot.max_point()));
for chunk in context.buffer().read(cx).text_for_range(selection.range()) {
text.push_str(chunk);
}
} else {
for message in context.messages(cx) {
if message.offset_range.start >= selection.range().end {
break;
} else if message.offset_range.end >= selection.range().start {
let range = cmp::max(message.offset_range.start, selection.range().start)
..cmp::min(message.offset_range.end, selection.range().end);
if !range.is_empty() {
for chunk in context.buffer().read(cx).text_for_range(range) {
text.push_str(chunk);
}
if message.offset_range.end < selection.range().end {
text.push('\n');
}
}
}
}
}
(text, CopyMetadata { creases }, vec![selection])
}
@@ -2071,8 +2072,8 @@ impl ContextEditor {
}
let active_completion_mode = context.completion_mode();
let max_mode_enabled = active_completion_mode == CompletionMode::Max;
let icon = if max_mode_enabled {
let burn_mode_enabled = active_completion_mode == CompletionMode::Burn;
let icon = if burn_mode_enabled {
IconName::ZedBurnModeOn
} else {
IconName::ZedBurnMode
@@ -2082,25 +2083,29 @@ impl ContextEditor {
IconButton::new("burn-mode", icon)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.toggle_state(max_mode_enabled)
.toggle_state(burn_mode_enabled)
.selected_icon_color(Color::Error)
.on_click(cx.listener(move |this, _event, _window, cx| {
this.context().update(cx, |context, _cx| {
context.set_completion_mode(match active_completion_mode {
CompletionMode::Max => CompletionMode::Normal,
CompletionMode::Normal => CompletionMode::Max,
CompletionMode::Burn => CompletionMode::Normal,
CompletionMode::Normal => CompletionMode::Burn,
});
});
}))
.tooltip(move |_window, cx| {
cx.new(|_| MaxModeTooltip::new().selected(max_mode_enabled))
cx.new(|_| MaxModeTooltip::new().selected(burn_mode_enabled))
.into()
})
.into_any_element(),
)
}
fn render_language_model_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
fn render_language_model_selector(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let active_model = LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default| default.model);
@@ -2110,7 +2115,7 @@ impl ContextEditor {
None => SharedString::from("No model selected"),
};
LanguageModelSelectorPopoverMenu::new(
PickerPopoverMenu::new(
self.language_model_selector.clone(),
ButtonLike::new("active-model")
.style(ButtonStyle::Subtle)
@@ -2138,8 +2143,10 @@ impl ContextEditor {
)
},
gpui::Corner::BottomLeft,
cx,
)
.with_handle(self.language_model_selector_menu_handle.clone())
.render(window, cx)
}
fn render_last_error(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
@@ -2615,7 +2622,7 @@ impl Render for ContextEditor {
.child(
h_flex()
.gap_1()
.child(self.render_language_model_selector(cx))
.child(self.render_language_model_selector(window, cx))
.child(self.render_send_button(window, cx)),
),
)
@@ -3258,74 +3265,92 @@ mod tests {
use super::*;
use fs::FakeFs;
use gpui::{App, TestAppContext, VisualTestContext};
use indoc::indoc;
use language::{Buffer, LanguageRegistry};
use pretty_assertions::assert_eq;
use prompt_store::PromptBuilder;
use text::OffsetRangeExt;
use unindent::Unindent;
use util::path;
#[gpui::test]
async fn test_copy_paste_whole_message(cx: &mut TestAppContext) {
let (context, context_editor, mut cx) = setup_context_editor_text(vec![
(Role::User, "What is the Zed editor?"),
(
Role::Assistant,
"Zed is a modern, high-performance code editor designed from the ground up for speed and collaboration.",
),
(Role::User, ""),
],cx).await;
// Select & Copy whole user message
assert_copy_paste_context_editor(
&context_editor,
message_range(&context, 0, &mut cx),
indoc! {"
What is the Zed editor?
Zed is a modern, high-performance code editor designed from the ground up for speed and collaboration.
What is the Zed editor?
"},
&mut cx,
);
// Select & Copy whole assistant message
assert_copy_paste_context_editor(
&context_editor,
message_range(&context, 1, &mut cx),
indoc! {"
What is the Zed editor?
Zed is a modern, high-performance code editor designed from the ground up for speed and collaboration.
What is the Zed editor?
Zed is a modern, high-performance code editor designed from the ground up for speed and collaboration.
"},
&mut cx,
);
}
#[gpui::test]
async fn test_copy_paste_no_selection(cx: &mut TestAppContext) {
cx.update(init_test);
let (context, context_editor, mut cx) = setup_context_editor_text(
vec![
(Role::User, "user1"),
(Role::Assistant, "assistant1"),
(Role::Assistant, "assistant2"),
(Role::User, ""),
],
cx,
)
.await;
let fs = FakeFs::new(cx.executor());
let registry = Arc::new(LanguageRegistry::test(cx.executor()));
let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap());
let context = cx.new(|cx| {
AssistantContext::local(
registry,
None,
None,
prompt_builder.clone(),
Arc::new(SlashCommandWorkingSet::default()),
cx,
)
});
let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let workspace = window.root(cx).unwrap();
let cx = &mut VisualTestContext::from_window(*window, cx);
// Copy and paste first assistant message
let message_2_range = message_range(&context, 1, &mut cx);
assert_copy_paste_context_editor(
&context_editor,
message_2_range.start..message_2_range.start,
indoc! {"
user1
assistant1
assistant2
assistant1
"},
&mut cx,
);
let context_editor = window
.update(cx, |_, window, cx| {
cx.new(|cx| {
ContextEditor::for_context(
context,
fs,
workspace.downgrade(),
project,
None,
window,
cx,
)
})
})
.unwrap();
context_editor.update_in(cx, |context_editor, window, cx| {
context_editor.editor.update(cx, |editor, cx| {
editor.set_text("abc\ndef\nghi", window, cx);
editor.move_to_beginning(&Default::default(), window, cx);
})
});
context_editor.update_in(cx, |context_editor, window, cx| {
context_editor.editor.update(cx, |editor, cx| {
editor.copy(&Default::default(), window, cx);
editor.paste(&Default::default(), window, cx);
assert_eq!(editor.text(cx), "abc\nabc\ndef\nghi");
})
});
context_editor.update_in(cx, |context_editor, window, cx| {
context_editor.editor.update(cx, |editor, cx| {
editor.cut(&Default::default(), window, cx);
assert_eq!(editor.text(cx), "abc\ndef\nghi");
editor.paste(&Default::default(), window, cx);
assert_eq!(editor.text(cx), "abc\nabc\ndef\nghi");
})
});
// Copy and cut second assistant message
let message_3_range = message_range(&context, 2, &mut cx);
assert_copy_paste_context_editor(
&context_editor,
message_3_range.start..message_3_range.start,
indoc! {"
user1
assistant1
assistant2
assistant1
assistant2
"},
&mut cx,
);
}
#[gpui::test]
@@ -3402,6 +3427,129 @@ mod tests {
}
}
async fn setup_context_editor_text(
messages: Vec<(Role, &str)>,
cx: &mut TestAppContext,
) -> (
Entity<AssistantContext>,
Entity<ContextEditor>,
VisualTestContext,
) {
cx.update(init_test);
let fs = FakeFs::new(cx.executor());
let context = create_context_with_messages(messages, cx);
let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let workspace = window.root(cx).unwrap();
let mut cx = VisualTestContext::from_window(*window, cx);
let context_editor = window
.update(&mut cx, |_, window, cx| {
cx.new(|cx| {
let editor = ContextEditor::for_context(
context.clone(),
fs,
workspace.downgrade(),
project,
None,
window,
cx,
);
editor
})
})
.unwrap();
(context, context_editor, cx)
}
fn message_range(
context: &Entity<AssistantContext>,
message_ix: usize,
cx: &mut TestAppContext,
) -> Range<usize> {
context.update(cx, |context, cx| {
context
.messages(cx)
.nth(message_ix)
.unwrap()
.anchor_range
.to_offset(&context.buffer().read(cx).snapshot())
})
}
fn assert_copy_paste_context_editor<T: editor::ToOffset>(
context_editor: &Entity<ContextEditor>,
range: Range<T>,
expected_text: &str,
cx: &mut VisualTestContext,
) {
context_editor.update_in(cx, |context_editor, window, cx| {
context_editor.editor.update(cx, |editor, cx| {
editor.change_selections(None, window, cx, |s| s.select_ranges([range]));
});
context_editor.copy(&Default::default(), window, cx);
context_editor.editor.update(cx, |editor, cx| {
editor.move_to_end(&Default::default(), window, cx);
});
context_editor.paste(&Default::default(), window, cx);
context_editor.editor.update(cx, |editor, cx| {
assert_eq!(editor.text(cx), expected_text);
});
});
}
fn create_context_with_messages(
mut messages: Vec<(Role, &str)>,
cx: &mut TestAppContext,
) -> Entity<AssistantContext> {
let registry = Arc::new(LanguageRegistry::test(cx.executor()));
let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap());
cx.new(|cx| {
let mut context = AssistantContext::local(
registry,
None,
None,
prompt_builder.clone(),
Arc::new(SlashCommandWorkingSet::default()),
cx,
);
let mut message_1 = context.messages(cx).next().unwrap();
let (role, text) = messages.remove(0);
loop {
if role == message_1.role {
context.buffer().update(cx, |buffer, cx| {
buffer.edit([(message_1.offset_range, text)], None, cx);
});
break;
}
let mut ids = HashSet::default();
ids.insert(message_1.id);
context.cycle_message_roles(ids, cx);
message_1 = context.messages(cx).next().unwrap();
}
let mut last_message_id = message_1.id;
for (role, text) in messages {
context.insert_message_after(last_message_id, role, MessageStatus::Done, cx);
let message = context.messages(cx).last().unwrap();
last_message_id = message.id;
context.buffer().update(cx, |buffer, cx| {
buffer.edit([(message.offset_range, text)], None, cx);
})
}
context
})
}
fn init_test(cx: &mut App) {
let settings_store = SettingsStore::test(cx);
prompt_store::init(cx);

View File

@@ -4,8 +4,7 @@ use collections::{HashSet, IndexMap};
use feature_flags::ZedProFeatureFlag;
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{
Action, AnyElement, AnyView, App, BackgroundExecutor, Corner, DismissEvent, Entity,
EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity,
Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task,
action_with_deprecated_aliases,
};
use language_model::{
@@ -15,7 +14,7 @@ use language_model::{
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
use proto::Plan;
use ui::{ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, PopoverTrigger, prelude::*};
use ui::{ListItem, ListItemSpacing, prelude::*};
action_with_deprecated_aliases!(
agent,
@@ -31,77 +30,128 @@ const TRY_ZED_PRO_URL: &str = "https://zed.dev/pro";
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
pub struct LanguageModelSelector {
picker: Entity<Picker<LanguageModelPickerDelegate>>,
pub type LanguageModelSelector = Picker<LanguageModelPickerDelegate>;
pub fn language_model_selector(
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
window: &mut Window,
cx: &mut Context<LanguageModelSelector>,
) -> LanguageModelSelector {
let delegate = LanguageModelPickerDelegate::new(get_active_model, on_model_changed, window, cx);
Picker::list(delegate, window, cx)
.show_scrollbar(true)
.width(rems(20.))
.max_height(Some(rems(20.).into()))
}
fn all_models(cx: &App) -> GroupedModels {
let providers = LanguageModelRegistry::global(cx).read(cx).providers();
let recommended = providers
.iter()
.flat_map(|provider| {
provider
.recommended_models(cx)
.into_iter()
.map(|model| ModelInfo {
model,
icon: provider.icon(),
})
})
.collect();
let other = providers
.iter()
.flat_map(|provider| {
provider
.provided_models(cx)
.into_iter()
.map(|model| ModelInfo {
model,
icon: provider.icon(),
})
})
.collect();
GroupedModels::new(other, recommended)
}
#[derive(Clone)]
struct ModelInfo {
model: Arc<dyn LanguageModel>,
icon: IconName,
}
pub struct LanguageModelPickerDelegate {
on_model_changed: OnModelChanged,
get_active_model: GetActiveModel,
all_models: Arc<GroupedModels>,
filtered_entries: Vec<LanguageModelPickerEntry>,
selected_index: usize,
_authenticate_all_providers_task: Task<()>,
_subscriptions: Vec<Subscription>,
}
impl LanguageModelSelector {
pub fn new(
impl LanguageModelPickerDelegate {
fn new(
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
window: &mut Window,
cx: &mut Context<Self>,
cx: &mut Context<Picker<Self>>,
) -> Self {
let on_model_changed = Arc::new(on_model_changed);
let models = all_models(cx);
let entries = models.entries();
let all_models = Self::all_models(cx);
let entries = all_models.entries();
let delegate = LanguageModelPickerDelegate {
language_model_selector: cx.entity().downgrade(),
Self {
on_model_changed: on_model_changed.clone(),
all_models: Arc::new(all_models),
all_models: Arc::new(models),
selected_index: Self::get_active_model_index(&entries, get_active_model(cx)),
filtered_entries: entries,
get_active_model: Arc::new(get_active_model),
};
let picker = cx.new(|cx| {
Picker::list(delegate, window, cx)
.show_scrollbar(true)
.width(rems(20.))
.max_height(Some(rems(20.).into()))
});
let subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
LanguageModelSelector {
picker,
_authenticate_all_providers_task: Self::authenticate_all_providers(cx),
_subscriptions: vec![
cx.subscribe_in(
&LanguageModelRegistry::global(cx),
window,
Self::handle_language_model_registry_event,
),
subscription,
],
_subscriptions: vec![cx.subscribe_in(
&LanguageModelRegistry::global(cx),
window,
|picker, _, event, window, cx| {
match event {
language_model::Event::ProviderStateChanged
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
let query = picker.query(cx);
picker.delegate.all_models = Arc::new(all_models(cx));
// Update matches will automatically drop the previous task
// if we get a provider event again
picker.update_matches(query, window, cx)
}
_ => {}
}
},
)],
}
}
fn handle_language_model_registry_event(
&mut self,
_registry: &Entity<LanguageModelRegistry>,
event: &language_model::Event,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
language_model::Event::ProviderStateChanged
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
self.picker.update(cx, |this, cx| {
let query = this.query(cx);
this.delegate.all_models = Arc::new(Self::all_models(cx));
// Update matches will automatically drop the previous task
// if we get a provider event again
this.update_matches(query, window, cx)
});
}
_ => {}
}
fn get_active_model_index(
entries: &[LanguageModelPickerEntry],
active_model: Option<ConfiguredModel>,
) -> usize {
entries
.iter()
.position(|entry| {
if let LanguageModelPickerEntry::Model(model) = entry {
active_model
.as_ref()
.map(|active_model| {
active_model.model.id() == model.model.id()
&& active_model.provider.id() == model.model.provider_id()
})
.unwrap_or_default()
} else {
false
}
})
.unwrap_or(0)
}
/// Authenticates all providers in the [`LanguageModelRegistry`].
@@ -154,169 +204,9 @@ impl LanguageModelSelector {
})
}
fn all_models(cx: &App) -> GroupedModels {
let mut recommended = Vec::new();
let mut recommended_set = HashSet::default();
for provider in LanguageModelRegistry::global(cx)
.read(cx)
.providers()
.iter()
{
let models = provider.recommended_models(cx);
recommended_set.extend(models.iter().map(|model| (model.provider_id(), model.id())));
recommended.extend(
provider
.recommended_models(cx)
.into_iter()
.map(move |model| ModelInfo {
model: model.clone(),
icon: provider.icon(),
}),
);
}
let other_models = LanguageModelRegistry::global(cx)
.read(cx)
.providers()
.iter()
.map(|provider| {
(
provider.id(),
provider
.provided_models(cx)
.into_iter()
.filter_map(|model| {
let not_included =
!recommended_set.contains(&(model.provider_id(), model.id()));
not_included.then(|| ModelInfo {
model: model.clone(),
icon: provider.icon(),
})
})
.collect::<Vec<_>>(),
)
})
.collect::<IndexMap<_, _>>();
GroupedModels {
recommended,
other: other_models,
}
}
pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
(self.picker.read(cx).delegate.get_active_model)(cx)
(self.get_active_model)(cx)
}
fn get_active_model_index(
entries: &[LanguageModelPickerEntry],
active_model: Option<ConfiguredModel>,
) -> usize {
entries
.iter()
.position(|entry| {
if let LanguageModelPickerEntry::Model(model) = entry {
active_model
.as_ref()
.map(|active_model| {
active_model.model.id() == model.model.id()
&& active_model.provider.id() == model.model.provider_id()
})
.unwrap_or_default()
} else {
false
}
})
.unwrap_or(0)
}
}
impl EventEmitter<DismissEvent> for LanguageModelSelector {}
impl Focusable for LanguageModelSelector {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for LanguageModelSelector {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.picker.clone()
}
}
#[derive(IntoElement)]
pub struct LanguageModelSelectorPopoverMenu<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
language_model_selector: Entity<LanguageModelSelector>,
trigger: T,
tooltip: TT,
handle: Option<PopoverMenuHandle<LanguageModelSelector>>,
anchor: Corner,
}
impl<T, TT> LanguageModelSelectorPopoverMenu<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
pub fn new(
language_model_selector: Entity<LanguageModelSelector>,
trigger: T,
tooltip: TT,
anchor: Corner,
) -> Self {
Self {
language_model_selector,
trigger,
tooltip,
handle: None,
anchor,
}
}
pub fn with_handle(mut self, handle: PopoverMenuHandle<LanguageModelSelector>) -> Self {
self.handle = Some(handle);
self
}
}
impl<T, TT> RenderOnce for LanguageModelSelectorPopoverMenu<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let language_model_selector = self.language_model_selector.clone();
PopoverMenu::new("model-switcher")
.menu(move |_window, _cx| Some(language_model_selector.clone()))
.trigger_with_tooltip(self.trigger, self.tooltip)
.anchor(self.anchor)
.when_some(self.handle.clone(), |menu, handle| menu.with_handle(handle))
.offset(gpui::Point {
x: px(0.0),
y: px(-2.0),
})
}
}
#[derive(Clone)]
struct ModelInfo {
model: Arc<dyn LanguageModel>,
icon: IconName,
}
pub struct LanguageModelPickerDelegate {
language_model_selector: WeakEntity<LanguageModelSelector>,
on_model_changed: OnModelChanged,
get_active_model: GetActiveModel,
all_models: Arc<GroupedModels>,
filtered_entries: Vec<LanguageModelPickerEntry>,
selected_index: usize,
}
struct GroupedModels {
@@ -326,11 +216,14 @@ struct GroupedModels {
impl GroupedModels {
pub fn new(other: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
let recommended_ids: HashSet<_> = recommended.iter().map(|info| info.model.id()).collect();
let recommended_ids = recommended
.iter()
.map(|info| (info.model.provider_id(), info.model.id()))
.collect::<HashSet<_>>();
let mut other_by_provider: IndexMap<_, Vec<ModelInfo>> = IndexMap::default();
for model in other {
if recommended_ids.contains(&model.model.id()) {
if recommended_ids.contains(&(model.model.provider_id(), model.model.id())) {
continue;
}
@@ -577,9 +470,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
}
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
self.language_model_selector
.update(cx, |_this, cx| cx.emit(DismissEvent))
.ok();
cx.emit(DismissEvent);
}
fn render_match(
@@ -917,4 +808,26 @@ mod tests {
// Recommended models should not appear in "other"
assert_models_eq(actual_other_models, vec!["zed/gemini", "copilot/o3"]);
}
#[gpui::test]
fn test_dont_exclude_models_from_other_providers(_cx: &mut TestAppContext) {
let recommended_models = create_models(vec![("zed", "claude")]);
let all_models = create_models(vec![
("zed", "claude"), // Should be filtered out from "other"
("zed", "gemini"),
("copilot", "claude"), // Should not be filtered out from "other"
]);
let grouped_models = GroupedModels::new(all_models, recommended_models);
let actual_other_models = grouped_models
.other
.values()
.flatten()
.cloned()
.collect::<Vec<_>>();
// Recommended models should not appear in "other"
assert_models_eq(actual_other_models, vec!["zed/gemini", "copilot/claude"]);
}
}

View File

@@ -1,4 +1,4 @@
use gpui::{Context, IntoElement, Render, Window};
use gpui::{Context, FontWeight, IntoElement, Render, Window};
use ui::{prelude::*, tooltip_container};
pub struct MaxModeTooltip {
@@ -18,39 +18,40 @@ impl MaxModeTooltip {
impl Render for MaxModeTooltip {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let icon = if self.selected {
IconName::ZedBurnModeOn
let (icon, color) = if self.selected {
(IconName::ZedBurnModeOn, Color::Error)
} else {
IconName::ZedBurnMode
(IconName::ZedBurnMode, Color::Default)
};
let turned_on = h_flex()
.h_4()
.px_1()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().text_accent.opacity(0.1))
.rounded_sm()
.child(
Label::new("ON")
.size(LabelSize::XSmall)
.weight(FontWeight::SEMIBOLD)
.color(Color::Accent),
);
let title = h_flex()
.gap_1()
.child(Icon::new(icon).size(IconSize::Small))
.child(Label::new("Burn Mode"));
.gap_1p5()
.child(Icon::new(icon).size(IconSize::Small).color(color))
.child(Label::new("Burn Mode"))
.when(self.selected, |title| title.child(turned_on));
tooltip_container(window, cx, |this, _, _| {
this.gap_0p5()
.map(|header| if self.selected {
header.child(
h_flex()
.justify_between()
.child(title)
.child(
h_flex()
.gap_0p5()
.child(Icon::new(IconName::Check).size(IconSize::XSmall).color(Color::Accent))
.child(Label::new("Turned On").size(LabelSize::XSmall).color(Color::Accent))
)
)
} else {
header.child(title)
})
this
.child(title)
.child(
div()
.max_w_72()
.max_w_64()
.child(
Label::new("Enables models to use large context windows, unlimited tool calls, and other capabilities for expanded reasoning, offering an unfettered agentic experience.")
Label::new("Enables models to use large context windows, unlimited tool calls, and other capabilities for expanded reasoning.")
.size(LabelSize::Small)
.color(Color::Muted)
)

View File

@@ -415,14 +415,38 @@ impl ActionLog {
self.project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
} else {
buffer
.read(cx)
.entry_id(cx)
.and_then(|entry_id| {
self.project
.update(cx, |project, cx| project.delete_entry(entry_id, false, cx))
})
.unwrap_or(Task::ready(Ok(())))
// For a file created by AI with no pre-existing content,
// only delete the file if we're certain it contains only AI content
// with no edits from the user.
let initial_version = tracked_buffer.version.clone();
let current_version = buffer.read(cx).version();
let current_content = buffer.read(cx).text();
let tracked_content = tracked_buffer.snapshot.text();
let is_ai_only_content =
initial_version == current_version && current_content == tracked_content;
if is_ai_only_content {
buffer
.read(cx)
.entry_id(cx)
.and_then(|entry_id| {
self.project.update(cx, |project, cx| {
project.delete_entry(entry_id, false, cx)
})
})
.unwrap_or(Task::ready(Ok(())))
} else {
// Not sure how to disentangle edits made by the user
// from edits made by the AI at this point.
// For now, preserve both to avoid data loss.
//
// TODO: Better solution (disable "Reject" after user makes some
// edit or find a way to differentiate between AI and user edits)
Task::ready(Ok(()))
}
};
self.tracked_buffers.remove(&buffer);
@@ -1576,7 +1600,6 @@ mod tests {
project.find_project_path("dir/new_file", cx)
})
.unwrap();
let buffer = project
.update(cx, |project, cx| project.open_buffer(file_path, cx))
.await
@@ -1619,6 +1642,72 @@ mod tests {
assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
}
#[gpui::test]
async fn test_reject_created_file_with_user_edits(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let file_path = project
.read_with(cx, |project, cx| {
project.find_project_path("dir/new_file", cx)
})
.unwrap();
let buffer = project
.update(cx, |project, cx| project.open_buffer(file_path, cx))
.await
.unwrap();
// AI creates file with initial content
cx.update(|cx| {
action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
buffer.update(cx, |buffer, cx| buffer.set_text("ai content", cx));
action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
});
project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
.await
.unwrap();
cx.run_until_parked();
// User makes additional edits
cx.update(|cx| {
buffer.update(cx, |buffer, cx| {
buffer.edit([(10..10, "\nuser added this line")], None, cx);
});
});
project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
.await
.unwrap();
assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
// Reject all
action_log
.update(cx, |log, cx| {
log.reject_edits_in_ranges(
buffer.clone(),
vec![Point::new(0, 0)..Point::new(100, 0)],
cx,
)
})
.await
.unwrap();
cx.run_until_parked();
// File should still contain all the content
assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
let content = buffer.read_with(cx, |buffer, _| buffer.text());
assert_eq!(content, "ai content\nuser added this line");
}
#[gpui::test(iterations = 100)]
async fn test_random_diffs(mut rng: StdRng, cx: &mut TestAppContext) {
init_test(cx);

View File

@@ -36,6 +36,7 @@ itertools.workspace = true
language.workspace = true
language_model.workspace = true
log.workspace = true
lsp.workspace = true
markdown.workspace = true
open.workspace = true
paths.workspace = true
@@ -64,6 +65,7 @@ workspace.workspace = true
zed_llm_client.workspace = true
[dev-dependencies]
lsp = { workspace = true, features = ["test-support"] }
client = { workspace = true, features = ["test-support"] }
clock = { workspace = true, features = ["test-support"] }
collections = { workspace = true, features = ["test-support"] }

View File

@@ -28,6 +28,7 @@ use std::{cmp, iter, mem, ops::Range, path::PathBuf, pin::Pin, sync::Arc, task::
use streaming_diff::{CharOperation, StreamingDiff};
use streaming_fuzzy_matcher::StreamingFuzzyMatcher;
use util::debug_panic;
use zed_llm_client::CompletionIntent;
#[derive(Serialize)]
struct CreateFilePromptTemplate {
@@ -106,7 +107,9 @@ impl EditAgent {
edit_description,
}
.render(&this.templates)?;
let new_chunks = this.request(conversation, prompt, cx).await?;
let new_chunks = this
.request(conversation, CompletionIntent::CreateFile, prompt, cx)
.await?;
let (output, mut inner_events) = this.overwrite_with_chunks(buffer, new_chunks, cx);
while let Some(event) = inner_events.next().await {
@@ -213,7 +216,9 @@ impl EditAgent {
edit_description,
}
.render(&this.templates)?;
let edit_chunks = this.request(conversation, prompt, cx).await?;
let edit_chunks = this
.request(conversation, CompletionIntent::EditFile, prompt, cx)
.await?;
this.apply_edit_chunks(buffer, edit_chunks, events_tx, cx)
.await
});
@@ -589,6 +594,7 @@ impl EditAgent {
async fn request(
&self,
mut conversation: LanguageModelRequest,
intent: CompletionIntent,
prompt: String,
cx: &mut AsyncApp,
) -> Result<BoxStream<'static, Result<String, LanguageModelCompletionError>>> {
@@ -646,6 +652,7 @@ impl EditAgent {
let request = LanguageModelRequest {
thread_id: conversation.thread_id,
prompt_id: conversation.prompt_id,
intent: Some(intent),
mode: conversation.mode,
messages: conversation.messages,
tool_choice,

View File

@@ -4,7 +4,7 @@ use std::cell::LazyCell;
use util::debug_panic;
const START_MARKER: LazyCell<Regex> = LazyCell::new(|| Regex::new(r"\n?```\S*\n").unwrap());
const END_MARKER: LazyCell<Regex> = LazyCell::new(|| Regex::new(r"\n```\s*$").unwrap());
const END_MARKER: LazyCell<Regex> = LazyCell::new(|| Regex::new(r"(^|\n)```\s*$").unwrap());
#[derive(Debug)]
pub enum CreateFileParserEvent {
@@ -184,6 +184,22 @@ mod tests {
);
}
#[gpui::test(iterations = 10)]
fn test_empty_file(mut rng: StdRng) {
let mut parser = CreateFileParser::new();
assert_eq!(
parse_random_chunks(
indoc! {"
```
```
"},
&mut parser,
&mut rng
),
"".to_string()
);
}
fn parse_random_chunks(input: &str, parser: &mut CreateFileParser, rng: &mut StdRng) -> String {
let chunk_count = rng.gen_range(1..=cmp::min(input.len(), 50));
let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count);

View File

@@ -18,16 +18,21 @@ use gpui::{
use indoc::formatdoc;
use language::{
Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope,
TextBuffer, language_settings::SoftWrap,
TextBuffer,
language_settings::{self, FormatOnSave, SoftWrap},
};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use project::{Project, ProjectPath};
use project::{
Project, ProjectPath,
lsp_store::{FormatTrigger, LspFormatTarget},
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use std::{
cmp::Reverse,
collections::HashSet,
ops::Range,
path::{Path, PathBuf},
sync::Arc,
@@ -189,8 +194,10 @@ impl Tool for EditFileTool {
});
let card_clone = card.clone();
let action_log_clone = action_log.clone();
let task = cx.spawn(async move |cx: &mut AsyncApp| {
let edit_agent = EditAgent::new(model, project.clone(), action_log, Templates::new());
let edit_agent =
EditAgent::new(model, project.clone(), action_log_clone, Templates::new());
let buffer = project
.update(cx, |project, cx| {
@@ -244,19 +251,53 @@ impl Tool for EditFileTool {
}
let agent_output = output.await?;
// If format_on_save is enabled, format the buffer
let format_on_save_enabled = buffer
.read_with(cx, |buffer, cx| {
let settings = language_settings::language_settings(
buffer.language().map(|l| l.name()),
buffer.file(),
cx,
);
!matches!(settings.format_on_save, FormatOnSave::Off)
})
.unwrap_or(false);
if format_on_save_enabled {
let format_task = project.update(cx, |project, cx| {
project.format(
HashSet::from_iter([buffer.clone()]),
LspFormatTarget::Buffers,
false, // Don't push to history since the tool did it.
FormatTrigger::Save,
cx,
)
})?;
format_task.await.log_err();
}
project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
.await?;
// Notify the action log that we've edited the buffer (*after* formatting has completed).
action_log.update(cx, |log, cx| {
log.buffer_edited(buffer.clone(), cx);
})?;
let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
let new_text = cx.background_spawn({
let new_snapshot = new_snapshot.clone();
async move { new_snapshot.text() }
});
let diff = cx.background_spawn(async move {
language::unified_diff(&old_snapshot.text(), &new_snapshot.text())
});
let (new_text, diff) = futures::join!(new_text, diff);
let (new_text, diff) = cx
.background_spawn({
let new_snapshot = new_snapshot.clone();
let old_text = old_text.clone();
async move {
let new_text = new_snapshot.text();
let diff = language::unified_diff(&old_text, &new_text);
(new_text, diff)
}
})
.await;
let output = EditFileToolOutput {
original_path: project_path.path.to_path_buf(),
@@ -1099,8 +1140,8 @@ async fn build_buffer_diff(
mod tests {
use super::*;
use client::TelemetrySettings;
use fs::FakeFs;
use gpui::TestAppContext;
use fs::{FakeFs, Fs};
use gpui::{TestAppContext, UpdateGlobal};
use language_model::fake_provider::FakeLanguageModel;
use serde_json::json;
use settings::SettingsStore;
@@ -1310,4 +1351,340 @@ mod tests {
Project::init_settings(cx);
});
}
#[gpui::test]
async fn test_format_on_save(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({"src": {}})).await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
// Set up a Rust language with LSP formatting support
let rust_language = Arc::new(language::Language::new(
language::LanguageConfig {
name: "Rust".into(),
matcher: language::LanguageMatcher {
path_suffixes: vec!["rs".to_string()],
..Default::default()
},
..Default::default()
},
None,
));
// Register the language and fake LSP
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_language);
let mut fake_language_servers = language_registry.register_fake_lsp(
"Rust",
language::FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
document_formatting_provider: Some(lsp::OneOf::Left(true)),
..Default::default()
},
..Default::default()
},
);
// Create the file
fs.save(
path!("/root/src/main.rs").as_ref(),
&"initial content".into(),
language::LineEnding::Unix,
)
.await
.unwrap();
// Open the buffer to trigger LSP initialization
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/root/src/main.rs"), cx)
})
.await
.unwrap();
// Register the buffer with language servers
let _handle = project.update(cx, |project, cx| {
project.register_buffer_with_language_servers(&buffer, cx)
});
const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
const FORMATTED_CONTENT: &str =
"This file was formatted by the fake formatter in the test.\n";
// Get the fake language server and set up formatting handler
let fake_language_server = fake_language_servers.next().await.unwrap();
fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
|_, _| async move {
Ok(Some(vec![lsp::TextEdit {
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
new_text: FORMATTED_CONTENT.to_string(),
}]))
}
});
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let model = Arc::new(FakeLanguageModel::default());
// First, test with format_on_save enabled
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<language::language_settings::AllLanguageSettings>(
cx,
|settings| {
settings.defaults.format_on_save = Some(FormatOnSave::On);
settings.defaults.formatter =
Some(language::language_settings::SelectedFormatter::Auto);
},
);
});
});
// Have the model stream unformatted content
let edit_result = {
let edit_task = cx.update(|cx| {
let input = serde_json::to_value(EditFileToolInput {
display_description: "Create main function".into(),
path: "root/src/main.rs".into(),
mode: EditFileMode::Overwrite,
})
.unwrap();
Arc::new(EditFileTool)
.run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
.output
});
// Stream the unformatted content
cx.executor().run_until_parked();
model.stream_last_completion_response(UNFORMATTED_CONTENT.to_string());
model.end_last_completion_stream();
edit_task.await
};
assert!(edit_result.is_ok());
// Wait for any async operations (e.g. formatting) to complete
cx.executor().run_until_parked();
// Read the file to verify it was formatted automatically
let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
assert_eq!(
// Ignore carriage returns on Windows
new_content.replace("\r\n", "\n"),
FORMATTED_CONTENT,
"Code should be formatted when format_on_save is enabled"
);
let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count());
assert_eq!(
stale_buffer_count, 0,
"BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
This causes the agent to think the file was modified externally when it was just formatted.",
stale_buffer_count
);
// Next, test with format_on_save disabled
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<language::language_settings::AllLanguageSettings>(
cx,
|settings| {
settings.defaults.format_on_save = Some(FormatOnSave::Off);
},
);
});
});
// Stream unformatted edits again
let edit_result = {
let edit_task = cx.update(|cx| {
let input = serde_json::to_value(EditFileToolInput {
display_description: "Update main function".into(),
path: "root/src/main.rs".into(),
mode: EditFileMode::Overwrite,
})
.unwrap();
Arc::new(EditFileTool)
.run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
.output
});
// Stream the unformatted content
cx.executor().run_until_parked();
model.stream_last_completion_response(UNFORMATTED_CONTENT.to_string());
model.end_last_completion_stream();
edit_task.await
};
assert!(edit_result.is_ok());
// Wait for any async operations (e.g. formatting) to complete
cx.executor().run_until_parked();
// Verify the file was not formatted
let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
assert_eq!(
// Ignore carriage returns on Windows
new_content.replace("\r\n", "\n"),
UNFORMATTED_CONTENT,
"Code should not be formatted when format_on_save is disabled"
);
}
#[gpui::test]
async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({"src": {}})).await;
// Create a simple file with trailing whitespace
fs.save(
path!("/root/src/main.rs").as_ref(),
&"initial content".into(),
language::LineEnding::Unix,
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let model = Arc::new(FakeLanguageModel::default());
// First, test with remove_trailing_whitespace_on_save enabled
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<language::language_settings::AllLanguageSettings>(
cx,
|settings| {
settings.defaults.remove_trailing_whitespace_on_save = Some(true);
},
);
});
});
const CONTENT_WITH_TRAILING_WHITESPACE: &str =
"fn main() { \n println!(\"Hello!\"); \n}\n";
// Have the model stream content that contains trailing whitespace
let edit_result = {
let edit_task = cx.update(|cx| {
let input = serde_json::to_value(EditFileToolInput {
display_description: "Create main function".into(),
path: "root/src/main.rs".into(),
mode: EditFileMode::Overwrite,
})
.unwrap();
Arc::new(EditFileTool)
.run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
.output
});
// Stream the content with trailing whitespace
cx.executor().run_until_parked();
model.stream_last_completion_response(CONTENT_WITH_TRAILING_WHITESPACE.to_string());
model.end_last_completion_stream();
edit_task.await
};
assert!(edit_result.is_ok());
// Wait for any async operations (e.g. formatting) to complete
cx.executor().run_until_parked();
// Read the file to verify trailing whitespace was removed automatically
assert_eq!(
// Ignore carriage returns on Windows
fs.load(path!("/root/src/main.rs").as_ref())
.await
.unwrap()
.replace("\r\n", "\n"),
"fn main() {\n println!(\"Hello!\");\n}\n",
"Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
);
// Next, test with remove_trailing_whitespace_on_save disabled
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<language::language_settings::AllLanguageSettings>(
cx,
|settings| {
settings.defaults.remove_trailing_whitespace_on_save = Some(false);
},
);
});
});
// Stream edits again with trailing whitespace
let edit_result = {
let edit_task = cx.update(|cx| {
let input = serde_json::to_value(EditFileToolInput {
display_description: "Update main function".into(),
path: "root/src/main.rs".into(),
mode: EditFileMode::Overwrite,
})
.unwrap();
Arc::new(EditFileTool)
.run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
.output
});
// Stream the content with trailing whitespace
cx.executor().run_until_parked();
model.stream_last_completion_response(CONTENT_WITH_TRAILING_WHITESPACE.to_string());
model.end_last_completion_stream();
edit_task.await
};
assert!(edit_result.is_ok());
// Wait for any async operations (e.g. formatting) to complete
cx.executor().run_until_parked();
// Verify the file still has trailing whitespace
// Read the file again - it should still have trailing whitespace
let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
assert_eq!(
// Ignore carriage returns on Windows
final_content.replace("\r\n", "\n"),
CONTENT_WITH_TRAILING_WHITESPACE,
"Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
);
}
}

View File

@@ -1,6 +1,6 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use std::{borrow::Cow, cell::RefCell};
use crate::schema::json_schema_for;
use anyhow::{Context as _, Result, anyhow, bail};
@@ -39,10 +39,11 @@ impl FetchTool {
}
async fn build_message(http_client: Arc<HttpClientWithUrl>, url: &str) -> Result<String> {
let mut url = url.to_owned();
if !url.starts_with("https://") && !url.starts_with("http://") {
url = format!("https://{url}");
}
let url = if !url.starts_with("https://") && !url.starts_with("http://") {
Cow::Owned(format!("https://{url}"))
} else {
Cow::Borrowed(url)
};
let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
@@ -156,8 +157,7 @@ impl Tool for FetchTool {
let text = cx.background_spawn({
let http_client = self.http_client.clone();
let url = input.url.clone();
async move { Self::build_message(http_client, &url).await }
async move { Self::build_message(http_client, &input.url).await }
});
cx.foreground_executor()

View File

@@ -119,14 +119,16 @@ impl Tool for FindPathTool {
)
.unwrap();
}
let output = FindPathToolOutput {
glob,
paths: matches.clone(),
};
for mat in matches.into_iter().skip(offset).take(RESULTS_PER_PAGE) {
for mat in matches.iter().skip(offset).take(RESULTS_PER_PAGE) {
write!(&mut message, "\n{}", mat.display()).unwrap();
}
let output = FindPathToolOutput {
glob,
paths: matches,
};
Ok(ToolResultOutput {
content: ToolResultContent::Text(message),
output: Some(serde_json::to_value(output)?),
@@ -235,8 +237,6 @@ impl ToolCard for FindPathToolCard {
format!("{} matches", self.paths.len()).into()
};
let glob_label = self.glob.to_string();
let content = if !self.paths.is_empty() && self.expanded {
Some(
v_flex()
@@ -310,7 +310,7 @@ impl ToolCard for FindPathToolCard {
.gap_1()
.child(
ToolCallCardHeader::new(IconName::SearchCode, matches_label)
.with_code_path(glob_label)
.with_code_path(&self.glob)
.disclosure_slot(
Disclosure::new("path-search-disclosure", self.expanded)
.opened_icon(IconName::ChevronUp)

View File

@@ -182,9 +182,8 @@ impl Tool for TerminalTool {
let mut child = pair.slave.spawn_command(cmd)?;
let mut reader = pair.master.try_clone_reader()?;
drop(pair);
let mut content = Vec::new();
reader.read_to_end(&mut content)?;
let mut content = String::from_utf8(content)?;
let mut content = String::new();
reader.read_to_string(&mut content)?;
// Massage the pty output a bit to try to match what the terminal codepath gives us
LineEnding::normalize(&mut content);
content = content

View File

@@ -166,7 +166,7 @@ impl ToolCard for WebSearchToolCard {
.gap_1()
.children(response.results.iter().enumerate().map(|(index, result)| {
let title = result.title.clone();
let url = result.url.clone();
let url = SharedString::from(result.url.clone());
Button::new(("result", index), title)
.label_size(LabelSize::Small)

View File

@@ -49,8 +49,12 @@ pub enum VersionCheckType {
pub enum AutoUpdateStatus {
Idle,
Checking,
Downloading,
Installing,
Downloading {
version: VersionCheckType,
},
Installing {
version: VersionCheckType,
},
Updated {
binary_path: PathBuf,
version: VersionCheckType,
@@ -511,12 +515,12 @@ impl AutoUpdater {
Self::get_latest_release(&this, "zed", OS, ARCH, release_channel, &mut cx).await?;
let fetched_version = fetched_release_data.clone().version;
let app_commit_sha = cx.update(|cx| AppCommitSha::try_global(cx).map(|sha| sha.full()));
let newer_version = Self::check_for_newer_version(
let newer_version = Self::check_if_fetched_version_is_newer(
*RELEASE_CHANNEL,
app_commit_sha,
installed_version,
previous_status.clone(),
fetched_version,
previous_status.clone(),
)?;
let Some(newer_version) = newer_version else {
@@ -531,7 +535,9 @@ impl AutoUpdater {
};
this.update(&mut cx, |this, cx| {
this.status = AutoUpdateStatus::Downloading;
this.status = AutoUpdateStatus::Downloading {
version: newer_version.clone(),
};
cx.notify();
})?;
@@ -540,7 +546,9 @@ impl AutoUpdater {
download_release(&target_path, fetched_release_data, client, &cx).await?;
this.update(&mut cx, |this, cx| {
this.status = AutoUpdateStatus::Installing;
this.status = AutoUpdateStatus::Installing {
version: newer_version.clone(),
};
cx.notify();
})?;
@@ -557,12 +565,12 @@ impl AutoUpdater {
})
}
fn check_for_newer_version(
fn check_if_fetched_version_is_newer(
release_channel: ReleaseChannel,
app_commit_sha: Result<Option<String>>,
installed_version: SemanticVersion,
status: AutoUpdateStatus,
fetched_version: String,
status: AutoUpdateStatus,
) -> Result<Option<VersionCheckType>> {
let parsed_fetched_version = fetched_version.parse::<SemanticVersion>();
@@ -575,7 +583,7 @@ impl AutoUpdater {
return Ok(newer_version);
}
VersionCheckType::Semantic(cached_version) => {
return Self::check_for_newer_version_non_nightly(
return Self::check_if_fetched_version_is_newer_non_nightly(
cached_version,
parsed_fetched_version?,
);
@@ -594,7 +602,7 @@ impl AutoUpdater {
.then(|| VersionCheckType::Sha(AppCommitSha::new(fetched_version)));
Ok(newer_version)
}
_ => Self::check_for_newer_version_non_nightly(
_ => Self::check_if_fetched_version_is_newer_non_nightly(
installed_version,
parsed_fetched_version?,
),
@@ -631,7 +639,7 @@ impl AutoUpdater {
}
}
fn check_for_newer_version_non_nightly(
fn check_if_fetched_version_is_newer_non_nightly(
installed_version: SemanticVersion,
fetched_version: SemanticVersion,
) -> Result<Option<VersionCheckType>> {
@@ -925,12 +933,12 @@ mod tests {
let status = AutoUpdateStatus::Idle;
let fetched_version = SemanticVersion::new(1, 0, 0);
let newer_version = AutoUpdater::check_for_newer_version(
let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
release_channel,
app_commit_sha,
installed_version,
status,
fetched_version.to_string(),
status,
);
assert_eq!(newer_version.unwrap(), None);
@@ -944,12 +952,12 @@ mod tests {
let status = AutoUpdateStatus::Idle;
let fetched_version = SemanticVersion::new(1, 0, 1);
let newer_version = AutoUpdater::check_for_newer_version(
let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
release_channel,
app_commit_sha,
installed_version,
status,
fetched_version.to_string(),
status,
);
assert_eq!(
@@ -969,12 +977,12 @@ mod tests {
};
let fetched_version = SemanticVersion::new(1, 0, 1);
let newer_version = AutoUpdater::check_for_newer_version(
let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
release_channel,
app_commit_sha,
installed_version,
status,
fetched_version.to_string(),
status,
);
assert_eq!(newer_version.unwrap(), None);
@@ -991,12 +999,12 @@ mod tests {
};
let fetched_version = SemanticVersion::new(1, 0, 2);
let newer_version = AutoUpdater::check_for_newer_version(
let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
release_channel,
app_commit_sha,
installed_version,
status,
fetched_version.to_string(),
status,
);
assert_eq!(
@@ -1013,12 +1021,12 @@ mod tests {
let status = AutoUpdateStatus::Idle;
let fetched_sha = "a".to_string();
let newer_version = AutoUpdater::check_for_newer_version(
let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
release_channel,
app_commit_sha,
installed_version,
status,
fetched_sha,
status,
);
assert_eq!(newer_version.unwrap(), None);
@@ -1032,12 +1040,12 @@ mod tests {
let status = AutoUpdateStatus::Idle;
let fetched_sha = "b".to_string();
let newer_version = AutoUpdater::check_for_newer_version(
let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
release_channel,
app_commit_sha,
installed_version,
status,
fetched_sha.clone(),
status,
);
assert_eq!(
@@ -1057,12 +1065,12 @@ mod tests {
};
let fetched_sha = "b".to_string();
let newer_version = AutoUpdater::check_for_newer_version(
let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
release_channel,
app_commit_sha,
installed_version,
status,
fetched_sha,
status,
);
assert_eq!(newer_version.unwrap(), None);
@@ -1079,12 +1087,12 @@ mod tests {
};
let fetched_sha = "c".to_string();
let newer_version = AutoUpdater::check_for_newer_version(
let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
release_channel,
app_commit_sha,
installed_version,
status,
fetched_sha.clone(),
status,
);
assert_eq!(
@@ -1101,12 +1109,12 @@ mod tests {
let status = AutoUpdateStatus::Idle;
let fetched_sha = "a".to_string();
let newer_version = AutoUpdater::check_for_newer_version(
let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
release_channel,
app_commit_sha,
installed_version,
status,
fetched_sha.clone(),
status,
);
assert_eq!(
@@ -1127,12 +1135,12 @@ mod tests {
};
let fetched_sha = "b".to_string();
let newer_version = AutoUpdater::check_for_newer_version(
let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
release_channel,
app_commit_sha,
installed_version,
status,
fetched_sha,
status,
);
assert_eq!(newer_version.unwrap(), None);
@@ -1150,12 +1158,12 @@ mod tests {
};
let fetched_sha = "c".to_string();
let newer_version = AutoUpdater::check_for_newer_version(
let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
release_channel,
app_commit_sha,
installed_version,
status,
fetched_sha.clone(),
status,
);
assert_eq!(

View File

@@ -91,7 +91,7 @@ fn view_release_notes_locally(
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let tab_content = SharedString::from(body.title.to_string());
let tab_content = Some(SharedString::from(body.title.to_string()));
let editor = cx.new(|cx| {
Editor::for_multibuffer(buffer, Some(project), window, cx)
});

View File

@@ -16,6 +16,7 @@ doctest = false
editor.workspace = true
gpui.workspace = true
itertools.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
workspace.workspace = true

View File

@@ -1,14 +1,15 @@
use editor::Editor;
use gpui::{
Context, Element, EventEmitter, Focusable, IntoElement, ParentElement, Render, StyledText,
Subscription, Window,
Context, Element, EventEmitter, Focusable, FontWeight, IntoElement, ParentElement, Render,
StyledText, Subscription, Window,
};
use itertools::Itertools;
use settings::Settings;
use std::cmp;
use theme::ActiveTheme;
use ui::{ButtonLike, ButtonStyle, Label, Tooltip, prelude::*};
use workspace::{
ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
TabBarSettings, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
item::{BreadcrumbText, ItemEvent, ItemHandle},
};
@@ -71,16 +72,23 @@ impl Render for Breadcrumbs {
);
}
let highlighted_segments = segments.into_iter().map(|segment| {
let highlighted_segments = segments.into_iter().enumerate().map(|(index, segment)| {
let mut text_style = window.text_style();
if let Some(font) = segment.font {
text_style.font_family = font.family;
text_style.font_features = font.features;
if let Some(ref font) = segment.font {
text_style.font_family = font.family.clone();
text_style.font_features = font.features.clone();
text_style.font_style = font.style;
text_style.font_weight = font.weight;
}
text_style.color = Color::Muted.color(cx);
if index == 0 && !TabBarSettings::get_global(cx).show && active_item.is_dirty(cx) {
if let Some(styled_element) = apply_dirty_filename_style(&segment, &text_style, cx)
{
return styled_element;
}
}
StyledText::new(segment.text.replace('\n', ""))
.with_default_highlights(&text_style, segment.highlights.unwrap_or_default())
.into_any()
@@ -184,3 +192,46 @@ impl ToolbarItemView for Breadcrumbs {
self.pane_focused = pane_focused;
}
}
fn apply_dirty_filename_style(
segment: &BreadcrumbText,
text_style: &gpui::TextStyle,
cx: &mut Context<Breadcrumbs>,
) -> Option<gpui::AnyElement> {
let text = segment.text.replace('\n', "");
let filename_position = std::path::Path::new(&segment.text)
.file_name()
.and_then(|f| {
let filename_str = f.to_string_lossy();
segment.text.rfind(filename_str.as_ref())
})?;
let bold_weight = FontWeight::BOLD;
let default_color = Color::Default.color(cx);
if filename_position == 0 {
let mut filename_style = text_style.clone();
filename_style.font_weight = bold_weight;
filename_style.color = default_color;
return Some(
StyledText::new(text)
.with_default_highlights(&filename_style, [])
.into_any(),
);
}
let highlight_style = gpui::HighlightStyle {
font_weight: Some(bold_weight),
color: Some(default_color),
..Default::default()
};
let highlight = vec![(filename_position..text.len(), highlight_style)];
Some(
StyledText::new(text)
.with_default_highlights(&text_style, highlight)
.into_any(),
)
}

View File

@@ -20,6 +20,7 @@ test-support = ["sqlite"]
[dependencies]
anyhow.workspace = true
async-stripe.workspace = true
async-trait.workspace = true
async-tungstenite.workspace = true
aws-config = { version = "1.1.5" }
aws-sdk-s3 = { version = "1.15.0" }

View File

@@ -17,8 +17,8 @@ use stripe::{
CreateBillingPortalSessionFlowDataAfterCompletionRedirect,
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm,
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems,
CreateBillingPortalSessionFlowDataType, Customer, CustomerId, EventObject, EventType,
Expandable, ListEvents, PaymentMethod, Subscription, SubscriptionId, SubscriptionStatus,
CreateBillingPortalSessionFlowDataType, CustomerId, EventObject, EventType, ListEvents,
PaymentMethod, Subscription, SubscriptionId, SubscriptionStatus,
};
use util::{ResultExt, maybe};
@@ -29,6 +29,10 @@ use crate::db::billing_subscription::{
use crate::llm::db::subscription_usage_meter::CompletionMode;
use crate::llm::{AGENT_EXTENDED_TRIAL_FEATURE_FLAG, DEFAULT_MAX_MONTHLY_SPEND};
use crate::rpc::{ResultExt as _, Server};
use crate::stripe_client::{
StripeCancellationDetailsReason, StripeClient, StripeCustomerId, StripeSubscription,
StripeSubscriptionId,
};
use crate::{AppState, Error, Result};
use crate::{db::UserId, llm::db::LlmDatabase};
use crate::{
@@ -54,10 +58,6 @@ pub fn router() -> Router {
"/billing/subscriptions/manage",
post(manage_billing_subscription),
)
.route(
"/billing/subscriptions/migrate",
post(migrate_to_new_billing),
)
.route(
"/billing/subscriptions/sync",
post(sync_billing_subscription),
@@ -282,7 +282,6 @@ async fn list_billing_subscriptions(
enum ProductCode {
ZedPro,
ZedProTrial,
ZedFree,
}
#[derive(Debug, Deserialize)]
@@ -338,8 +337,7 @@ async fn create_billing_subscription(
}
let customer_id = if let Some(existing_customer) = &existing_billing_customer {
CustomerId::from_str(&existing_customer.stripe_customer_id)
.context("failed to parse customer ID")?
StripeCustomerId(existing_customer.stripe_customer_id.clone().into())
} else {
stripe_billing
.find_or_create_customer_by_email(user.email_address.as_deref())
@@ -354,7 +352,7 @@ async fn create_billing_subscription(
let checkout_session_url = match body.product {
ProductCode::ZedPro => {
stripe_billing
.checkout_with_zed_pro(customer_id, &user.github_login, &success_url)
.checkout_with_zed_pro(&customer_id, &user.github_login, &success_url)
.await?
}
ProductCode::ZedProTrial => {
@@ -371,18 +369,13 @@ async fn create_billing_subscription(
stripe_billing
.checkout_with_zed_pro_trial(
customer_id,
&customer_id,
&user.github_login,
feature_flags,
&success_url,
)
.await?
}
ProductCode::ZedFree => {
stripe_billing
.checkout_with_zed_free(customer_id, &user.github_login, &success_url)
.await?
}
};
Ok(Json(CreateBillingSubscriptionResponse {
@@ -432,7 +425,7 @@ async fn manage_billing_subscription(
.await?
.context("user not found")?;
let Some(stripe_client) = app.stripe_client.clone() else {
let Some(stripe_client) = app.real_stripe_client.clone() else {
log::error!("failed to retrieve Stripe client");
Err(Error::http(
StatusCode::NOT_IMPLEMENTED,
@@ -498,8 +491,10 @@ async fn manage_billing_subscription(
let flow = match body.intent {
ManageSubscriptionIntent::ManageSubscription => None,
ManageSubscriptionIntent::UpgradeToPro => {
let zed_pro_price_id = stripe_billing.zed_pro_price_id().await?;
let zed_free_price_id = stripe_billing.zed_free_price_id().await?;
let zed_pro_price_id: stripe::PriceId =
stripe_billing.zed_pro_price_id().await?.try_into()?;
let zed_free_price_id: stripe::PriceId =
stripe_billing.zed_free_price_id().await?.try_into()?;
let stripe_subscription =
Subscription::retrieve(&stripe_client, &subscription_id, &[]).await?;
@@ -633,86 +628,6 @@ async fn manage_billing_subscription(
}))
}
#[derive(Debug, Deserialize)]
struct MigrateToNewBillingBody {
github_user_id: i32,
}
#[derive(Debug, Serialize)]
struct MigrateToNewBillingResponse {
/// The ID of the subscription that was canceled.
canceled_subscription_id: Option<String>,
}
async fn migrate_to_new_billing(
Extension(app): Extension<Arc<AppState>>,
extract::Json(body): extract::Json<MigrateToNewBillingBody>,
) -> Result<Json<MigrateToNewBillingResponse>> {
let Some(stripe_client) = app.stripe_client.clone() else {
log::error!("failed to retrieve Stripe client");
Err(Error::http(
StatusCode::NOT_IMPLEMENTED,
"not supported".into(),
))?
};
let user = app
.db
.get_user_by_github_user_id(body.github_user_id)
.await?
.context("user not found")?;
let old_billing_subscriptions_by_user = app
.db
.get_active_billing_subscriptions(HashSet::from_iter([user.id]))
.await?;
let canceled_subscription_id = if let Some((_billing_customer, billing_subscription)) =
old_billing_subscriptions_by_user.get(&user.id)
{
let stripe_subscription_id = billing_subscription
.stripe_subscription_id
.parse::<stripe::SubscriptionId>()
.context("failed to parse Stripe subscription ID from database")?;
Subscription::cancel(
&stripe_client,
&stripe_subscription_id,
stripe::CancelSubscription {
invoice_now: Some(true),
..Default::default()
},
)
.await?;
Some(stripe_subscription_id)
} else {
None
};
let all_feature_flags = app.db.list_feature_flags().await?;
let user_feature_flags = app.db.get_user_flags(user.id).await?;
for feature_flag in ["new-billing", "assistant2"] {
let already_in_feature_flag = user_feature_flags.iter().any(|flag| flag == feature_flag);
if already_in_feature_flag {
continue;
}
let feature_flag = all_feature_flags
.iter()
.find(|flag| flag.flag == feature_flag)
.context("failed to find feature flag: {feature_flag:?}")?;
app.db.add_user_flag(user.id, feature_flag.id).await?;
}
Ok(Json(MigrateToNewBillingResponse {
canceled_subscription_id: canceled_subscription_id
.map(|subscription_id| subscription_id.to_string()),
}))
}
#[derive(Debug, Deserialize)]
struct SyncBillingSubscriptionBody {
github_user_id: i32,
@@ -746,23 +661,13 @@ async fn sync_billing_subscription(
.get_billing_customer_by_user_id(user.id)
.await?
.context("billing customer not found")?;
let stripe_customer_id = billing_customer
.stripe_customer_id
.parse::<stripe::CustomerId>()
.context("failed to parse Stripe customer ID from database")?;
let stripe_customer_id = StripeCustomerId(billing_customer.stripe_customer_id.clone().into());
let subscriptions = Subscription::list(
&stripe_client,
&stripe::ListSubscriptions {
customer: Some(stripe_customer_id),
// Sync all non-canceled subscriptions.
status: None,
..Default::default()
},
)
.await?;
let subscriptions = stripe_client
.list_subscriptions_for_customer(&stripe_customer_id)
.await?;
for subscription in subscriptions.data {
for subscription in subscriptions {
let subscription_id = subscription.id.clone();
sync_subscription(&app, &stripe_client, subscription)
@@ -810,6 +715,10 @@ const NUMBER_OF_ALREADY_PROCESSED_PAGES_BEFORE_WE_STOP: usize = 4;
/// Polls the Stripe events API periodically to reconcile the records in our
/// database with the data in Stripe.
pub fn poll_stripe_events_periodically(app: Arc<AppState>, rpc_server: Arc<Server>) {
let Some(real_stripe_client) = app.real_stripe_client.clone() else {
log::warn!("failed to retrieve Stripe client");
return;
};
let Some(stripe_client) = app.stripe_client.clone() else {
log::warn!("failed to retrieve Stripe client");
return;
@@ -820,7 +729,7 @@ pub fn poll_stripe_events_periodically(app: Arc<AppState>, rpc_server: Arc<Serve
let executor = executor.clone();
async move {
loop {
poll_stripe_events(&app, &rpc_server, &stripe_client)
poll_stripe_events(&app, &rpc_server, &stripe_client, &real_stripe_client)
.await
.log_err();
@@ -833,7 +742,8 @@ pub fn poll_stripe_events_periodically(app: Arc<AppState>, rpc_server: Arc<Serve
async fn poll_stripe_events(
app: &Arc<AppState>,
rpc_server: &Arc<Server>,
stripe_client: &stripe::Client,
stripe_client: &Arc<dyn StripeClient>,
real_stripe_client: &stripe::Client,
) -> anyhow::Result<()> {
fn event_type_to_string(event_type: EventType) -> String {
// Calling `to_string` on `stripe::EventType` members gives us a quoted string,
@@ -865,7 +775,7 @@ async fn poll_stripe_events(
params.types = Some(event_types.clone());
params.limit = Some(EVENTS_LIMIT_PER_PAGE);
let mut event_pages = stripe::Event::list(&stripe_client, &params)
let mut event_pages = stripe::Event::list(&real_stripe_client, &params)
.await?
.paginate(params);
@@ -909,7 +819,7 @@ async fn poll_stripe_events(
break;
} else {
log::info!("Stripe events: retrieving next page");
event_pages = event_pages.next(&stripe_client).await?;
event_pages = event_pages.next(&real_stripe_client).await?;
}
} else {
break;
@@ -949,7 +859,7 @@ async fn poll_stripe_events(
let process_result = match event.type_ {
EventType::CustomerCreated | EventType::CustomerUpdated => {
handle_customer_event(app, stripe_client, event).await
handle_customer_event(app, real_stripe_client, event).await
}
EventType::CustomerSubscriptionCreated
| EventType::CustomerSubscriptionUpdated
@@ -1024,8 +934,8 @@ async fn handle_customer_event(
async fn sync_subscription(
app: &Arc<AppState>,
stripe_client: &stripe::Client,
subscription: stripe::Subscription,
stripe_client: &Arc<dyn StripeClient>,
subscription: StripeSubscription,
) -> anyhow::Result<billing_customer::Model> {
let subscription_kind = if let Some(stripe_billing) = &app.stripe_billing {
stripe_billing
@@ -1036,7 +946,7 @@ async fn sync_subscription(
};
let billing_customer =
find_or_create_billing_customer(app, stripe_client, subscription.customer)
find_or_create_billing_customer(app, stripe_client.as_ref(), &subscription.customer)
.await?
.context("billing customer not found")?;
@@ -1064,7 +974,7 @@ async fn sync_subscription(
.as_ref()
.and_then(|details| details.reason)
.map_or(false, |reason| {
reason == CancellationDetailsReason::PaymentFailed
reason == StripeCancellationDetailsReason::PaymentFailed
});
if was_canceled_due_to_payment_failure {
@@ -1081,7 +991,7 @@ async fn sync_subscription(
if let Some(existing_subscription) = app
.db
.get_billing_subscription_by_stripe_subscription_id(&subscription.id)
.get_billing_subscription_by_stripe_subscription_id(subscription.id.0.as_ref())
.await?
{
app.db
@@ -1122,20 +1032,13 @@ async fn sync_subscription(
if existing_subscription.kind == Some(SubscriptionKind::ZedFree)
&& subscription_kind == Some(SubscriptionKind::ZedProTrial)
{
let stripe_subscription_id = existing_subscription
.stripe_subscription_id
.parse::<stripe::SubscriptionId>()
.context("failed to parse Stripe subscription ID from database")?;
let stripe_subscription_id = StripeSubscriptionId(
existing_subscription.stripe_subscription_id.clone().into(),
);
Subscription::cancel(
&stripe_client,
&stripe_subscription_id,
stripe::CancelSubscription {
invoice_now: None,
..Default::default()
},
)
.await?;
stripe_client
.cancel_subscription(&stripe_subscription_id)
.await?;
} else {
// If the user already has an active billing subscription, ignore the
// event and return an `Ok` to signal that it was processed
@@ -1186,10 +1089,8 @@ async fn sync_subscription(
.has_active_billing_subscription(billing_customer.user_id)
.await?;
if !already_has_active_billing_subscription {
let stripe_customer_id = billing_customer
.stripe_customer_id
.parse::<stripe::CustomerId>()
.context("failed to parse Stripe customer ID from database")?;
let stripe_customer_id =
StripeCustomerId(billing_customer.stripe_customer_id.clone().into());
stripe_billing
.subscribe_to_zed_free(stripe_customer_id)
@@ -1204,7 +1105,7 @@ async fn sync_subscription(
async fn handle_customer_subscription_event(
app: &Arc<AppState>,
rpc_server: &Arc<Server>,
stripe_client: &stripe::Client,
stripe_client: &Arc<dyn StripeClient>,
event: stripe::Event,
) -> anyhow::Result<()> {
let EventObject::Subscription(subscription) = event.data.object else {
@@ -1213,7 +1114,7 @@ async fn handle_customer_subscription_event(
log::info!("handling Stripe {} event: {}", event.type_, event.id);
let billing_customer = sync_subscription(app, stripe_client, subscription).await?;
let billing_customer = sync_subscription(app, stripe_client, subscription.into()).await?;
// When the user's subscription changes, push down any changes to their plan.
rpc_server
@@ -1409,30 +1310,20 @@ impl From<CancellationDetailsReason> for StripeCancellationReason {
/// Finds or creates a billing customer using the provided customer.
pub async fn find_or_create_billing_customer(
app: &Arc<AppState>,
stripe_client: &stripe::Client,
customer_or_id: Expandable<Customer>,
stripe_client: &dyn StripeClient,
customer_id: &StripeCustomerId,
) -> anyhow::Result<Option<billing_customer::Model>> {
let customer_id = match &customer_or_id {
Expandable::Id(id) => id,
Expandable::Object(customer) => customer.id.as_ref(),
};
// If we already have a billing customer record associated with the Stripe customer,
// there's nothing more we need to do.
if let Some(billing_customer) = app
.db
.get_billing_customer_by_stripe_customer_id(customer_id)
.get_billing_customer_by_stripe_customer_id(customer_id.0.as_ref())
.await?
{
return Ok(Some(billing_customer));
}
// If all we have is a customer ID, resolve it to a full customer record by
// hitting the Stripe API.
let customer = match customer_or_id {
Expandable::Id(id) => Customer::retrieve(stripe_client, &id, &[]).await?,
Expandable::Object(customer) => *customer,
};
let customer = stripe_client.get_customer(customer_id).await?;
let Some(email) = customer.email else {
return Ok(None);
@@ -1542,14 +1433,10 @@ async fn sync_model_request_usage_with_stripe(
);
};
let stripe_customer_id = billing_customer
.stripe_customer_id
.parse::<stripe::CustomerId>()
.context("failed to parse Stripe customer ID from database")?;
let stripe_subscription_id = billing_subscription
.stripe_subscription_id
.parse::<stripe::SubscriptionId>()
.context("failed to parse Stripe subscription ID from database")?;
let stripe_customer_id =
StripeCustomerId(billing_customer.stripe_customer_id.clone().into());
let stripe_subscription_id =
StripeSubscriptionId(billing_subscription.stripe_subscription_id.clone().into());
let model = llm_db.model_by_id(usage_meter.model_id)?;

View File

@@ -1,4 +1,5 @@
use crate::db::{BillingCustomerId, BillingSubscriptionId};
use crate::stripe_client;
use chrono::{Datelike as _, NaiveDate, Utc};
use sea_orm::entity::prelude::*;
use serde::Serialize;
@@ -159,3 +160,17 @@ pub enum StripeCancellationReason {
#[sea_orm(string_value = "payment_failed")]
PaymentFailed,
}
impl From<stripe_client::StripeCancellationDetailsReason> for StripeCancellationReason {
fn from(value: stripe_client::StripeCancellationDetailsReason) -> Self {
match value {
stripe_client::StripeCancellationDetailsReason::CancellationRequested => {
Self::CancellationRequested
}
stripe_client::StripeCancellationDetailsReason::PaymentDisputed => {
Self::PaymentDisputed
}
stripe_client::StripeCancellationDetailsReason::PaymentFailed => Self::PaymentFailed,
}
}
}

View File

@@ -9,6 +9,7 @@ pub mod migrations;
pub mod rpc;
pub mod seed;
pub mod stripe_billing;
pub mod stripe_client;
pub mod user_backfiller;
#[cfg(test)]
@@ -29,6 +30,7 @@ use std::{path::PathBuf, sync::Arc};
use util::ResultExt;
use crate::stripe_billing::StripeBilling;
use crate::stripe_client::{RealStripeClient, StripeClient};
pub type Result<T, E = Error> = std::result::Result<T, E>;
@@ -269,7 +271,10 @@ pub struct AppState {
pub llm_db: Option<Arc<LlmDatabase>>,
pub livekit_client: Option<Arc<dyn livekit_api::Client>>,
pub blob_store_client: Option<aws_sdk_s3::Client>,
pub stripe_client: Option<Arc<stripe::Client>>,
/// This is a real instance of the Stripe client; we're working to replace references to this with the
/// [`StripeClient`] trait.
pub real_stripe_client: Option<Arc<stripe::Client>>,
pub stripe_client: Option<Arc<dyn StripeClient>>,
pub stripe_billing: Option<Arc<StripeBilling>>,
pub executor: Executor,
pub kinesis_client: Option<::aws_sdk_kinesis::Client>,
@@ -322,7 +327,9 @@ impl AppState {
stripe_billing: stripe_client
.clone()
.map(|stripe_client| Arc::new(StripeBilling::new(stripe_client))),
stripe_client,
real_stripe_client: stripe_client.clone(),
stripe_client: stripe_client
.map(|stripe_client| Arc::new(RealStripeClient::new(stripe_client)) as _),
executor,
kinesis_client: if config.kinesis_access_key.is_some() {
build_kinesis_client(&config).await.log_err()

View File

@@ -5,6 +5,7 @@ use crate::api::{CloudflareIpCountryHeader, SystemIdHeader};
use crate::db::billing_subscription::SubscriptionKind;
use crate::llm::db::LlmDatabase;
use crate::llm::{AGENT_EXTENDED_TRIAL_FEATURE_FLAG, LlmTokenClaims};
use crate::stripe_client::StripeCustomerId;
use crate::{
AppState, Error, Result, auth,
db::{
@@ -4033,31 +4034,26 @@ async fn get_llm_api_token(
.as_ref()
.context("failed to retrieve Stripe billing object")?;
let billing_customer =
if let Some(billing_customer) = db.get_billing_customer_by_user_id(user.id).await? {
billing_customer
} else {
let customer_id = stripe_billing
.find_or_create_customer_by_email(user.email_address.as_deref())
.await?;
let billing_customer = if let Some(billing_customer) =
db.get_billing_customer_by_user_id(user.id).await?
{
billing_customer
} else {
let customer_id = stripe_billing
.find_or_create_customer_by_email(user.email_address.as_deref())
.await?;
find_or_create_billing_customer(
&session.app_state,
&stripe_client,
stripe::Expandable::Id(customer_id),
)
find_or_create_billing_customer(&session.app_state, stripe_client.as_ref(), &customer_id)
.await?
.context("billing customer not found")?
};
};
let billing_subscription =
if let Some(billing_subscription) = db.get_active_billing_subscription(user.id).await? {
billing_subscription
} else {
let stripe_customer_id = billing_customer
.stripe_customer_id
.parse::<stripe::CustomerId>()
.context("failed to parse Stripe customer ID from database")?;
let stripe_customer_id =
StripeCustomerId(billing_customer.stripe_customer_id.clone().into());
let stripe_subscription = stripe_billing
.subscribe_to_zed_free(stripe_customer_id)

View File

@@ -1,30 +1,49 @@
use std::sync::Arc;
use anyhow::{Context as _, anyhow};
use chrono::Utc;
use collections::HashMap;
use stripe::SubscriptionStatus;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::Result;
use crate::db::billing_subscription::SubscriptionKind;
use crate::llm::AGENT_EXTENDED_TRIAL_FEATURE_FLAG;
use anyhow::{Context as _, anyhow};
use chrono::Utc;
use collections::HashMap;
use serde::{Deserialize, Serialize};
use stripe::{CreateCustomer, Customer, CustomerId, PriceId, SubscriptionStatus};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::stripe_client::{
RealStripeClient, StripeCheckoutSessionMode, StripeCheckoutSessionPaymentMethodCollection,
StripeClient, StripeCreateCheckoutSessionLineItems, StripeCreateCheckoutSessionParams,
StripeCreateCheckoutSessionSubscriptionData, StripeCreateMeterEventParams,
StripeCreateMeterEventPayload, StripeCreateSubscriptionItems, StripeCreateSubscriptionParams,
StripeCustomerId, StripeMeter, StripePrice, StripePriceId, StripeSubscription,
StripeSubscriptionId, StripeSubscriptionTrialSettings,
StripeSubscriptionTrialSettingsEndBehavior,
StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, UpdateSubscriptionItems,
UpdateSubscriptionParams,
};
pub struct StripeBilling {
state: RwLock<StripeBillingState>,
client: Arc<stripe::Client>,
client: Arc<dyn StripeClient>,
}
#[derive(Default)]
struct StripeBillingState {
meters_by_event_name: HashMap<String, StripeMeter>,
price_ids_by_meter_id: HashMap<String, stripe::PriceId>,
prices_by_lookup_key: HashMap<String, stripe::Price>,
price_ids_by_meter_id: HashMap<String, StripePriceId>,
prices_by_lookup_key: HashMap<String, StripePrice>,
}
impl StripeBilling {
pub fn new(client: Arc<stripe::Client>) -> Self {
Self {
client: Arc::new(RealStripeClient::new(client.clone())),
state: RwLock::default(),
}
}
#[cfg(test)]
pub fn test(client: Arc<crate::stripe_client::FakeStripeClient>) -> Self {
Self {
client,
state: RwLock::default(),
@@ -36,24 +55,16 @@ impl StripeBilling {
let mut state = self.state.write().await;
let (meters, prices) = futures::try_join!(
StripeMeter::list(&self.client),
stripe::Price::list(
&self.client,
&stripe::ListPrices {
limit: Some(100),
..Default::default()
}
)
)?;
let (meters, prices) =
futures::try_join!(self.client.list_meters(), self.client.list_prices())?;
for meter in meters.data {
for meter in meters {
state
.meters_by_event_name
.insert(meter.event_name.clone(), meter);
}
for price in prices.data {
for price in prices {
if let Some(lookup_key) = price.lookup_key.clone() {
state.prices_by_lookup_key.insert(lookup_key, price.clone());
}
@@ -70,15 +81,15 @@ impl StripeBilling {
Ok(())
}
pub async fn zed_pro_price_id(&self) -> Result<PriceId> {
pub async fn zed_pro_price_id(&self) -> Result<StripePriceId> {
self.find_price_id_by_lookup_key("zed-pro").await
}
pub async fn zed_free_price_id(&self) -> Result<PriceId> {
pub async fn zed_free_price_id(&self) -> Result<StripePriceId> {
self.find_price_id_by_lookup_key("zed-free").await
}
pub async fn find_price_id_by_lookup_key(&self, lookup_key: &str) -> Result<PriceId> {
pub async fn find_price_id_by_lookup_key(&self, lookup_key: &str) -> Result<StripePriceId> {
self.state
.read()
.await
@@ -88,7 +99,7 @@ impl StripeBilling {
.ok_or_else(|| crate::Error::Internal(anyhow!("no price ID found for {lookup_key:?}")))
}
pub async fn find_price_by_lookup_key(&self, lookup_key: &str) -> Result<stripe::Price> {
pub async fn find_price_by_lookup_key(&self, lookup_key: &str) -> Result<StripePrice> {
self.state
.read()
.await
@@ -100,12 +111,12 @@ impl StripeBilling {
pub async fn determine_subscription_kind(
&self,
subscription: &stripe::Subscription,
subscription: &StripeSubscription,
) -> Option<SubscriptionKind> {
let zed_pro_price_id = self.zed_pro_price_id().await.ok()?;
let zed_free_price_id = self.zed_free_price_id().await.ok()?;
subscription.items.data.iter().find_map(|item| {
subscription.items.iter().find_map(|item| {
let price = item.price.as_ref()?;
if price.id == zed_pro_price_id {
@@ -129,18 +140,11 @@ impl StripeBilling {
pub async fn find_or_create_customer_by_email(
&self,
email_address: Option<&str>,
) -> Result<CustomerId> {
) -> Result<StripeCustomerId> {
let existing_customer = if let Some(email) = email_address {
let customers = Customer::list(
&self.client,
&stripe::ListCustomers {
email: Some(email),
..Default::default()
},
)
.await?;
let customers = self.client.list_customers_by_email(email).await?;
customers.data.first().cloned()
customers.first().cloned()
} else {
None
};
@@ -148,14 +152,12 @@ impl StripeBilling {
let customer_id = if let Some(existing_customer) = existing_customer {
existing_customer.id
} else {
let customer = Customer::create(
&self.client,
CreateCustomer {
let customer = self
.client
.create_customer(crate::stripe_client::CreateCustomerParams {
email: email_address,
..Default::default()
},
)
.await?;
})
.await?;
customer.id
};
@@ -165,11 +167,10 @@ impl StripeBilling {
pub async fn subscribe_to_price(
&self,
subscription_id: &stripe::SubscriptionId,
price: &stripe::Price,
subscription_id: &StripeSubscriptionId,
price: &StripePrice,
) -> Result<()> {
let subscription =
stripe::Subscription::retrieve(&self.client, &subscription_id, &[]).await?;
let subscription = self.client.get_subscription(subscription_id).await?;
if subscription_contains_price(&subscription, &price.id) {
return Ok(());
@@ -180,39 +181,36 @@ impl StripeBilling {
let price_per_unit = price.unit_amount.unwrap_or_default();
let _units_for_billing_threshold = BILLING_THRESHOLD_IN_CENTS / price_per_unit;
stripe::Subscription::update(
&self.client,
subscription_id,
stripe::UpdateSubscription {
items: Some(vec![stripe::UpdateSubscriptionItems {
price: Some(price.id.to_string()),
..Default::default()
}]),
trial_settings: Some(stripe::UpdateSubscriptionTrialSettings {
end_behavior: stripe::UpdateSubscriptionTrialSettingsEndBehavior {
missing_payment_method: stripe::UpdateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel,
},
}),
..Default::default()
},
)
.await?;
self.client
.update_subscription(
subscription_id,
UpdateSubscriptionParams {
items: Some(vec![UpdateSubscriptionItems {
price: Some(price.id.clone()),
}]),
trial_settings: Some(StripeSubscriptionTrialSettings {
end_behavior: StripeSubscriptionTrialSettingsEndBehavior {
missing_payment_method: StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel
},
}),
},
)
.await?;
Ok(())
}
pub async fn bill_model_request_usage(
&self,
customer_id: &stripe::CustomerId,
customer_id: &StripeCustomerId,
event_name: &str,
requests: i32,
) -> Result<()> {
let timestamp = Utc::now().timestamp();
let idempotency_key = Uuid::new_v4();
StripeMeterEvent::create(
&self.client,
StripeCreateMeterEventParams {
self.client
.create_meter_event(StripeCreateMeterEventParams {
identifier: &format!("model_requests/{}", idempotency_key),
event_name,
payload: StripeCreateMeterEventPayload {
@@ -220,39 +218,37 @@ impl StripeBilling {
stripe_customer_id: customer_id,
},
timestamp: Some(timestamp),
},
)
.await?;
})
.await?;
Ok(())
}
pub async fn checkout_with_zed_pro(
&self,
customer_id: stripe::CustomerId,
customer_id: &StripeCustomerId,
github_login: &str,
success_url: &str,
) -> Result<String> {
let zed_pro_price_id = self.zed_pro_price_id().await?;
let mut params = stripe::CreateCheckoutSession::new();
params.mode = Some(stripe::CheckoutSessionMode::Subscription);
let mut params = StripeCreateCheckoutSessionParams::default();
params.mode = Some(StripeCheckoutSessionMode::Subscription);
params.customer = Some(customer_id);
params.client_reference_id = Some(github_login);
params.line_items = Some(vec![stripe::CreateCheckoutSessionLineItems {
params.line_items = Some(vec![StripeCreateCheckoutSessionLineItems {
price: Some(zed_pro_price_id.to_string()),
quantity: Some(1),
..Default::default()
}]);
params.success_url = Some(success_url);
let session = stripe::CheckoutSession::create(&self.client, params).await?;
let session = self.client.create_checkout_session(params).await?;
Ok(session.url.context("no checkout session URL")?)
}
pub async fn checkout_with_zed_pro_trial(
&self,
customer_id: stripe::CustomerId,
customer_id: &StripeCustomerId,
github_login: &str,
feature_flags: Vec<String>,
success_url: &str,
@@ -273,172 +269,75 @@ impl StripeBilling {
);
}
let mut params = stripe::CreateCheckoutSession::new();
params.subscription_data = Some(stripe::CreateCheckoutSessionSubscriptionData {
let mut params = StripeCreateCheckoutSessionParams::default();
params.subscription_data = Some(StripeCreateCheckoutSessionSubscriptionData {
trial_period_days: Some(trial_period_days),
trial_settings: Some(stripe::CreateCheckoutSessionSubscriptionDataTrialSettings {
end_behavior: stripe::CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehavior {
missing_payment_method: stripe::CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehaviorMissingPaymentMethod::Cancel,
}
trial_settings: Some(StripeSubscriptionTrialSettings {
end_behavior: StripeSubscriptionTrialSettingsEndBehavior {
missing_payment_method:
StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel,
},
}),
metadata: if !subscription_metadata.is_empty() {
Some(subscription_metadata)
} else {
None
},
..Default::default()
});
params.mode = Some(stripe::CheckoutSessionMode::Subscription);
params.mode = Some(StripeCheckoutSessionMode::Subscription);
params.payment_method_collection =
Some(stripe::CheckoutSessionPaymentMethodCollection::IfRequired);
Some(StripeCheckoutSessionPaymentMethodCollection::IfRequired);
params.customer = Some(customer_id);
params.client_reference_id = Some(github_login);
params.line_items = Some(vec![stripe::CreateCheckoutSessionLineItems {
params.line_items = Some(vec![StripeCreateCheckoutSessionLineItems {
price: Some(zed_pro_price_id.to_string()),
quantity: Some(1),
..Default::default()
}]);
params.success_url = Some(success_url);
let session = stripe::CheckoutSession::create(&self.client, params).await?;
let session = self.client.create_checkout_session(params).await?;
Ok(session.url.context("no checkout session URL")?)
}
pub async fn subscribe_to_zed_free(
&self,
customer_id: stripe::CustomerId,
) -> Result<stripe::Subscription> {
customer_id: StripeCustomerId,
) -> Result<StripeSubscription> {
let zed_free_price_id = self.zed_free_price_id().await?;
let existing_subscriptions = stripe::Subscription::list(
&self.client,
&stripe::ListSubscriptions {
customer: Some(customer_id.clone()),
status: None,
..Default::default()
},
)
.await?;
let existing_subscriptions = self
.client
.list_subscriptions_for_customer(&customer_id)
.await?;
let existing_active_subscription =
existing_subscriptions
.data
.into_iter()
.find(|subscription| {
subscription.status == SubscriptionStatus::Active
|| subscription.status == SubscriptionStatus::Trialing
});
existing_subscriptions.into_iter().find(|subscription| {
subscription.status == SubscriptionStatus::Active
|| subscription.status == SubscriptionStatus::Trialing
});
if let Some(subscription) = existing_active_subscription {
return Ok(subscription);
}
let mut params = stripe::CreateSubscription::new(customer_id);
params.items = Some(vec![stripe::CreateSubscriptionItems {
price: Some(zed_free_price_id.to_string()),
quantity: Some(1),
..Default::default()
}]);
let params = StripeCreateSubscriptionParams {
customer: customer_id,
items: vec![StripeCreateSubscriptionItems {
price: Some(zed_free_price_id),
quantity: Some(1),
}],
};
let subscription = stripe::Subscription::create(&self.client, params).await?;
let subscription = self.client.create_subscription(params).await?;
Ok(subscription)
}
pub async fn checkout_with_zed_free(
&self,
customer_id: stripe::CustomerId,
github_login: &str,
success_url: &str,
) -> Result<String> {
let zed_free_price_id = self.zed_free_price_id().await?;
let mut params = stripe::CreateCheckoutSession::new();
params.mode = Some(stripe::CheckoutSessionMode::Subscription);
params.payment_method_collection =
Some(stripe::CheckoutSessionPaymentMethodCollection::IfRequired);
params.customer = Some(customer_id);
params.client_reference_id = Some(github_login);
params.line_items = Some(vec![stripe::CreateCheckoutSessionLineItems {
price: Some(zed_free_price_id.to_string()),
quantity: Some(1),
..Default::default()
}]);
params.success_url = Some(success_url);
let session = stripe::CheckoutSession::create(&self.client, params).await?;
Ok(session.url.context("no checkout session URL")?)
}
}
#[derive(Clone, Deserialize)]
struct StripeMeter {
id: String,
event_name: String,
}
impl StripeMeter {
pub fn list(client: &stripe::Client) -> stripe::Response<stripe::List<Self>> {
#[derive(Serialize)]
struct Params {
#[serde(skip_serializing_if = "Option::is_none")]
limit: Option<u64>,
}
client.get_query("/billing/meters", Params { limit: Some(100) })
}
}
#[derive(Deserialize)]
struct StripeMeterEvent {
identifier: String,
}
impl StripeMeterEvent {
pub async fn create(
client: &stripe::Client,
params: StripeCreateMeterEventParams<'_>,
) -> Result<Self, stripe::StripeError> {
let identifier = params.identifier;
match client.post_form("/billing/meter_events", params).await {
Ok(event) => Ok(event),
Err(stripe::StripeError::Stripe(error)) => {
if error.http_status == 400
&& error
.message
.as_ref()
.map_or(false, |message| message.contains(identifier))
{
Ok(Self {
identifier: identifier.to_string(),
})
} else {
Err(stripe::StripeError::Stripe(error))
}
}
Err(error) => Err(error),
}
}
}
#[derive(Serialize)]
struct StripeCreateMeterEventParams<'a> {
identifier: &'a str,
event_name: &'a str,
payload: StripeCreateMeterEventPayload<'a>,
timestamp: Option<i64>,
}
#[derive(Serialize)]
struct StripeCreateMeterEventPayload<'a> {
value: u64,
stripe_customer_id: &'a stripe::CustomerId,
}
fn subscription_contains_price(
subscription: &stripe::Subscription,
price_id: &stripe::PriceId,
subscription: &StripeSubscription,
price_id: &StripePriceId,
) -> bool {
subscription.items.data.iter().any(|item| {
subscription.items.iter().any(|item| {
item.price
.as_ref()
.map_or(false, |price| price.id == *price_id)

View File

@@ -0,0 +1,229 @@
#[cfg(test)]
mod fake_stripe_client;
mod real_stripe_client;
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
#[cfg(test)]
pub use fake_stripe_client::*;
pub use real_stripe_client::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display, Serialize)]
pub struct StripeCustomerId(pub Arc<str>);
#[derive(Debug, Clone)]
pub struct StripeCustomer {
pub id: StripeCustomerId,
pub email: Option<String>,
}
#[derive(Debug)]
pub struct CreateCustomerParams<'a> {
pub email: Option<&'a str>,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display)]
pub struct StripeSubscriptionId(pub Arc<str>);
#[derive(Debug, PartialEq, Clone)]
pub struct StripeSubscription {
pub id: StripeSubscriptionId,
pub customer: StripeCustomerId,
// TODO: Create our own version of this enum.
pub status: stripe::SubscriptionStatus,
pub current_period_end: i64,
pub current_period_start: i64,
pub items: Vec<StripeSubscriptionItem>,
pub cancel_at: Option<i64>,
pub cancellation_details: Option<StripeCancellationDetails>,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display)]
pub struct StripeSubscriptionItemId(pub Arc<str>);
#[derive(Debug, PartialEq, Clone)]
pub struct StripeSubscriptionItem {
pub id: StripeSubscriptionItemId,
pub price: Option<StripePrice>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StripeCancellationDetails {
pub reason: Option<StripeCancellationDetailsReason>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum StripeCancellationDetailsReason {
CancellationRequested,
PaymentDisputed,
PaymentFailed,
}
#[derive(Debug)]
pub struct StripeCreateSubscriptionParams {
pub customer: StripeCustomerId,
pub items: Vec<StripeCreateSubscriptionItems>,
}
#[derive(Debug)]
pub struct StripeCreateSubscriptionItems {
pub price: Option<StripePriceId>,
pub quantity: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct UpdateSubscriptionParams {
pub items: Option<Vec<UpdateSubscriptionItems>>,
pub trial_settings: Option<StripeSubscriptionTrialSettings>,
}
#[derive(Debug, PartialEq, Clone)]
pub struct UpdateSubscriptionItems {
pub price: Option<StripePriceId>,
}
#[derive(Debug, PartialEq, Clone)]
pub struct StripeSubscriptionTrialSettings {
pub end_behavior: StripeSubscriptionTrialSettingsEndBehavior,
}
#[derive(Debug, PartialEq, Clone)]
pub struct StripeSubscriptionTrialSettingsEndBehavior {
pub missing_payment_method: StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod {
Cancel,
CreateInvoice,
Pause,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display)]
pub struct StripePriceId(pub Arc<str>);
#[derive(Debug, PartialEq, Clone)]
pub struct StripePrice {
pub id: StripePriceId,
pub unit_amount: Option<i64>,
pub lookup_key: Option<String>,
pub recurring: Option<StripePriceRecurring>,
}
#[derive(Debug, PartialEq, Clone)]
pub struct StripePriceRecurring {
pub meter: Option<String>,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display, Deserialize)]
pub struct StripeMeterId(pub Arc<str>);
#[derive(Debug, Clone, Deserialize)]
pub struct StripeMeter {
pub id: StripeMeterId,
pub event_name: String,
}
#[derive(Debug, Serialize)]
pub struct StripeCreateMeterEventParams<'a> {
pub identifier: &'a str,
pub event_name: &'a str,
pub payload: StripeCreateMeterEventPayload<'a>,
pub timestamp: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct StripeCreateMeterEventPayload<'a> {
pub value: u64,
pub stripe_customer_id: &'a StripeCustomerId,
}
#[derive(Debug, Default)]
pub struct StripeCreateCheckoutSessionParams<'a> {
pub customer: Option<&'a StripeCustomerId>,
pub client_reference_id: Option<&'a str>,
pub mode: Option<StripeCheckoutSessionMode>,
pub line_items: Option<Vec<StripeCreateCheckoutSessionLineItems>>,
pub payment_method_collection: Option<StripeCheckoutSessionPaymentMethodCollection>,
pub subscription_data: Option<StripeCreateCheckoutSessionSubscriptionData>,
pub success_url: Option<&'a str>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum StripeCheckoutSessionMode {
Payment,
Setup,
Subscription,
}
#[derive(Debug, PartialEq, Clone)]
pub struct StripeCreateCheckoutSessionLineItems {
pub price: Option<String>,
pub quantity: Option<u64>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum StripeCheckoutSessionPaymentMethodCollection {
Always,
IfRequired,
}
#[derive(Debug, PartialEq, Clone)]
pub struct StripeCreateCheckoutSessionSubscriptionData {
pub metadata: Option<HashMap<String, String>>,
pub trial_period_days: Option<u32>,
pub trial_settings: Option<StripeSubscriptionTrialSettings>,
}
#[derive(Debug)]
pub struct StripeCheckoutSession {
pub url: Option<String>,
}
#[async_trait]
pub trait StripeClient: Send + Sync {
async fn list_customers_by_email(&self, email: &str) -> Result<Vec<StripeCustomer>>;
async fn get_customer(&self, customer_id: &StripeCustomerId) -> Result<StripeCustomer>;
async fn create_customer(&self, params: CreateCustomerParams<'_>) -> Result<StripeCustomer>;
async fn list_subscriptions_for_customer(
&self,
customer_id: &StripeCustomerId,
) -> Result<Vec<StripeSubscription>>;
async fn get_subscription(
&self,
subscription_id: &StripeSubscriptionId,
) -> Result<StripeSubscription>;
async fn create_subscription(
&self,
params: StripeCreateSubscriptionParams,
) -> Result<StripeSubscription>;
async fn update_subscription(
&self,
subscription_id: &StripeSubscriptionId,
params: UpdateSubscriptionParams,
) -> Result<()>;
async fn cancel_subscription(&self, subscription_id: &StripeSubscriptionId) -> Result<()>;
async fn list_prices(&self) -> Result<Vec<StripePrice>>;
async fn list_meters(&self) -> Result<Vec<StripeMeter>>;
async fn create_meter_event(&self, params: StripeCreateMeterEventParams<'_>) -> Result<()>;
async fn create_checkout_session(
&self,
params: StripeCreateCheckoutSessionParams<'_>,
) -> Result<StripeCheckoutSession>;
}

View File

@@ -0,0 +1,224 @@
use std::sync::Arc;
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use collections::HashMap;
use parking_lot::Mutex;
use uuid::Uuid;
use crate::stripe_client::{
CreateCustomerParams, StripeCheckoutSession, StripeCheckoutSessionMode,
StripeCheckoutSessionPaymentMethodCollection, StripeClient,
StripeCreateCheckoutSessionLineItems, StripeCreateCheckoutSessionParams,
StripeCreateCheckoutSessionSubscriptionData, StripeCreateMeterEventParams,
StripeCreateSubscriptionParams, StripeCustomer, StripeCustomerId, StripeMeter, StripeMeterId,
StripePrice, StripePriceId, StripeSubscription, StripeSubscriptionId, StripeSubscriptionItem,
StripeSubscriptionItemId, UpdateSubscriptionParams,
};
#[derive(Debug, Clone)]
pub struct StripeCreateMeterEventCall {
pub identifier: Arc<str>,
pub event_name: Arc<str>,
pub value: u64,
pub stripe_customer_id: StripeCustomerId,
pub timestamp: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct StripeCreateCheckoutSessionCall {
pub customer: Option<StripeCustomerId>,
pub client_reference_id: Option<String>,
pub mode: Option<StripeCheckoutSessionMode>,
pub line_items: Option<Vec<StripeCreateCheckoutSessionLineItems>>,
pub payment_method_collection: Option<StripeCheckoutSessionPaymentMethodCollection>,
pub subscription_data: Option<StripeCreateCheckoutSessionSubscriptionData>,
pub success_url: Option<String>,
}
pub struct FakeStripeClient {
pub customers: Arc<Mutex<HashMap<StripeCustomerId, StripeCustomer>>>,
pub subscriptions: Arc<Mutex<HashMap<StripeSubscriptionId, StripeSubscription>>>,
pub update_subscription_calls:
Arc<Mutex<Vec<(StripeSubscriptionId, UpdateSubscriptionParams)>>>,
pub prices: Arc<Mutex<HashMap<StripePriceId, StripePrice>>>,
pub meters: Arc<Mutex<HashMap<StripeMeterId, StripeMeter>>>,
pub create_meter_event_calls: Arc<Mutex<Vec<StripeCreateMeterEventCall>>>,
pub create_checkout_session_calls: Arc<Mutex<Vec<StripeCreateCheckoutSessionCall>>>,
}
impl FakeStripeClient {
pub fn new() -> Self {
Self {
customers: Arc::new(Mutex::new(HashMap::default())),
subscriptions: Arc::new(Mutex::new(HashMap::default())),
update_subscription_calls: Arc::new(Mutex::new(Vec::new())),
prices: Arc::new(Mutex::new(HashMap::default())),
meters: Arc::new(Mutex::new(HashMap::default())),
create_meter_event_calls: Arc::new(Mutex::new(Vec::new())),
create_checkout_session_calls: Arc::new(Mutex::new(Vec::new())),
}
}
}
#[async_trait]
impl StripeClient for FakeStripeClient {
async fn list_customers_by_email(&self, email: &str) -> Result<Vec<StripeCustomer>> {
Ok(self
.customers
.lock()
.values()
.filter(|customer| customer.email.as_deref() == Some(email))
.cloned()
.collect())
}
async fn get_customer(&self, customer_id: &StripeCustomerId) -> Result<StripeCustomer> {
self.customers
.lock()
.get(customer_id)
.cloned()
.ok_or_else(|| anyhow!("no customer found for {customer_id:?}"))
}
async fn create_customer(&self, params: CreateCustomerParams<'_>) -> Result<StripeCustomer> {
let customer = StripeCustomer {
id: StripeCustomerId(format!("cus_{}", Uuid::new_v4()).into()),
email: params.email.map(|email| email.to_string()),
};
self.customers
.lock()
.insert(customer.id.clone(), customer.clone());
Ok(customer)
}
async fn list_subscriptions_for_customer(
&self,
customer_id: &StripeCustomerId,
) -> Result<Vec<StripeSubscription>> {
let subscriptions = self
.subscriptions
.lock()
.values()
.filter(|subscription| subscription.customer == *customer_id)
.cloned()
.collect();
Ok(subscriptions)
}
async fn get_subscription(
&self,
subscription_id: &StripeSubscriptionId,
) -> Result<StripeSubscription> {
self.subscriptions
.lock()
.get(subscription_id)
.cloned()
.ok_or_else(|| anyhow!("no subscription found for {subscription_id:?}"))
}
async fn create_subscription(
&self,
params: StripeCreateSubscriptionParams,
) -> Result<StripeSubscription> {
let now = Utc::now();
let subscription = StripeSubscription {
id: StripeSubscriptionId(format!("sub_{}", Uuid::new_v4()).into()),
customer: params.customer,
status: stripe::SubscriptionStatus::Active,
current_period_start: now.timestamp(),
current_period_end: (now + Duration::days(30)).timestamp(),
items: params
.items
.into_iter()
.map(|item| StripeSubscriptionItem {
id: StripeSubscriptionItemId(format!("si_{}", Uuid::new_v4()).into()),
price: item
.price
.and_then(|price_id| self.prices.lock().get(&price_id).cloned()),
})
.collect(),
cancel_at: None,
cancellation_details: None,
};
self.subscriptions
.lock()
.insert(subscription.id.clone(), subscription.clone());
Ok(subscription)
}
async fn update_subscription(
&self,
subscription_id: &StripeSubscriptionId,
params: UpdateSubscriptionParams,
) -> Result<()> {
let subscription = self.get_subscription(subscription_id).await?;
self.update_subscription_calls
.lock()
.push((subscription.id, params));
Ok(())
}
async fn cancel_subscription(&self, subscription_id: &StripeSubscriptionId) -> Result<()> {
// TODO: Implement fake subscription cancellation.
let _ = subscription_id;
Ok(())
}
async fn list_prices(&self) -> Result<Vec<StripePrice>> {
let prices = self.prices.lock().values().cloned().collect();
Ok(prices)
}
async fn list_meters(&self) -> Result<Vec<StripeMeter>> {
let meters = self.meters.lock().values().cloned().collect();
Ok(meters)
}
async fn create_meter_event(&self, params: StripeCreateMeterEventParams<'_>) -> Result<()> {
self.create_meter_event_calls
.lock()
.push(StripeCreateMeterEventCall {
identifier: params.identifier.into(),
event_name: params.event_name.into(),
value: params.payload.value,
stripe_customer_id: params.payload.stripe_customer_id.clone(),
timestamp: params.timestamp,
});
Ok(())
}
async fn create_checkout_session(
&self,
params: StripeCreateCheckoutSessionParams<'_>,
) -> Result<StripeCheckoutSession> {
self.create_checkout_session_calls
.lock()
.push(StripeCreateCheckoutSessionCall {
customer: params.customer.cloned(),
client_reference_id: params.client_reference_id.map(|id| id.to_string()),
mode: params.mode,
line_items: params.line_items,
payment_method_collection: params.payment_method_collection,
subscription_data: params.subscription_data,
success_url: params.success_url.map(|url| url.to_string()),
});
Ok(StripeCheckoutSession {
url: Some("https://checkout.stripe.com/c/pay/cs_test_1".to_string()),
})
}
}

View File

@@ -0,0 +1,500 @@
use std::str::FromStr as _;
use std::sync::Arc;
use anyhow::{Context as _, Result, anyhow};
use async_trait::async_trait;
use serde::Serialize;
use stripe::{
CancellationDetails, CancellationDetailsReason, CheckoutSession, CheckoutSessionMode,
CheckoutSessionPaymentMethodCollection, CreateCheckoutSession, CreateCheckoutSessionLineItems,
CreateCheckoutSessionSubscriptionData, CreateCheckoutSessionSubscriptionDataTrialSettings,
CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehavior,
CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehaviorMissingPaymentMethod,
CreateCustomer, Customer, CustomerId, ListCustomers, Price, PriceId, Recurring, Subscription,
SubscriptionId, SubscriptionItem, SubscriptionItemId, UpdateSubscriptionItems,
UpdateSubscriptionTrialSettings, UpdateSubscriptionTrialSettingsEndBehavior,
UpdateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod,
};
use crate::stripe_client::{
CreateCustomerParams, StripeCancellationDetails, StripeCancellationDetailsReason,
StripeCheckoutSession, StripeCheckoutSessionMode, StripeCheckoutSessionPaymentMethodCollection,
StripeClient, StripeCreateCheckoutSessionLineItems, StripeCreateCheckoutSessionParams,
StripeCreateCheckoutSessionSubscriptionData, StripeCreateMeterEventParams,
StripeCreateSubscriptionParams, StripeCustomer, StripeCustomerId, StripeMeter, StripePrice,
StripePriceId, StripePriceRecurring, StripeSubscription, StripeSubscriptionId,
StripeSubscriptionItem, StripeSubscriptionItemId, StripeSubscriptionTrialSettings,
StripeSubscriptionTrialSettingsEndBehavior,
StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, UpdateSubscriptionParams,
};
pub struct RealStripeClient {
client: Arc<stripe::Client>,
}
impl RealStripeClient {
pub fn new(client: Arc<stripe::Client>) -> Self {
Self { client }
}
}
#[async_trait]
impl StripeClient for RealStripeClient {
async fn list_customers_by_email(&self, email: &str) -> Result<Vec<StripeCustomer>> {
let response = Customer::list(
&self.client,
&ListCustomers {
email: Some(email),
..Default::default()
},
)
.await?;
Ok(response
.data
.into_iter()
.map(StripeCustomer::from)
.collect())
}
async fn get_customer(&self, customer_id: &StripeCustomerId) -> Result<StripeCustomer> {
let customer_id = customer_id.try_into()?;
let customer = Customer::retrieve(&self.client, &customer_id, &[]).await?;
Ok(StripeCustomer::from(customer))
}
async fn create_customer(&self, params: CreateCustomerParams<'_>) -> Result<StripeCustomer> {
let customer = Customer::create(
&self.client,
CreateCustomer {
email: params.email,
..Default::default()
},
)
.await?;
Ok(StripeCustomer::from(customer))
}
async fn list_subscriptions_for_customer(
&self,
customer_id: &StripeCustomerId,
) -> Result<Vec<StripeSubscription>> {
let customer_id = customer_id.try_into()?;
let subscriptions = stripe::Subscription::list(
&self.client,
&stripe::ListSubscriptions {
customer: Some(customer_id),
status: None,
..Default::default()
},
)
.await?;
Ok(subscriptions
.data
.into_iter()
.map(StripeSubscription::from)
.collect())
}
async fn get_subscription(
&self,
subscription_id: &StripeSubscriptionId,
) -> Result<StripeSubscription> {
let subscription_id = subscription_id.try_into()?;
let subscription = Subscription::retrieve(&self.client, &subscription_id, &[]).await?;
Ok(StripeSubscription::from(subscription))
}
async fn create_subscription(
&self,
params: StripeCreateSubscriptionParams,
) -> Result<StripeSubscription> {
let customer_id = params.customer.try_into()?;
let mut create_subscription = stripe::CreateSubscription::new(customer_id);
create_subscription.items = Some(
params
.items
.into_iter()
.map(|item| stripe::CreateSubscriptionItems {
price: item.price.map(|price| price.to_string()),
quantity: item.quantity,
..Default::default()
})
.collect(),
);
let subscription = Subscription::create(&self.client, create_subscription).await?;
Ok(StripeSubscription::from(subscription))
}
async fn update_subscription(
&self,
subscription_id: &StripeSubscriptionId,
params: UpdateSubscriptionParams,
) -> Result<()> {
let subscription_id = subscription_id.try_into()?;
stripe::Subscription::update(
&self.client,
&subscription_id,
stripe::UpdateSubscription {
items: params.items.map(|items| {
items
.into_iter()
.map(|item| UpdateSubscriptionItems {
price: item.price.map(|price| price.to_string()),
..Default::default()
})
.collect()
}),
trial_settings: params.trial_settings.map(Into::into),
..Default::default()
},
)
.await?;
Ok(())
}
async fn cancel_subscription(&self, subscription_id: &StripeSubscriptionId) -> Result<()> {
let subscription_id = subscription_id.try_into()?;
Subscription::cancel(
&self.client,
&subscription_id,
stripe::CancelSubscription {
invoice_now: None,
..Default::default()
},
)
.await?;
Ok(())
}
async fn list_prices(&self) -> Result<Vec<StripePrice>> {
let response = stripe::Price::list(
&self.client,
&stripe::ListPrices {
limit: Some(100),
..Default::default()
},
)
.await?;
Ok(response.data.into_iter().map(StripePrice::from).collect())
}
async fn list_meters(&self) -> Result<Vec<StripeMeter>> {
#[derive(Serialize)]
struct Params {
#[serde(skip_serializing_if = "Option::is_none")]
limit: Option<u64>,
}
let response = self
.client
.get_query::<stripe::List<StripeMeter>, _>(
"/billing/meters",
Params { limit: Some(100) },
)
.await?;
Ok(response.data)
}
async fn create_meter_event(&self, params: StripeCreateMeterEventParams<'_>) -> Result<()> {
let identifier = params.identifier;
match self.client.post_form("/billing/meter_events", params).await {
Ok(event) => Ok(event),
Err(stripe::StripeError::Stripe(error)) => {
if error.http_status == 400
&& error
.message
.as_ref()
.map_or(false, |message| message.contains(identifier))
{
Ok(())
} else {
Err(anyhow!(stripe::StripeError::Stripe(error)))
}
}
Err(error) => Err(anyhow!(error)),
}
}
async fn create_checkout_session(
&self,
params: StripeCreateCheckoutSessionParams<'_>,
) -> Result<StripeCheckoutSession> {
let params = params.try_into()?;
let session = CheckoutSession::create(&self.client, params).await?;
Ok(session.into())
}
}
impl From<CustomerId> for StripeCustomerId {
fn from(value: CustomerId) -> Self {
Self(value.as_str().into())
}
}
impl TryFrom<StripeCustomerId> for CustomerId {
type Error = anyhow::Error;
fn try_from(value: StripeCustomerId) -> Result<Self, Self::Error> {
Self::from_str(value.0.as_ref()).context("failed to parse Stripe customer ID")
}
}
impl TryFrom<&StripeCustomerId> for CustomerId {
type Error = anyhow::Error;
fn try_from(value: &StripeCustomerId) -> Result<Self, Self::Error> {
Self::from_str(value.0.as_ref()).context("failed to parse Stripe customer ID")
}
}
impl From<Customer> for StripeCustomer {
fn from(value: Customer) -> Self {
StripeCustomer {
id: value.id.into(),
email: value.email,
}
}
}
impl From<SubscriptionId> for StripeSubscriptionId {
fn from(value: SubscriptionId) -> Self {
Self(value.as_str().into())
}
}
impl TryFrom<&StripeSubscriptionId> for SubscriptionId {
type Error = anyhow::Error;
fn try_from(value: &StripeSubscriptionId) -> Result<Self, Self::Error> {
Self::from_str(value.0.as_ref()).context("failed to parse Stripe subscription ID")
}
}
impl From<Subscription> for StripeSubscription {
fn from(value: Subscription) -> Self {
Self {
id: value.id.into(),
customer: value.customer.id().into(),
status: value.status,
current_period_start: value.current_period_start,
current_period_end: value.current_period_end,
items: value.items.data.into_iter().map(Into::into).collect(),
cancel_at: value.cancel_at,
cancellation_details: value.cancellation_details.map(Into::into),
}
}
}
impl From<CancellationDetails> for StripeCancellationDetails {
fn from(value: CancellationDetails) -> Self {
Self {
reason: value.reason.map(Into::into),
}
}
}
impl From<CancellationDetailsReason> for StripeCancellationDetailsReason {
fn from(value: CancellationDetailsReason) -> Self {
match value {
CancellationDetailsReason::CancellationRequested => Self::CancellationRequested,
CancellationDetailsReason::PaymentDisputed => Self::PaymentDisputed,
CancellationDetailsReason::PaymentFailed => Self::PaymentFailed,
}
}
}
impl From<SubscriptionItemId> for StripeSubscriptionItemId {
fn from(value: SubscriptionItemId) -> Self {
Self(value.as_str().into())
}
}
impl From<SubscriptionItem> for StripeSubscriptionItem {
fn from(value: SubscriptionItem) -> Self {
Self {
id: value.id.into(),
price: value.price.map(Into::into),
}
}
}
impl From<StripeSubscriptionTrialSettings> for UpdateSubscriptionTrialSettings {
fn from(value: StripeSubscriptionTrialSettings) -> Self {
Self {
end_behavior: value.end_behavior.into(),
}
}
}
impl From<StripeSubscriptionTrialSettingsEndBehavior>
for UpdateSubscriptionTrialSettingsEndBehavior
{
fn from(value: StripeSubscriptionTrialSettingsEndBehavior) -> Self {
Self {
missing_payment_method: value.missing_payment_method.into(),
}
}
}
impl From<StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod>
for UpdateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod
{
fn from(value: StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod) -> Self {
match value {
StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel => Self::Cancel,
StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::CreateInvoice => {
Self::CreateInvoice
}
StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Pause => Self::Pause,
}
}
}
impl From<PriceId> for StripePriceId {
fn from(value: PriceId) -> Self {
Self(value.as_str().into())
}
}
impl TryFrom<StripePriceId> for PriceId {
type Error = anyhow::Error;
fn try_from(value: StripePriceId) -> Result<Self, Self::Error> {
Self::from_str(value.0.as_ref()).context("failed to parse Stripe price ID")
}
}
impl From<Price> for StripePrice {
fn from(value: Price) -> Self {
Self {
id: value.id.into(),
unit_amount: value.unit_amount,
lookup_key: value.lookup_key,
recurring: value.recurring.map(StripePriceRecurring::from),
}
}
}
impl From<Recurring> for StripePriceRecurring {
fn from(value: Recurring) -> Self {
Self { meter: value.meter }
}
}
impl<'a> TryFrom<StripeCreateCheckoutSessionParams<'a>> for CreateCheckoutSession<'a> {
type Error = anyhow::Error;
fn try_from(value: StripeCreateCheckoutSessionParams<'a>) -> Result<Self, Self::Error> {
Ok(Self {
customer: value
.customer
.map(|customer_id| customer_id.try_into())
.transpose()?,
client_reference_id: value.client_reference_id,
mode: value.mode.map(Into::into),
line_items: value
.line_items
.map(|line_items| line_items.into_iter().map(Into::into).collect()),
payment_method_collection: value.payment_method_collection.map(Into::into),
subscription_data: value.subscription_data.map(Into::into),
success_url: value.success_url,
..Default::default()
})
}
}
impl From<StripeCheckoutSessionMode> for CheckoutSessionMode {
fn from(value: StripeCheckoutSessionMode) -> Self {
match value {
StripeCheckoutSessionMode::Payment => Self::Payment,
StripeCheckoutSessionMode::Setup => Self::Setup,
StripeCheckoutSessionMode::Subscription => Self::Subscription,
}
}
}
impl From<StripeCreateCheckoutSessionLineItems> for CreateCheckoutSessionLineItems {
fn from(value: StripeCreateCheckoutSessionLineItems) -> Self {
Self {
price: value.price,
quantity: value.quantity,
..Default::default()
}
}
}
impl From<StripeCheckoutSessionPaymentMethodCollection> for CheckoutSessionPaymentMethodCollection {
fn from(value: StripeCheckoutSessionPaymentMethodCollection) -> Self {
match value {
StripeCheckoutSessionPaymentMethodCollection::Always => Self::Always,
StripeCheckoutSessionPaymentMethodCollection::IfRequired => Self::IfRequired,
}
}
}
impl From<StripeCreateCheckoutSessionSubscriptionData> for CreateCheckoutSessionSubscriptionData {
fn from(value: StripeCreateCheckoutSessionSubscriptionData) -> Self {
Self {
trial_period_days: value.trial_period_days,
trial_settings: value.trial_settings.map(Into::into),
metadata: value.metadata,
..Default::default()
}
}
}
impl From<StripeSubscriptionTrialSettings> for CreateCheckoutSessionSubscriptionDataTrialSettings {
fn from(value: StripeSubscriptionTrialSettings) -> Self {
Self {
end_behavior: value.end_behavior.into(),
}
}
}
impl From<StripeSubscriptionTrialSettingsEndBehavior>
for CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehavior
{
fn from(value: StripeSubscriptionTrialSettingsEndBehavior) -> Self {
Self {
missing_payment_method: value.missing_payment_method.into(),
}
}
}
impl From<StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod>
for CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehaviorMissingPaymentMethod
{
fn from(value: StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod) -> Self {
match value {
StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel => Self::Cancel,
StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::CreateInvoice => {
Self::CreateInvoice
}
StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Pause => Self::Pause,
}
}
}
impl From<CheckoutSession> for StripeCheckoutSession {
fn from(value: CheckoutSession) -> Self {
Self { url: value.url }
}
}

View File

@@ -18,6 +18,7 @@ mod random_channel_buffer_tests;
mod random_project_collaboration_tests;
mod randomized_test_helpers;
mod remote_editing_collaboration_tests;
mod stripe_billing_tests;
mod test_server;
use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};

View File

@@ -1010,7 +1010,6 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T
workspace_b.update_in(cx_b, |workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| {
pane.close_inactive_items(&Default::default(), window, cx)
.unwrap()
.detach();
});
});

View File

@@ -0,0 +1,565 @@
use std::sync::Arc;
use chrono::{Duration, Utc};
use pretty_assertions::assert_eq;
use crate::llm::AGENT_EXTENDED_TRIAL_FEATURE_FLAG;
use crate::stripe_billing::StripeBilling;
use crate::stripe_client::{
FakeStripeClient, StripeCheckoutSessionMode, StripeCheckoutSessionPaymentMethodCollection,
StripeCreateCheckoutSessionLineItems, StripeCreateCheckoutSessionSubscriptionData,
StripeCustomerId, StripeMeter, StripeMeterId, StripePrice, StripePriceId, StripePriceRecurring,
StripeSubscription, StripeSubscriptionId, StripeSubscriptionItem, StripeSubscriptionItemId,
StripeSubscriptionTrialSettings, StripeSubscriptionTrialSettingsEndBehavior,
StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, UpdateSubscriptionItems,
};
fn make_stripe_billing() -> (StripeBilling, Arc<FakeStripeClient>) {
let stripe_client = Arc::new(FakeStripeClient::new());
let stripe_billing = StripeBilling::test(stripe_client.clone());
(stripe_billing, stripe_client)
}
#[gpui::test]
async fn test_initialize() {
let (stripe_billing, stripe_client) = make_stripe_billing();
// Add test meters
let meter1 = StripeMeter {
id: StripeMeterId("meter_1".into()),
event_name: "event_1".to_string(),
};
let meter2 = StripeMeter {
id: StripeMeterId("meter_2".into()),
event_name: "event_2".to_string(),
};
stripe_client
.meters
.lock()
.insert(meter1.id.clone(), meter1);
stripe_client
.meters
.lock()
.insert(meter2.id.clone(), meter2);
// Add test prices
let price1 = StripePrice {
id: StripePriceId("price_1".into()),
unit_amount: Some(1_000),
lookup_key: Some("zed-pro".to_string()),
recurring: None,
};
let price2 = StripePrice {
id: StripePriceId("price_2".into()),
unit_amount: Some(0),
lookup_key: Some("zed-free".to_string()),
recurring: None,
};
let price3 = StripePrice {
id: StripePriceId("price_3".into()),
unit_amount: Some(500),
lookup_key: None,
recurring: Some(StripePriceRecurring {
meter: Some("meter_1".to_string()),
}),
};
stripe_client
.prices
.lock()
.insert(price1.id.clone(), price1);
stripe_client
.prices
.lock()
.insert(price2.id.clone(), price2);
stripe_client
.prices
.lock()
.insert(price3.id.clone(), price3);
// Initialize the billing system
stripe_billing.initialize().await.unwrap();
// Verify that prices can be found by lookup key
let zed_pro_price_id = stripe_billing.zed_pro_price_id().await.unwrap();
assert_eq!(zed_pro_price_id.to_string(), "price_1");
let zed_free_price_id = stripe_billing.zed_free_price_id().await.unwrap();
assert_eq!(zed_free_price_id.to_string(), "price_2");
// Verify that a price can be found by lookup key
let zed_pro_price = stripe_billing
.find_price_by_lookup_key("zed-pro")
.await
.unwrap();
assert_eq!(zed_pro_price.id.to_string(), "price_1");
assert_eq!(zed_pro_price.unit_amount, Some(1_000));
// Verify that finding a non-existent lookup key returns an error
let result = stripe_billing
.find_price_by_lookup_key("non-existent")
.await;
assert!(result.is_err());
}
#[gpui::test]
async fn test_find_or_create_customer_by_email() {
let (stripe_billing, stripe_client) = make_stripe_billing();
// Create a customer with an email that doesn't yet correspond to a customer.
{
let email = "user@example.com";
let customer_id = stripe_billing
.find_or_create_customer_by_email(Some(email))
.await
.unwrap();
let customer = stripe_client
.customers
.lock()
.get(&customer_id)
.unwrap()
.clone();
assert_eq!(customer.email.as_deref(), Some(email));
}
// Create a customer with an email that corresponds to an existing customer.
{
let email = "user2@example.com";
let existing_customer_id = stripe_billing
.find_or_create_customer_by_email(Some(email))
.await
.unwrap();
let customer_id = stripe_billing
.find_or_create_customer_by_email(Some(email))
.await
.unwrap();
assert_eq!(customer_id, existing_customer_id);
let customer = stripe_client
.customers
.lock()
.get(&customer_id)
.unwrap()
.clone();
assert_eq!(customer.email.as_deref(), Some(email));
}
}
#[gpui::test]
async fn test_subscribe_to_price() {
let (stripe_billing, stripe_client) = make_stripe_billing();
let price = StripePrice {
id: StripePriceId("price_test".into()),
unit_amount: Some(2000),
lookup_key: Some("test-price".to_string()),
recurring: None,
};
stripe_client
.prices
.lock()
.insert(price.id.clone(), price.clone());
let now = Utc::now();
let subscription = StripeSubscription {
id: StripeSubscriptionId("sub_test".into()),
customer: StripeCustomerId("cus_test".into()),
status: stripe::SubscriptionStatus::Active,
current_period_start: now.timestamp(),
current_period_end: (now + Duration::days(30)).timestamp(),
items: vec![],
cancel_at: None,
cancellation_details: None,
};
stripe_client
.subscriptions
.lock()
.insert(subscription.id.clone(), subscription.clone());
stripe_billing
.subscribe_to_price(&subscription.id, &price)
.await
.unwrap();
let update_subscription_calls = stripe_client
.update_subscription_calls
.lock()
.iter()
.map(|(id, params)| (id.clone(), params.clone()))
.collect::<Vec<_>>();
assert_eq!(update_subscription_calls.len(), 1);
assert_eq!(update_subscription_calls[0].0, subscription.id);
assert_eq!(
update_subscription_calls[0].1.items,
Some(vec![UpdateSubscriptionItems {
price: Some(price.id.clone())
}])
);
// Subscribing to a price that is already on the subscription is a no-op.
{
let now = Utc::now();
let subscription = StripeSubscription {
id: StripeSubscriptionId("sub_test".into()),
customer: StripeCustomerId("cus_test".into()),
status: stripe::SubscriptionStatus::Active,
current_period_start: now.timestamp(),
current_period_end: (now + Duration::days(30)).timestamp(),
items: vec![StripeSubscriptionItem {
id: StripeSubscriptionItemId("si_test".into()),
price: Some(price.clone()),
}],
cancel_at: None,
cancellation_details: None,
};
stripe_client
.subscriptions
.lock()
.insert(subscription.id.clone(), subscription.clone());
stripe_billing
.subscribe_to_price(&subscription.id, &price)
.await
.unwrap();
assert_eq!(stripe_client.update_subscription_calls.lock().len(), 1);
}
}
#[gpui::test]
async fn test_subscribe_to_zed_free() {
let (stripe_billing, stripe_client) = make_stripe_billing();
let zed_pro_price = StripePrice {
id: StripePriceId("price_1".into()),
unit_amount: Some(0),
lookup_key: Some("zed-pro".to_string()),
recurring: None,
};
stripe_client
.prices
.lock()
.insert(zed_pro_price.id.clone(), zed_pro_price.clone());
let zed_free_price = StripePrice {
id: StripePriceId("price_2".into()),
unit_amount: Some(0),
lookup_key: Some("zed-free".to_string()),
recurring: None,
};
stripe_client
.prices
.lock()
.insert(zed_free_price.id.clone(), zed_free_price.clone());
stripe_billing.initialize().await.unwrap();
// Customer is subscribed to Zed Free when not already subscribed to a plan.
{
let customer_id = StripeCustomerId("cus_no_plan".into());
let subscription = stripe_billing
.subscribe_to_zed_free(customer_id)
.await
.unwrap();
assert_eq!(subscription.items[0].price.as_ref(), Some(&zed_free_price));
}
// Customer is not subscribed to Zed Free when they already have an active subscription.
{
let customer_id = StripeCustomerId("cus_active_subscription".into());
let now = Utc::now();
let existing_subscription = StripeSubscription {
id: StripeSubscriptionId("sub_existing_active".into()),
customer: customer_id.clone(),
status: stripe::SubscriptionStatus::Active,
current_period_start: now.timestamp(),
current_period_end: (now + Duration::days(30)).timestamp(),
items: vec![StripeSubscriptionItem {
id: StripeSubscriptionItemId("si_test".into()),
price: Some(zed_pro_price.clone()),
}],
cancel_at: None,
cancellation_details: None,
};
stripe_client.subscriptions.lock().insert(
existing_subscription.id.clone(),
existing_subscription.clone(),
);
let subscription = stripe_billing
.subscribe_to_zed_free(customer_id)
.await
.unwrap();
assert_eq!(subscription, existing_subscription);
}
// Customer is not subscribed to Zed Free when they already have a trial subscription.
{
let customer_id = StripeCustomerId("cus_trial_subscription".into());
let now = Utc::now();
let existing_subscription = StripeSubscription {
id: StripeSubscriptionId("sub_existing_trial".into()),
customer: customer_id.clone(),
status: stripe::SubscriptionStatus::Trialing,
current_period_start: now.timestamp(),
current_period_end: (now + Duration::days(14)).timestamp(),
items: vec![StripeSubscriptionItem {
id: StripeSubscriptionItemId("si_test".into()),
price: Some(zed_pro_price.clone()),
}],
cancel_at: None,
cancellation_details: None,
};
stripe_client.subscriptions.lock().insert(
existing_subscription.id.clone(),
existing_subscription.clone(),
);
let subscription = stripe_billing
.subscribe_to_zed_free(customer_id)
.await
.unwrap();
assert_eq!(subscription, existing_subscription);
}
}
#[gpui::test]
async fn test_bill_model_request_usage() {
let (stripe_billing, stripe_client) = make_stripe_billing();
let customer_id = StripeCustomerId("cus_test".into());
stripe_billing
.bill_model_request_usage(&customer_id, "some_model/requests", 73)
.await
.unwrap();
let create_meter_event_calls = stripe_client
.create_meter_event_calls
.lock()
.iter()
.cloned()
.collect::<Vec<_>>();
assert_eq!(create_meter_event_calls.len(), 1);
assert!(
create_meter_event_calls[0]
.identifier
.starts_with("model_requests/")
);
assert_eq!(create_meter_event_calls[0].stripe_customer_id, customer_id);
assert_eq!(
create_meter_event_calls[0].event_name.as_ref(),
"some_model/requests"
);
assert_eq!(create_meter_event_calls[0].value, 73);
}
#[gpui::test]
async fn test_checkout_with_zed_pro() {
let (stripe_billing, stripe_client) = make_stripe_billing();
let customer_id = StripeCustomerId("cus_test".into());
let github_login = "zeduser1";
let success_url = "https://example.com/success";
// It returns an error when the Zed Pro price doesn't exist.
{
let result = stripe_billing
.checkout_with_zed_pro(&customer_id, github_login, success_url)
.await;
assert!(result.is_err());
assert_eq!(
result.err().unwrap().to_string(),
r#"no price ID found for "zed-pro""#
);
}
// Successful checkout.
{
let price = StripePrice {
id: StripePriceId("price_1".into()),
unit_amount: Some(2000),
lookup_key: Some("zed-pro".to_string()),
recurring: None,
};
stripe_client
.prices
.lock()
.insert(price.id.clone(), price.clone());
stripe_billing.initialize().await.unwrap();
let checkout_url = stripe_billing
.checkout_with_zed_pro(&customer_id, github_login, success_url)
.await
.unwrap();
assert!(checkout_url.starts_with("https://checkout.stripe.com/c/pay"));
let create_checkout_session_calls = stripe_client
.create_checkout_session_calls
.lock()
.drain(..)
.collect::<Vec<_>>();
assert_eq!(create_checkout_session_calls.len(), 1);
let call = create_checkout_session_calls.into_iter().next().unwrap();
assert_eq!(call.customer, Some(customer_id));
assert_eq!(call.client_reference_id.as_deref(), Some(github_login));
assert_eq!(call.mode, Some(StripeCheckoutSessionMode::Subscription));
assert_eq!(
call.line_items,
Some(vec![StripeCreateCheckoutSessionLineItems {
price: Some(price.id.to_string()),
quantity: Some(1)
}])
);
assert_eq!(call.payment_method_collection, None);
assert_eq!(call.subscription_data, None);
assert_eq!(call.success_url.as_deref(), Some(success_url));
}
}
#[gpui::test]
async fn test_checkout_with_zed_pro_trial() {
let (stripe_billing, stripe_client) = make_stripe_billing();
let customer_id = StripeCustomerId("cus_test".into());
let github_login = "zeduser1";
let success_url = "https://example.com/success";
// It returns an error when the Zed Pro price doesn't exist.
{
let result = stripe_billing
.checkout_with_zed_pro_trial(&customer_id, github_login, Vec::new(), success_url)
.await;
assert!(result.is_err());
assert_eq!(
result.err().unwrap().to_string(),
r#"no price ID found for "zed-pro""#
);
}
let price = StripePrice {
id: StripePriceId("price_1".into()),
unit_amount: Some(2000),
lookup_key: Some("zed-pro".to_string()),
recurring: None,
};
stripe_client
.prices
.lock()
.insert(price.id.clone(), price.clone());
stripe_billing.initialize().await.unwrap();
// Successful checkout.
{
let checkout_url = stripe_billing
.checkout_with_zed_pro_trial(&customer_id, github_login, Vec::new(), success_url)
.await
.unwrap();
assert!(checkout_url.starts_with("https://checkout.stripe.com/c/pay"));
let create_checkout_session_calls = stripe_client
.create_checkout_session_calls
.lock()
.drain(..)
.collect::<Vec<_>>();
assert_eq!(create_checkout_session_calls.len(), 1);
let call = create_checkout_session_calls.into_iter().next().unwrap();
assert_eq!(call.customer.as_ref(), Some(&customer_id));
assert_eq!(call.client_reference_id.as_deref(), Some(github_login));
assert_eq!(call.mode, Some(StripeCheckoutSessionMode::Subscription));
assert_eq!(
call.line_items,
Some(vec![StripeCreateCheckoutSessionLineItems {
price: Some(price.id.to_string()),
quantity: Some(1)
}])
);
assert_eq!(
call.payment_method_collection,
Some(StripeCheckoutSessionPaymentMethodCollection::IfRequired)
);
assert_eq!(
call.subscription_data,
Some(StripeCreateCheckoutSessionSubscriptionData {
trial_period_days: Some(14),
trial_settings: Some(StripeSubscriptionTrialSettings {
end_behavior: StripeSubscriptionTrialSettingsEndBehavior {
missing_payment_method:
StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel,
},
}),
metadata: None,
})
);
assert_eq!(call.success_url.as_deref(), Some(success_url));
}
// Successful checkout with extended trial.
{
let checkout_url = stripe_billing
.checkout_with_zed_pro_trial(
&customer_id,
github_login,
vec![AGENT_EXTENDED_TRIAL_FEATURE_FLAG.to_string()],
success_url,
)
.await
.unwrap();
assert!(checkout_url.starts_with("https://checkout.stripe.com/c/pay"));
let create_checkout_session_calls = stripe_client
.create_checkout_session_calls
.lock()
.drain(..)
.collect::<Vec<_>>();
assert_eq!(create_checkout_session_calls.len(), 1);
let call = create_checkout_session_calls.into_iter().next().unwrap();
assert_eq!(call.customer, Some(customer_id));
assert_eq!(call.client_reference_id.as_deref(), Some(github_login));
assert_eq!(call.mode, Some(StripeCheckoutSessionMode::Subscription));
assert_eq!(
call.line_items,
Some(vec![StripeCreateCheckoutSessionLineItems {
price: Some(price.id.to_string()),
quantity: Some(1)
}])
);
assert_eq!(
call.payment_method_collection,
Some(StripeCheckoutSessionPaymentMethodCollection::IfRequired)
);
assert_eq!(
call.subscription_data,
Some(StripeCreateCheckoutSessionSubscriptionData {
trial_period_days: Some(60),
trial_settings: Some(StripeSubscriptionTrialSettings {
end_behavior: StripeSubscriptionTrialSettingsEndBehavior {
missing_payment_method:
StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel,
},
}),
metadata: Some(std::collections::HashMap::from_iter([(
"promo_feature_flag".into(),
AGENT_EXTENDED_TRIAL_FEATURE_FLAG.into()
)])),
})
);
assert_eq!(call.success_url.as_deref(), Some(success_url));
}
}

View File

@@ -1,3 +1,4 @@
use crate::stripe_client::FakeStripeClient;
use crate::{
AppState, Config,
db::{NewUserParams, UserId, tests::TestDb},
@@ -522,7 +523,8 @@ impl TestServer {
llm_db: None,
livekit_client: Some(Arc::new(livekit_test_server.create_api_client())),
blob_store_client: None,
stripe_client: None,
real_stripe_client: None,
stripe_client: Some(Arc::new(FakeStripeClient::new())),
stripe_billing: None,
executor,
kinesis_client: None,

View File

@@ -298,6 +298,7 @@ pub async fn download_adapter_from_github(
response.status().to_string()
);
delegate.output_to_console("Download complete".to_owned());
match file_type {
DownloadedFileType::GzipTar => {
let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
@@ -369,21 +370,19 @@ pub trait DebugAdapter: 'static + Send + Sync {
None
}
fn validate_config(
/// Extracts the kind (attach/launch) of debug configuration from the given JSON config.
/// This method should only return error when the kind cannot be determined for a given configuration;
/// in particular, it *should not* validate whether the request as a whole is valid, because that's best left to the debug adapter itself to decide.
fn request_kind(
&self,
config: &serde_json::Value,
) -> Result<StartDebuggingRequestArgumentsRequest> {
let map = config.as_object().context("Config isn't an object")?;
let request_variant = map
.get("request")
.and_then(|val| val.as_str())
.context("request argument is not found or invalid")?;
match request_variant {
"launch" => Ok(StartDebuggingRequestArgumentsRequest::Launch),
"attach" => Ok(StartDebuggingRequestArgumentsRequest::Attach),
_ => Err(anyhow!("request must be either 'launch' or 'attach'")),
match config.get("request") {
Some(val) if val == "launch" => Ok(StartDebuggingRequestArgumentsRequest::Launch),
Some(val) if val == "attach" => Ok(StartDebuggingRequestArgumentsRequest::Attach),
_ => Err(anyhow!(
"missing or invalid `request` field in config. Expected 'launch' or 'attach'"
)),
}
}
@@ -413,7 +412,7 @@ impl DebugAdapter for FakeAdapter {
serde_json::Value::Null
}
fn validate_config(
fn request_kind(
&self,
config: &serde_json::Value,
) -> Result<StartDebuggingRequestArgumentsRequest> {
@@ -458,7 +457,7 @@ impl DebugAdapter for FakeAdapter {
envs: HashMap::default(),
cwd: None,
request_args: StartDebuggingRequestArguments {
request: self.validate_config(&task_definition.config)?,
request: self.request_kind(&task_definition.config)?,
configuration: task_definition.config.clone(),
},
})

View File

@@ -52,7 +52,7 @@ pub fn send_telemetry(scenario: &DebugScenario, location: TelemetrySpawnLocation
return;
};
let kind = adapter
.validate_config(&scenario.config)
.request_kind(&scenario.config)
.ok()
.map(serde_json::to_value)
.and_then(Result::ok);

View File

@@ -4,7 +4,7 @@ use dap_types::{
messages::{Message, Response},
};
use futures::{AsyncRead, AsyncReadExt as _, AsyncWrite, FutureExt as _, channel::oneshot, select};
use gpui::AsyncApp;
use gpui::{AppContext as _, AsyncApp, Task};
use settings::Settings as _;
use smallvec::SmallVec;
use smol::{
@@ -22,7 +22,7 @@ use std::{
time::Duration,
};
use task::TcpArgumentsTemplate;
use util::{ResultExt as _, TryFutureExt};
use util::{ConnectionResult, ResultExt as _};
use crate::{adapters::DebugAdapterBinary, debugger_settings::DebuggerSettings};
@@ -126,7 +126,7 @@ pub(crate) struct TransportDelegate {
pending_requests: Requests,
transport: Transport,
server_tx: Arc<Mutex<Option<Sender<Message>>>>,
_tasks: Vec<gpui::Task<Option<()>>>,
_tasks: Vec<Task<()>>,
}
impl TransportDelegate {
@@ -141,7 +141,7 @@ impl TransportDelegate {
log_handlers: Default::default(),
current_requests: Default::default(),
pending_requests: Default::default(),
_tasks: Default::default(),
_tasks: Vec::new(),
};
let messages = this.start_handlers(transport_pipes, cx).await?;
Ok((messages, this))
@@ -166,45 +166,76 @@ impl TransportDelegate {
None
};
let adapter_log_handler = log_handler.clone();
cx.update(|cx| {
if let Some(stdout) = params.stdout.take() {
self._tasks.push(
cx.background_executor()
.spawn(Self::handle_adapter_log(stdout, log_handler.clone()).log_err()),
);
self._tasks.push(cx.background_spawn(async move {
match Self::handle_adapter_log(stdout, adapter_log_handler).await {
ConnectionResult::Timeout => {
log::error!("Timed out when handling debugger log");
}
ConnectionResult::ConnectionReset => {
log::info!("Debugger logs connection closed");
}
ConnectionResult::Result(Ok(())) => {}
ConnectionResult::Result(Err(e)) => {
log::error!("Error handling debugger log: {e}");
}
}
}));
}
self._tasks.push(
cx.background_executor().spawn(
Self::handle_output(
params.output,
client_tx,
self.pending_requests.clone(),
log_handler.clone(),
)
.log_err(),
),
);
let pending_requests = self.pending_requests.clone();
let output_log_handler = log_handler.clone();
self._tasks.push(cx.background_spawn(async move {
match Self::handle_output(
params.output,
client_tx,
pending_requests,
output_log_handler,
)
.await
{
Ok(()) => {}
Err(e) => log::error!("Error handling debugger output: {e}"),
}
}));
if let Some(stderr) = params.stderr.take() {
self._tasks.push(
cx.background_executor()
.spawn(Self::handle_error(stderr, self.log_handlers.clone()).log_err()),
);
let log_handlers = self.log_handlers.clone();
self._tasks.push(cx.background_spawn(async move {
match Self::handle_error(stderr, log_handlers).await {
ConnectionResult::Timeout => {
log::error!("Timed out reading debugger error stream")
}
ConnectionResult::ConnectionReset => {
log::info!("Debugger closed its error stream")
}
ConnectionResult::Result(Ok(())) => {}
ConnectionResult::Result(Err(e)) => {
log::error!("Error handling debugger error: {e}")
}
}
}));
}
self._tasks.push(
cx.background_executor().spawn(
Self::handle_input(
params.input,
client_rx,
self.current_requests.clone(),
self.pending_requests.clone(),
log_handler.clone(),
)
.log_err(),
),
);
let current_requests = self.current_requests.clone();
let pending_requests = self.pending_requests.clone();
let log_handler = log_handler.clone();
self._tasks.push(cx.background_spawn(async move {
match Self::handle_input(
params.input,
client_rx,
current_requests,
pending_requests,
log_handler,
)
.await
{
Ok(()) => {}
Err(e) => log::error!("Error handling debugger input: {e}"),
}
}));
})?;
{
@@ -235,7 +266,7 @@ impl TransportDelegate {
async fn handle_adapter_log<Stdout>(
stdout: Stdout,
log_handlers: Option<LogHandlers>,
) -> Result<()>
) -> ConnectionResult<()>
where
Stdout: AsyncRead + Unpin + Send + 'static,
{
@@ -245,13 +276,14 @@ impl TransportDelegate {
let result = loop {
line.truncate(0);
let bytes_read = match reader.read_line(&mut line).await {
Ok(bytes_read) => bytes_read,
Err(e) => break Err(e.into()),
};
if bytes_read == 0 {
anyhow::bail!("Debugger log stream closed");
match reader
.read_line(&mut line)
.await
.context("reading adapter log line")
{
Ok(0) => break ConnectionResult::ConnectionReset,
Ok(_) => {}
Err(e) => break ConnectionResult::Result(Err(e)),
}
if let Some(log_handlers) = log_handlers.as_ref() {
@@ -337,35 +369,35 @@ impl TransportDelegate {
let mut reader = BufReader::new(server_stdout);
let result = loop {
let message =
Self::receive_server_message(&mut reader, &mut recv_buffer, log_handlers.as_ref())
.await;
match message {
Ok(Message::Response(res)) => {
match Self::receive_server_message(&mut reader, &mut recv_buffer, log_handlers.as_ref())
.await
{
ConnectionResult::Timeout => anyhow::bail!("Timed out when connecting to debugger"),
ConnectionResult::ConnectionReset => {
log::info!("Debugger closed the connection");
return Ok(());
}
ConnectionResult::Result(Ok(Message::Response(res))) => {
if let Some(tx) = pending_requests.lock().await.remove(&res.request_seq) {
if let Err(e) = tx.send(Self::process_response(res)) {
log::trace!("Did not send response `{:?}` for a cancelled", e);
}
} else {
client_tx.send(Message::Response(res)).await?;
};
}
}
Ok(message) => {
client_tx.send(message).await?;
}
Err(e) => break Err(e),
ConnectionResult::Result(Ok(message)) => client_tx.send(message).await?,
ConnectionResult::Result(Err(e)) => break Err(e),
}
};
drop(client_tx);
log::debug!("Handle adapter output dropped");
result
}
async fn handle_error<Stderr>(stderr: Stderr, log_handlers: LogHandlers) -> Result<()>
async fn handle_error<Stderr>(stderr: Stderr, log_handlers: LogHandlers) -> ConnectionResult<()>
where
Stderr: AsyncRead + Unpin + Send + 'static,
{
@@ -375,8 +407,12 @@ impl TransportDelegate {
let mut reader = BufReader::new(stderr);
let result = loop {
match reader.read_line(&mut buffer).await {
Ok(0) => anyhow::bail!("debugger error stream closed"),
match reader
.read_line(&mut buffer)
.await
.context("reading error log line")
{
Ok(0) => break ConnectionResult::ConnectionReset,
Ok(_) => {
for (kind, log_handler) in log_handlers.lock().iter_mut() {
if matches!(kind, LogKind::Adapter) {
@@ -386,7 +422,7 @@ impl TransportDelegate {
buffer.truncate(0);
}
Err(error) => break Err(error.into()),
Err(error) => break ConnectionResult::Result(Err(error)),
}
};
@@ -420,7 +456,7 @@ impl TransportDelegate {
reader: &mut BufReader<Stdout>,
buffer: &mut String,
log_handlers: Option<&LogHandlers>,
) -> Result<Message>
) -> ConnectionResult<Message>
where
Stdout: AsyncRead + Unpin + Send + 'static,
{
@@ -428,48 +464,58 @@ impl TransportDelegate {
loop {
buffer.truncate(0);
if reader
match reader
.read_line(buffer)
.await
.with_context(|| "reading a message from server")?
== 0
.with_context(|| "reading a message from server")
{
anyhow::bail!("debugger reader stream closed");
Ok(0) => return ConnectionResult::ConnectionReset,
Ok(_) => {}
Err(e) => return ConnectionResult::Result(Err(e)),
};
if buffer == "\r\n" {
break;
}
let parts = buffer.trim().split_once(": ");
match parts {
Some(("Content-Length", value)) => {
content_length = Some(value.parse().context("invalid content length")?);
if let Some(("Content-Length", value)) = buffer.trim().split_once(": ") {
match value.parse().context("invalid content length") {
Ok(length) => content_length = Some(length),
Err(e) => return ConnectionResult::Result(Err(e)),
}
_ => {}
}
}
let content_length = content_length.context("missing content length")?;
let content_length = match content_length.context("missing content length") {
Ok(length) => length,
Err(e) => return ConnectionResult::Result(Err(e)),
};
let mut content = vec![0; content_length];
reader
if let Err(e) = reader
.read_exact(&mut content)
.await
.with_context(|| "reading after a loop")?;
.with_context(|| "reading after a loop")
{
return ConnectionResult::Result(Err(e));
}
let message = std::str::from_utf8(&content).context("invalid utf8 from server")?;
let message_str = match std::str::from_utf8(&content).context("invalid utf8 from server") {
Ok(str) => str,
Err(e) => return ConnectionResult::Result(Err(e)),
};
if let Some(log_handlers) = log_handlers {
for (kind, log_handler) in log_handlers.lock().iter_mut() {
if matches!(kind, LogKind::Rpc) {
log_handler(IoKind::StdOut, &message);
log_handler(IoKind::StdOut, message_str);
}
}
}
Ok(serde_json::from_str::<Message>(message)?)
ConnectionResult::Result(
serde_json::from_str::<Message>(message_str).context("deserializing server message"),
)
}
pub async fn shutdown(&self) -> Result<()> {
@@ -658,9 +704,13 @@ impl StdioTransport {
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut process = command
.spawn()
.with_context(|| "failed to spawn command.")?;
let mut process = command.spawn().with_context(|| {
format!(
"failed to spawn command `{} {}`.",
binary.command,
binary.arguments.join(" ")
)
})?;
let stdin = process.stdin.take().context("Failed to open stdin")?;
let stdout = process.stdout.take().context("Failed to open stdout")?;
@@ -773,71 +823,31 @@ impl FakeTransport {
let response_handlers = this.response_handlers.clone();
let stdout_writer = Arc::new(Mutex::new(stdout_writer));
cx.background_executor()
.spawn(async move {
let mut reader = BufReader::new(stdin_reader);
let mut buffer = String::new();
cx.background_spawn(async move {
let mut reader = BufReader::new(stdin_reader);
let mut buffer = String::new();
loop {
let message =
TransportDelegate::receive_server_message(&mut reader, &mut buffer, None)
.await;
match message {
Err(error) => {
break anyhow::anyhow!(error);
}
Ok(message) => {
match message {
Message::Request(request) => {
// redirect reverse requests to stdout writer/reader
if request.command == RunInTerminal::COMMAND
|| request.command == StartDebugging::COMMAND
{
let message =
serde_json::to_string(&Message::Request(request))
.unwrap();
let mut writer = stdout_writer.lock().await;
writer
.write_all(
TransportDelegate::build_rpc_message(message)
.as_bytes(),
)
.await
.unwrap();
writer.flush().await.unwrap();
} else {
let response = if let Some(handle) = request_handlers
.lock()
.get_mut(request.command.as_str())
{
handle(
request.seq,
request.arguments.unwrap_or(json!({})),
)
} else {
panic!("No request handler for {}", request.command);
};
let message =
serde_json::to_string(&Message::Response(response))
.unwrap();
let mut writer = stdout_writer.lock().await;
writer
.write_all(
TransportDelegate::build_rpc_message(message)
.as_bytes(),
)
.await
.unwrap();
writer.flush().await.unwrap();
}
}
Message::Event(event) => {
loop {
match TransportDelegate::receive_server_message(&mut reader, &mut buffer, None)
.await
{
ConnectionResult::Timeout => {
anyhow::bail!("Timed out when connecting to debugger");
}
ConnectionResult::ConnectionReset => {
log::info!("Debugger closed the connection");
break Ok(());
}
ConnectionResult::Result(Err(e)) => break Err(e),
ConnectionResult::Result(Ok(message)) => {
match message {
Message::Request(request) => {
// redirect reverse requests to stdout writer/reader
if request.command == RunInTerminal::COMMAND
|| request.command == StartDebugging::COMMAND
{
let message =
serde_json::to_string(&Message::Event(event)).unwrap();
serde_json::to_string(&Message::Request(request)).unwrap();
let mut writer = stdout_writer.lock().await;
writer
@@ -848,22 +858,58 @@ impl FakeTransport {
.await
.unwrap();
writer.flush().await.unwrap();
}
Message::Response(response) => {
if let Some(handle) =
response_handlers.lock().get(response.command.as_str())
} else {
let response = if let Some(handle) =
request_handlers.lock().get_mut(request.command.as_str())
{
handle(response);
handle(request.seq, request.arguments.unwrap_or(json!({})))
} else {
log::error!("No response handler for {}", response.command);
}
panic!("No request handler for {}", request.command);
};
let message =
serde_json::to_string(&Message::Response(response))
.unwrap();
let mut writer = stdout_writer.lock().await;
writer
.write_all(
TransportDelegate::build_rpc_message(message)
.as_bytes(),
)
.await
.unwrap();
writer.flush().await.unwrap();
}
}
Message::Event(event) => {
let message =
serde_json::to_string(&Message::Event(event)).unwrap();
let mut writer = stdout_writer.lock().await;
writer
.write_all(
TransportDelegate::build_rpc_message(message).as_bytes(),
)
.await
.unwrap();
writer.flush().await.unwrap();
}
Message::Response(response) => {
if let Some(handle) =
response_handlers.lock().get(response.command.as_str())
{
handle(response);
} else {
log::error!("No response handler for {}", response.command);
}
}
}
}
}
})
.detach();
}
})
.detach();
Ok((
TransportPipe::new(Box::new(stdin_writer), Box::new(stdout_reader), None, None),

View File

@@ -1,11 +1,8 @@
use std::{collections::HashMap, path::PathBuf, sync::OnceLock};
use anyhow::{Context as _, Result, anyhow};
use anyhow::{Context as _, Result};
use async_trait::async_trait;
use dap::{
StartDebuggingRequestArgumentsRequest,
adapters::{DebugTaskDefinition, latest_github_release},
};
use dap::adapters::{DebugTaskDefinition, latest_github_release};
use futures::StreamExt;
use gpui::AsyncApp;
use serde_json::Value;
@@ -37,7 +34,7 @@ impl CodeLldbDebugAdapter {
Value::String(String::from(task_definition.label.as_ref())),
);
let request = self.validate_config(&configuration)?;
let request = self.request_kind(&configuration)?;
Ok(dap::StartDebuggingRequestArguments {
request,
@@ -89,48 +86,6 @@ impl DebugAdapter for CodeLldbDebugAdapter {
DebugAdapterName(Self::ADAPTER_NAME.into())
}
fn validate_config(
&self,
config: &serde_json::Value,
) -> Result<StartDebuggingRequestArgumentsRequest> {
let map = config
.as_object()
.ok_or_else(|| anyhow!("Config isn't an object"))?;
let request_variant = map
.get("request")
.and_then(|r| r.as_str())
.ok_or_else(|| anyhow!("request field is required and must be a string"))?;
match request_variant {
"launch" => {
// For launch, verify that one of the required configs exists
if !(map.contains_key("program")
|| map.contains_key("targetCreateCommands")
|| map.contains_key("cargo"))
{
return Err(anyhow!(
"launch request requires either 'program', 'targetCreateCommands', or 'cargo' field"
));
}
Ok(StartDebuggingRequestArgumentsRequest::Launch)
}
"attach" => {
// For attach, verify that either pid or program exists
if !(map.contains_key("pid") || map.contains_key("program")) {
return Err(anyhow!(
"attach request requires either 'pid' or 'program' field"
));
}
Ok(StartDebuggingRequestArgumentsRequest::Attach)
}
_ => Err(anyhow!(
"request must be either 'launch' or 'attach', got '{}'",
request_variant
)),
}
}
fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
let mut configuration = json!({
"request": match zed_scenario.request {

View File

@@ -37,7 +37,7 @@ pub fn init(cx: &mut App) {
registry.add_adapter(Arc::from(PhpDebugAdapter::default()));
registry.add_adapter(Arc::from(JsDebugAdapter::default()));
registry.add_adapter(Arc::from(RubyDebugAdapter));
registry.add_adapter(Arc::from(GoDebugAdapter));
registry.add_adapter(Arc::from(GoDebugAdapter::default()));
registry.add_adapter(Arc::from(GdbDebugAdapter));
#[cfg(any(test, feature = "test-support"))]

View File

@@ -178,7 +178,7 @@ impl DebugAdapter for GdbDebugAdapter {
let gdb_path = user_setting_path.unwrap_or(gdb_path?);
let request_args = StartDebuggingRequestArguments {
request: self.validate_config(&config.config)?,
request: self.request_kind(&config.config)?,
configuration: config.config.clone(),
};

View File

@@ -1,22 +1,87 @@
use anyhow::{Context as _, anyhow, bail};
use anyhow::{Context as _, bail};
use dap::{
StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest,
adapters::DebugTaskDefinition,
StartDebuggingRequestArguments,
adapters::{
DebugTaskDefinition, DownloadedFileType, download_adapter_from_github,
latest_github_release,
},
};
use gpui::{AsyncApp, SharedString};
use language::LanguageName;
use std::{collections::HashMap, ffi::OsStr, path::PathBuf};
use std::{collections::HashMap, env::consts, ffi::OsStr, path::PathBuf, sync::OnceLock};
use util;
use crate::*;
#[derive(Default, Debug)]
pub(crate) struct GoDebugAdapter;
pub(crate) struct GoDebugAdapter {
shim_path: OnceLock<PathBuf>,
}
impl GoDebugAdapter {
const ADAPTER_NAME: &'static str = "Delve";
const DEFAULT_TIMEOUT_MS: u64 = 60000;
async fn fetch_latest_adapter_version(
delegate: &Arc<dyn DapDelegate>,
) -> Result<AdapterVersion> {
let release = latest_github_release(
&"zed-industries/delve-shim-dap",
true,
false,
delegate.http_client(),
)
.await?;
let os = match consts::OS {
"macos" => "apple-darwin",
"linux" => "unknown-linux-gnu",
"windows" => "pc-windows-msvc",
other => bail!("Running on unsupported os: {other}"),
};
let suffix = if consts::OS == "windows" {
".zip"
} else {
".tar.gz"
};
let asset_name = format!("delve-shim-dap-{}-{os}{suffix}", consts::ARCH);
let asset = release
.assets
.iter()
.find(|asset| asset.name == asset_name)
.with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
Ok(AdapterVersion {
tag_name: release.tag_name,
url: asset.browser_download_url.clone(),
})
}
async fn install_shim(&self, delegate: &Arc<dyn DapDelegate>) -> anyhow::Result<PathBuf> {
if let Some(path) = self.shim_path.get().cloned() {
return Ok(path);
}
let asset = Self::fetch_latest_adapter_version(delegate).await?;
let ty = if consts::OS == "windows" {
DownloadedFileType::Zip
} else {
DownloadedFileType::GzipTar
};
download_adapter_from_github(
"delve-shim-dap".into(),
asset.clone(),
ty,
delegate.as_ref(),
)
.await?;
let path = paths::debug_adapters_dir()
.join("delve-shim-dap")
.join(format!("delve-shim-dap_{}", asset.tag_name))
.join(format!("delve-shim-dap{}", std::env::consts::EXE_SUFFIX));
self.shim_path.set(path.clone()).ok();
Ok(path)
}
}
#[async_trait(?Send)]
@@ -285,24 +350,6 @@ impl DebugAdapter for GoDebugAdapter {
})
}
fn validate_config(
&self,
config: &serde_json::Value,
) -> Result<StartDebuggingRequestArgumentsRequest> {
let map = config.as_object().context("Config isn't an object")?;
let request_variant = map
.get("request")
.and_then(|val| val.as_str())
.context("request argument is not found or invalid")?;
match request_variant {
"launch" => Ok(StartDebuggingRequestArgumentsRequest::Launch),
"attach" => Ok(StartDebuggingRequestArgumentsRequest::Attach),
_ => Err(anyhow!("request must be either 'launch' or 'attach'")),
}
}
fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
let mut args = match &zed_scenario.request {
dap::DebugRequest::Attach(attach_config) => {
@@ -349,13 +396,15 @@ impl DebugAdapter for GoDebugAdapter {
&self,
delegate: &Arc<dyn DapDelegate>,
task_definition: &DebugTaskDefinition,
_user_installed_path: Option<PathBuf>,
user_installed_path: Option<PathBuf>,
_cx: &mut AsyncApp,
) -> Result<DebugAdapterBinary> {
let adapter_path = paths::debug_adapters_dir().join(&Self::ADAPTER_NAME);
let dlv_path = adapter_path.join("dlv");
let delve_path = if let Some(path) = delegate.which(OsStr::new("dlv")).await {
let delve_path = if let Some(path) = user_installed_path {
path.to_string_lossy().to_string()
} else if let Some(path) = delegate.which(OsStr::new("dlv")).await {
path.to_string_lossy().to_string()
} else if delegate.fs().is_file(&dlv_path).await {
dlv_path.to_string_lossy().to_string()
@@ -384,16 +433,10 @@ impl DebugAdapter for GoDebugAdapter {
adapter_path.join("dlv").to_string_lossy().to_string()
};
let minidelve_path = self.install_shim(delegate).await?;
let tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default();
let mut tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default();
if tcp_connection.timeout.is_none()
|| tcp_connection.timeout.unwrap_or(0) < Self::DEFAULT_TIMEOUT_MS
{
tcp_connection.timeout = Some(Self::DEFAULT_TIMEOUT_MS);
}
let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
let (host, port, _) = crate::configure_tcp_connection(tcp_connection).await?;
let cwd = task_definition
.config
@@ -404,6 +447,7 @@ impl DebugAdapter for GoDebugAdapter {
let arguments = if cfg!(windows) {
vec![
delve_path,
"dap".into(),
"--listen".into(),
format!("{}:{}", host, port),
@@ -411,6 +455,7 @@ impl DebugAdapter for GoDebugAdapter {
]
} else {
vec![
delve_path,
"dap".into(),
"--listen".into(),
format!("{}:{}", host, port),
@@ -418,18 +463,14 @@ impl DebugAdapter for GoDebugAdapter {
};
Ok(DebugAdapterBinary {
command: delve_path,
command: minidelve_path.to_string_lossy().into_owned(),
arguments,
cwd: Some(cwd),
envs: HashMap::default(),
connection: Some(adapters::TcpArguments {
host,
port,
timeout,
}),
connection: None,
request_args: StartDebuggingRequestArguments {
configuration: task_definition.config.clone(),
request: self.validate_config(&task_definition.config)?,
request: self.request_kind(&task_definition.config)?,
},
})
}

View File

@@ -1,9 +1,6 @@
use adapters::latest_github_release;
use anyhow::{Context as _, anyhow};
use dap::{
StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest,
adapters::DebugTaskDefinition,
};
use anyhow::Context as _;
use dap::{StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
use gpui::AsyncApp;
use std::{collections::HashMap, path::PathBuf, sync::OnceLock};
use task::DebugRequest;
@@ -26,7 +23,7 @@ impl JsDebugAdapter {
delegate: &Arc<dyn DapDelegate>,
) -> Result<AdapterVersion> {
let release = latest_github_release(
&format!("{}/{}", "microsoft", Self::ADAPTER_NPM_NAME),
&format!("microsoft/{}", Self::ADAPTER_NPM_NAME),
true,
false,
delegate.http_client(),
@@ -95,7 +92,7 @@ impl JsDebugAdapter {
}),
request_args: StartDebuggingRequestArguments {
configuration: task_definition.config.clone(),
request: self.validate_config(&task_definition.config)?,
request: self.request_kind(&task_definition.config)?,
},
})
}
@@ -107,29 +104,6 @@ impl DebugAdapter for JsDebugAdapter {
DebugAdapterName(Self::ADAPTER_NAME.into())
}
fn validate_config(
&self,
config: &serde_json::Value,
) -> Result<dap::StartDebuggingRequestArgumentsRequest> {
match config.get("request") {
Some(val) if val == "launch" => {
if config.get("program").is_none() && config.get("url").is_none() {
return Err(anyhow!(
"either program or url is required for launch request"
));
}
Ok(StartDebuggingRequestArgumentsRequest::Launch)
}
Some(val) if val == "attach" => {
if !config.get("processId").is_some_and(|val| val.is_u64()) {
return Err(anyhow!("processId must be a number"));
}
Ok(StartDebuggingRequestArgumentsRequest::Attach)
}
_ => Err(anyhow!("missing or invalid request field in config")),
}
}
fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
let mut args = json!({
"type": "pwa-node",
@@ -449,6 +423,8 @@ impl DebugAdapter for JsDebugAdapter {
delegate.as_ref(),
)
.await?;
} else {
delegate.output_to_console(format!("{} debug adapter is up to date", self.name()));
}
}

View File

@@ -94,7 +94,7 @@ impl PhpDebugAdapter {
envs: HashMap::default(),
request_args: StartDebuggingRequestArguments {
configuration: task_definition.config.clone(),
request: <Self as DebugAdapter>::validate_config(self, &task_definition.config)?,
request: <Self as DebugAdapter>::request_kind(self, &task_definition.config)?,
},
})
}
@@ -149,22 +149,8 @@ impl DebugAdapter for PhpDebugAdapter {
"default": false
},
"pathMappings": {
"type": "array",
"description": "A list of server paths mapping to the local source paths on your machine for remote host debugging",
"items": {
"type": "object",
"properties": {
"serverPath": {
"type": "string",
"description": "Path on the server"
},
"localPath": {
"type": "string",
"description": "Corresponding path on the local machine"
}
},
"required": ["serverPath", "localPath"]
}
"type": "object",
"description": "A mapping of server paths to local paths.",
},
"log": {
"type": "boolean",
@@ -296,10 +282,7 @@ impl DebugAdapter for PhpDebugAdapter {
Some(SharedString::new_static("PHP").into())
}
fn validate_config(
&self,
_: &serde_json::Value,
) -> Result<StartDebuggingRequestArgumentsRequest> {
fn request_kind(&self, _: &serde_json::Value) -> Result<StartDebuggingRequestArgumentsRequest> {
Ok(StartDebuggingRequestArgumentsRequest::Launch)
}

View File

@@ -1,9 +1,6 @@
use crate::*;
use anyhow::{Context as _, anyhow};
use dap::{
DebugRequest, StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest,
adapters::DebugTaskDefinition,
};
use anyhow::Context as _;
use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
use gpui::{AsyncApp, SharedString};
use json_dotpath::DotPaths;
use language::{LanguageName, Toolchain};
@@ -86,7 +83,7 @@ impl PythonDebugAdapter {
&self,
task_definition: &DebugTaskDefinition,
) -> Result<StartDebuggingRequestArguments> {
let request = self.validate_config(&task_definition.config)?;
let request = self.request_kind(&task_definition.config)?;
let mut configuration = task_definition.config.clone();
if let Ok(console) = configuration.dot_get_mut("console") {
@@ -254,24 +251,6 @@ impl DebugAdapter for PythonDebugAdapter {
})
}
fn validate_config(
&self,
config: &serde_json::Value,
) -> Result<StartDebuggingRequestArgumentsRequest> {
let map = config.as_object().context("Config isn't an object")?;
let request_variant = map
.get("request")
.and_then(|val| val.as_str())
.context("request is not valid")?;
match request_variant {
"launch" => Ok(StartDebuggingRequestArgumentsRequest::Launch),
"attach" => Ok(StartDebuggingRequestArgumentsRequest::Attach),
_ => Err(anyhow!("request must be either 'launch' or 'attach'")),
}
}
async fn dap_schema(&self) -> serde_json::Value {
json!({
"properties": {
@@ -660,7 +639,7 @@ impl DebugAdapter for PythonDebugAdapter {
}
}
self.get_installed_binary(delegate, &config, None, None, false)
self.get_installed_binary(delegate, &config, None, toolchain, false)
.await
}
}

View File

@@ -265,7 +265,7 @@ impl DebugAdapter for RubyDebugAdapter {
cwd: None,
envs: std::collections::HashMap::default(),
request_args: StartDebuggingRequestArguments {
request: self.validate_config(&definition.config)?,
request: self.request_kind(&definition.config)?,
configuration: definition.config.clone(),
},
})

View File

@@ -5,7 +5,7 @@ use crate::{
ClearAllBreakpoints, Continue, Detach, FocusBreakpointList, FocusConsole, FocusFrames,
FocusLoadedSources, FocusModules, FocusTerminal, FocusVariables, Pause, Restart,
ShowStackTrace, StepBack, StepInto, StepOut, StepOver, Stop, ToggleIgnoreBreakpoints,
ToggleSessionPicker, ToggleThreadPicker, persistence,
ToggleSessionPicker, ToggleThreadPicker, persistence, spawn_task_or_modal,
};
use anyhow::{Context as _, Result, anyhow};
use command_palette_hooks::CommandPaletteFilter;
@@ -65,6 +65,7 @@ pub struct DebugPanel {
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
debug_scenario_scheduled_last: bool,
pub(crate) thread_picker_menu_handle: PopoverMenuHandle<ContextMenu>,
pub(crate) session_picker_menu_handle: PopoverMenuHandle<ContextMenu>,
fs: Arc<dyn Fs>,
@@ -103,6 +104,7 @@ impl DebugPanel {
thread_picker_menu_handle,
session_picker_menu_handle,
_subscriptions: [focus_subscription],
debug_scenario_scheduled_last: true,
}
})
}
@@ -264,6 +266,7 @@ impl DebugPanel {
cx,
)
});
self.debug_scenario_scheduled_last = true;
if let Some(inventory) = self
.project
.read(cx)
@@ -432,7 +435,10 @@ impl DebugPanel {
};
let dap_store_handle = self.project.read(cx).dap_store().clone();
let label = parent_session.read(cx).label().clone();
let mut label = parent_session.read(cx).label().clone();
if !label.ends_with("(child)") {
label = format!("{label} (child)").into();
}
let adapter = parent_session.read(cx).adapter().clone();
let mut binary = parent_session.read(cx).binary().clone();
binary.request_args = request.clone();
@@ -1378,4 +1384,30 @@ impl workspace::DebuggerProvider for DebuggerProvider {
})
})
}
fn spawn_task_or_modal(
&self,
workspace: &mut Workspace,
action: &tasks_ui::Spawn,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
spawn_task_or_modal(workspace, action, window, cx);
}
fn debug_scenario_scheduled(&self, cx: &mut App) {
self.0.update(cx, |this, _| {
this.debug_scenario_scheduled_last = true;
});
}
fn task_scheduled(&self, cx: &mut App) {
self.0.update(cx, |this, _| {
this.debug_scenario_scheduled_last = false;
})
}
fn debug_scenario_scheduled_last(&self, cx: &App) -> bool {
self.0.read(cx).debug_scenario_scheduled_last
}
}

View File

@@ -3,11 +3,12 @@ use debugger_panel::{DebugPanel, ToggleFocus};
use editor::Editor;
use feature_flags::{DebuggerFeatureFlag, FeatureFlagViewExt};
use gpui::{App, EntityInputHandler, actions};
use new_session_modal::NewSessionModal;
use new_session_modal::{NewSessionModal, NewSessionMode};
use project::debugger::{self, breakpoint_store::SourceBreakpoint};
use session::DebugSession;
use settings::Settings;
use stack_trace_view::StackTraceView;
use tasks_ui::{Spawn, TaskOverrides};
use util::maybe;
use workspace::{ItemHandle, ShutdownDebugAdapters, Workspace};
@@ -62,6 +63,7 @@ pub fn init(cx: &mut App) {
cx.when_flag_enabled::<DebuggerFeatureFlag>(window, |workspace, _, _| {
workspace
.register_action(spawn_task_or_modal)
.register_action(|workspace, _: &ToggleFocus, window, cx| {
workspace.toggle_panel_focus::<DebugPanel>(window, cx);
})
@@ -208,7 +210,7 @@ pub fn init(cx: &mut App) {
},
)
.register_action(|workspace: &mut Workspace, _: &Start, window, cx| {
NewSessionModal::show(workspace, window, cx);
NewSessionModal::show(workspace, window, NewSessionMode::Launch, None, cx);
})
.register_action(
|workspace: &mut Workspace, _: &RerunLastSession, window, cx| {
@@ -309,3 +311,48 @@ pub fn init(cx: &mut App) {
})
.detach();
}
fn spawn_task_or_modal(
workspace: &mut Workspace,
action: &Spawn,
window: &mut ui::Window,
cx: &mut ui::Context<Workspace>,
) {
match action {
Spawn::ByName {
task_name,
reveal_target,
} => {
let overrides = reveal_target.map(|reveal_target| TaskOverrides {
reveal_target: Some(reveal_target),
});
let name = task_name.clone();
tasks_ui::spawn_tasks_filtered(
move |(_, task)| task.label.eq(&name),
overrides,
window,
cx,
)
.detach_and_log_err(cx)
}
Spawn::ByTag {
task_tag,
reveal_target,
} => {
let overrides = reveal_target.map(|reveal_target| TaskOverrides {
reveal_target: Some(reveal_target),
});
let tag = task_tag.clone();
tasks_ui::spawn_tasks_filtered(
move |(_, task)| task.tags.contains(&tag),
overrides,
window,
cx,
)
.detach_and_log_err(cx)
}
Spawn::ViaModal { reveal_target } => {
NewSessionModal::show(workspace, window, NewSessionMode::Task, *reveal_target, cx);
}
}
}

View File

@@ -1,4 +1,6 @@
use gpui::Entity;
use std::time::Duration;
use gpui::{Animation, AnimationExt as _, Entity, Transformation, percentage};
use project::debugger::session::{ThreadId, ThreadStatus};
use ui::{ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*};
@@ -23,31 +25,40 @@ impl DebugPanel {
let sessions = self.sessions().clone();
let weak = cx.weak_entity();
let running_state = running_state.read(cx);
let label = if let Some(active_session) = active_session {
let label = if let Some(active_session) = active_session.clone() {
active_session.read(cx).session(cx).read(cx).label()
} else {
SharedString::new_static("Unknown Session")
};
let is_terminated = running_state.session().read(cx).is_terminated();
let session_state_indicator = {
if is_terminated {
Some(Indicator::dot().color(Color::Error))
} else {
match running_state.thread_status(cx).unwrap_or_default() {
project::debugger::session::ThreadStatus::Stopped => {
Some(Indicator::dot().color(Color::Conflict))
}
_ => Some(Indicator::dot().color(Color::Success)),
let is_started = active_session
.is_some_and(|session| session.read(cx).session(cx).read(cx).is_started());
let session_state_indicator = if is_terminated {
Indicator::dot().color(Color::Error).into_any_element()
} else if !is_started {
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()
} else {
match running_state.thread_status(cx).unwrap_or_default() {
ThreadStatus::Stopped => {
Indicator::dot().color(Color::Conflict).into_any_element()
}
_ => Indicator::dot().color(Color::Success).into_any_element(),
}
};
let trigger = h_flex()
.gap_2()
.when_some(session_state_indicator, |this, indicator| {
this.child(indicator)
})
.child(session_state_indicator)
.justify_between()
.child(
DebugPanel::dropdown_label(label)

View File

@@ -8,6 +8,7 @@ use std::{
time::Duration,
usize,
};
use tasks_ui::{TaskOverrides, TasksModal};
use dap::{
DapRegistry, DebugRequest, TelemetrySpawnLocation, adapters::DebugAdapterName, send_telemetry,
@@ -16,19 +17,19 @@ use editor::{Anchor, Editor, EditorElement, EditorStyle, scroll::Autoscroll};
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
Animation, AnimationExt as _, App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle,
Focusable, Render, Subscription, TextStyle, Transformation, WeakEntity, percentage,
Focusable, KeyContext, Render, Subscription, TextStyle, Transformation, WeakEntity, percentage,
};
use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch};
use project::{ProjectPath, TaskContexts, TaskSourceKind, task_store::TaskStore};
use settings::Settings;
use task::{DebugScenario, LaunchRequest, ZedDebugConfig};
use task::{DebugScenario, LaunchRequest, RevealTarget, ZedDebugConfig};
use theme::ThemeSettings;
use ui::{
ActiveTheme, Button, ButtonCommon, ButtonSize, CheckboxWithLabel, Clickable, Color, Context,
ContextMenu, Disableable, DropdownMenu, FluentBuilder, Icon, IconButton, IconName, IconSize,
InteractiveElement, IntoElement, Label, LabelCommon as _, ListItem, ListItemSpacing,
ParentElement, RenderOnce, SharedString, Styled, StyledExt, ToggleButton, ToggleState,
Toggleable, Window, div, h_flex, relative, rems, v_flex,
IconWithIndicator, Indicator, InteractiveElement, IntoElement, Label, LabelCommon as _,
ListItem, ListItemSpacing, ParentElement, RenderOnce, SharedString, Styled, StyledExt,
ToggleButton, ToggleState, Toggleable, Window, div, h_flex, relative, rems, v_flex,
};
use util::ResultExt;
use workspace::{ModalView, Workspace, pane};
@@ -47,10 +48,11 @@ pub(super) struct NewSessionModal {
mode: NewSessionMode,
launch_picker: Entity<Picker<DebugScenarioDelegate>>,
attach_mode: Entity<AttachMode>,
custom_mode: Entity<CustomMode>,
configure_mode: Entity<ConfigureMode>,
task_mode: TaskMode,
debugger: Option<DebugAdapterName>,
save_scenario_state: Option<SaveScenarioState>,
_subscriptions: [Subscription; 2],
_subscriptions: [Subscription; 3],
}
fn suggested_label(request: &DebugRequest, debugger: &str) -> SharedString {
@@ -75,6 +77,8 @@ impl NewSessionModal {
pub(super) fn show(
workspace: &mut Workspace,
window: &mut Window,
mode: NewSessionMode,
reveal_target: Option<RevealTarget>,
cx: &mut Context<Workspace>,
) {
let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) else {
@@ -84,20 +88,50 @@ impl NewSessionModal {
let languages = workspace.app_state().languages.clone();
cx.spawn_in(window, async move |workspace, cx| {
let task_contexts = workspace
.update_in(cx, |workspace, window, cx| {
tasks_ui::task_contexts(workspace, window, cx)
})?
.await;
let task_contexts = Arc::new(task_contexts);
workspace.update_in(cx, |workspace, window, cx| {
let workspace_handle = workspace.weak_handle();
workspace.toggle_modal(window, cx, |window, cx| {
let attach_mode = AttachMode::new(None, workspace_handle.clone(), window, cx);
let launch_picker = cx.new(|cx| {
Picker::uniform_list(
DebugScenarioDelegate::new(debug_panel.downgrade(), task_store),
window,
cx,
)
.modal(false)
let mut delegate =
DebugScenarioDelegate::new(debug_panel.downgrade(), task_store.clone());
delegate.task_contexts_loaded(task_contexts.clone(), languages, window, cx);
Picker::uniform_list(delegate, window, cx).modal(false)
});
let configure_mode = ConfigureMode::new(None, window, cx);
if let Some(active_cwd) = task_contexts
.active_context()
.and_then(|context| context.cwd.clone())
{
configure_mode.update(cx, |configure_mode, cx| {
configure_mode.load(active_cwd, window, cx);
});
}
let task_overrides = Some(TaskOverrides { reveal_target });
let task_mode = TaskMode {
task_modal: cx.new(|cx| {
TasksModal::new(
task_store.clone(),
task_contexts,
task_overrides,
false,
workspace_handle.clone(),
window,
cx,
)
}),
};
let _subscriptions = [
cx.subscribe(&launch_picker, |_, _, _, cx| {
cx.emit(DismissEvent);
@@ -108,52 +142,18 @@ impl NewSessionModal {
cx.emit(DismissEvent);
},
),
cx.subscribe(&task_mode.task_modal, |_, _, _: &DismissEvent, cx| {
cx.emit(DismissEvent)
}),
];
let custom_mode = CustomMode::new(None, window, cx);
cx.spawn_in(window, {
let workspace_handle = workspace_handle.clone();
async move |this, cx| {
let task_contexts = workspace_handle
.update_in(cx, |workspace, window, cx| {
tasks_ui::task_contexts(workspace, window, cx)
})?
.await;
this.update_in(cx, |this, window, cx| {
if let Some(active_cwd) = task_contexts
.active_context()
.and_then(|context| context.cwd.clone())
{
this.custom_mode.update(cx, |custom, cx| {
custom.load(active_cwd, window, cx);
});
this.debugger = None;
}
this.launch_picker.update(cx, |picker, cx| {
picker.delegate.task_contexts_loaded(
task_contexts,
languages,
window,
cx,
);
picker.refresh(window, cx);
cx.notify();
});
})
}
})
.detach();
Self {
launch_picker,
attach_mode,
custom_mode,
configure_mode,
task_mode,
debugger: None,
mode: NewSessionMode::Launch,
mode,
debug_panel: debug_panel.downgrade(),
workspace: workspace_handle,
save_scenario_state: None,
@@ -170,10 +170,17 @@ impl NewSessionModal {
fn render_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
let dap_menu = self.adapter_drop_down_menu(window, cx);
match self.mode {
NewSessionMode::Task => self
.task_mode
.task_modal
.read(cx)
.picker
.clone()
.into_any_element(),
NewSessionMode::Attach => self.attach_mode.update(cx, |this, cx| {
this.clone().render(window, cx).into_any_element()
}),
NewSessionMode::Custom => self.custom_mode.update(cx, |this, cx| {
NewSessionMode::Configure => self.configure_mode.update(cx, |this, cx| {
this.clone().render(dap_menu, window, cx).into_any_element()
}),
NewSessionMode::Launch => v_flex()
@@ -185,16 +192,17 @@ impl NewSessionModal {
fn mode_focus_handle(&self, cx: &App) -> FocusHandle {
match self.mode {
NewSessionMode::Task => self.task_mode.task_modal.focus_handle(cx),
NewSessionMode::Attach => self.attach_mode.read(cx).attach_picker.focus_handle(cx),
NewSessionMode::Custom => self.custom_mode.read(cx).program.focus_handle(cx),
NewSessionMode::Configure => self.configure_mode.read(cx).program.focus_handle(cx),
NewSessionMode::Launch => self.launch_picker.focus_handle(cx),
}
}
fn debug_scenario(&self, debugger: &str, cx: &App) -> Option<DebugScenario> {
let request = match self.mode {
NewSessionMode::Custom => Some(DebugRequest::Launch(
self.custom_mode.read(cx).debug_request(cx),
NewSessionMode::Configure => Some(DebugRequest::Launch(
self.configure_mode.read(cx).debug_request(cx),
)),
NewSessionMode::Attach => Some(DebugRequest::Attach(
self.attach_mode.read(cx).debug_request(),
@@ -203,8 +211,8 @@ impl NewSessionModal {
}?;
let label = suggested_label(&request, debugger);
let stop_on_entry = if let NewSessionMode::Custom = &self.mode {
Some(self.custom_mode.read(cx).stop_on_entry.selected())
let stop_on_entry = if let NewSessionMode::Configure = &self.mode {
Some(self.configure_mode.read(cx).stop_on_entry.selected())
} else {
None
};
@@ -527,7 +535,8 @@ static SELECT_DEBUGGER_LABEL: SharedString = SharedString::new_static("Select De
#[derive(Clone)]
pub(crate) enum NewSessionMode {
Custom,
Task,
Configure,
Attach,
Launch,
}
@@ -535,9 +544,10 @@ pub(crate) enum NewSessionMode {
impl std::fmt::Display for NewSessionMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mode = match self {
NewSessionMode::Launch => "Launch".to_owned(),
NewSessionMode::Attach => "Attach".to_owned(),
NewSessionMode::Custom => "Custom".to_owned(),
NewSessionMode::Task => "Run",
NewSessionMode::Launch => "Debug",
NewSessionMode::Attach => "Attach",
NewSessionMode::Configure => "Configure Debugger",
};
write!(f, "{}", mode)
@@ -597,36 +607,39 @@ impl Render for NewSessionModal {
v_flex()
.size_full()
.w(rems(34.))
.key_context("Pane")
.key_context({
let mut key_context = KeyContext::new_with_defaults();
key_context.add("Pane");
key_context.add("RunModal");
key_context
})
.elevation_3(cx)
.bg(cx.theme().colors().elevated_surface_background)
.on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
cx.emit(DismissEvent);
}))
.on_action(cx.listener(|this, _: &pane::ActivateNextItem, window, cx| {
this.mode = match this.mode {
NewSessionMode::Task => NewSessionMode::Launch,
NewSessionMode::Launch => NewSessionMode::Attach,
NewSessionMode::Attach => NewSessionMode::Configure,
NewSessionMode::Configure => NewSessionMode::Task,
};
this.mode_focus_handle(cx).focus(window);
}))
.on_action(
cx.listener(|this, _: &pane::ActivatePreviousItem, window, cx| {
this.mode = match this.mode {
NewSessionMode::Task => NewSessionMode::Configure,
NewSessionMode::Launch => NewSessionMode::Task,
NewSessionMode::Attach => NewSessionMode::Launch,
NewSessionMode::Launch => NewSessionMode::Attach,
_ => {
return;
}
NewSessionMode::Configure => NewSessionMode::Attach,
};
this.mode_focus_handle(cx).focus(window);
}),
)
.on_action(cx.listener(|this, _: &pane::ActivateNextItem, window, cx| {
this.mode = match this.mode {
NewSessionMode::Attach => NewSessionMode::Launch,
NewSessionMode::Launch => NewSessionMode::Attach,
_ => {
return;
}
};
this.mode_focus_handle(cx).focus(window);
}))
.child(
h_flex()
.w_full()
@@ -637,37 +650,73 @@ impl Render for NewSessionModal {
.justify_start()
.w_full()
.child(
ToggleButton::new("debugger-session-ui-picker-button", "Launch")
.size(ButtonSize::Default)
.style(ui::ButtonStyle::Subtle)
.toggle_state(matches!(self.mode, NewSessionMode::Launch))
.on_click(cx.listener(|this, _, window, cx| {
this.mode = NewSessionMode::Launch;
this.mode_focus_handle(cx).focus(window);
cx.notify();
}))
.first(),
ToggleButton::new(
"debugger-session-ui-tasks-button",
NewSessionMode::Task.to_string(),
)
.size(ButtonSize::Default)
.toggle_state(matches!(self.mode, NewSessionMode::Task))
.style(ui::ButtonStyle::Subtle)
.on_click(cx.listener(|this, _, window, cx| {
this.mode = NewSessionMode::Task;
this.mode_focus_handle(cx).focus(window);
cx.notify();
}))
.first(),
)
.child(
ToggleButton::new("debugger-session-ui-attach-button", "Attach")
.size(ButtonSize::Default)
.toggle_state(matches!(self.mode, NewSessionMode::Attach))
.style(ui::ButtonStyle::Subtle)
.on_click(cx.listener(|this, _, window, cx| {
this.mode = NewSessionMode::Attach;
ToggleButton::new(
"debugger-session-ui-launch-button",
NewSessionMode::Launch.to_string(),
)
.size(ButtonSize::Default)
.style(ui::ButtonStyle::Subtle)
.toggle_state(matches!(self.mode, NewSessionMode::Launch))
.on_click(cx.listener(|this, _, window, cx| {
this.mode = NewSessionMode::Launch;
this.mode_focus_handle(cx).focus(window);
cx.notify();
}))
.middle(),
)
.child(
ToggleButton::new(
"debugger-session-ui-attach-button",
NewSessionMode::Attach.to_string(),
)
.size(ButtonSize::Default)
.toggle_state(matches!(self.mode, NewSessionMode::Attach))
.style(ui::ButtonStyle::Subtle)
.on_click(cx.listener(|this, _, window, cx| {
this.mode = NewSessionMode::Attach;
if let Some(debugger) = this.debugger.as_ref() {
Self::update_attach_picker(
&this.attach_mode,
&debugger,
window,
cx,
);
}
this.mode_focus_handle(cx).focus(window);
cx.notify();
}))
.last(),
if let Some(debugger) = this.debugger.as_ref() {
Self::update_attach_picker(
&this.attach_mode,
&debugger,
window,
cx,
);
}
this.mode_focus_handle(cx).focus(window);
cx.notify();
}))
.middle(),
)
.child(
ToggleButton::new(
"debugger-session-ui-custom-button",
NewSessionMode::Configure.to_string(),
)
.size(ButtonSize::Default)
.toggle_state(matches!(self.mode, NewSessionMode::Configure))
.style(ui::ButtonStyle::Subtle)
.on_click(cx.listener(|this, _, window, cx| {
this.mode = NewSessionMode::Configure;
this.mode_focus_handle(cx).focus(window);
cx.notify();
}))
.last(),
),
)
.justify_between()
@@ -675,83 +724,83 @@ impl Render for NewSessionModal {
.border_b_1(),
)
.child(v_flex().child(self.render_mode(window, cx)))
.child(
h_flex()
.map(|el| {
let container = h_flex()
.justify_between()
.gap_2()
.p_2()
.border_color(cx.theme().colors().border_variant)
.border_t_1()
.w_full()
.child(match self.mode {
NewSessionMode::Attach => {
div().child(self.adapter_drop_down_menu(window, cx))
}
NewSessionMode::Launch => div().child(
Button::new("new-session-modal-custom", "Custom").on_click({
let this = cx.weak_entity();
move |_, window, cx| {
this.update(cx, |this, cx| {
this.mode = NewSessionMode::Custom;
this.mode_focus_handle(cx).focus(window);
})
.ok();
}
}),
),
NewSessionMode::Custom => h_flex()
.w_full();
match self.mode {
NewSessionMode::Configure => el.child(
container
.child(
Button::new("new-session-modal-back", "Save to .zed/debug.json...")
h_flex()
.child(
Button::new(
"new-session-modal-back",
"Save to .zed/debug.json...",
)
.on_click(cx.listener(|this, _, window, cx| {
this.save_debug_scenario(window, cx);
}))
.disabled(
self.debugger.is_none()
|| self
.configure_mode
.read(cx)
.program
.read(cx)
.is_empty(cx)
|| self.save_scenario_state.is_some(),
),
)
.child(self.render_save_state(cx)),
)
.child(
Button::new("debugger-spawn", "Start")
.on_click(cx.listener(|this, _, window, cx| {
this.save_debug_scenario(window, cx);
this.start_new_session(window, cx)
}))
.disabled(
self.debugger.is_none()
|| self
.custom_mode
.configure_mode
.read(cx)
.program
.read(cx)
.is_empty(cx)
|| self.save_scenario_state.is_some(),
.is_empty(cx),
),
)
.child(self.render_save_state(cx)),
})
.child(
Button::new("debugger-spawn", "Start")
.on_click(cx.listener(|this, _, window, cx| match &this.mode {
NewSessionMode::Launch => {
this.launch_picker.update(cx, |picker, cx| {
picker.delegate.confirm(true, window, cx)
})
}
_ => this.start_new_session(window, cx),
}))
.disabled(match self.mode {
NewSessionMode::Launch => {
!self.launch_picker.read(cx).delegate.matches.is_empty()
}
NewSessionMode::Attach => {
self.debugger.is_none()
|| self
.attach_mode
.read(cx)
.attach_picker
.read(cx)
.picker
.read(cx)
.delegate
.match_count()
== 0
}
NewSessionMode::Custom => {
self.debugger.is_none()
|| self.custom_mode.read(cx).program.read(cx).is_empty(cx)
}
}),
),
),
)
NewSessionMode::Attach => el.child(
container
.child(div().child(self.adapter_drop_down_menu(window, cx)))
.child(
Button::new("debugger-spawn", "Start")
.on_click(cx.listener(|this, _, window, cx| {
this.start_new_session(window, cx)
}))
.disabled(
self.debugger.is_none()
|| self
.attach_mode
.read(cx)
.attach_picker
.read(cx)
.picker
.read(cx)
.delegate
.match_count()
== 0,
),
),
),
NewSessionMode::Launch => el,
NewSessionMode::Task => el,
}
})
}
}
@@ -774,13 +823,13 @@ impl RenderOnce for AttachMode {
}
#[derive(Clone)]
pub(super) struct CustomMode {
pub(super) struct ConfigureMode {
program: Entity<Editor>,
cwd: Entity<Editor>,
stop_on_entry: ToggleState,
}
impl CustomMode {
impl ConfigureMode {
pub(super) fn new(
past_launch_config: Option<LaunchRequest>,
window: &mut Window,
@@ -940,6 +989,11 @@ impl AttachMode {
}
}
#[derive(Clone)]
pub(super) struct TaskMode {
pub(super) task_modal: Entity<TasksModal>,
}
pub(super) struct DebugScenarioDelegate {
task_store: Entity<TaskStore>,
candidates: Vec<(Option<TaskSourceKind>, DebugScenario)>,
@@ -995,12 +1049,12 @@ impl DebugScenarioDelegate {
pub fn task_contexts_loaded(
&mut self,
task_contexts: TaskContexts,
task_contexts: Arc<TaskContexts>,
languages: Arc<LanguageRegistry>,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
self.task_contexts = Some(Arc::new(task_contexts));
self.task_contexts = Some(task_contexts);
let (recent, scenarios) = self
.task_store
@@ -1168,21 +1222,32 @@ impl PickerDelegate for DebugScenarioDelegate {
let task_kind = &self.candidates[hit.candidate_id].0;
let icon = match task_kind {
Some(TaskSourceKind::Lsp(..)) => Some(Icon::new(IconName::BoltFilled)),
Some(TaskSourceKind::UserInput) => Some(Icon::new(IconName::Terminal)),
Some(TaskSourceKind::AbsPath { .. }) => Some(Icon::new(IconName::Settings)),
Some(TaskSourceKind::Worktree { .. }) => Some(Icon::new(IconName::FileTree)),
Some(TaskSourceKind::Language { name }) => file_icons::FileIcons::get(cx)
Some(TaskSourceKind::Lsp {
language_name: name,
..
})
| Some(TaskSourceKind::Language { name }) => file_icons::FileIcons::get(cx)
.get_icon_for_type(&name.to_lowercase(), cx)
.map(Icon::from_path),
None => Some(Icon::new(IconName::HistoryRerun)),
}
.map(|icon| icon.color(Color::Muted).size(ui::IconSize::Small));
.map(|icon| icon.color(Color::Muted).size(IconSize::Small));
let indicator = if matches!(task_kind, Some(TaskSourceKind::Lsp { .. })) {
Some(Indicator::icon(
Icon::new(IconName::BoltFilled).color(Color::Muted),
))
} else {
None
};
let icon = icon.map(|icon| IconWithIndicator::new(icon, indicator));
Some(
ListItem::new(SharedString::from(format!("debug-scenario-selection-{ix}")))
.inset(true)
.start_slot::<Icon>(icon)
.start_slot::<IconWithIndicator>(icon)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(highlighted_location.render(window, cx)),
@@ -1206,7 +1271,7 @@ pub(crate) fn resolve_path(path: &mut String) {
#[cfg(test)]
impl NewSessionModal {
pub(crate) fn set_custom(
pub(crate) fn set_configure(
&mut self,
program: impl AsRef<str>,
cwd: impl AsRef<str>,
@@ -1214,21 +1279,21 @@ impl NewSessionModal {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.mode = NewSessionMode::Custom;
self.mode = NewSessionMode::Configure;
self.debugger = Some(dap::adapters::DebugAdapterName("fake-adapter".into()));
self.custom_mode.update(cx, |custom, cx| {
custom.program.update(cx, |editor, cx| {
self.configure_mode.update(cx, |configure, cx| {
configure.program.update(cx, |editor, cx| {
editor.clear(window, cx);
editor.set_text(program.as_ref(), window, cx);
});
custom.cwd.update(cx, |editor, cx| {
configure.cwd.update(cx, |editor, cx| {
editor.clear(window, cx);
editor.set_text(cwd.as_ref(), window, cx);
});
custom.stop_on_entry = match stop_on_entry {
configure.stop_on_entry = match stop_on_entry {
true => ToggleState::Selected,
_ => ToggleState::Unselected,
}
@@ -1239,28 +1304,3 @@ impl NewSessionModal {
self.save_debug_scenario(window, cx);
}
}
#[cfg(test)]
mod tests {
use paths::home_dir;
#[test]
fn test_normalize_paths() {
let sep = std::path::MAIN_SEPARATOR;
let home = home_dir().to_string_lossy().to_string();
let resolve_path = |path: &str| -> String {
let mut path = path.to_string();
super::resolve_path(&mut path);
path
};
assert_eq!(resolve_path("bin"), format!("bin"));
assert_eq!(resolve_path(&format!("{sep}foo")), format!("{sep}foo"));
assert_eq!(resolve_path(""), format!(""));
assert_eq!(
resolve_path(&format!("~{sep}blah")),
format!("{home}{sep}blah")
);
assert_eq!(resolve_path("~"), home);
}
}

View File

@@ -61,6 +61,28 @@ impl DebuggerPaneItem {
DebuggerPaneItem::Terminal => SharedString::new_static("Terminal"),
}
}
pub(crate) fn tab_tooltip(self) -> SharedString {
let tooltip = match self {
DebuggerPaneItem::Console => {
"Displays program output and allows manual input of debugger commands."
}
DebuggerPaneItem::Variables => {
"Shows current values of local and global variables in the current stack frame."
}
DebuggerPaneItem::BreakpointList => "Lists all active breakpoints set in the code.",
DebuggerPaneItem::Frames => {
"Displays the call stack, letting you navigate between function calls."
}
DebuggerPaneItem::Modules => "Shows all modules or libraries loaded by the program.",
DebuggerPaneItem::LoadedSources => {
"Lists all source files currently loaded and used by the debugger."
}
DebuggerPaneItem::Terminal => {
"Provides an interactive terminal session within the debugging environment."
}
};
SharedString::new_static(tooltip)
}
}
impl From<DebuggerPaneItem> for SharedString {

View File

@@ -173,6 +173,10 @@ impl Item for SubView {
self.kind.to_shared_string()
}
fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
Some(self.kind.tab_tooltip())
}
fn tab_content(
&self,
params: workspace::item::TabContentParams,
@@ -399,6 +403,9 @@ pub(crate) fn new_debugger_pane(
.p_1()
.rounded_md()
.cursor_pointer()
.when_some(item.tab_tooltip_text(cx), |this, tooltip| {
this.tooltip(Tooltip::text(tooltip))
})
.map(|this| {
let theme = cx.theme();
if selected {
@@ -805,7 +812,7 @@ impl RunningState {
let request_type = dap_registry
.adapter(&adapter)
.ok_or_else(|| anyhow!("{}: is not a valid adapter name", &adapter))
.and_then(|adapter| adapter.validate_config(&config));
.and_then(|adapter| adapter.request_kind(&config));
let config_is_valid = request_type.is_ok();
@@ -874,7 +881,6 @@ impl RunningState {
args,
..task.resolved.clone()
};
let terminal = project
.update_in(cx, |project, window, cx| {
project.create_terminal(
@@ -919,6 +925,12 @@ impl RunningState {
};
if config_is_valid {
// Ok(DebugTaskDefinition {
// label,
// adapter: DebugAdapterName(adapter),
// config,
// tcp_connection,
// })
} else if let Some((task, locator_name)) = build_output {
let locator_name =
locator_name.context("Could not find a valid locator for a build task")?;
@@ -937,12 +949,15 @@ impl RunningState {
let scenario = dap_registry
.adapter(&adapter)
.context(format!("{}: is not a valid adapter name", &adapter))
.ok_or_else(|| anyhow!("{}: is not a valid adapter name", &adapter))
.map(|adapter| adapter.config_from_zed_format(zed_config))??;
config = scenario.config;
Self::substitute_variables_in_config(&mut config, &task_context);
} else {
anyhow::bail!("No request or build provided");
let Err(e) = request_type else {
unreachable!();
};
anyhow::bail!("Zed cannot determine how to run this debug scenario. `build` field was not provided and Debug Adapter won't accept provided configuration because: {e}");
};
Ok(DebugTaskDefinition {

View File

@@ -110,7 +110,7 @@ impl Console {
}
fn is_running(&self, cx: &Context<Self>) -> bool {
self.session.read(cx).is_local()
self.session.read(cx).is_running()
}
fn handle_stack_frame_list_events(
@@ -176,16 +176,18 @@ impl Console {
}
fn render_console(&self, cx: &Context<Self>) -> impl IntoElement {
EditorElement::new(&self.console, self.editor_style(cx))
EditorElement::new(&self.console, Self::editor_style(&self.console, cx))
}
fn editor_style(&self, cx: &Context<Self>) -> EditorStyle {
fn editor_style(editor: &Entity<Editor>, cx: &Context<Self>) -> EditorStyle {
let is_read_only = editor.read(cx).read_only(cx);
let settings = ThemeSettings::get_global(cx);
let theme = cx.theme();
let text_style = TextStyle {
color: if self.console.read(cx).read_only(cx) {
cx.theme().colors().text_disabled
color: if is_read_only {
theme.colors().text_muted
} else {
cx.theme().colors().text
theme.colors().text
},
font_family: settings.buffer_font.family.clone(),
font_features: settings.buffer_font.features.clone(),
@@ -195,15 +197,15 @@ impl Console {
..Default::default()
};
EditorStyle {
background: cx.theme().colors().editor_background,
local_player: cx.theme().players().local(),
background: theme.colors().editor_background,
local_player: theme.players().local(),
text: text_style,
..Default::default()
}
}
fn render_query_bar(&self, cx: &Context<Self>) -> impl IntoElement {
EditorElement::new(&self.query_bar, self.editor_style(cx))
EditorElement::new(&self.query_bar, Self::editor_style(&self.query_bar, cx))
}
fn update_output(&mut self, window: &mut Window, cx: &mut Context<Self>) {

View File

@@ -250,9 +250,6 @@ impl StackFrameList {
let Some(abs_path) = Self::abs_path_from_stack_frame(&stack_frame) else {
return Task::ready(Err(anyhow!("Project path not found")));
};
if abs_path.starts_with("<node_internals>") {
return Task::ready(Ok(()));
}
let row = stack_frame.line.saturating_sub(1) as u32;
cx.emit(StackFrameListEvent::SelectedStackFrameChanged(
stack_frame_id,
@@ -345,6 +342,7 @@ impl StackFrameList {
s.path
.as_deref()
.map(|path| Arc::<Path>::from(Path::new(path)))
.filter(|path| path.is_absolute())
})
}

View File

@@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use task::{DebugRequest, DebugScenario, LaunchRequest, TaskContext, VariableName, ZedDebugConfig};
use util::path;
use crate::new_session_modal::NewSessionMode;
use crate::tests::{init_test, init_test_workspace};
#[gpui::test]
@@ -170,7 +171,13 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut
workspace
.update(cx, |workspace, window, cx| {
crate::new_session_modal::NewSessionModal::show(workspace, window, cx);
crate::new_session_modal::NewSessionModal::show(
workspace,
window,
NewSessionMode::Launch,
None,
cx,
);
})
.unwrap();
@@ -184,7 +191,7 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut
.expect("Modal should be active");
modal.update_in(cx, |modal, window, cx| {
modal.set_custom("/project/main", "/project", false, window, cx);
modal.set_configure("/project/main", "/project", false, window, cx);
modal.save_scenario(window, cx);
});
@@ -213,7 +220,7 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut
pretty_assertions::assert_eq!(expected_content, actual_lines);
modal.update_in(cx, |modal, window, cx| {
modal.set_custom("/project/other", "/project", true, window, cx);
modal.set_configure("/project/other", "/project", true, window, cx);
modal.save_scenario(window, cx);
});
@@ -315,7 +322,7 @@ async fn test_dap_adapter_config_conversion_and_validation(cx: &mut TestAppConte
);
let request_type = adapter
.validate_config(&debug_scenario.config)
.request_kind(&debug_scenario.config)
.unwrap_or_else(|_| {
panic!(
"Adapter {} should validate the config successfully",

View File

@@ -82,6 +82,7 @@ tree-sitter-rust = { workspace = true, optional = true }
tree-sitter-typescript = { workspace = true, optional = true }
tree-sitter-python = { workspace = true, optional = true }
unicode-segmentation.workspace = true
unicode-script.workspace = true
unindent = { workspace = true, optional = true }
ui.workspace = true
url.workspace = true
@@ -97,6 +98,7 @@ gpui = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] }
languages = {workspace = true, features = ["test-support"] }
lsp = { workspace = true, features = ["test-support"] }
markdown = { workspace = true, features = ["test-support"] }
multi_buffer = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
release_channel.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -9111,11 +9111,10 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
lsp::Url::from_file_path(path!("/file.rs")).unwrap()
);
assert_eq!(params.options.tab_size, 8);
Ok(Some(vec![]))
Ok(Some(Vec::new()))
})
.next()
.await;
cx.executor().start_waiting();
save.await;
}
@@ -10480,6 +10479,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
run_description: &'static str,
initial_state: String,
buffer_marked_text: String,
completion_label: &'static str,
completion_text: &'static str,
expected_with_insert_mode: String,
expected_with_replace_mode: String,
@@ -10492,6 +10492,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
run_description: "Start of word matches completion text",
initial_state: "before ediˇ after".into(),
buffer_marked_text: "before <edi|> after".into(),
completion_label: "editor",
completion_text: "editor",
expected_with_insert_mode: "before editorˇ after".into(),
expected_with_replace_mode: "before editorˇ after".into(),
@@ -10502,6 +10503,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
run_description: "Accept same text at the middle of the word",
initial_state: "before ediˇtor after".into(),
buffer_marked_text: "before <edi|tor> after".into(),
completion_label: "editor",
completion_text: "editor",
expected_with_insert_mode: "before editorˇtor after".into(),
expected_with_replace_mode: "before editorˇ after".into(),
@@ -10512,6 +10514,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
run_description: "End of word matches completion text -- cursor at end",
initial_state: "before torˇ after".into(),
buffer_marked_text: "before <tor|> after".into(),
completion_label: "editor",
completion_text: "editor",
expected_with_insert_mode: "before editorˇ after".into(),
expected_with_replace_mode: "before editorˇ after".into(),
@@ -10522,6 +10525,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
run_description: "End of word matches completion text -- cursor at start",
initial_state: "before ˇtor after".into(),
buffer_marked_text: "before <|tor> after".into(),
completion_label: "editor",
completion_text: "editor",
expected_with_insert_mode: "before editorˇtor after".into(),
expected_with_replace_mode: "before editorˇ after".into(),
@@ -10532,6 +10536,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
run_description: "Prepend text containing whitespace",
initial_state: "pˇfield: bool".into(),
buffer_marked_text: "<p|field>: bool".into(),
completion_label: "pub ",
completion_text: "pub ",
expected_with_insert_mode: "pub ˇfield: bool".into(),
expected_with_replace_mode: "pub ˇ: bool".into(),
@@ -10542,6 +10547,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
run_description: "Add element to start of list",
initial_state: "[element_ˇelement_2]".into(),
buffer_marked_text: "[<element_|element_2>]".into(),
completion_label: "element_1",
completion_text: "element_1",
expected_with_insert_mode: "[element_1ˇelement_2]".into(),
expected_with_replace_mode: "[element_1ˇ]".into(),
@@ -10552,6 +10558,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
run_description: "Add element to start of list -- first and second elements are equal",
initial_state: "[elˇelement]".into(),
buffer_marked_text: "[<el|element>]".into(),
completion_label: "element",
completion_text: "element",
expected_with_insert_mode: "[elementˇelement]".into(),
expected_with_replace_mode: "[elementˇ]".into(),
@@ -10562,6 +10569,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
run_description: "Ends with matching suffix",
initial_state: "SubˇError".into(),
buffer_marked_text: "<Sub|Error>".into(),
completion_label: "SubscriptionError",
completion_text: "SubscriptionError",
expected_with_insert_mode: "SubscriptionErrorˇError".into(),
expected_with_replace_mode: "SubscriptionErrorˇ".into(),
@@ -10572,6 +10580,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
run_description: "Suffix is a subsequence -- contiguous",
initial_state: "SubˇErr".into(),
buffer_marked_text: "<Sub|Err>".into(),
completion_label: "SubscriptionError",
completion_text: "SubscriptionError",
expected_with_insert_mode: "SubscriptionErrorˇErr".into(),
expected_with_replace_mode: "SubscriptionErrorˇ".into(),
@@ -10582,6 +10591,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
run_description: "Suffix is a subsequence -- non-contiguous -- replace intended",
initial_state: "Suˇscrirr".into(),
buffer_marked_text: "<Su|scrirr>".into(),
completion_label: "SubscriptionError",
completion_text: "SubscriptionError",
expected_with_insert_mode: "SubscriptionErrorˇscrirr".into(),
expected_with_replace_mode: "SubscriptionErrorˇ".into(),
@@ -10592,12 +10602,46 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
run_description: "Suffix is a subsequence -- non-contiguous -- replace unintended",
initial_state: "foo(indˇix)".into(),
buffer_marked_text: "foo(<ind|ix>)".into(),
completion_label: "node_index",
completion_text: "node_index",
expected_with_insert_mode: "foo(node_indexˇix)".into(),
expected_with_replace_mode: "foo(node_indexˇ)".into(),
expected_with_replace_subsequence_mode: "foo(node_indexˇix)".into(),
expected_with_replace_suffix_mode: "foo(node_indexˇix)".into(),
},
Run {
run_description: "Replace range ends before cursor - should extend to cursor",
initial_state: "before editˇo after".into(),
buffer_marked_text: "before <{ed}>it|o after".into(),
completion_label: "editor",
completion_text: "editor",
expected_with_insert_mode: "before editorˇo after".into(),
expected_with_replace_mode: "before editorˇo after".into(),
expected_with_replace_subsequence_mode: "before editorˇo after".into(),
expected_with_replace_suffix_mode: "before editorˇo after".into(),
},
Run {
run_description: "Uses label for suffix matching",
initial_state: "before ediˇtor after".into(),
buffer_marked_text: "before <edi|tor> after".into(),
completion_label: "editor",
completion_text: "editor()",
expected_with_insert_mode: "before editor()ˇtor after".into(),
expected_with_replace_mode: "before editor()ˇ after".into(),
expected_with_replace_subsequence_mode: "before editor()ˇ after".into(),
expected_with_replace_suffix_mode: "before editor()ˇ after".into(),
},
Run {
run_description: "Case insensitive subsequence and suffix matching",
initial_state: "before EDiˇtoR after".into(),
buffer_marked_text: "before <EDi|toR> after".into(),
completion_label: "editor",
completion_text: "editor",
expected_with_insert_mode: "before editorˇtoR after".into(),
expected_with_replace_mode: "before editorˇ after".into(),
expected_with_replace_subsequence_mode: "before editorˇ after".into(),
expected_with_replace_suffix_mode: "before editorˇ after".into(),
},
];
for run in runs {
@@ -10638,7 +10682,7 @@ async fn test_completion_mode(cx: &mut TestAppContext) {
handle_completion_request_with_insert_and_replace(
&mut cx,
&run.buffer_marked_text,
vec![run.completion_text],
vec![(run.completion_label, run.completion_text)],
counter.clone(),
)
.await;
@@ -10698,7 +10742,7 @@ async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext)
handle_completion_request_with_insert_and_replace(
&mut cx,
&buffer_marked_text,
vec![completion_text],
vec![(completion_text, completion_text)],
counter.clone(),
)
.await;
@@ -10732,7 +10776,7 @@ async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext)
handle_completion_request_with_insert_and_replace(
&mut cx,
&buffer_marked_text,
vec![completion_text],
vec![(completion_text, completion_text)],
counter.clone(),
)
.await;
@@ -10819,7 +10863,7 @@ async fn test_completion_replacing_surrounding_text_with_multicursors(cx: &mut T
handle_completion_request_with_insert_and_replace(
&mut cx,
completion_marked_buffer,
vec![completion_text],
vec![(completion_text, completion_text)],
Arc::new(AtomicUsize::new(0)),
)
.await;
@@ -10873,7 +10917,7 @@ async fn test_completion_replacing_surrounding_text_with_multicursors(cx: &mut T
handle_completion_request_with_insert_and_replace(
&mut cx,
completion_marked_buffer,
vec![completion_text],
vec![(completion_text, completion_text)],
Arc::new(AtomicUsize::new(0)),
)
.await;
@@ -10922,7 +10966,7 @@ async fn test_completion_replacing_surrounding_text_with_multicursors(cx: &mut T
handle_completion_request_with_insert_and_replace(
&mut cx,
completion_marked_buffer,
vec![completion_text],
vec![(completion_text, completion_text)],
Arc::new(AtomicUsize::new(0)),
)
.await;
@@ -16769,9 +16813,9 @@ fn indent_guide(buffer_id: BufferId, start_row: u32, end_row: u32, depth: u32) -
async fn test_indent_guide_single_line(cx: &mut TestAppContext) {
let (buffer_id, mut cx) = setup_indent_guides_editor(
&"
fn main() {
let a = 1;
}"
fn main() {
let a = 1;
}"
.unindent(),
cx,
)
@@ -16784,10 +16828,10 @@ async fn test_indent_guide_single_line(cx: &mut TestAppContext) {
async fn test_indent_guide_simple_block(cx: &mut TestAppContext) {
let (buffer_id, mut cx) = setup_indent_guides_editor(
&"
fn main() {
let a = 1;
let b = 2;
}"
fn main() {
let a = 1;
let b = 2;
}"
.unindent(),
cx,
)
@@ -16800,14 +16844,14 @@ async fn test_indent_guide_simple_block(cx: &mut TestAppContext) {
async fn test_indent_guide_nested(cx: &mut TestAppContext) {
let (buffer_id, mut cx) = setup_indent_guides_editor(
&"
fn main() {
let a = 1;
if a == 3 {
let b = 2;
} else {
let c = 3;
}
}"
fn main() {
let a = 1;
if a == 3 {
let b = 2;
} else {
let c = 3;
}
}"
.unindent(),
cx,
)
@@ -16829,11 +16873,11 @@ async fn test_indent_guide_nested(cx: &mut TestAppContext) {
async fn test_indent_guide_tab(cx: &mut TestAppContext) {
let (buffer_id, mut cx) = setup_indent_guides_editor(
&"
fn main() {
let a = 1;
let b = 2;
let c = 3;
}"
fn main() {
let a = 1;
let b = 2;
let c = 3;
}"
.unindent(),
cx,
)
@@ -16963,6 +17007,72 @@ async fn test_indent_guide_ends_off_screen(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_indent_guide_with_folds(cx: &mut TestAppContext) {
let (buffer_id, mut cx) = setup_indent_guides_editor(
&"
fn main() {
if a {
b(
c,
d,
)
} else {
e(
f
)
}
}"
.unindent(),
cx,
)
.await;
assert_indent_guides(
0..11,
vec![
indent_guide(buffer_id, 1, 10, 0),
indent_guide(buffer_id, 2, 5, 1),
indent_guide(buffer_id, 7, 9, 1),
indent_guide(buffer_id, 3, 4, 2),
indent_guide(buffer_id, 8, 8, 2),
],
None,
&mut cx,
);
cx.update_editor(|editor, window, cx| {
editor.fold_at(MultiBufferRow(2), window, cx);
assert_eq!(
editor.display_text(cx),
"
fn main() {
if a {
b(⋯
)
} else {
e(
f
)
}
}"
.unindent()
);
});
assert_indent_guides(
0..11,
vec![
indent_guide(buffer_id, 1, 10, 0),
indent_guide(buffer_id, 2, 5, 1),
indent_guide(buffer_id, 7, 9, 1),
indent_guide(buffer_id, 8, 8, 2),
],
None,
&mut cx,
);
}
#[gpui::test]
async fn test_indent_guide_without_brackets(cx: &mut TestAppContext) {
let (buffer_id, mut cx) = setup_indent_guides_editor(
@@ -17017,6 +17127,64 @@ async fn test_indent_guide_ends_before_empty_line(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_indent_guide_ignored_only_whitespace_lines(cx: &mut TestAppContext) {
let (buffer_id, mut cx) = setup_indent_guides_editor(
&"
function component() {
\treturn (
\t\t\t
\t\t<div>
\t\t\t<abc></abc>
\t\t</div>
\t)
}"
.unindent(),
cx,
)
.await;
assert_indent_guides(
0..8,
vec![
indent_guide(buffer_id, 1, 6, 0),
indent_guide(buffer_id, 2, 5, 1),
indent_guide(buffer_id, 4, 4, 2),
],
None,
&mut cx,
);
}
#[gpui::test]
async fn test_indent_guide_fallback_to_next_non_entirely_whitespace_line(cx: &mut TestAppContext) {
let (buffer_id, mut cx) = setup_indent_guides_editor(
&"
function component() {
\treturn (
\t
\t\t<div>
\t\t\t<abc></abc>
\t\t</div>
\t)
}"
.unindent(),
cx,
)
.await;
assert_indent_guides(
0..8,
vec![
indent_guide(buffer_id, 1, 6, 0),
indent_guide(buffer_id, 2, 5, 1),
indent_guide(buffer_id, 4, 4, 2),
],
None,
&mut cx,
);
}
#[gpui::test]
async fn test_indent_guide_continuing_off_screen(cx: &mut TestAppContext) {
let (buffer_id, mut cx) = setup_indent_guides_editor(
@@ -19951,7 +20119,6 @@ println!("5");
pane_1
.update_in(cx, |pane, window, cx| {
pane.close_inactive_items(&CloseInactiveItems::default(), window, cx)
.unwrap()
})
.await
.unwrap();
@@ -19988,7 +20155,6 @@ println!("5");
pane_2
.update_in(cx, |pane, window, cx| {
pane.close_inactive_items(&CloseInactiveItems::default(), window, cx)
.unwrap()
})
.await
.unwrap();
@@ -20164,7 +20330,6 @@ println!("5");
});
pane.update_in(cx, |pane, window, cx| {
pane.close_all_items(&CloseAllItems::default(), window, cx)
.unwrap()
})
.await
.unwrap();
@@ -20518,7 +20683,6 @@ async fn test_invisible_worktree_servers(cx: &mut TestAppContext) {
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(&CloseActiveItem::default(), window, cx)
})
.unwrap()
.await
.unwrap();
pane.update_in(cx, |pane, window, cx| {
@@ -21003,19 +21167,27 @@ pub fn handle_completion_request(
/// Similar to `handle_completion_request`, but a [`CompletionTextEdit::InsertAndReplace`] will be
/// given instead, which also contains an `insert` range.
///
/// This function uses the cursor position to mimic what Rust-Analyzer provides as the `insert` range,
/// that is, `replace_range.start..cursor_pos`.
/// This function uses markers to define ranges:
/// - `|` marks the cursor position
/// - `<>` marks the replace range
/// - `[]` marks the insert range (optional, defaults to `replace_range.start..cursor_pos`which is what Rust-Analyzer provides)
pub fn handle_completion_request_with_insert_and_replace(
cx: &mut EditorLspTestContext,
marked_string: &str,
completions: Vec<&'static str>,
completions: Vec<(&'static str, &'static str)>, // (label, new_text)
counter: Arc<AtomicUsize>,
) -> impl Future<Output = ()> {
let complete_from_marker: TextRangeMarker = '|'.into();
let replace_range_marker: TextRangeMarker = ('<', '>').into();
let insert_range_marker: TextRangeMarker = ('{', '}').into();
let (_, mut marked_ranges) = marked_text_ranges_by(
marked_string,
vec![complete_from_marker.clone(), replace_range_marker.clone()],
vec![
complete_from_marker.clone(),
replace_range_marker.clone(),
insert_range_marker.clone(),
],
);
let complete_from_position =
@@ -21023,6 +21195,14 @@ pub fn handle_completion_request_with_insert_and_replace(
let replace_range =
cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone());
let insert_range = match marked_ranges.remove(&insert_range_marker) {
Some(ranges) if !ranges.is_empty() => cx.to_lsp_range(ranges[0].clone()),
_ => lsp::Range {
start: replace_range.start,
end: complete_from_position,
},
};
let mut request =
cx.set_request_handler::<lsp::request::Completion, _, _>(move |url, params, _| {
let completions = completions.clone();
@@ -21036,16 +21216,13 @@ pub fn handle_completion_request_with_insert_and_replace(
Ok(Some(lsp::CompletionResponse::Array(
completions
.iter()
.map(|completion_text| lsp::CompletionItem {
label: completion_text.to_string(),
.map(|(label, new_text)| lsp::CompletionItem {
label: label.to_string(),
text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace(
lsp::InsertReplaceEdit {
insert: lsp::Range {
start: replace_range.start,
end: complete_from_position,
},
insert: insert_range,
replace: replace_range,
new_text: completion_text.to_string(),
new_text: new_text.to_string(),
},
)),
..Default::default()

View File

@@ -42,13 +42,13 @@ use git::{
use gpui::{
Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
Bounds, ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges,
Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, Hsla,
InteractiveElement, IntoElement, IsZero, Keystroke, Length, ModifiersChangedEvent, MouseButton,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta,
ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement,
Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill,
linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background,
transparent_black,
Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox,
HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, Keystroke, Length,
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, WeakEntity,
Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px,
quad, relative, size, solid_background, transparent_black,
};
use itertools::Itertools;
use language::language_settings::{
@@ -682,7 +682,7 @@ impl EditorElement {
editor.select(
SelectPhase::BeginColumnar {
position,
reset: false,
reset: true,
goal_column: point_for_position.exact_unclipped.column(),
},
window,
@@ -1512,6 +1512,17 @@ impl EditorElement {
ShowScrollbar::Never => return None,
};
// The horizontal scrollbar is usually slightly offset to align nicely with
// indent guides. However, this offset is not needed if indent guides are
// disabled for the current editor.
let content_offset = self
.editor
.read(cx)
.show_indent_guides
.is_none_or(|should_show| should_show)
.then_some(content_offset)
.unwrap_or_default();
Some(EditorScrollbars::from_scrollbar_axes(
ScrollbarAxes {
horizontal: scrollbar_settings.axes.horizontal
@@ -1609,7 +1620,7 @@ impl EditorElement {
);
let layout = ScrollbarLayout::for_minimap(
window.insert_hitbox(minimap_bounds, false),
window.insert_hitbox(minimap_bounds, HitboxBehavior::Normal),
visible_editor_lines,
total_editor_lines,
minimap_line_height,
@@ -1780,7 +1791,7 @@ impl EditorElement {
if matches!(hunk, DisplayDiffHunk::Unfolded { .. }) {
let hunk_bounds =
Self::diff_hunk_bounds(snapshot, line_height, gutter_hitbox.bounds, hunk);
*hitbox = Some(window.insert_hitbox(hunk_bounds, true));
*hitbox = Some(window.insert_hitbox(hunk_bounds, HitboxBehavior::BlockMouse));
}
}
}
@@ -2872,7 +2883,7 @@ impl EditorElement {
let hitbox = line_origin.map(|line_origin| {
window.insert_hitbox(
Bounds::new(line_origin, size(shaped_line.width, line_height)),
false,
HitboxBehavior::Normal,
)
});
#[cfg(test)]
@@ -6360,7 +6371,7 @@ impl EditorElement {
}
};
if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) {
if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) {
delta = delta.coalesce(event.delta);
editor.update(cx, |editor, cx| {
let position_map: &PositionMap = &position_map;
@@ -7607,7 +7618,10 @@ impl Element for EditorElement {
editor.gutter_dimensions = gutter_dimensions;
editor.set_visible_line_count(bounds.size.height / line_height, window, cx);
if matches!(editor.mode, EditorMode::Minimap { .. }) {
if matches!(
editor.mode,
EditorMode::AutoHeight { .. } | EditorMode::Minimap { .. }
) {
snapshot
} else {
let wrap_width_for = |column: u32| (column as f32 * em_advance).ceil();
@@ -7637,15 +7651,17 @@ impl Element for EditorElement {
.map(|(guide, active)| (self.column_pixels(*guide, window, cx), *active))
.collect::<SmallVec<[_; 2]>>();
let hitbox = window.insert_hitbox(bounds, false);
let gutter_hitbox =
window.insert_hitbox(gutter_bounds(bounds, gutter_dimensions), false);
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
let gutter_hitbox = window.insert_hitbox(
gutter_bounds(bounds, gutter_dimensions),
HitboxBehavior::Normal,
);
let text_hitbox = window.insert_hitbox(
Bounds {
origin: gutter_hitbox.top_right(),
size: size(text_width, bounds.size.height),
},
false,
HitboxBehavior::Normal,
);
let content_origin = text_hitbox.origin + content_offset;
@@ -8866,7 +8882,7 @@ impl EditorScrollbars {
})
.map(|(viewport_size, scroll_range)| {
ScrollbarLayout::new(
window.insert_hitbox(scrollbar_bounds_for(axis), false),
window.insert_hitbox(scrollbar_bounds_for(axis), HitboxBehavior::Normal),
viewport_size,
scroll_range,
glyph_grid_cell.along(axis),
@@ -9626,7 +9642,6 @@ fn compute_auto_height_layout(
let font_size = style.text.font_size.to_pixels(window.rem_size());
let line_height = style.text.line_height_in_pixels(window.rem_size());
let em_width = window.text_system().em_width(font_id, font_size).unwrap();
let em_advance = window.text_system().em_advance(font_id, font_size).unwrap();
let mut snapshot = editor.snapshot(window, cx);
let gutter_dimensions = snapshot
@@ -9643,18 +9658,10 @@ fn compute_auto_height_layout(
let overscroll = size(em_width, px(0.));
let editor_width = text_width - gutter_dimensions.margin - overscroll.width - em_width;
let content_offset = point(gutter_dimensions.margin, Pixels::ZERO);
let editor_content_width = editor_width - content_offset.x;
let wrap_width_for = |column: u32| (column as f32 * em_advance).ceil();
let wrap_width = match editor.soft_wrap_mode(cx) {
SoftWrap::GitDiff => None,
SoftWrap::None => Some(wrap_width_for(MAX_LINE_LEN as u32 / 2)),
SoftWrap::EditorWidth => Some(editor_content_width),
SoftWrap::Column(column) => Some(wrap_width_for(column)),
SoftWrap::Bounded(column) => Some(editor_content_width.min(wrap_width_for(column))),
};
if editor.set_wrap_width(wrap_width, cx) {
snapshot = editor.snapshot(window, cx);
if !matches!(editor.soft_wrap_mode(cx), SoftWrap::None) {
if editor.set_wrap_width(Some(editor_width), cx) {
snapshot = editor.snapshot(window, cx);
}
}
let scroll_height = (snapshot.max_point().row().next_row().0 as f32) * line_height;

View File

@@ -1050,7 +1050,9 @@ mod tests {
for (range, event) in slice.iter() {
match event {
MarkdownEvent::SubstitutedText(parsed) => rendered_text.push_str(parsed),
MarkdownEvent::SubstitutedText(parsed) => {
rendered_text.push_str(parsed.as_str())
}
MarkdownEvent::Text | MarkdownEvent::Code => {
rendered_text.push_str(&text[range.clone()])
}

View File

@@ -1,9 +1,9 @@
use std::{ops::Range, time::Duration};
use std::{cmp::Ordering, ops::Range, time::Duration};
use collections::HashSet;
use gpui::{App, AppContext as _, Context, Task, Window};
use language::language_settings::language_settings;
use multi_buffer::{IndentGuide, MultiBufferRow};
use multi_buffer::{IndentGuide, MultiBufferRow, ToPoint};
use text::{LineIndent, Point};
use util::ResultExt;
@@ -154,12 +154,28 @@ pub fn indent_guides_in_range(
snapshot: &DisplaySnapshot,
cx: &App,
) -> Vec<IndentGuide> {
let start_anchor = snapshot
let start_offset = snapshot
.buffer_snapshot
.anchor_before(Point::new(visible_buffer_range.start.0, 0));
let end_anchor = snapshot
.point_to_offset(Point::new(visible_buffer_range.start.0, 0));
let end_offset = snapshot
.buffer_snapshot
.anchor_after(Point::new(visible_buffer_range.end.0, 0));
.point_to_offset(Point::new(visible_buffer_range.end.0, 0));
let start_anchor = snapshot.buffer_snapshot.anchor_before(start_offset);
let end_anchor = snapshot.buffer_snapshot.anchor_after(end_offset);
let mut fold_ranges = Vec::<Range<Point>>::new();
let mut folds = snapshot.folds_in_range(start_offset..end_offset).peekable();
while let Some(fold) = folds.next() {
let start = fold.range.start.to_point(&snapshot.buffer_snapshot);
let end = fold.range.end.to_point(&snapshot.buffer_snapshot);
if let Some(last_range) = fold_ranges.last_mut() {
if last_range.end >= start {
last_range.end = last_range.end.max(end);
continue;
}
}
fold_ranges.push(start..end);
}
snapshot
.buffer_snapshot
@@ -169,15 +185,19 @@ pub fn indent_guides_in_range(
return false;
}
let start = MultiBufferRow(indent_guide.start_row.0.saturating_sub(1));
// Filter out indent guides that are inside a fold
// All indent guides that are starting "offscreen" have a start value of the first visible row minus one
// Therefore checking if a line is folded at first visible row minus one causes the other indent guides that are not related to the fold to disappear as well
let is_folded = snapshot.is_line_folded(start);
let line_indent = snapshot.line_indent_for_buffer_row(start);
let contained_in_fold =
line_indent.len(indent_guide.tab_size) <= indent_guide.indent_level();
!(is_folded && contained_in_fold)
let has_containing_fold = fold_ranges
.binary_search_by(|fold_range| {
if fold_range.start >= Point::new(indent_guide.start_row.0, 0) {
Ordering::Greater
} else if fold_range.end < Point::new(indent_guide.end_row.0, 0) {
Ordering::Less
} else {
Ordering::Equal
}
})
.is_ok();
!has_containing_fold
})
.collect()
}

View File

@@ -600,7 +600,7 @@ pub(crate) fn handle_from(
})
.collect::<Vec<_>>();
this.update_in(cx, |this, window, cx| {
this.change_selections_inner(None, false, window, cx, |s| {
this.change_selections_without_showing_completions(None, window, cx, |s| {
s.select(base_selections);
});
})

View File

@@ -1,4 +1,5 @@
use std::sync::Arc;
use std::time::Duration;
use crate::Editor;
use collections::HashMap;
@@ -16,10 +17,12 @@ use project::LocationLink;
use project::Project;
use project::TaskSourceKind;
use project::lsp_store::lsp_ext_command::GetLspRunnables;
use smol::future::FutureExt as _;
use smol::stream::StreamExt;
use task::ResolvedTask;
use task::TaskContext;
use text::BufferId;
use ui::SharedString;
use util::ResultExt as _;
pub(crate) fn find_specific_language_server_in_selection<F>(
@@ -130,44 +133,70 @@ pub fn lsp_tasks(
.collect::<FuturesUnordered<_>>();
cx.spawn(async move |cx| {
let mut lsp_tasks = Vec::new();
while let Some(server_to_query) = lsp_task_sources.next().await {
if let Some((server_id, buffers)) = server_to_query {
let source_kind = TaskSourceKind::Lsp(server_id);
let id_base = source_kind.to_id_base();
let mut new_lsp_tasks = Vec::new();
for buffer in buffers {
let lsp_buffer_context = lsp_task_context(&project, &buffer, cx)
.await
.unwrap_or_default();
if let Ok(runnables_task) = project.update(cx, |project, cx| {
let buffer_id = buffer.read(cx).remote_id();
project.request_lsp(
buffer,
LanguageServerToQuery::Other(server_id),
GetLspRunnables {
buffer_id,
position: for_position,
cx.spawn(async move |cx| {
let mut lsp_tasks = HashMap::default();
while let Some(server_to_query) = lsp_task_sources.next().await {
if let Some((server_id, buffers)) = server_to_query {
let mut new_lsp_tasks = Vec::new();
for buffer in buffers {
let source_kind = match buffer.update(cx, |buffer, _| {
buffer.language().map(|language| language.name())
}) {
Ok(Some(language_name)) => TaskSourceKind::Lsp {
server: server_id,
language_name: SharedString::from(language_name),
},
cx,
)
}) {
if let Some(new_runnables) = runnables_task.await.log_err() {
new_lsp_tasks.extend(new_runnables.runnables.into_iter().filter_map(
|(location, runnable)| {
let resolved_task =
runnable.resolve_task(&id_base, &lsp_buffer_context)?;
Some((location, resolved_task))
Ok(None) => continue,
Err(_) => return Vec::new(),
};
let id_base = source_kind.to_id_base();
let lsp_buffer_context = lsp_task_context(&project, &buffer, cx)
.await
.unwrap_or_default();
if let Ok(runnables_task) = project.update(cx, |project, cx| {
let buffer_id = buffer.read(cx).remote_id();
project.request_lsp(
buffer,
LanguageServerToQuery::Other(server_id),
GetLspRunnables {
buffer_id,
position: for_position,
},
));
cx,
)
}) {
if let Some(new_runnables) = runnables_task.await.log_err() {
new_lsp_tasks.extend(
new_runnables.runnables.into_iter().filter_map(
|(location, runnable)| {
let resolved_task = runnable
.resolve_task(&id_base, &lsp_buffer_context)?;
Some((location, resolved_task))
},
),
);
}
}
lsp_tasks
.entry(source_kind)
.or_insert_with(Vec::new)
.append(&mut new_lsp_tasks);
}
}
lsp_tasks.push((source_kind, new_lsp_tasks));
}
}
lsp_tasks
lsp_tasks.into_iter().collect()
})
.race({
// `lsp::LSP_REQUEST_TIMEOUT` is larger than we want for the modal to open fast
let timer = cx.background_executor().timer(Duration::from_millis(200));
async move {
timer.await;
log::info!("Timed out waiting for LSP tasks");
Vec::new()
}
})
.await
})
}

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