Compare commits

..

91 Commits

Author SHA1 Message Date
Nathan Sobo
139c5c59b7 WIP 2024-10-21 17:52:45 -06:00
Nathan Sobo
781fff220c Fix merging of an update of a symbol with an insert_before operation before the same symbol (#19450)
When we insert before some text and then update that same text, we need
to preserve and concatenate the new text associated with both
operations.

Release Notes:

- N/A
2024-10-19 08:36:21 -06:00
Max Brunsfeld
d209eab058 Combine excerpt footer and header into a single block (#19441)
This simplifies rendering of excerpt headers and footers, and removes
the need to store a `BlockDisposition` on these boundary blocks. It's a
step toward implementing "replace blocks", which we want to use in the
assistant panel.

We've also cleaned up the way heights are specified for headers and
footers and fixed some visual asymmetries between the "expand upward"
and "expand downward" buttons.

Release Notes:

- N/A

---------

Co-authored-by: Richard <richard@zed.dev>
2024-10-18 17:58:07 -07:00
Vitaly Slobodin
3e0c5c10b7 lsp: Handle unregistration "textDocument/rename" from a server (#19427)
Hi. While working on https://github.com/zed-industries/zed/pull/19230 I
noticed that some servers send a request to unregistered the
`textDocument/rename` capability. I thought it would be good to handle
that message in Zed:

```plaintext
[2024-10-18T21:25:07+02:00 WARN  project::lsp_store] unhandled capability unregistration: Unregistration { id: "biome_rename", method: "textDocument/rename" }
```

So this pull request implements that. Thanks.

Release Notes:

- N/A
2024-10-19 00:52:17 +02:00
Mikayla Maki
8a912726d7 Fix flakey SSH connection (#19439)
Fixes a bug due to the `select!` macro tossing futures that had
partially read messages, causing us to desync our message reading with
the input stream.

Release Notes:

- N/A

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: conrad <conrad@zed.dev>
2024-10-18 15:41:43 -07:00
Marshall Bowers
30e081b3f7 elixir: Bump to v0.1.1 (#19437)
This PR bumps the Elixir extension to v0.1.1.

Changes:

- https://github.com/zed-industries/zed/pull/19135

Release Notes:

- N/A
2024-10-18 18:31:08 -04:00
Conrad Irwin
a5492b3ea6 Revert "SSH reconnect reliability (#19398)" (#19440)
This reverts commit 98ecb43b2d.

Tests fail on main?!

Closes #ISSUE

Release Notes:

- N/A
2024-10-18 15:08:56 -07:00
Marshall Bowers
47380001cc remote: Fix formatting (#19438)
This PR fixes some formatting issues from #19398 that slipped past CI,
somehow.

Release Notes:

- N/A
2024-10-18 17:31:41 -04:00
Conrad Irwin
98ecb43b2d SSH reconnect reliability (#19398)
Release Notes:

- SSH Remoting: Fix message reliability across restarts

---------

Co-authored-by: Nathan <nathan@zed.dev>
2024-10-18 15:28:08 -06:00
Valentine Briese
be474a6d6f docs: Update info on JDTLS install for Java (#19436) 2024-10-18 17:12:34 -04:00
Marshall Bowers
b44bed0115 collab: Unconditionally execute billing checks (#19432)
This PR removes the conditional checks around the billing-related
enforcement for LLM completions.

These were just in place to prevent executing any billing code before we
had rolled it out. Now that it is rolled out, we don't need this
conditional execution anymore.

Release Notes:

- N/A
2024-10-18 15:55:28 -04:00
Joseph T. Lyons
11a82e3347 Do not run CI on changes to community action config files (#19430)
Release Notes:

- N/A
2024-10-18 15:36:20 -04:00
Joseph T. Lyons
be81e29b0f Prefer users to open new issues if closed stale issue is valid (#19428)
I no longer want to have to keep my ears and eyes open for GitHub
notifications that relate to someone requesting a closed stale issue be
reopened. As a community maintainer, I can get hundreds or even
thousands of notifications a week, and a lot of those are about activity
on closed issues. If everyone following an issue did not react fast
enough (7 days) to keep an issue flagged as `stale` open, let's instruct
them to open new issues, so we are forced to see it during next triage.

Release Notes:

- N/A
2024-10-18 15:04:26 -04:00
Valentine Briese
6a463be1ae docs: Direct Java extension users to JDTLS initialization options (#19401)
Continuation of #19390
2024-10-18 15:02:42 -04:00
Vladimir Varankin
64a6e9cafb docs: Outline Jsonnet language (#19410)
This PR adds a basic documentation about the Jsonnet language support.

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
2024-10-18 14:20:15 -04:00
Marshall Bowers
fa738ee5e1 vue: Extract to zed-extensions/vue repository (#19426)
This PR extracts the Vue extension to the
[zed-extensions/vue](https://github.com/zed-extensions/vue) repository.

Release Notes:

- N/A
2024-10-18 14:08:32 -04:00
Marshall Bowers
15449cdf30 svelte: Extract to zed-extensions/svelte repository (#19425)
This PR extracts the Svelte extension to the
[zed-extensions/svelte](https://github.com/zed-extensions/svelte)
repository.

Release Notes:

- N/A
2024-10-18 13:36:07 -04:00
Shish
2db9090a2f remote: Polish for connection progress & error dialogs (#19379)
Before/after:

![err1-before](https://github.com/user-attachments/assets/43d959b3-c9d9-45dd-938e-42d34ec1cfc5)

![err1-after](https://github.com/user-attachments/assets/311d53e0-752c-4eb8-9816-64b1970c228d)

Before/after (I feel like text-wrapping would be more useful than
text-ellipsis here, but I don't see any wrap function):

![err2-before](https://github.com/user-attachments/assets/1626cda9-bf06-43fe-9b7d-3ec64f4db08a)

![err2-after](https://github.com/user-attachments/assets/749a6950-1409-4e75-808e-a1a96dbfc87e)

Before/after:

![prog-before](https://github.com/user-attachments/assets/f5f5a171-db42-4797-bab0-ad71c750bb20)

![prog-after](https://github.com/user-attachments/assets/b52a7694-36f6-4f7a-8a90-ceb223f12ec1)

Release Notes:

- N/A
2024-10-18 11:09:52 -06:00
Thomas
34b8655bf6 Improve increment/decrement with leading zeros in vim mode (#18362)
- Closes: 18360

Release Notes:

- Added support for incrementing and decrementing numbers with leading
zeros
2024-10-18 11:03:34 -06:00
张小白
5b745a82e1 reqwest_client: Fix socks proxy settings (#19123)
Closes #19362

This pull request includes several updates to the `reqwest_client` crate
and its dependencies. The most important changes involve adding support
for SOCKS proxies, improving error handling for proxy URIs, and adding
tests for proxy functionality.

### Dependency Updates:
*
[`Cargo.toml`](diffhunk://#diff-2e9d962a08321605940b5a657135052fbcef87b5e360662bb527c96d9a615542L394-R401):
Added support for SOCKS proxies in the `reqwest` dependency by including
the `socks` feature.

### Code Improvements:
*
[`crates/reqwest_client/src/reqwest_client.rs`](diffhunk://#diff-8e036b034e987390be2f57373864b75d6983f0cf84e85c43793eb431d13538f3L47-R52):
Improved error handling when parsing proxy URIs by logging errors
instead of directly panicking.

### Testing Enhancements:
*
[`crates/reqwest_client/src/reqwest_client.rs`](diffhunk://#diff-8e036b034e987390be2f57373864b75d6983f0cf84e85c43793eb431d13538f3R274-R317):
Added tests to verify the handling of various proxy URIs, including
valid and invalid cases.

Release Notes:

- N/A
2024-10-18 09:57:00 -07:00
renovate[bot]
c59a75db1d Update actions/upload-artifact digest to b4b15b8 (#19310)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[actions/upload-artifact](https://redirect.github.com/actions/upload-artifact)
| action | digest | `604373d` -> `b4b15b8` |

---

### Configuration

📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone
America/New_York, Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

Release Notes:

- N/A

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOC4xMjAuMSIsInVwZGF0ZWRJblZlciI6IjM4LjEyMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-18 12:41:36 -04:00
David Baldwin
b3c93130ec elixir: Support describe, test, setup, setup_all in outlines (#19135)
Closes #9894 

Release Notes:

- N/A

### Before


![2024-10-12T204848@2x](https://github.com/user-attachments/assets/84b7f123-8845-4e6d-b1b1-444e54ea6599)

### After


![2024-10-12T204749@2x](https://github.com/user-attachments/assets/67fdcead-bad3-4967-9ac4-0b85f1da7bca)
2024-10-18 12:39:24 -04:00
Marshall Bowers
73a6c542f3 vue: Bump to v0.1.1 (#19421)
This PR bumps the Vue extension to v0.1.1.

Changes:

- https://github.com/zed-industries/zed/pull/19419

Release Notes:

- N/A
2024-10-18 11:56:16 -04:00
Marshall Bowers
2cd6c19873 svelte: Bump to v0.2.1 (#19420)
This PR bumps the Svelte extension to v0.2.1.

Changes:

- https://github.com/zed-industries/zed/pull/19418

Release Notes:

- N/A
2024-10-18 11:37:51 -04:00
Marshall Bowers
6f24c1da79 vue: Support lang attribute for style tag injections (#19419)
This PR adds support for injecting languages into `<style>` tags using
`<style lang="...">` in Vue.

Extracted from https://github.com/zed-industries/zed/pull/18052.

Release Notes:

- N/A

Co-authored-by: Albert Marashi <albert@lumina.earth>
2024-10-18 11:36:30 -04:00
Marshall Bowers
5508832ba6 svelte: Adjust block keyword highlighting (#19418)
This PR adjusts the highlights for `{#each ...}` and `{#if ...}`
expression blocks to use keyword highlighting.

Extracted from https://github.com/zed-industries/zed/pull/18052.

Release Notes:

- N/A

Co-authored-by: Albert Marashi <albert@lumina.earth>
2024-10-18 11:25:03 -04:00
Marshall Bowers
35f2f2aac4 Treat .postcss files as CSS (#19416)
This PR makes it so `.postcss` files are recognized as CSS.

The `tree-sitter-css` grammar has basic support for PostCSS:
https://github.com/tree-sitter/tree-sitter-css/issues/17#issuecomment-1830349808.

Closes #18051.

Release Notes:

- `.postcss` files are now recognized as CSS.
2024-10-18 11:11:29 -04:00
Peter Tripp
9e27b6694a keymap: Add cmd-o 'Go to symbol' for JetBrains MacOS key bindings (#19415) 2024-10-18 10:12:39 -04:00
CharlesChen0823
f5124c21d1 scrollbar: Fix horizontal scrollbar display overflow last item (#19403)
currently, the laste item display is overflow by scrollbar, cause it
cannot clickable.
So remain one item height for display horizontal scrollbar,.
![Screenshot 2024-10-18
192352](https://github.com/user-attachments/assets/d686f0e8-93f8-426e-8816-6f00ed17a599)



Release Notes:

- N/A
2024-10-18 14:07:50 +02:00
Thorsten Ball
ea460014ab ssh remoting: Undo the spawning of message handlers (#19409)
I suspect that this might have something to do with saving files not
being possible after a while.

I want to merge this and bump nightly.

Release Notes:

- N/A
2024-10-18 10:48:11 +02:00
Peter Tripp
5168fc27a1 docs: More Java extension documentation (#19390)
Follow up of: https://github.com/zed-industries/zed/pull/19113
2024-10-17 20:21:45 -04:00
Marshall Bowers
2bcf9fc490 Add client::zed_urls module for constructing zed.dev URLs (#19391)
This PR adds a new `zed_urls` module to the `client` crate.

This module contains functions for constructing URLs to Zed properties,
such as zed.dev.

The URLs produced by this module will respect the server URL set via
settings or the `ZED_SERVER_URL` environment variable. This allows them
to correctly reflect the current environment (such as when testing Zed
against a local collab/zed.dev).

Release Notes:

- N/A
2024-10-17 16:18:35 -04:00
Marshall Bowers
3c32f01a8f paths: Update doc comment (#19388)
This PR updates a doc comment to use proper capitalization.

Also removes an unneeded `return`.

Release Notes:

- N/A
2024-10-17 15:36:29 -04:00
Marshall Bowers
9d61cd5120 ci: Check spelling separately from formatting (#19385)
This PR moves the spelling check out of the `check_style` action, which
we can leave for just checking formatting.

We can't use the `crates-ci-typos` action as-is on the macOS runners due
to the absence of `wget`.

Release Notes:

- N/A
2024-10-17 13:47:23 -04:00
Max Brunsfeld
411f64b374 Restructure assistant edits to show all changes in a proposed-change editor (#18240)
This changes the `/workflow` command so that instead of emitting edits
in separate steps, the user is presented with a single tab, with an
editable diff that they can apply to the buffer.

Todo

* Assistant panel
* [x] Show a patch title and a list of changed files in a block
decoration
* [x] Don't store resolved patches as state on Context. Resolve on
demand.
    * [ ] Better presentation of patches in the panel
    * [ ] Show a spinner while patch is streaming in
* Patches
* [x] Preserve leading whitespace in new text, auto-indent insertions
    * [x] Ensure patch title is very short, to fit better in tab
* [x] Improve patch location resolution, prefer skipping whitespace over
skipping `}`
    * [x] Ensure patch edits are auto-indented properly
* [ ] Apply `Update` edits via a diff between the old and new text, to
get fine-grained edits.
* Proposed changes editor
    * [x] Show patch title in the tab
    * [x] Add a toolbar with an "Apply all" button
* [x] Make `open excerpts` open the corresponding location in the base
buffer (https://github.com/zed-industries/zed/pull/18591)
* [x] Add an apply button above every hunk
(https://github.com/zed-industries/zed/pull/18592)
* [x] Expand all diff hunks by default
(https://github.com/zed-industries/zed/pull/18598)
    * [x] Fix https://github.com/zed-industries/zed/issues/18589
* [x] Syntax highlighting doesn't work until the buffer is edited
(https://github.com/zed-industries/zed/pull/18648)
* [x] Disable LSP interaction in Proposed Changes editor
(https://github.com/zed-industries/zed/pull/18945)
* [x] No auto-indent? (https://github.com/zed-industries/zed/pull/18984)
* Prompt
    * [ ] make sure old_text is unique

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
Co-authored-by: Antonio <antonio@zed.dev>
Co-authored-by: Richard <richard@zed.dev>
Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Nate Butler <iamnbutler@gmail.com>
Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
2024-10-17 13:18:13 -04:00
Peter Tripp
4ae2f93086 ci: Improve GitHub Action modularity (#18861)
- Closes: https://github.com/zed-industries/zed/issues/19351
- Switch to using the official [typos GitHub Action](https://github.com/crate-ci/typos/blob/master/docs/github-action.md)
- Move the typos check into `actions/check_style`
- Move Squawk Postgres migration check out of `actions/check_style` file into ci.yml
- `actions/check_style` can now be run on stateless/linux runners (previous required self-hosted MacOS runner)
- ci.yml: Split old `style` into checks into those that can run statelessly (linux) and everything else into a new `migration` group which benefit from the full git checkout available on the MacOS runners.
- ci.yml: Move `Check unused dependencies` from style to `linux_tests`
- Add `if: github.repository_owner == 'zed-industries'` to all jobs so they won't try and run on GitHub forks.
2024-10-17 12:50:19 -04:00
Conrad Irwin
65fb2782eb Always have cmd-o open a local project (#19376)
Release Notes:

- Fixed `cmd-o` in an SSH project to always open a local project
2024-10-17 10:36:53 -06:00
Thorsten Ball
e6b9a8ef9b ssh remoting: Handle OpenNewBuffer request (#19373)
Release Notes:

- N/A
2024-10-17 17:49:17 +02:00
Elliot Thomas
398d0396b6 workspace: Fix inconsistent paths order serialization (#19232)
Release Notes:

- Fixed inconsistent serialization of workspace paths order
2024-10-17 17:38:28 +02:00
Finn Evers
e9e4c770ca Update all occurrences of option_as_meta to new default value (#19369)
This PR is a quick follow-up to #19364 which updates some left-out
occurrences of `option_as_meta` to the new default value (`false`).
2024-10-17 11:21:07 -04:00
Thorsten Ball
4be9da2641 remote ssh: Make "get permalink to line" work (#19366)
This makes the `editor: copy permalink to line` and `editor: copy
permalink to line` actions work in SSH remote projects.

Previously it would only work in local projects.

Demo:


https://github.com/user-attachments/assets/a8012152-b631-4b34-9ff2-e4d033c97dee




Release Notes:

- N/A
2024-10-17 17:07:42 +02:00
Thorsten Ball
c186e99a3d ssh remote: Reset missed heartbeats on connection activity (#19368)
Ran into this this morning. At least I suspect I ran into it. In any
case: we need to reset the missed hearbeats to 0 in case we got any
connection activity.

Release Notes:

- N/A
2024-10-17 17:07:34 +02:00
Peter Tripp
4df882c295 Make terminal.option_as_meta=false in default settings (#19364)
- This reverts the change I made in https://github.com/zed-industries/zed/pull/15535 which set `option_as_meta` to `true` in the default settings.
- `true` is a reasonable default for US Keyboards, but is terrible for many others which rely on `alt+<key>` for totally normal keystroke combinations.
2024-10-17 10:31:35 -04:00
Marshall Bowers
17f2929b4c collab: Anchor new subscription's billing cycle to the first of the month (#19367)
This PR makes it so new subscriptions will have their billing cycle
anchored to the first of the month.

When someone signs up today, they will be billed starting on the first
of next month.

Release Notes:

- N/A

Co-authored-by: Antonio <antontio@zed.dev>
Co-authored-by: Richard <richard@zed.dev>
2024-10-17 10:18:12 -04:00
Peter Tripp
5ad392035e Support uppercase extensions in image preview (#19304) 2024-10-17 08:48:18 -04:00
Antonio Scandurra
8c910540ed Subtract FREE_TIER_MONTHLY_SPENDING_LIMIT from reported monthly spend (#19358)
Release Notes:

- N/A
2024-10-17 13:09:50 +02:00
Antonio Scandurra
455f241c6a Introduce a new /billing/monthly_spend API (#19354)
Fixes https://github.com/zed-industries/zed/issues/19353

Release Notes:

- N/A
2024-10-17 12:34:25 +02:00
Antonio Scandurra
498ecd6404 Fetch more than one page when polling stripe events (#19343)
This fixes a bug that was causing most users to be unable to use the
LLMs via Zed. It was caused by not using pagination and, instead, always
querying the very first page of stripe events.

Note that we're also allowing processing events generated in the last 24
hours (before, this was only 1 hour). I did this so that we can process
the backlog of events that the aforementioned bug was skipping.

Release Notes:

- N/A
2024-10-17 09:47:25 +02:00
Thorsten Ball
3216de7eb5 ssh remoting: Do not print error backtrace on non-zero exit (#19290)
Closes #ISSUE


Release Notes:

- N/A
2024-10-17 09:41:16 +02:00
renovate[bot]
57369b5a54 Update Rust crate tree-sitter-elixir to v0.3.1 (#19335) 2024-10-17 08:44:51 +03:00
Heavysnowjakarta
f9d4272e13 docs: Java extension settings (#19113)
Co-authored-by: Peter Tripp <peter@zed.dev>
2024-10-17 00:04:59 -04:00
Conrad Irwin
378a2cf9d8 Allow passing args to ssh (#19336)
This is useful for passing a custom identity file, jump hosts, etc.

Unlike with the v1 feature, we won't support `gh`/`gcloud` ssh wrappers
(yet?). I think the right way of supporting those would be to let
extensions provide remote projects.

Closes #19118

Release Notes:

- SSH remoting: restored ability to set arguments for SSH
2024-10-16 21:09:31 -06:00
Conrad Irwin
f1d01d59ac Simplify PR template (#19337)
Release Notes:

- N/A
2024-10-16 20:22:08 -06:00
Danilo Leal
78093b8e76 ssh: Clean up title bar indicator icon (#19328)
This PR cleans up the custom icon with indicator implementation in favor
of `IconWithIndicator`, which we already had. It seems like it isn't
super used still, but it's good to try to enforce some consistency
either way. I checked my changes against the REPL stuff (one instance
where its used) and everything's looking good so far. As far as SSH,
nothing has visually changed; we just have less code for this thing now.

<img width="800" alt="Screenshot 2024-10-17 at 2 15 47 AM"
src="https://github.com/user-attachments/assets/5c146757-501e-4242-b145-a576a8f289b5">

---

Release Notes:

- N/A
2024-10-16 22:25:27 -03:00
Danilo Leal
a41e973782 ssh: Remove server count from modal header (#19329)
The server count was something that existed since the remote development
implementation and we just kept it there without a lot of critical
thinking. However, it doesn't feel like it's particularly useful yet,
which means that, at least for now, we could clean it up more and wait
for further feedback to add it back, if ever requested.

Release Notes:

- N/A
2024-10-16 22:25:15 -03:00
Danilo Leal
9a3d8733ce ssh: Use system prompt for the server removal action (#19332)
This PR replaces a toast for the system prompt to confirm the action of
removing a server from the remote list. The alert dialog component is
the right choice here as we want to have a modal action that forces
choice. This should make it easier to convert to a nativa alert dialog
in the future, as well as for other platforms.

<img width="800" alt="Screenshot 2024-10-17 at 3 01 41 AM"
src="https://github.com/user-attachments/assets/7bb1210a-54bf-40da-a85a-f269484825a1">

Release Notes:

- N/A
2024-10-16 22:25:03 -03:00
Conrad Irwin
c888101e4b SSH remoting: Don't panic when opening root, open ~ instead (#19322)
Release Notes:

- Fixed a panic when doing `zed ssh://server/`
2024-10-16 17:17:20 -06:00
Conrad Irwin
0c04fb9862 SSH remoting: better error message for projects (#19320)
Before this, if no project paths were opened you were in a wierd UI
state where
most things didn't work because the project was ssh, but no
files/folders were open.

Release Notes:

- Fixed error handling when no project paths could be opened
2024-10-16 17:16:56 -06:00
Marshall Bowers
f6fad3b09e collab: Remove lifetime spending limit in favor of LLM usage billing (#19321)
This PR removes the lifetime spending limit that was added in #16780.

We had previously added this as a way to prevent runaway usage, but now
that we have a cap on free usage per month with paid access after that,
we don't need this check anymore.

Release Notes:

- N/A
2024-10-16 18:14:07 -04:00
renovate[bot]
6614feff97 Pin astral-sh/setup-uv action to f3bcaeb (#19309)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [astral-sh/setup-uv](https://redirect.github.com/astral-sh/setup-uv) |
action | pinDigest | -> `f3bcaeb` |

---

### Configuration

📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone
America/New_York, Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

Release Notes:

- N/A

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOC4xMjAuMSIsInVwZGF0ZWRJblZlciI6IjM4LjEyMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-16 17:20:39 -04:00
Conrad Irwin
08b1545c85 Show a user-visible error message if saving fails (#19311)
Release Notes:

- Added a user-visible error message when a manual save fails.
2024-10-16 15:17:38 -06:00
Marshall Bowers
fedd177b08 collab: Add context to errors syncing billing events to Stripe (#19315)
This PR adds context to errors that occur when trying to sync billing
events to Stripe.

Release Notes:

- N/A
2024-10-16 17:09:26 -04:00
renovate[bot]
4288096ca1 Update Rust crate tree-sitter-cpp to v0.23.1 (#18974)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[tree-sitter-cpp](https://redirect.github.com/tree-sitter/tree-sitter-cpp)
| workspace.dependencies | patch | `0.23.0` -> `0.23.1` |

---

### Release Notes

<details>
<summary>tree-sitter/tree-sitter-cpp (tree-sitter-cpp)</summary>

###
[`v0.23.1`](https://redirect.github.com/tree-sitter/tree-sitter-cpp/compare/v0.23.0...v0.23.1)

[Compare
Source](https://redirect.github.com/tree-sitter/tree-sitter-cpp/compare/v0.23.0...v0.23.1)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone
America/New_York, Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

Release Notes:

- N/A

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOC4xMTQuMCIsInVwZGF0ZWRJblZlciI6IjM4LjExNC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-16 23:21:23 +03:00
renovate[bot]
256c31a5d9 Update Rust crate tree-sitter-c to v0.23.1 (#18958)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [tree-sitter-c](https://redirect.github.com/tree-sitter/tree-sitter-c)
| workspace.dependencies | patch | `0.23.0` -> `0.23.1` |

---

### Release Notes

<details>
<summary>tree-sitter/tree-sitter-c (tree-sitter-c)</summary>

###
[`v0.23.1`](https://redirect.github.com/tree-sitter/tree-sitter-c/compare/v0.23.0...v0.23.1)

[Compare
Source](https://redirect.github.com/tree-sitter/tree-sitter-c/compare/v0.23.0...v0.23.1)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone
America/New_York, Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

Release Notes:

- N/A

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOC4xMTQuMCIsInVwZGF0ZWRJblZlciI6IjM4LjExNC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-16 23:19:10 +03:00
David Soria Parra
c8b6ad9666 Context Servers: Protocol fixes and UI improvements (#19087)
This PR does two things. It fixes some minor inconsistencies in the
protocol. This is mostly about handling JSON RPC notifications correctly
and skipping fields when set to None.

Second part is about improving the rendering of context server commands,
by passing on the description
of the command to the slash command UI and showing the name of the
argument as a CodeLabel.

Release Notes:

- N/A
2024-10-16 13:07:15 -07:00
Peter Tripp
0e22c9f275 docs: Add C++ clangd example arguments (#19308) 2024-10-16 16:07:05 -04:00
Kirill Bulatov
56f69be2e7 Do not allow [re]running ssh tasks when not connected to the server (#19306)
Release Notes:

- N/A
2024-10-16 22:57:39 +03:00
Kirill Bulatov
02f63e49ed Resolve proto hints with empty resolve data (#19274)
Fixed ssh remoting not showing a lot of hints


Release Notes:

- N/A
2024-10-16 21:50:51 +03:00
Kirill Bulatov
3dcc638537 Better handle shell for remote ssh projects (#19297)
Release Notes:

- N/A

Co-authored-by: Conrad Irwin <conrad@zed.dev>
2024-10-16 21:49:54 +03:00
Marshall Bowers
d35b646dbb assistant: Direct user to account page to subscribe for more LLM usage (#19300)
This PR updates the location where we send the user to subscribe for
more LLM usage to the account page.

Release Notes:

- Updated the URL to the account page when subscribing to LLM usage.
2024-10-16 14:03:42 -04:00
张小白
338bf3fd28 windows: Fix window not displaying correctly on launch (#19124)
Closes #18705 (comment)

This PR fixes the issue where the Zed window was not displaying
correctly on launch. Now, when Zed is closed in a maximized state, it
will reopen in a maximized state.

On macOS, when a window is created but not yet visible, calling `zoom`
or `toggle_fullscreen` will still affect the hidden window. However,
this behavior is different on Windows, so special handling is required.

Also, since #18705 hasn't been reviewed yet, I'm not sure if this PR
should be merged now or if it should wait until #18705 is reviewed
first.


Release Notes:

- N/A
2024-10-16 10:29:42 -07:00
Matin Aniss
879a2ea06f gpui: Replace redundant code in animation (#19273)
Just a small change to replace some redundant code in the animation
element.

Release Notes:

- N/A
2024-10-16 10:26:26 -07:00
Piotr Osiewicz
7a5003bea2 ssh: Do not look up dev servers when rendering the default mode (#19295)
This should help with the bug where there's a mismatch between
connection count and the list showing empty state.

Closes #ISSUE

Release Notes:

- N/A
2024-10-16 18:53:05 +02:00
Joseph T. Lyons
f8f3f369f6 v0.159.x dev 2024-10-16 12:47:57 -04:00
Antonio Scandurra
474e670bbd Increase monthly free tier spend from 5 dollars to 10 dollars (#19291)
Release Notes:

- N/A

Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Richard <richard@zed.dev>
2024-10-16 12:22:24 -04:00
Marshall Bowers
fe0bcc063c collab: Add Stripe API key to Kubernetes template (#19292)
This PR adds the Stripe API key to the Kubernetes template.

It's optional right now, so we can set the API key when we're ready.

Release Notes:

- N/A
2024-10-16 12:10:39 -04:00
Thorsten Ball
69abe71bf7 ssh remoting: Treat closed stderr as error (#19289)
Before this change we had a race condition bug: if stderr was closed
before the other two sockets, we wouldn't properly detect when the
server died, and not report or retry anything.

That's because we treated a closed stderr as a non-error.

Technically, it isn't an error (closing a connection is okay!), but
until we have a proper shutdown ceremony between all three processes, we
can treat it as an error, because that lets us to detect when the server
is gone.

On the client-side, we also always react to these errors by
reconnecting. Except when we shutdown: there we do a proper shutdown and
won't error on the proxy exit code.

So, this works, even if I wish there was a better way for the server to
communicate to the proxy that it shutdown properly. But I don't want a
fourth socket.

Release Notes:

- N/A
2024-10-16 18:05:52 +02:00
Marshall Bowers
9c3d80d6e8 collab: Fetch more meters and prices when initializing StripeBilling (#19288)
This PR makes it so we fetch more meters and prices when initializing
`StripeBilling`, as we have more than 10 meters defined.

Release Notes:

- N/A
2024-10-16 11:40:56 -04:00
Kirill Bulatov
834d50f0db Properly open worktrees when cmd-clicking in terminal or on inlay hints (#19280)
* uses the state that's synced, to fetch the language server name
* uses proper, canonicalized path when creating a remote ssh worktree,
otherwise `~/foo/something` stays unexpanded

Release Notes:

- N/A
2024-10-16 18:12:36 +03:00
Kirill Bulatov
bcdb10b3cb Do not attempt to install prettier if the language change is unrelated (#19283)
Release Notes:

- Fix prettier install being attempted too much
2024-10-16 18:10:05 +03:00
Marshall Bowers
598939d186 collab: Refresh the user's LLM token when their subscription changes (#19281)
This PR makes it so collab will trigger a refresh for a user's LLM token
whenever their subscription changes.

This allows us to proactively push down changes to their subscription.

In order to facilitate this, the Stripe event processing has been moved
from the `api` service to the `collab` service in order to access the
RPC server.

Release Notes:

- N/A
2024-10-16 10:58:28 -04:00
Thorsten Ball
9d944d0662 ssh remote: Restore ControlPersist=no (#19277)
This restores the change from #19193 that I erroneously reverted in
#19234.

I think the bug in #19275 got in my way when testing.

With that bug fixed, the changes in here also work fine.


Release Notes:

- N/A
2024-10-16 16:13:31 +02:00
Tilman Roeder
7d2628e805 Make the divider rule color more muted (#19255)
I've been a bit annoyed by the hover divider rule being extremely bright
compared to other divider rules in the UI. This PR updates their color
to use the regular border color from the current theme instead of the
muted (but still pretty bright) text color.

Apologies for the unsolicited PR (and please feel free to close if it
goes against some other plans / designs you already have in place :).

#### Example screenshot before:
<img width="302" alt="Screenshot 2024-10-15 at 23 29 18"
src="https://github.com/user-attachments/assets/7ea22808-8135-4a46-9457-e670225aebaa">

#### Example screenshot after:
<img width="312" alt="Screenshot 2024-10-15 at 23 28 16"
src="https://github.com/user-attachments/assets/63ac0d02-ae6d-4962-84a2-1fdb95519b15">

***

Release Notes:

- Make the divider rule in LSP hovers more muted
2024-10-16 11:00:22 -03:00
Ihnat Aŭtuška
84df3a0cad Allow formatting selections via LSP (#18752)
Release Notes:

- Added a new `editor: format selections` action that allows formatting
only the currently selected text via the primary language server.

---------

Co-authored-by: Thorsten Ball <mrnugget@gmail.com>
2024-10-16 15:58:37 +02:00
Thorsten Ball
eb76065ad3 ssh remoting: Fix hang when activity channel gets dropped (#19275)
When the SSH command dies or the server, the channel gets dropped and
the heartbeat method went into an infinite loop causing a hang.

Oversight from yesterday. Fixed now.

Release Notes:

- N/A
2024-10-16 15:57:58 +02:00
Peter Tripp
84018d7a2d zig: Bump to v0.3.1 (#19252)
Includes:
- https://github.com/zed-industries/zed/pull/18323
- https://github.com/zed-industries/zed/pull/17488
2024-10-16 08:42:45 -04:00
Peter Tripp
57c55b32e1 html: Bump to v0.1.3 (#19251)
Includes:
- https://github.com/zed-industries/zed/pull/18024
2024-10-16 08:42:27 -04:00
Peter Tripp
a4357c429a elixir: Bump to v0.1.0 (#19250)
Includes:
- https://github.com/zed-industries/zed/pull/18024
- https://github.com/zed-industries/zed/pull/17488
- https://github.com/zed-industries/zed/pull/16985
2024-10-16 08:42:07 -04:00
Peter Tripp
103665ee28 astro: Bump to v0.1.1 (#19249)
Includes:
- https://github.com/zed-industries/zed/pull/18024
2024-10-16 08:41:45 -04:00
Thorsten Ball
2f960c4aba project environment: Log when which env is used (#19270)
This adds more logging for debugging purposes.

Release Notes:

- N/A
2024-10-16 14:12:45 +02:00
Piotr Osiewicz
109ebc5f27 ui: Add Scrollbar component (#18927)
Closes #ISSUE

Release Notes:

- N/A
2024-10-16 13:57:28 +02:00
154 changed files with 5055 additions and 5813 deletions

View File

@@ -7,9 +7,3 @@ runs:
- name: cargo fmt
shell: bash -euxo pipefail {0}
run: cargo fmt --all -- --check
- name: Find modified migrations
shell: bash -euxo pipefail {0}
run: |
export SQUAWK_GITHUB_TOKEN=${{ github.token }}
. ./script/squawk

View File

@@ -2,14 +2,4 @@ Closes #ISSUE
Release Notes:
- Added/Fixed/Improved ...
Optionally, include screenshots / media showcasing your addition that can be included in the release notes.
### Or...
Closes #ISSUE
Release Notes:
- N/A
- N/A *or* Added/Fixed/Improved ...

View File

@@ -14,6 +14,7 @@ on:
- "**"
paths-ignore:
- "docs/**"
- ".github/workflows/community_*"
concurrency:
# Allow only one workflow per any non-`main` branch.
@@ -26,9 +27,10 @@ env:
RUST_BACKTRACE: 1
jobs:
style:
migration_checks:
name: Check Postgres and Protobuf migrations, mergability
if: github.repository_owner == 'zed-industries'
timeout-minutes: 60
name: Check formatting and spelling
runs-on:
- self-hosted
- test
@@ -37,25 +39,16 @@ jobs:
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
with:
clean: false
fetch-depth: 0
fetch-depth: 0 # fetch full history
- name: Remove untracked files
run: git clean -df
- name: Check spelling
run: script/check-spelling
- name: Run style checks
uses: ./.github/actions/check_style
- name: Check unused dependencies
uses: bnjbvr/cargo-machete@main
- name: Check licenses are present
run: script/check-licenses
- name: Check license generation
run: script/generate-licenses /tmp/zed_licenses_output
- name: Find modified migrations
shell: bash -euxo pipefail {0}
run: |
export SQUAWK_GITHUB_TOKEN=${{ github.token }}
. ./script/squawk
- name: Ensure fresh merge
shell: bash -euxo pipefail {0}
@@ -77,6 +70,24 @@ jobs:
input: "crates/proto/proto/"
against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/proto/proto/"
style:
timeout-minutes: 60
name: Check formatting and spelling
if: github.repository_owner == 'zed-industries'
runs-on:
- buildjet-8vcpu-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
- name: Run style checks
uses: ./.github/actions/check_style
- name: Check for typos
uses: crate-ci/typos@v1.24.6
with:
config: ./typos.toml
macos_tests:
timeout-minutes: 60
name: (macOS) Run Clippy and tests
@@ -92,6 +103,14 @@ jobs:
- name: cargo clippy
run: ./script/clippy
- name: Check unused dependencies
uses: bnjbvr/cargo-machete@main
- name: Check licenses
run: |
script/check-licenses
script/generate-licenses /tmp/zed_licenses_output
- name: Run tests
uses: ./.github/actions/run_tests
@@ -248,20 +267,20 @@ jobs:
mv target/x86_64-apple-darwin/release/Zed.dmg target/x86_64-apple-darwin/release/Zed-x86_64.dmg
- name: Upload app bundle (universal) to workflow run if main branch or specific label
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
with:
name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg
path: target/release/Zed.dmg
- name: Upload app bundle (aarch64) to workflow run if main branch or specific label
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
with:
name: Zed_${{ github.event.pull_request.head.sha || github.sha }}-aarch64.dmg
path: target/aarch64-apple-darwin/release/Zed-aarch64.dmg
- name: Upload app bundle (x86_64) to workflow run if main branch or specific label
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
with:
name: Zed_${{ github.event.pull_request.head.sha || github.sha }}-x86_64.dmg
@@ -312,7 +331,7 @@ jobs:
run: script/bundle-linux
- name: Upload Linux bundle to workflow run if main branch or specific label
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
with:
name: zed-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.tar.gz
@@ -359,7 +378,7 @@ jobs:
run: script/bundle-linux
- name: Upload Linux bundle to workflow run if main branch or specific label
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
with:
name: zed-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.tar.gz

View File

@@ -17,7 +17,7 @@ jobs:
We're working to clean up our issue tracker by closing older issues that might not be relevant anymore. Are you able to reproduce this issue in the latest version of Zed? If so, please let us know by commenting on this issue and we will keep it open; otherwise, we'll close it in 7 days. Feel free to open a new issue if you're seeing this message after the issue has been closed.
Thanks for your help!
close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, feel free to ping a Zed team member to reopen this issue or open a new one."
close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please open a new issue with a link to this issue."
# We will increase `days-before-stale` to 365 on or after Jan 24th,
# 2024. This date marks one year since migrating issues from
# 'community' to 'zed' repository. The migration added activity to all

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
- name: Set up uv
uses: astral-sh/setup-uv@v3
uses: astral-sh/setup-uv@f3bcaebff5eace81a1c062af9f9011aae482ca9d # v3
with:
version: "latest"
enable-cache: true

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
- name: Set up uv
uses: astral-sh/setup-uv@v3
uses: astral-sh/setup-uv@f3bcaebff5eace81a1c062af9f9011aae482ca9d # v3
with:
version: "latest"
enable-cache: true

View File

@@ -8,6 +8,7 @@ on:
jobs:
deploy-docs:
name: Deploy Docs
if: github.repository_owner == 'zed-industries'
runs-on: ubuntu-latest
steps:

View File

@@ -11,6 +11,7 @@ on:
jobs:
check_formatting:
name: "Check formatting"
if: github.repository_owner == 'zed-industries'
runs-on: ubuntu-latest
steps:
@@ -29,5 +30,8 @@ jobs:
false
}
- name: Check spelling
run: script/check-spelling docs/
- name: Check for Typos with Typos-CLI
uses: crate-ci/typos@v1.24.6
with:
config: ./typos.toml
files: ./docs/

View File

@@ -1,50 +0,0 @@
name: Mark for Nightly
on:
issue_comment:
types: [created]
jobs:
mark-for-nightly:
if: github.event.issue.pull_request && contains(github.event.comment.body, '/nightly')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get PR details and check if open
id: pr_details
uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
if (pr.data.state !== 'open') {
console.log('PR is not open. Skipping.');
return null;
}
return {
sha: pr.data.head.sha,
ref: pr.data.head.ref
}
- name: Edit comment with SHA
if: steps.pr_details.outputs.result != 'null'
uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const sha = '${{ fromJson(steps.pr_details.outputs.result).sha }}';
const originalBody = context.payload.comment.body;
const updatedBody = originalBody.replace('/nightly', `/nightly:${sha}`);
const finalBody = `${updatedBody}\n\nThis PR's current HEAD (${sha}) will be included in the next nightly build. To remove it from the nightly build, comment with \`/nightly:remove\`.`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
body: finalBody
});

View File

@@ -14,63 +14,6 @@ env:
RUST_BACKTRACE: 1
jobs:
prepare-nightly-train:
name: Prepare nightly-train branch
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
with:
fetch-depth: 0
- name: Create or update nightly-train branch
run: |
git config user.name github-actions
git config user.email github-actions@github.com
git checkout -B nightly-train main
# Find all comments with /nightly:SHA
nightly_comments=$(gh api graphql -f query='
query($owner:String!, $repo:String!) {
repository(owner:$owner, name:$repo) {
pullRequests(last:128, states:OPEN) {
nodes {
number
comments(last:128) {
nodes {
body
}
}
}
}
}
}' -f owner=${{ github.repository_owner }} -f repo=${{ github.event.repository.name }} | jq -r '.data.repository.pullRequests.nodes[] | select(.comments.nodes[].body | contains("/nightly:")) | {number: .number, comments: [.comments.nodes[].body | select(contains("/nightly:"))]} | @json')
# Process each PR
echo "$nightly_comments" | jq -c '.' | while read -r pr; do
pr_number=$(echo "$pr" | jq -r '.number')
last_command=$(echo "$pr" | jq -r '.comments[-1]')
sha=$(echo "$last_command" | grep -oP '/nightly:\K[a-f0-9]+')
if [ -n "$sha" ]; then
if git cherry-pick $sha; then
if cargo check --workspace; then
echo "Successfully cherry-picked and checked $sha from PR #$pr_number"
else
git reset --hard HEAD~1
echo "::warning::Commit $sha from PR #$pr_number failed cargo check, excluding from nightly-train"
fi
else
echo "::warning::Failed to cherry-pick $sha from PR #$pr_number, skipping"
git cherry-pick --abort
fi
elif [[ $last_command == *"/nightly:remove"* ]]; then
echo "Skipping PR #$pr_number due to /nightly:remove command"
fi
done
git push origin nightly-train --force
style:
timeout-minutes: 60
name: Check formatting and Clippy lints
@@ -78,14 +21,12 @@ jobs:
runs-on:
- self-hosted
- test
needs: prepare-nightly-train
steps:
- name: Checkout repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
with:
clean: false
fetch-depth: 0
ref: nightly-train
- name: Run style checks
uses: ./.github/actions/check_style
@@ -106,7 +47,6 @@ jobs:
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
with:
clean: false
ref: nightly-train
- name: Run tests
uses: ./.github/actions/run_tests
@@ -138,7 +78,6 @@ jobs:
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
with:
clean: false
ref: nightly-train
- name: Set release channel to nightly
run: |
@@ -173,7 +112,6 @@ jobs:
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
with:
clean: false
ref: nightly-train
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
@@ -214,7 +152,6 @@ jobs:
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
with:
clean: false
ref: nightly-train
- name: Install Linux dependencies
run: ./script/linux
@@ -248,7 +185,6 @@ jobs:
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
with:
fetch-depth: 0
ref: nightly-train
- name: Update nightly tag
run: |

45
Cargo.lock generated
View File

@@ -412,6 +412,7 @@ dependencies = [
"parking_lot",
"paths",
"picker",
"pretty_assertions",
"project",
"proto",
"rand 0.8.5",
@@ -8472,6 +8473,7 @@ dependencies = [
"terminal",
"text",
"unindent",
"url",
"util",
"which 6.0.3",
"windows 0.58.0",
@@ -8973,6 +8975,7 @@ dependencies = [
"futures 0.3.30",
"fuzzy",
"gpui",
"itertools 0.13.0",
"language",
"log",
"menu",
@@ -9126,6 +9129,7 @@ dependencies = [
"rpc",
"serde",
"serde_json",
"shlex",
"smol",
"tempfile",
"thiserror",
@@ -9146,6 +9150,8 @@ dependencies = [
"env_logger",
"fs",
"futures 0.3.30",
"git",
"git_hosting_providers",
"gpui",
"http_client",
"language",
@@ -9297,6 +9303,7 @@ dependencies = [
"system-configuration 0.6.1",
"tokio",
"tokio-rustls 0.26.0",
"tokio-socks",
"tokio-util",
"tower-service",
"url",
@@ -11995,6 +12002,7 @@ dependencies = [
"futures-io",
"futures-util",
"thiserror",
"tokio",
]
[[package]]
@@ -12285,9 +12293,9 @@ dependencies = [
[[package]]
name = "tree-sitter-c"
version = "0.23.0"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e795ad541f7ae6a80d22975296340a75a12a29afd3a7089f4368021613728e17"
checksum = "c8b3fb515e498e258799a31d78e6603767cd6892770d9e2290ec00af5c3ad80b"
dependencies = [
"cc",
"tree-sitter-language",
@@ -12295,9 +12303,9 @@ dependencies = [
[[package]]
name = "tree-sitter-cpp"
version = "0.23.0"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0a588a816017469b69f2e3544742e34a5a59dddfb4b9457b657a6052e2ea39c"
checksum = "1d67e862242878d6ee50e1e5814f267ee3eea0168aea2cdbd700ccfb4c74b6d3"
dependencies = [
"cc",
"tree-sitter-language",
@@ -12325,9 +12333,9 @@ dependencies = [
[[package]]
name = "tree-sitter-elixir"
version = "0.3.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6174acad8a059851f6f768d7893f4b25eedc80eb6643283d545dd71bbb38222a"
checksum = "97bf0efa4be41120018f23305b105ad4dfd3be1b7f302dc4071d0e6c2dec3a32"
dependencies = [
"cc",
"tree-sitter-language",
@@ -14577,7 +14585,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.158.0"
version = "0.159.0"
dependencies = [
"activity_indicator",
"anyhow",
@@ -14698,7 +14706,7 @@ dependencies = [
[[package]]
name = "zed_astro"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"serde",
"zed_extension_api 0.1.0",
@@ -14734,7 +14742,7 @@ dependencies = [
[[package]]
name = "zed_elixir"
version = "0.0.9"
version = "0.1.1"
dependencies = [
"zed_extension_api 0.1.0",
]
@@ -14796,7 +14804,7 @@ dependencies = [
[[package]]
name = "zed_html"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"zed_extension_api 0.1.0",
]
@@ -14858,13 +14866,6 @@ dependencies = [
"zed_extension_api 0.1.0",
]
[[package]]
name = "zed_svelte"
version = "0.2.0"
dependencies = [
"zed_extension_api 0.1.0",
]
[[package]]
name = "zed_terraform"
version = "0.1.1"
@@ -14893,17 +14894,9 @@ dependencies = [
"zed_extension_api 0.1.0",
]
[[package]]
name = "zed_vue"
version = "0.1.0"
dependencies = [
"serde",
"zed_extension_api 0.1.0",
]
[[package]]
name = "zed_zig"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"zed_extension_api 0.1.0",
]

View File

@@ -158,12 +158,10 @@ members = [
"extensions/ruff",
"extensions/slash-commands-example",
"extensions/snippets",
"extensions/svelte",
"extensions/terraform",
"extensions/test-extension",
"extensions/toml",
"extensions/uiua",
"extensions/vue",
"extensions/zig",
#
@@ -391,7 +389,14 @@ pulldown-cmark = { version = "0.12.0", default-features = false }
rand = "0.8.5"
regex = "1.5"
repair_json = "0.1.0"
reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "fd110f6998da16bbca97b6dddda9be7827c50e29", default-features = false, features = ["charset", "http2", "macos-system-configuration", "rustls-tls-native-roots", "stream"]}
reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "fd110f6998da16bbca97b6dddda9be7827c50e29", default-features = false, features = [
"charset",
"http2",
"macos-system-configuration",
"rustls-tls-native-roots",
"socks",
"stream",
] }
rsa = "0.9.6"
runtimelib = { version = "0.15", default-features = false, features = [
"async-dispatcher-runtime",

1
assets/icons/diff.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-diff"><path d="M12 3v14"/><path d="M5 10h14"/><path d="M5 21h14"/></svg>

After

Width:  |  Height:  |  Size: 275 B

View File

@@ -128,6 +128,7 @@
"php": "php",
"plist": "template",
"png": "image",
"postcss": "css",
"ppt": "document",
"pptx": "document",
"prettierignore": "prettier",

View File

@@ -34,7 +34,7 @@
"cmd-]": "pane::GoForward",
"alt-f7": "editor::FindAllReferences",
"cmd-alt-f7": "editor::FindAllReferences",
"cmd-b": "editor::GoToDefinition",
"cmd-b": "editor::GoToDefinition", // Conflicts with workspace::ToggleLeftDock
"cmd-alt-b": "editor::GoToDefinitionSplit",
"cmd-shift-b": "editor::GoToTypeDefinition",
"cmd-alt-shift-b": "editor::GoToTypeDefinitionSplit",
@@ -64,7 +64,8 @@
"cmd-shift-o": "file_finder::Toggle",
"cmd-shift-a": "command_palette::Toggle",
"shift shift": "command_palette::Toggle",
"cmd-alt-o": "project_symbols::Toggle",
"cmd-alt-o": "project_symbols::Toggle", // JetBrains: Go to Symbol
"cmd-o": "project_symbols::Toggle", // JetBrains: Go to Class
"cmd-1": "workspace::ToggleLeftDock",
"cmd-6": "diagnostics::Deploy"
}

View File

@@ -1,85 +1,33 @@
<task_description>
# Code Change Workflow
The user of a code editor wants to make a change to their codebase.
You must describe the change using the following XML structure:
Your task is to guide the user through code changes using a series of steps. Each step should describe a high-level change, which can consist of multiple edits to distinct locations in the codebase.
## Output Example
Provide output as XML, with the following format:
<step>
Update the Person struct to store an age
```rust
struct Person {
// existing fields...
age: u8,
height: f32,
// existing fields...
}
impl Person {
fn age(&self) -> u8 {
self.age
}
}
```
<edit>
<path>src/person.rs</path>
<operation>insert_before</operation>
<search>height: f32,</search>
<description>Add the age field</description>
</edit>
<edit>
<path>src/person.rs</path>
<operation>insert_after</operation>
<search>impl Person {</search>
<description>Add the age getter</description>
</edit>
</step>
## Output Format
First, each `<step>` must contain a written description of the change that should be made. The description should begin with a high-level overview, and can contain markdown code blocks as well. The description should be self-contained and actionable.
After the description, each `<step>` must contain one or more `<edit>` tags, each of which refer to a specific range in a source file. Each `<edit>` tag must contain the following child tags:
### `<path>` (required)
This tag contains the path to the file that will be changed. It can be an existing path, or a path that should be created.
### `<search>` (optional)
This tag contains a search string to locate in the source file, e.g. `pub fn baz() {`. If not provided, the new content will be inserted at the top of the file. Make sure to produce a string that exists in the source file and that isn't ambiguous. When there's ambiguity, add more lines to the search to eliminate it.
### `<description>` (required)
This tag contains a single-line description of the edit that should be made at the given location.
### `<operation>` (required)
This tag indicates what type of change should be made, relative to the given location. It can be one of the following:
- `update`: Rewrites the specified string entirely based on the given description.
- `create`: Creates a new file with the given path based on the provided description.
- `insert_before`: Inserts new text based on the given description before the specified search string.
- `insert_after`: Inserts new text based on the given description after the specified search string.
- `delete`: Deletes the specified string from the containing file.
- <patch> - A group of related code changes.
Child tags:
- <title> (required) - A high-level description of the changes. This should be as short
as possible, possibly using common abbreviations.
- <edit> (1 or more) - An edit to make at a particular range within a file.
Includes the following child tags:
- <path> (required) - The path to the file that will be changed.
- <description> (optional) - An arbitrarily-long comment that describes the purpose
of this edit.
- <old_text> (optional) - An excerpt from the file's current contents that uniquely
identifies a range within the file where the edit should occur. If this tag is not
specified, then the entire file will be used as the range.
- <new_text> (required) - The new text to insert into the file.
- <operation> (required) - The type of change that should occur at the given range
of the file. Must be one of the following values:
- `update`: Replaces the entire range with the new text.
- `insert_before`: Inserts the new text before the range.
- `insert_after`: Inserts new text after the range.
- `create`: Creates a new file with the given path and the new text.
- `delete`: Deletes the specified range from the file.
<guidelines>
- There's no need to describe *what* to do, just *where* to do it.
- Only reference locations that actually exist (unless you're creating a file).
- If creating a file, assume any subsequent updates are included at the time of creation.
- Don't create and then update a file. Always create new files in one hot.
- Prefer multiple edits to smaller regions, as opposed to one big edit to a larger region.
- Don't produce edits that intersect each other. In that case, merge them into a bigger edit.
- Never nest an edit with another edit. Never include CDATA. All edits are leaf nodes.
- Descriptions are required for all edits except delete.
- When generating multiple edits, ensure the descriptions are specific to each individual operation.
- Avoid referring to the search string in the description. Focus on the change to be made, not the location where it's made. That's implicit with the `search` string you provide.
- Don't generate multiple edits at the same location. Instead, combine them together in a single edit with a succinct combined description.
- Never provide multiple edits whose ranges intersect each other. Instead, merge them into one edit.
- Prefer multiple edits to smaller, disjoint ranges, rather than one edit to a larger range.
- There's no need to escape angle brackets within XML tags.
- Always ensure imports are added if you're referencing symbols that are not in scope.
</guidelines>
@@ -124,189 +72,137 @@ Update all shapes to store their origin as an (x, y) tuple and implement Display
<message role="assistant">
We'll need to update both the rectangle and circle modules.
<step>
Add origin fields to both shape types.
```rust
struct Rectangle {
// existing fields ...
origin: (f64, f64),
}
```
```rust
struct Circle {
// existing fields ...
origin: (f64, f64),
}
```
<patch>
<title>Add origins and display impls to shapes</title>
<edit>
<path>src/shapes/rectangle.rs</path>
<description>Add the origin field to Rectangle struct</description>
<operation>insert_after</operation>
<old_text>
pub struct Rectangle {
</old_text>
<new_text>
origin: (f64, f64),
</new_text>
</edit>
<edit>
<path>src/shapes/rectangle.rs</path>
<operation>insert_before</operation>
<search>
width: f64,
height: f64,
</search>
<description>Add the origin field to Rectangle</description>
<description>Update the Rectangle's new function to take an origin parameter</description>
<operation>update</operation>
<old_text>
fn new(width: f64, height: f64) -> Self {
Rectangle { width, height }
}
</old_text>
<new_text>
fn new(origin: (f64, f64), width: f64, height: f64) -> Self {
Rectangle { origin, width, height }
}
</new_text>
</edit>
<edit>
<path>src/shapes/circle.rs</path>
<operation>insert_before</operation>
<search>
<description>Add the origin field to Circle struct</description>
<operation>insert_after</operation>
<old_text>
pub struct Circle {
radius: f64,
</search>
<description>Add the origin field to Circle</description>
</edit>
<step>
Update both shape's constructors to take an origin.
<edit>
<path>src/shapes/rectangle.rs</path>
<operation>update</operation>
<search>
fn new(width: f64, height: f64) -> Self {
Rectangle { width, height }
}
</search>
<description>Update the Rectangle new function to take an origin</description>
</old_text>
<new_text>
origin: (f64, f64),
</new_text>
</edit>
<edit>
<path>src/shapes/circle.rs</path>
<description>Update the Circle's new function to take an origin parameter</description>
<operation>update</operation>
<search>
fn new(radius: f64) -> Self {
Circle { radius }
}
</search>
<description>Update the Circle new function to take an origin</description>
<old_text>
fn new(radius: f64) -> Self {
Circle { radius }
}
</old_text>
<new_text>
fn new(origin: (f64, f64), radius: f64) -> Self {
Circle { origin, radius }
}
</new_text>
</edit>
</step>
<step>
Implement Display for both shapes
<edit>
<path>src/shapes/rectangle.rs</path>
<description>Add an import for the std::fmt module</description>
<operation>insert_before</operation>
<search>
<old_text>
struct Rectangle {
</search>
<description>Add an import for the `std::fmt` module</description>
</old_text>
<new_text>
use std::fmt;
</new_text>
</edit>
<edit>
<path>src/shapes/rectangle.rs</path>
<description>Add a Display implementation for Rectangle</description>
<operation>insert_after</operation>
<search>
<old_text>
Rectangle { width, height }
}
}
</search>
<description>Add a Display implementation for Rectangle</description>
</old_text>
<new_text>
impl fmt::Display for Rectangle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.format_struct(f, "Rectangle")
.field("origin", &self.origin)
.field("width", &self.width)
.field("height", &self.height)
.finish()
}
}
</new_text>
</edit>
<edit>
<path>src/shapes/circle.rs</path>
<operation>insert_before</operation>
<search>
struct Circle {
</search>
<description>Add an import for the `std::fmt` module</description>
<operation>insert_before</operation>
<old_text>
struct Circle {
</old_text>
<new_text>
use std::fmt;
</new_text>
</edit>
<edit>
<path>src/shapes/circle.rs</path>
<description>Add a Display implementation for Circle</description>
<operation>insert_after</operation>
<search>
<old_text>
Circle { radius }
}
}
</search>
<description>Add a Display implementation for Circle</description>
</old_text>
<new_text>
impl fmt::Display for Rectangle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.format_struct(f, "Rectangle")
.field("origin", &self.origin)
.field("width", &self.width)
.field("height", &self.height)
.finish()
}
}
</new_text>
</edit>
</step>
</patch>
</message>
</example>
<example number=2>
<message role="user">
```rs src/user.rs
struct User {
pub name: String,
age: u32,
email: String,
}
impl User {
fn new(name: String, age: u32, email: String) -> Self {
User { name, age, email }
}
pub fn print_info(&self) {
todo!()
}
}
```
Let's print all the user information and delete the email field.
</message>
<message role="assistant">
<step>
Update the 'print_info' method to print user information
```rust
impl User {
// ... other methods ...
pub fn print_info(&self) {
println!("Name: {name}, Age: {age}", name = self.name, age = self.age);
}
}
```
<edit>
<path>src/user.rs</path>
<operation>update</operation>
<search>
pub fn print_info(&self) {
todo!()
}
</search>
<description>Print all the user information</description>
</edit>
</step>
<step>
Remove the 'email' field from the User struct
<edit>
<path>src/user.rs</path>
<operation>delete</operation>
<search>
email: String,
</search>
</edit>
<edit>
<path>src/user.rs</path>
<operation>update</operation>
<symbol>
fn new(name: String, age: u32, email: String) -> Self {
User { name, age, email }
}
</symbol>
<description>Remove email parameter from new method</description>
</edit>
</step>
</message>
</example>
You should think step by step. When possible, produce smaller, coherent logical steps as opposed to one big step that combines lots of heterogeneous edits.
</task_description>

View File

@@ -1,496 +0,0 @@
<overview>
Your task is to map a step from a workflow to locations in source code where code needs to be changed to fulfill that step.
Given a workflow containing background context plus a series of <step> tags, you will resolve *one* of these step tags to resolve to one or more locations in the code.
With each location, you will produce a brief, one-line description of the changes to be made.
<guidelines>
- There's no need to describe *what* to do, just *where* to do it.
- Only reference locations that actually exist (unless you're creating a file).
- If creating a file, assume any subsequent updates are included at the time of creation.
- Don't create and then update a file. Always create new files in shot.
- Prefer updating symbols lower in the syntax tree if possible.
- Never include suggestions on a parent symbol and one of its children in the same suggestions block.
- Never nest an operation with another operation or include CDATA or other content. All suggestions are leaf nodes.
- Descriptions are required for all suggestions except delete.
- When generating multiple suggestions, ensure the descriptions are specific to each individual operation.
- Avoid referring to the location in the description. Focus on the change to be made, not the location where it's made. That's implicit with the symbol you provide.
- Don't generate multiple suggestions at the same location. Instead, combine them together in a single operation with a succinct combined description.
- To add imports respond with a suggestion where the `"symbol"` key is set to `"#imports"`
</guidelines>
</overview>
<examples>
<example>
<workflow_context>
<message role="user">
```rs src/rectangle.rs
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
fn new(width: f64, height: f64) -> Self {
Rectangle { width, height }
}
}
```
We need to add methods to calculate the area and perimeter of the rectangle. Can you help with that?
</message>
<message role="assistant">
Sure, I can help with that!
<step>Add new methods 'calculate_area' and 'calculate_perimeter' to the Rectangle struct</step>
<step>Implement the 'Display' trait for the Rectangle struct</step>
</message>
</workflow_context>
<step_to_resolve>
Add new methods 'calculate_area' and 'calculate_perimeter' to the Rectangle struct
</step_to_resolve>
<incorrect_output reason="NEVER append multiple children at the same location.">
{
"title": "Add Rectangle methods",
"suggestions": [
{
"kind": "AppendChild",
"path": "src/shapes.rs",
"symbol": "impl Rectangle",
"description": "Add calculate_area method"
},
{
"kind": "AppendChild",
"path": "src/shapes.rs",
"symbol": "impl Rectangle",
"description": "Add calculate_perimeter method"
}
]
}
</incorrect_output>
<correct_output>
{
"title": "Add Rectangle methods",
"suggestions": [
{
"kind": "AppendChild",
"path": "src/shapes.rs",
"symbol": "impl Rectangle",
"description": "Add calculate area and perimeter methods"
}
]
}
</correct_output>
<step_to_resolve>
Implement the 'Display' trait for the Rectangle struct
</step_to_resolve>
<output>
{
"title": "Implement Display for Rectangle",
"suggestions": [
{
"kind": "InsertSiblingAfter",
"path": "src/shapes.rs",
"symbol": "impl Rectangle",
"description": "Implement Display trait for Rectangle"
}
]
}
</output>
<example>
<workflow_context>
<message role="user">
```rs src/user.rs
struct User {
pub name: String,
age: u32,
email: String,
}
impl User {
fn new(name: String, age: u32, email: String) -> Self {
User { name, age, email }
}
pub fn print_info(&self) {
println!("Name: {}, Age: {}, Email: {}", self.name, self.age, self.email);
}
}
```
</message>
<message role="assistant">
Certainly!
<step>Update the 'print_info' method to use formatted output</step>
<step>Remove the 'email' field from the User struct</step>
</message>
</workflow_context>
<step_to_resolve>
Update the 'print_info' method to use formatted output
</step_to_resolve>
<output>
{
"title": "Use formatted output",
"suggestions": [
{
"kind": "Update",
"path": "src/user.rs",
"symbol": "impl User pub fn print_info",
"description": "Use formatted output"
}
]
}
</output>
<step_to_resolve>
Remove the 'email' field from the User struct
</step_to_resolve>
<output>
{
"title": "Remove email field",
"suggestions": [
{
"kind": "Delete",
"path": "src/user.rs",
"symbol": "struct User email"
}
]
}
</output>
</example>
<example>
<workflow_context>
<message role="user">
```rs src/vehicle.rs
struct Vehicle {
make: String,
model: String,
year: u32,
}
impl Vehicle {
fn new(make: String, model: String, year: u32) -> Self {
Vehicle { make, model, year }
}
fn print_year(&self) {
println!("Year: {}", self.year);
}
}
```
</message>
<message role="assistant">
<step>Add a 'use std::fmt;' statement at the beginning of the file</step>
<step>Add a new method 'start_engine' in the Vehicle impl block</step>
</message>
</workflow_context>
<step_to_resolve>
Add a 'use std::fmt;' statement at the beginning of the file
</step_to_resolve>
<output>
{
"title": "Add use std::fmt statement",
"suggestions": [
{
"kind": "PrependChild",
"path": "src/vehicle.rs",
"symbol": "#imports",
"description": "Add 'use std::fmt' statement"
}
]
}
</output>
<step_to_resolve>
Add a new method 'start_engine' in the Vehicle impl block
</step_to_resolve>
<output>
{
"title": "Add start_engine method",
"suggestions": [
{
"kind": "InsertSiblingAfter",
"path": "src/vehicle.rs",
"symbol": "impl Vehicle fn new",
"description": "Add start_engine method"
}
]
}
</output>
</example>
<example>
<workflow_context>
<message role="user">
```rs src/employee.rs
struct Employee {
name: String,
position: String,
salary: u32,
department: String,
}
impl Employee {
fn new(name: String, position: String, salary: u32, department: String) -> Self {
Employee { name, position, salary, department }
}
fn print_details(&self) {
println!("Name: {}, Position: {}, Salary: {}, Department: {}",
self.name, self.position, self.salary, self.department);
}
fn give_raise(&mut self, amount: u32) {
self.salary += amount;
}
}
```
</message>
<message role="assistant">
<step>Make salary an f32</step>
<step>Remove the 'department' field and update the 'print_details' method</step>
</message>
</workflow_context>
<step_to_resolve>
Make salary an f32
</step_to_resolve>
<incorrect_output reason="NEVER include suggestions on a parent symbol and one of its children in the same suggestions block.">
{
"title": "Change salary to f32",
"suggestions": [
{
"kind": "Update",
"path": "src/employee.rs",
"symbol": "struct Employee",
"description": "Change the type of salary to an f32"
},
{
"kind": "Update",
"path": "src/employee.rs",
"symbol": "struct Employee salary",
"description": "Change the type to an f32"
}
]
}
</incorrect_output>
<correct_output>
{
"title": "Change salary to f32",
"suggestions": [
{
"kind": "Update",
"path": "src/employee.rs",
"symbol": "struct Employee salary",
"description": "Change the type to an f32"
}
]
}
</correct_output>
<step_to_resolve>
Remove the 'department' field and update the 'print_details' method
</step_to_resolve>
<output>
{
"title": "Remove department",
"suggestions": [
{
"kind": "Delete",
"path": "src/employee.rs",
"symbol": "struct Employee department"
},
{
"kind": "Update",
"path": "src/employee.rs",
"symbol": "impl Employee fn print_details",
"description": "Don't print the 'department' field"
}
]
}
</output>
</example>
<example>
<workflow_context>
<message role="user">
```rs src/game.rs
struct Player {
name: String,
health: i32,
pub score: u32,
}
impl Player {
pub fn new(name: String) -> Self {
Player { name, health: 100, score: 0 }
}
}
struct Game {
players: Vec<Player>,
}
impl Game {
fn new() -> Self {
Game { players: Vec::new() }
}
}
```
</message>
<message role="assistant">
<step>Add a 'level' field to Player and update the 'new' method</step>
</message>
</workflow_context>
<step_to_resolve>
Add a 'level' field to Player and update the 'new' method
</step_to_resolve>
<output>
{
"title": "Add level field to Player",
"suggestions": [
{
"kind": "InsertSiblingAfter",
"path": "src/game.rs",
"symbol": "struct Player pub score",
"description": "Add level field to Player"
},
{
"kind": "Update",
"path": "src/game.rs",
"symbol": "impl Player pub fn new",
"description": "Initialize level in new method"
}
]
}
</output>
</example>
<example>
<workflow_context>
<message role="user">
```rs src/config.rs
use std::collections::HashMap;
struct Config {
settings: HashMap<String, String>,
}
impl Config {
fn new() -> Self {
Config { settings: HashMap::new() }
}
}
```
</message>
<message role="assistant">
<step>Add a 'load_from_file' method to Config and import necessary modules</step>
</message>
</workflow_context>
<step_to_resolve>
Add a 'load_from_file' method to Config and import necessary modules
</step_to_resolve>
<output>
{
"title": "Add load_from_file method",
"suggestions": [
{
"kind": "PrependChild",
"path": "src/config.rs",
"symbol": "#imports",
"description": "Import std::fs and std::io modules"
},
{
"kind": "AppendChild",
"path": "src/config.rs",
"symbol": "impl Config",
"description": "Add load_from_file method"
}
]
}
</output>
</example>
<example>
<workflow_context>
<message role="user">
```rs src/database.rs
pub(crate) struct Database {
connection: Connection,
}
impl Database {
fn new(url: &str) -> Result<Self, Error> {
let connection = Connection::connect(url)?;
Ok(Database { connection })
}
async fn query(&self, sql: &str) -> Result<Vec<Row>, Error> {
self.connection.query(sql, &[])
}
}
```
</message>
<message role="assistant">
<step>Add error handling to the 'query' method and create a custom error type</step>
</message>
</workflow_context>
<step_to_resolve>
Add error handling to the 'query' method and create a custom error type
</step_to_resolve>
<output>
{
"title": "Add error handling to query",
"suggestions": [
{
"kind": "PrependChild",
"path": "src/database.rs",
"description": "Import necessary error handling modules"
},
{
"kind": "InsertSiblingBefore",
"path": "src/database.rs",
"symbol": "pub(crate) struct Database",
"description": "Define custom DatabaseError enum"
},
{
"kind": "Update",
"path": "src/database.rs",
"symbol": "impl Database async fn query",
"description": "Implement error handling in query method"
}
]
}
</output>
</example>
</examples>
Now generate the suggestions for the following step:
<workflow_context>
{{{workflow_context}}}
</workflow_context>
<step_to_resolve>
{{{step_to_resolve}}}
</step_to_resolve>

View File

@@ -712,10 +712,10 @@
// May take 2 values:
// 1. Rely on default platform handling of option key, on macOS
// this means generating certain unicode characters
// "option_to_meta": false,
// "option_as_meta": false,
// 2. Make the option keys behave as a 'meta' key, e.g. for emacs
// "option_to_meta": true,
"option_as_meta": true,
// "option_as_meta": true,
"option_as_meta": false,
// Whether or not selecting text in the terminal will automatically
// copy to the system clipboard.
"copy_on_select": false,

View File

@@ -97,6 +97,7 @@ language = { workspace = true, features = ["test-support"] }
language_model = { workspace = true, features = ["test-support"] }
languages = { workspace = true, features = ["test-support"] }
log.workspace = true
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
rand.workspace = true
serde_json_lenient.workspace = true

View File

@@ -6,6 +6,7 @@ mod context;
pub mod context_store;
mod inline_assistant;
mod model_selector;
mod patch;
mod prompt_library;
mod prompts;
mod slash_command;
@@ -14,7 +15,6 @@ pub mod slash_command_settings;
mod streaming_diff;
mod terminal_inline_assistant;
mod tools;
mod workflow;
pub use assistant_panel::{AssistantPanel, AssistantPanelEvent};
use assistant_settings::AssistantSettings;
@@ -35,11 +35,13 @@ use language_model::{
LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, LanguageModelResponseMessage,
};
pub(crate) use model_selector::*;
pub use patch::*;
pub use prompts::PromptBuilder;
use prompts::PromptLoadingParams;
use semantic_index::{CloudEmbeddingProvider, SemanticDb};
use serde::{Deserialize, Serialize};
use settings::{update_settings_file, Settings, SettingsStore};
use slash_command::workflow_command::WorkflowSlashCommand;
use slash_command::{
auto_command, cargo_workspace_command, context_server_command, default_command, delta_command,
diagnostics_command, docs_command, fetch_command, file_command, now_command, project_command,
@@ -50,7 +52,6 @@ use std::path::PathBuf;
use std::sync::Arc;
pub(crate) use streaming_diff::*;
use util::ResultExt;
pub use workflow::*;
use crate::slash_command_settings::SlashCommandSettings;
@@ -393,12 +394,25 @@ fn register_slash_commands(prompt_builder: Option<Arc<PromptBuilder>>, cx: &mut
slash_command_registry.register_command(now_command::NowSlashCommand, false);
slash_command_registry.register_command(diagnostics_command::DiagnosticsSlashCommand, true);
slash_command_registry.register_command(fetch_command::FetchSlashCommand, false);
slash_command_registry.register_command(fetch_command::FetchSlashCommand, false);
if let Some(prompt_builder) = prompt_builder {
slash_command_registry.register_command(
workflow_command::WorkflowSlashCommand::new(prompt_builder.clone()),
true,
);
cx.observe_global::<SettingsStore>({
let slash_command_registry = slash_command_registry.clone();
let prompt_builder = prompt_builder.clone();
move |cx| {
if AssistantSettings::get_global(cx).are_live_diffs_enabled(cx) {
slash_command_registry.register_command(
workflow_command::WorkflowSlashCommand::new(prompt_builder.clone()),
true,
);
} else {
slash_command_registry.unregister_command_by_name(WorkflowSlashCommand::NAME);
}
}
})
.detach();
cx.observe_flag::<project_command::ProjectSlashCommandFeatureFlag, _>({
let slash_command_registry = slash_command_registry.clone();
move |is_enabled, _cx| {

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ use std::sync::Arc;
use ::open_ai::Model as OpenAiModel;
use anthropic::Model as AnthropicModel;
use feature_flags::FeatureFlagAppExt;
use fs::Fs;
use gpui::{AppContext, Pixels};
use language_model::provider::open_ai;
@@ -61,6 +62,13 @@ pub struct AssistantSettings {
pub default_model: LanguageModelSelection,
pub inline_alternatives: Vec<LanguageModelSelection>,
pub using_outdated_settings_version: bool,
pub enable_experimental_live_diffs: bool,
}
impl AssistantSettings {
pub fn are_live_diffs_enabled(&self, cx: &AppContext) -> bool {
cx.is_staff() || self.enable_experimental_live_diffs
}
}
/// Assistant panel settings
@@ -238,6 +246,7 @@ impl AssistantSettingsContent {
}
}),
inline_alternatives: None,
enable_experimental_live_diffs: None,
},
VersionedAssistantSettingsContent::V2(settings) => settings.clone(),
},
@@ -257,6 +266,7 @@ impl AssistantSettingsContent {
.to_string(),
}),
inline_alternatives: None,
enable_experimental_live_diffs: None,
},
}
}
@@ -373,6 +383,7 @@ impl Default for VersionedAssistantSettingsContent {
default_height: None,
default_model: None,
inline_alternatives: None,
enable_experimental_live_diffs: None,
})
}
}
@@ -403,6 +414,10 @@ pub struct AssistantSettingsContentV2 {
default_model: Option<LanguageModelSelection>,
/// Additional models with which to generate alternatives when performing inline assists.
inline_alternatives: Option<Vec<LanguageModelSelection>>,
/// Enable experimental live diffs in the assistant panel.
///
/// Default: false
enable_experimental_live_diffs: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
@@ -525,7 +540,10 @@ impl Settings for AssistantSettings {
);
merge(&mut settings.default_model, value.default_model);
merge(&mut settings.inline_alternatives, value.inline_alternatives);
// merge(&mut settings.infer_context, value.infer_context); TODO re-enable this once we ship context inference
merge(
&mut settings.enable_experimental_live_diffs,
value.enable_experimental_live_diffs,
);
}
Ok(settings)
@@ -584,6 +602,7 @@ mod tests {
dock: None,
default_width: None,
default_height: None,
enable_experimental_live_diffs: None,
}),
)
},

View File

@@ -2,8 +2,8 @@
mod context_tests;
use crate::{
prompts::PromptBuilder, slash_command::SlashCommandLine, MessageId, MessageStatus,
WorkflowStep, WorkflowStepEdit, WorkflowStepResolution, WorkflowSuggestionGroup,
prompts::PromptBuilder, slash_command::SlashCommandLine, AssistantEdit, AssistantPatch,
AssistantPatchStatus, MessageId, MessageStatus,
};
use anyhow::{anyhow, Context as _, Result};
use assistant_slash_command::{
@@ -15,13 +15,10 @@ use clock::ReplicaId;
use collections::{HashMap, HashSet};
use feature_flags::{FeatureFlag, FeatureFlagAppExt};
use fs::{Fs, RemoveOptions};
use futures::{
future::{self, Shared},
FutureExt, StreamExt,
};
use futures::{future::Shared, FutureExt, StreamExt};
use gpui::{
AppContext, AsyncAppContext, Context as _, EventEmitter, Model, ModelContext, RenderImage,
SharedString, Subscription, Task,
AppContext, Context as _, EventEmitter, Model, ModelContext, RenderImage, SharedString,
Subscription, Task,
};
use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, Point, ToOffset};
@@ -38,7 +35,7 @@ use project::Project;
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
use std::{
cmp::{self, max, Ordering},
cmp::{max, Ordering},
fmt::Debug,
iter, mem,
ops::Range,
@@ -300,7 +297,7 @@ pub enum ContextEvent {
MessagesEdited,
SummaryChanged,
StreamedCompletion,
WorkflowStepsUpdated {
PatchesUpdated {
removed: Vec<Range<language::Anchor>>,
updated: Vec<Range<language::Anchor>>,
},
@@ -454,13 +451,14 @@ pub struct XmlTag {
#[derive(Copy, Clone, Debug, strum::EnumString, PartialEq, Eq, strum::AsRefStr)]
#[strum(serialize_all = "snake_case")]
pub enum XmlTagKind {
Step,
Patch,
Title,
Edit,
Path,
Search,
Within,
Operation,
Description,
OldText,
NewText,
Operation,
}
pub struct Context {
@@ -490,7 +488,7 @@ pub struct Context {
_subscriptions: Vec<Subscription>,
telemetry: Option<Arc<Telemetry>>,
language_registry: Arc<LanguageRegistry>,
workflow_steps: Vec<WorkflowStep>,
patches: Vec<AssistantPatch>,
xml_tags: Vec<XmlTag>,
project: Option<Model<Project>>,
prompt_builder: Arc<PromptBuilder>,
@@ -506,7 +504,7 @@ impl ContextAnnotation for PendingSlashCommand {
}
}
impl ContextAnnotation for WorkflowStep {
impl ContextAnnotation for AssistantPatch {
fn range(&self) -> &Range<language::Anchor> {
&self.range
}
@@ -591,7 +589,7 @@ impl Context {
telemetry,
project,
language_registry,
workflow_steps: Vec::new(),
patches: Vec::new(),
xml_tags: Vec::new(),
prompt_builder,
};
@@ -929,48 +927,49 @@ impl Context {
self.summary.as_ref()
}
pub(crate) fn workflow_step_containing(
pub(crate) fn patch_containing(
&self,
offset: usize,
position: Point,
cx: &AppContext,
) -> Option<&WorkflowStep> {
) -> Option<&AssistantPatch> {
let buffer = self.buffer.read(cx);
let index = self
.workflow_steps
.binary_search_by(|step| {
let step_range = step.range.to_offset(&buffer);
if offset < step_range.start {
Ordering::Greater
} else if offset > step_range.end {
Ordering::Less
} else {
Ordering::Equal
}
})
.ok()?;
Some(&self.workflow_steps[index])
let index = self.patches.binary_search_by(|patch| {
let patch_range = patch.range.to_point(&buffer);
if position < patch_range.start {
Ordering::Greater
} else if position > patch_range.end {
Ordering::Less
} else {
Ordering::Equal
}
});
if let Ok(ix) = index {
Some(&self.patches[ix])
} else {
None
}
}
pub fn workflow_step_ranges(&self) -> impl Iterator<Item = Range<language::Anchor>> + '_ {
self.workflow_steps.iter().map(|step| step.range.clone())
pub fn patch_ranges(&self) -> impl Iterator<Item = Range<language::Anchor>> + '_ {
self.patches.iter().map(|patch| patch.range.clone())
}
pub(crate) fn workflow_step_for_range(
pub(crate) fn patch_for_range(
&self,
range: &Range<language::Anchor>,
cx: &AppContext,
) -> Option<&WorkflowStep> {
) -> Option<&AssistantPatch> {
let buffer = self.buffer.read(cx);
let index = self.workflow_step_index_for_range(range, buffer).ok()?;
Some(&self.workflow_steps[index])
let index = self.patch_index_for_range(range, buffer).ok()?;
Some(&self.patches[index])
}
fn workflow_step_index_for_range(
fn patch_index_for_range(
&self,
tagged_range: &Range<text::Anchor>,
buffer: &text::BufferSnapshot,
) -> Result<usize, usize> {
self.workflow_steps
self.patches
.binary_search_by(|probe| probe.range.cmp(&tagged_range, buffer))
}
@@ -1018,8 +1017,6 @@ impl Context {
language::BufferEvent::Edited => {
self.count_remaining_tokens(cx);
self.reparse(cx);
// Use `inclusive = true` to invalidate a step when an edit occurs
// at the start/end of a parsed step.
cx.emit(ContextEvent::MessagesEdited);
}
_ => {}
@@ -1248,8 +1245,8 @@ impl Context {
let mut removed_slash_command_ranges = Vec::new();
let mut updated_slash_commands = Vec::new();
let mut removed_steps = Vec::new();
let mut updated_steps = Vec::new();
let mut removed_patches = Vec::new();
let mut updated_patches = Vec::new();
while let Some(mut row_range) = row_ranges.next() {
while let Some(next_row_range) = row_ranges.peek() {
if row_range.end >= next_row_range.start {
@@ -1273,11 +1270,11 @@ impl Context {
&mut removed_slash_command_ranges,
cx,
);
self.reparse_workflow_steps_in_range(
self.reparse_patches_in_range(
start..end,
&buffer,
&mut updated_steps,
&mut removed_steps,
&mut updated_patches,
&mut removed_patches,
cx,
);
}
@@ -1289,10 +1286,10 @@ impl Context {
});
}
if !updated_steps.is_empty() || !removed_steps.is_empty() {
cx.emit(ContextEvent::WorkflowStepsUpdated {
removed: removed_steps,
updated: updated_steps,
if !updated_patches.is_empty() || !removed_patches.is_empty() {
cx.emit(ContextEvent::PatchesUpdated {
removed: removed_patches,
updated: updated_patches,
});
}
}
@@ -1354,7 +1351,7 @@ impl Context {
removed.extend(removed_commands.map(|command| command.source_range));
}
fn reparse_workflow_steps_in_range(
fn reparse_patches_in_range(
&mut self,
range: Range<text::Anchor>,
buffer: &BufferSnapshot,
@@ -1369,41 +1366,32 @@ impl Context {
self.xml_tags
.splice(intersecting_tags_range.clone(), new_tags);
// Find which steps intersect the changed range.
let intersecting_steps_range =
self.indices_intersecting_buffer_range(&self.workflow_steps, range.clone(), cx);
// Find which patches intersect the changed range.
let intersecting_patches_range =
self.indices_intersecting_buffer_range(&self.patches, range.clone(), cx);
// Reparse all tags after the last unchanged step before the change.
// Reparse all tags after the last unchanged patch before the change.
let mut tags_start_ix = 0;
if let Some(preceding_unchanged_step) =
self.workflow_steps[..intersecting_steps_range.start].last()
if let Some(preceding_unchanged_patch) =
self.patches[..intersecting_patches_range.start].last()
{
tags_start_ix = match self.xml_tags.binary_search_by(|tag| {
tag.range
.start
.cmp(&preceding_unchanged_step.range.end, buffer)
.cmp(&preceding_unchanged_patch.range.end, buffer)
.then(Ordering::Less)
}) {
Ok(ix) | Err(ix) => ix,
};
}
// Rebuild the edit suggestions in the range.
let mut new_steps = self.parse_steps(tags_start_ix, range.end, buffer);
if let Some(project) = self.project() {
for step in &mut new_steps {
Self::resolve_workflow_step_internal(step, &project, cx);
}
}
updated.extend(new_steps.iter().map(|step| step.range.clone()));
let removed_steps = self
.workflow_steps
.splice(intersecting_steps_range, new_steps);
// Rebuild the patches in the range.
let new_patches = self.parse_patches(tags_start_ix, range.end, buffer, cx);
updated.extend(new_patches.iter().map(|patch| patch.range.clone()));
let removed_patches = self.patches.splice(intersecting_patches_range, new_patches);
removed.extend(
removed_steps
.map(|step| step.range)
removed_patches
.map(|patch| patch.range)
.filter(|range| !updated.contains(&range)),
);
}
@@ -1464,60 +1452,95 @@ impl Context {
tags
}
fn parse_steps(
fn parse_patches(
&mut self,
tags_start_ix: usize,
buffer_end: text::Anchor,
buffer: &BufferSnapshot,
) -> Vec<WorkflowStep> {
let mut new_steps = Vec::new();
let mut pending_step = None;
let mut edit_step_depth = 0;
cx: &AppContext,
) -> Vec<AssistantPatch> {
let mut new_patches = Vec::new();
let mut pending_patch = None;
let mut patch_tag_depth = 0;
let mut tags = self.xml_tags[tags_start_ix..].iter().peekable();
'tags: while let Some(tag) = tags.next() {
if tag.range.start.cmp(&buffer_end, buffer).is_gt() && edit_step_depth == 0 {
if tag.range.start.cmp(&buffer_end, buffer).is_gt() && patch_tag_depth == 0 {
break;
}
if tag.kind == XmlTagKind::Step && tag.is_open_tag {
edit_step_depth += 1;
let edit_start = tag.range.start;
let mut edits = Vec::new();
let mut step = WorkflowStep {
range: edit_start..edit_start,
leading_tags_end: tag.range.end,
trailing_tag_start: None,
if tag.kind == XmlTagKind::Patch && tag.is_open_tag {
patch_tag_depth += 1;
let patch_start = tag.range.start;
let mut edits = Vec::<Result<AssistantEdit>>::new();
let mut patch = AssistantPatch {
range: patch_start..patch_start,
title: String::new().into(),
edits: Default::default(),
resolution: None,
resolution_task: None,
status: crate::AssistantPatchStatus::Pending,
};
while let Some(tag) = tags.next() {
step.trailing_tag_start.get_or_insert(tag.range.start);
if tag.kind == XmlTagKind::Patch && !tag.is_open_tag {
patch_tag_depth -= 1;
if patch_tag_depth == 0 {
patch.range.end = tag.range.end;
if tag.kind == XmlTagKind::Step && !tag.is_open_tag {
// step.trailing_tag_start = Some(tag.range.start);
edit_step_depth -= 1;
if edit_step_depth == 0 {
step.range.end = tag.range.end;
step.edits = edits.into();
new_steps.push(step);
// Include the line immediately after this <patch> tag if it's empty.
let patch_end_offset = patch.range.end.to_offset(buffer);
let mut patch_end_chars = buffer.chars_at(patch_end_offset);
if patch_end_chars.next() == Some('\n')
&& patch_end_chars.next().map_or(true, |ch| ch == '\n')
{
let messages = self.messages_for_offsets(
[patch_end_offset, patch_end_offset + 1],
cx,
);
if messages.len() == 1 {
patch.range.end = buffer.anchor_before(patch_end_offset + 1);
}
}
edits.sort_unstable_by(|a, b| {
if let (Ok(a), Ok(b)) = (a, b) {
a.path.cmp(&b.path)
} else {
Ordering::Equal
}
});
patch.edits = edits.into();
patch.status = AssistantPatchStatus::Ready;
new_patches.push(patch);
continue 'tags;
}
}
if tag.kind == XmlTagKind::Title && tag.is_open_tag {
let content_start = tag.range.end;
while let Some(tag) = tags.next() {
if tag.kind == XmlTagKind::Title && !tag.is_open_tag {
let content_end = tag.range.start;
patch.title =
trimmed_text_in_range(buffer, content_start..content_end)
.into();
break;
}
}
}
if tag.kind == XmlTagKind::Edit && tag.is_open_tag {
let mut path = None;
let mut search = None;
let mut old_text = None;
let mut new_text = None;
let mut operation = None;
let mut description = None;
while let Some(tag) = tags.next() {
if tag.kind == XmlTagKind::Edit && !tag.is_open_tag {
edits.push(WorkflowStepEdit::new(
edits.push(AssistantEdit::new(
path,
operation,
search,
old_text,
new_text,
description,
));
break;
@@ -1526,7 +1549,8 @@ impl Context {
if tag.is_open_tag
&& [
XmlTagKind::Path,
XmlTagKind::Search,
XmlTagKind::OldText,
XmlTagKind::NewText,
XmlTagKind::Operation,
XmlTagKind::Description,
]
@@ -1538,15 +1562,18 @@ impl Context {
if tag.kind == kind && !tag.is_open_tag {
let tag = tags.next().unwrap();
let content_end = tag.range.start;
let mut content = buffer
.text_for_range(content_start..content_end)
.collect::<String>();
content.truncate(content.trim_end().len());
let content = trimmed_text_in_range(
buffer,
content_start..content_end,
);
match kind {
XmlTagKind::Path => path = Some(content),
XmlTagKind::Operation => operation = Some(content),
XmlTagKind::Search => {
search = Some(content).filter(|s| !s.is_empty())
XmlTagKind::OldText => {
old_text = Some(content).filter(|s| !s.is_empty())
}
XmlTagKind::NewText => {
new_text = Some(content).filter(|s| !s.is_empty())
}
XmlTagKind::Description => {
description =
@@ -1561,162 +1588,28 @@ impl Context {
}
}
pending_step = Some(step);
patch.edits = edits.into();
pending_patch = Some(patch);
}
}
if let Some(mut pending_step) = pending_step {
pending_step.range.end = text::Anchor::MAX;
new_steps.push(pending_step);
}
new_steps
}
pub fn resolve_workflow_step(
&mut self,
tagged_range: Range<text::Anchor>,
cx: &mut ModelContext<Self>,
) -> Option<()> {
let index = self
.workflow_step_index_for_range(&tagged_range, self.buffer.read(cx))
.ok()?;
let step = &mut self.workflow_steps[index];
let project = self.project.as_ref()?;
step.resolution.take();
Self::resolve_workflow_step_internal(step, project, cx);
None
}
fn resolve_workflow_step_internal(
step: &mut WorkflowStep,
project: &Model<Project>,
cx: &mut ModelContext<'_, Context>,
) {
step.resolution_task = Some(cx.spawn({
let range = step.range.clone();
let edits = step.edits.clone();
let project = project.clone();
|this, mut cx| async move {
let suggestion_groups =
Self::compute_step_resolution(project, edits, &mut cx).await;
this.update(&mut cx, |this, cx| {
let buffer = this.buffer.read(cx).text_snapshot();
let ix = this.workflow_step_index_for_range(&range, &buffer).ok();
if let Some(ix) = ix {
let step = &mut this.workflow_steps[ix];
let resolution = suggestion_groups.map(|suggestion_groups| {
let mut title = String::new();
for mut chunk in buffer.text_for_range(
step.leading_tags_end
..step.trailing_tag_start.unwrap_or(step.range.end),
) {
if title.is_empty() {
chunk = chunk.trim_start();
}
if let Some((prefix, _)) = chunk.split_once('\n') {
title.push_str(prefix);
break;
} else {
title.push_str(chunk);
}
}
WorkflowStepResolution {
title,
suggestion_groups,
}
});
step.resolution = Some(Arc::new(resolution));
cx.emit(ContextEvent::WorkflowStepsUpdated {
removed: vec![],
updated: vec![range],
})
}
})
.ok();
}
}));
}
async fn compute_step_resolution(
project: Model<Project>,
edits: Arc<[Result<WorkflowStepEdit>]>,
cx: &mut AsyncAppContext,
) -> Result<HashMap<Model<Buffer>, Vec<WorkflowSuggestionGroup>>> {
let mut suggestion_tasks = Vec::new();
for edit in edits.iter() {
let edit = edit.as_ref().map_err(|e| anyhow!("{e}"))?;
suggestion_tasks.push(edit.resolve(project.clone(), cx.clone()));
}
// Expand the context ranges of each suggestion and group suggestions with overlapping context ranges.
let suggestions = future::try_join_all(suggestion_tasks).await?;
let mut suggestions_by_buffer = HashMap::default();
for (buffer, suggestion) in suggestions {
suggestions_by_buffer
.entry(buffer)
.or_insert_with(Vec::new)
.push(suggestion);
}
let mut suggestion_groups_by_buffer = HashMap::default();
for (buffer, mut suggestions) in suggestions_by_buffer {
let mut suggestion_groups = Vec::<WorkflowSuggestionGroup>::new();
let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
// Sort suggestions by their range so that earlier, larger ranges come first
suggestions.sort_by(|a, b| a.range().cmp(&b.range(), &snapshot));
// Merge overlapping suggestions
suggestions.dedup_by(|a, b| b.try_merge(a, &snapshot));
// Create context ranges for each suggestion
for suggestion in suggestions {
let context_range = {
let suggestion_point_range = suggestion.range().to_point(&snapshot);
let start_row = suggestion_point_range.start.row.saturating_sub(5);
let end_row =
cmp::min(suggestion_point_range.end.row + 5, snapshot.max_point().row);
let start = snapshot.anchor_before(Point::new(start_row, 0));
let end =
snapshot.anchor_after(Point::new(end_row, snapshot.line_len(end_row)));
start..end
};
if let Some(last_group) = suggestion_groups.last_mut() {
if last_group
.context_range
.end
.cmp(&context_range.start, &snapshot)
.is_ge()
{
// Merge with the previous group if context ranges overlap
last_group.context_range.end = context_range.end;
last_group.suggestions.push(suggestion);
} else {
// Create a new group
suggestion_groups.push(WorkflowSuggestionGroup {
context_range,
suggestions: vec![suggestion],
});
}
if let Some(mut pending_patch) = pending_patch {
let patch_start = pending_patch.range.start.to_offset(buffer);
if let Some(message) = self.message_for_offset(patch_start, cx) {
if message.anchor_range.end == text::Anchor::MAX {
pending_patch.range.end = text::Anchor::MAX;
} else {
// Create the first group
suggestion_groups.push(WorkflowSuggestionGroup {
context_range,
suggestions: vec![suggestion],
});
let message_end = buffer.anchor_after(message.offset_range.end - 1);
pending_patch.range.end = message_end;
}
} else {
pending_patch.range.end = text::Anchor::MAX;
}
suggestion_groups_by_buffer.insert(buffer, suggestion_groups);
new_patches.push(pending_patch);
}
Ok(suggestion_groups_by_buffer)
new_patches
}
pub fn pending_command_for_position(
@@ -2315,11 +2208,11 @@ impl Context {
let mut updated = Vec::new();
let mut removed = Vec::new();
for range in ranges {
self.reparse_workflow_steps_in_range(range, &buffer, &mut updated, &mut removed, cx);
self.reparse_patches_in_range(range, &buffer, &mut updated, &mut removed, cx);
}
if !updated.is_empty() || !removed.is_empty() {
cx.emit(ContextEvent::WorkflowStepsUpdated { removed, updated })
cx.emit(ContextEvent::PatchesUpdated { removed, updated })
}
}
@@ -2825,6 +2718,24 @@ impl Context {
}
}
fn trimmed_text_in_range(buffer: &BufferSnapshot, range: Range<text::Anchor>) -> String {
let mut is_start = true;
let mut content = buffer
.text_for_range(range)
.map(|mut chunk| {
if is_start {
chunk = chunk.trim_start_matches('\n');
if !chunk.is_empty() {
is_start = false;
}
}
chunk
})
.collect::<String>();
content.truncate(content.trim_end().len());
content
}
#[derive(Debug, Default)]
pub struct ContextVersion {
context: clock::Global,

View File

@@ -1,8 +1,7 @@
use super::{MessageCacheMetadata, WorkflowStepEdit};
use super::{AssistantEdit, MessageCacheMetadata};
use crate::{
assistant_panel, prompt_library, slash_command::file_command, CacheStatus, Context,
ContextEvent, ContextId, ContextOperation, MessageId, MessageStatus, PromptBuilder,
WorkflowStepEditKind,
assistant_panel, prompt_library, slash_command::file_command, AssistantEditKind, CacheStatus,
Context, ContextEvent, ContextId, ContextOperation, MessageId, MessageStatus, PromptBuilder,
};
use anyhow::Result;
use assistant_slash_command::{
@@ -15,6 +14,7 @@ use gpui::{AppContext, Model, SharedString, Task, TestAppContext, WeakView};
use language::{Buffer, BufferSnapshot, LanguageRegistry, LspAdapterDelegate};
use language_model::{LanguageModelCacheConfiguration, LanguageModelRegistry, Role};
use parking_lot::Mutex;
use pretty_assertions::assert_eq;
use project::Project;
use rand::prelude::*;
use serde_json::json;
@@ -478,7 +478,15 @@ async fn test_slash_commands(cx: &mut TestAppContext) {
#[gpui::test]
async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
cx.update(prompt_library::init);
let settings_store = cx.update(SettingsStore::test);
let mut settings_store = cx.update(SettingsStore::test);
cx.update(|cx| {
settings_store
.set_user_settings(
r#"{ "assistant": { "enable_experimental_live_diffs": true } }"#,
cx,
)
.unwrap()
});
cx.set_global(settings_store);
cx.update(language::init);
cx.update(Project::init_settings);
@@ -520,7 +528,7 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
»",
cx,
);
expect_steps(
expect_patches(
&context,
"
@@ -539,17 +547,17 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
one
two
«
<step»",
<patch»",
cx,
);
expect_steps(
expect_patches(
&context,
"
one
two
<step",
<patch",
&[],
cx,
);
@@ -563,36 +571,24 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
one
two
<step«>
Add a second function
```rust
fn two() {}
```
<patch«>
<edit>»",
cx,
);
expect_steps(
expect_patches(
&context,
"
one
two
«<step>
Add a second function
```rust
fn two() {}
```
«<patch>
<edit>»",
&[&[]],
cx,
);
// The full suggestion is added
// The full patch is added
edit(
&context,
"
@@ -600,51 +596,46 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
one
two
<step>
Add a second function
```rust
fn two() {}
```
<patch>
<edit>«
<description>add a `two` function</description>
<path>src/lib.rs</path>
<operation>insert_after</operation>
<search>fn one</search>
<description>add a `two` function</description>
<old_text>fn one</old_text>
<new_text>
fn two() {}
</new_text>
</edit>
</step>
</patch>
also,»",
cx,
);
expect_steps(
expect_patches(
&context,
"
one
two
«<step>
Add a second function
```rust
fn two() {}
```
«<patch>
<edit>
<description>add a `two` function</description>
<path>src/lib.rs</path>
<operation>insert_after</operation>
<search>fn one</search>
<description>add a `two` function</description>
<old_text>fn one</old_text>
<new_text>
fn two() {}
</new_text>
</edit>
</step>»
</patch>
»
also,",
&[&[WorkflowStepEdit {
&[&[AssistantEdit {
path: "src/lib.rs".into(),
kind: WorkflowStepEditKind::InsertAfter {
search: "fn one".into(),
kind: AssistantEditKind::InsertAfter {
old_text: "fn one".into(),
new_text: "fn two() {}".into(),
description: "add a `two` function".into(),
},
}]],
@@ -659,51 +650,46 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
one
two
<step>
Add a second function
```rust
fn two() {}
```
<patch>
<edit>
<description>add a `two` function</description>
<path>src/lib.rs</path>
<operation>insert_after</operation>
<search>«fn zero»</search>
<description>add a `two` function</description>
<old_text>«fn zero»</old_text>
<new_text>
fn two() {}
</new_text>
</edit>
</step>
</patch>
also,",
cx,
);
expect_steps(
expect_patches(
&context,
"
one
two
«<step>
Add a second function
```rust
fn two() {}
```
«<patch>
<edit>
<description>add a `two` function</description>
<path>src/lib.rs</path>
<operation>insert_after</operation>
<search>fn zero</search>
<description>add a `two` function</description>
<old_text>fn zero</old_text>
<new_text>
fn two() {}
</new_text>
</edit>
</step>»
</patch>
»
also,",
&[&[WorkflowStepEdit {
&[&[AssistantEdit {
path: "src/lib.rs".into(),
kind: WorkflowStepEditKind::InsertAfter {
search: "fn zero".into(),
kind: AssistantEditKind::InsertAfter {
old_text: "fn zero".into(),
new_text: "fn two() {}".into(),
description: "add a `two` function".into(),
},
}]],
@@ -715,27 +701,24 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
context.cycle_message_roles(HashSet::from_iter([assistant_message_id]), cx);
context.cycle_message_roles(HashSet::from_iter([assistant_message_id]), cx);
});
expect_steps(
expect_patches(
&context,
"
one
two
<step>
Add a second function
```rust
fn two() {}
```
<patch>
<edit>
<description>add a `two` function</description>
<path>src/lib.rs</path>
<operation>insert_after</operation>
<search>fn zero</search>
<description>add a `two` function</description>
<old_text>fn zero</old_text>
<new_text>
fn two() {}
</new_text>
</edit>
</step>
</patch>
also,",
&[],
@@ -746,33 +729,31 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
context.update(cx, |context, cx| {
context.cycle_message_roles(HashSet::from_iter([assistant_message_id]), cx);
});
expect_steps(
expect_patches(
&context,
"
one
two
«<step>
Add a second function
```rust
fn two() {}
```
«<patch>
<edit>
<description>add a `two` function</description>
<path>src/lib.rs</path>
<operation>insert_after</operation>
<search>fn zero</search>
<description>add a `two` function</description>
<old_text>fn zero</old_text>
<new_text>
fn two() {}
</new_text>
</edit>
</step>»
</patch>
»
also,",
&[&[WorkflowStepEdit {
&[&[AssistantEdit {
path: "src/lib.rs".into(),
kind: WorkflowStepEditKind::InsertAfter {
search: "fn zero".into(),
kind: AssistantEditKind::InsertAfter {
old_text: "fn zero".into(),
new_text: "fn two() {}".into(),
description: "add a `two` function".into(),
},
}]],
@@ -792,33 +773,31 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
cx,
)
});
expect_steps(
expect_patches(
&deserialized_context,
"
one
two
«<step>
Add a second function
```rust
fn two() {}
```
«<patch>
<edit>
<description>add a `two` function</description>
<path>src/lib.rs</path>
<operation>insert_after</operation>
<search>fn zero</search>
<description>add a `two` function</description>
<old_text>fn zero</old_text>
<new_text>
fn two() {}
</new_text>
</edit>
</step>»
</patch>
»
also,",
&[&[WorkflowStepEdit {
&[&[AssistantEdit {
path: "src/lib.rs".into(),
kind: WorkflowStepEditKind::InsertAfter {
search: "fn zero".into(),
kind: AssistantEditKind::InsertAfter {
old_text: "fn zero".into(),
new_text: "fn two() {}".into(),
description: "add a `two` function".into(),
},
}]],
@@ -834,48 +813,58 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
cx.executor().run_until_parked();
}
fn expect_steps(
#[track_caller]
fn expect_patches(
context: &Model<Context>,
expected_marked_text: &str,
expected_suggestions: &[&[WorkflowStepEdit]],
expected_suggestions: &[&[AssistantEdit]],
cx: &mut TestAppContext,
) {
context.update(cx, |context, cx| {
let expected_marked_text = expected_marked_text.unindent();
let (expected_text, expected_ranges) = marked_text_ranges(&expected_marked_text, false);
let expected_marked_text = expected_marked_text.unindent();
let (expected_text, _) = marked_text_ranges(&expected_marked_text, false);
let (buffer_text, ranges, patches) = context.update(cx, |context, cx| {
context.buffer.read_with(cx, |buffer, _| {
assert_eq!(buffer.text(), expected_text);
let ranges = context
.workflow_steps
.patches
.iter()
.map(|entry| entry.range.to_offset(buffer))
.collect::<Vec<_>>();
let marked = generate_marked_text(&expected_text, &ranges, false);
assert_eq!(
marked,
expected_marked_text,
"unexpected suggestion ranges. actual: {ranges:?}, expected: {expected_ranges:?}"
);
let suggestions = context
.workflow_steps
.iter()
.map(|step| {
step.edits
.iter()
.map(|edit| {
let edit = edit.as_ref().unwrap();
WorkflowStepEdit {
path: edit.path.clone(),
kind: edit.kind.clone(),
}
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
assert_eq!(suggestions, expected_suggestions);
});
(
buffer.text(),
ranges,
context
.patches
.iter()
.map(|step| step.edits.clone())
.collect::<Vec<_>>(),
)
})
});
assert_eq!(buffer_text, expected_text);
let actual_marked_text = generate_marked_text(&expected_text, &ranges, false);
assert_eq!(actual_marked_text, expected_marked_text);
assert_eq!(
patches
.iter()
.map(|patch| {
patch
.iter()
.map(|edit| {
let edit = edit.as_ref().unwrap();
AssistantEdit {
path: edit.path.clone(),
kind: edit.kind.clone(),
}
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>(),
expected_suggestions
);
}
}

View File

@@ -82,13 +82,6 @@ pub struct InlineAssistant {
assists: HashMap<InlineAssistId, InlineAssist>,
assists_by_editor: HashMap<WeakView<Editor>, EditorInlineAssists>,
assist_groups: HashMap<InlineAssistGroupId, InlineAssistGroup>,
assist_observations: HashMap<
InlineAssistId,
(
async_watch::Sender<AssistStatus>,
async_watch::Receiver<AssistStatus>,
),
>,
confirmed_assists: HashMap<InlineAssistId, Model<CodegenAlternative>>,
prompt_history: VecDeque<String>,
prompt_builder: Arc<PromptBuilder>,
@@ -96,19 +89,6 @@ pub struct InlineAssistant {
fs: Arc<dyn Fs>,
}
pub enum AssistStatus {
Idle,
Started,
Stopped,
Finished,
}
impl AssistStatus {
pub fn is_done(&self) -> bool {
matches!(self, Self::Stopped | Self::Finished)
}
}
impl Global for InlineAssistant {}
impl InlineAssistant {
@@ -123,7 +103,6 @@ impl InlineAssistant {
assists: HashMap::default(),
assists_by_editor: HashMap::default(),
assist_groups: HashMap::default(),
assist_observations: HashMap::default(),
confirmed_assists: HashMap::default(),
prompt_history: VecDeque::default(),
prompt_builder,
@@ -835,17 +814,6 @@ impl InlineAssistant {
.insert(assist_id, confirmed_alternative);
}
}
// Remove the assist from the status updates map
self.assist_observations.remove(&assist_id);
}
pub fn undo_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool {
let Some(codegen) = self.confirmed_assists.remove(&assist_id) else {
return false;
};
codegen.update(cx, |this, cx| this.undo(cx));
true
}
fn dismiss_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool {
@@ -1039,10 +1007,6 @@ impl InlineAssistant {
codegen.start(user_prompt, assistant_panel_context, cx)
})
.log_err();
if let Some((tx, _)) = self.assist_observations.get(&assist_id) {
tx.send(AssistStatus::Started).ok();
}
}
pub fn stop_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) {
@@ -1053,25 +1017,6 @@ impl InlineAssistant {
};
assist.codegen.update(cx, |codegen, cx| codegen.stop(cx));
if let Some((tx, _)) = self.assist_observations.get(&assist_id) {
tx.send(AssistStatus::Stopped).ok();
}
}
pub fn assist_status(&self, assist_id: InlineAssistId, cx: &AppContext) -> InlineAssistStatus {
if let Some(assist) = self.assists.get(&assist_id) {
match assist.codegen.read(cx).status(cx) {
CodegenStatus::Idle => InlineAssistStatus::Idle,
CodegenStatus::Pending => InlineAssistStatus::Pending,
CodegenStatus::Done => InlineAssistStatus::Done,
CodegenStatus::Error(_) => InlineAssistStatus::Error,
}
} else if self.confirmed_assists.contains_key(&assist_id) {
InlineAssistStatus::Confirmed
} else {
InlineAssistStatus::Canceled
}
}
fn update_editor_highlights(&self, editor: &View<Editor>, cx: &mut WindowContext) {
@@ -1257,42 +1202,6 @@ impl InlineAssistant {
.collect();
})
}
pub fn observe_assist(
&mut self,
assist_id: InlineAssistId,
) -> async_watch::Receiver<AssistStatus> {
if let Some((_, rx)) = self.assist_observations.get(&assist_id) {
rx.clone()
} else {
let (tx, rx) = async_watch::channel(AssistStatus::Idle);
self.assist_observations.insert(assist_id, (tx, rx.clone()));
rx
}
}
}
pub enum InlineAssistStatus {
Idle,
Pending,
Done,
Error,
Confirmed,
Canceled,
}
impl InlineAssistStatus {
pub(crate) fn is_pending(&self) -> bool {
matches!(self, Self::Pending)
}
pub(crate) fn is_confirmed(&self) -> bool {
matches!(self, Self::Confirmed)
}
pub(crate) fn is_done(&self) -> bool {
matches!(self, Self::Done)
}
}
struct EditorInlineAssists {
@@ -2290,8 +2199,6 @@ impl InlineAssist {
if assist.decorations.is_none() {
this.finish_assist(assist_id, false, cx);
} else if let Some(tx) = this.assist_observations.get(&assist_id) {
tx.0.send(AssistStatus::Finished).ok();
}
}
})

View File

@@ -0,0 +1,829 @@
use anyhow::{anyhow, Context as _, Result};
use collections::HashMap;
use editor::ProposedChangesEditor;
use futures::{future, TryFutureExt as _};
use gpui::{AppContext, AsyncAppContext, Model, SharedString};
use language::{AutoindentMode, Buffer, BufferSnapshot};
use project::{Project, ProjectPath};
use std::{cmp, ops::Range, path::Path, sync::Arc};
use text::{AnchorRangeExt as _, Bias, OffsetRangeExt as _, Point};
#[derive(Clone, Debug)]
pub(crate) struct AssistantPatch {
pub range: Range<language::Anchor>,
pub title: SharedString,
pub edits: Arc<[Result<AssistantEdit>]>,
pub status: AssistantPatchStatus,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum AssistantPatchStatus {
Pending,
Ready,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AssistantEdit {
pub path: String,
pub kind: AssistantEditKind,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AssistantEditKind {
Update {
old_text: String,
new_text: String,
description: String,
},
Create {
new_text: String,
description: String,
},
InsertBefore {
old_text: String,
new_text: String,
description: String,
},
InsertAfter {
old_text: String,
new_text: String,
description: String,
},
Delete {
old_text: String,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ResolvedPatch {
pub edit_groups: HashMap<Model<Buffer>, Vec<ResolvedEditGroup>>,
pub errors: Vec<AssistantPatchResolutionError>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResolvedEditGroup {
pub context_range: Range<language::Anchor>,
pub edits: Vec<ResolvedEdit>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResolvedEdit {
range: Range<language::Anchor>,
new_text: String,
description: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct AssistantPatchResolutionError {
pub edit_ix: usize,
pub message: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum SearchDirection {
Up,
Left,
Diagonal,
}
// A measure of the currently quality of an in-progress fuzzy search.
//
// Uses 60 bits to store a numeric cost, and 4 bits to store the preceding
// operation in the search.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct SearchState {
score: u32,
direction: SearchDirection,
}
impl SearchState {
fn new(score: u32, direction: SearchDirection) -> Self {
Self { score, direction }
}
}
impl ResolvedPatch {
pub fn apply(&self, editor: &ProposedChangesEditor, cx: &mut AppContext) {
for (buffer, groups) in &self.edit_groups {
let branch = editor.branch_buffer_for_base(buffer).unwrap();
Self::apply_edit_groups(groups, &branch, cx);
}
editor.recalculate_all_buffer_diffs();
}
fn apply_edit_groups(
groups: &Vec<ResolvedEditGroup>,
buffer: &Model<Buffer>,
cx: &mut AppContext,
) {
let mut edits = Vec::new();
for group in groups {
for suggestion in &group.edits {
edits.push((suggestion.range.clone(), suggestion.new_text.clone()));
}
}
buffer.update(cx, |buffer, cx| {
buffer.edit(
edits,
Some(AutoindentMode::Block {
original_indent_columns: Vec::new(),
}),
cx,
);
});
}
}
impl ResolvedEdit {
pub fn try_merge(&mut self, other: &Self, buffer: &text::BufferSnapshot) -> bool {
let range = &self.range;
let other_range = &other.range;
// Don't merge if we don't contain the other suggestion.
if range.start.cmp(&other_range.start, buffer).is_gt()
|| range.end.cmp(&other_range.end, buffer).is_lt()
{
return false;
}
let other_offset_range = other_range.to_offset(buffer);
let offset_range = range.to_offset(buffer);
// If the other range is empty at the start of this edit's range, combine the new text
if other_offset_range.is_empty() && other_offset_range.start == offset_range.start {
self.new_text = format!("{}\n{}", other.new_text, self.new_text);
self.range.start = other_range.start;
if let Some((description, other_description)) =
self.description.as_mut().zip(other.description.as_ref())
{
*description = format!("{}\n{}", other_description, description)
}
} else {
if let Some((description, other_description)) =
self.description.as_mut().zip(other.description.as_ref())
{
description.push('\n');
description.push_str(other_description);
}
}
true
}
}
impl AssistantEdit {
pub fn new(
path: Option<String>,
operation: Option<String>,
old_text: Option<String>,
new_text: Option<String>,
description: Option<String>,
) -> Result<Self> {
let path = path.ok_or_else(|| anyhow!("missing path"))?;
let operation = operation.ok_or_else(|| anyhow!("missing operation"))?;
let kind = match operation.as_str() {
"update" => AssistantEditKind::Update {
old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?,
new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?,
description: description.ok_or_else(|| anyhow!("missing description"))?,
},
"insert_before" => AssistantEditKind::InsertBefore {
old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?,
new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?,
description: description.ok_or_else(|| anyhow!("missing description"))?,
},
"insert_after" => AssistantEditKind::InsertAfter {
old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?,
new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?,
description: description.ok_or_else(|| anyhow!("missing description"))?,
},
"delete" => AssistantEditKind::Delete {
old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?,
},
"create" => AssistantEditKind::Create {
description: description.ok_or_else(|| anyhow!("missing description"))?,
new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?,
},
_ => Err(anyhow!("unknown operation {operation:?}"))?,
};
Ok(Self { path, kind })
}
pub async fn resolve(
&self,
project: Model<Project>,
mut cx: AsyncAppContext,
) -> Result<(Model<Buffer>, ResolvedEdit)> {
let path = self.path.clone();
let kind = self.kind.clone();
let buffer = project
.update(&mut cx, |project, cx| {
let project_path = project
.find_project_path(Path::new(&path), cx)
.or_else(|| {
// If we couldn't find a project path for it, put it in the active worktree
// so that when we create the buffer, it can be saved.
let worktree = project
.active_entry()
.and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
.or_else(|| project.worktrees(cx).next())?;
let worktree = worktree.read(cx);
Some(ProjectPath {
worktree_id: worktree.id(),
path: Arc::from(Path::new(&path)),
})
})
.with_context(|| format!("worktree not found for {:?}", path))?;
anyhow::Ok(project.open_buffer(project_path, cx))
})??
.await?;
let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
let suggestion = cx
.background_executor()
.spawn(async move { kind.resolve(&snapshot) })
.await;
Ok((buffer, suggestion))
}
}
impl AssistantEditKind {
fn resolve(self, snapshot: &BufferSnapshot) -> ResolvedEdit {
match self {
Self::Update {
old_text,
new_text,
description,
} => {
let range = Self::resolve_location(&snapshot, &old_text);
ResolvedEdit {
range,
new_text,
description: Some(description),
}
}
Self::Create {
new_text,
description,
} => ResolvedEdit {
range: text::Anchor::MIN..text::Anchor::MAX,
description: Some(description),
new_text,
},
Self::InsertBefore {
old_text,
mut new_text,
description,
} => {
let range = Self::resolve_location(&snapshot, &old_text);
new_text.push('\n');
ResolvedEdit {
range: range.start..range.start,
new_text,
description: Some(description),
}
}
Self::InsertAfter {
old_text,
mut new_text,
description,
} => {
let range = Self::resolve_location(&snapshot, &old_text);
new_text.insert(0, '\n');
ResolvedEdit {
range: range.end..range.end,
new_text,
description: Some(description),
}
}
Self::Delete { old_text } => {
let range = Self::resolve_location(&snapshot, &old_text);
ResolvedEdit {
range,
new_text: String::new(),
description: None,
}
}
}
}
fn resolve_location(buffer: &text::BufferSnapshot, search_query: &str) -> Range<text::Anchor> {
const INSERTION_COST: u32 = 3;
const WHITESPACE_INSERTION_COST: u32 = 1;
const DELETION_COST: u32 = 3;
const WHITESPACE_DELETION_COST: u32 = 1;
const EQUALITY_BONUS: u32 = 5;
struct Matrix {
cols: usize,
data: Vec<SearchState>,
}
impl Matrix {
fn new(rows: usize, cols: usize) -> Self {
Matrix {
cols,
data: vec![SearchState::new(0, SearchDirection::Diagonal); rows * cols],
}
}
fn get(&self, row: usize, col: usize) -> SearchState {
self.data[row * self.cols + col]
}
fn set(&mut self, row: usize, col: usize, cost: SearchState) {
self.data[row * self.cols + col] = cost;
}
}
let buffer_len = buffer.len();
let query_len = search_query.len();
let mut matrix = Matrix::new(query_len + 1, buffer_len + 1);
for (row, query_byte) in search_query.bytes().enumerate() {
for (col, buffer_byte) in buffer.bytes_in_range(0..buffer.len()).flatten().enumerate() {
let deletion_cost = if query_byte.is_ascii_whitespace() {
WHITESPACE_DELETION_COST
} else {
DELETION_COST
};
let insertion_cost = if buffer_byte.is_ascii_whitespace() {
WHITESPACE_INSERTION_COST
} else {
INSERTION_COST
};
let up = SearchState::new(
matrix.get(row, col + 1).score.saturating_sub(deletion_cost),
SearchDirection::Up,
);
let left = SearchState::new(
matrix
.get(row + 1, col)
.score
.saturating_sub(insertion_cost),
SearchDirection::Left,
);
let diagonal = SearchState::new(
if query_byte == *buffer_byte {
matrix.get(row, col).score.saturating_add(EQUALITY_BONUS)
} else {
matrix
.get(row, col)
.score
.saturating_sub(deletion_cost + insertion_cost)
},
SearchDirection::Diagonal,
);
matrix.set(row + 1, col + 1, up.max(left).max(diagonal));
}
}
// Traceback to find the best match
let mut best_buffer_end = buffer_len;
let mut best_score = 0;
for col in 1..=buffer_len {
let score = matrix.get(query_len, col).score;
if score > best_score {
best_score = score;
best_buffer_end = col;
}
}
let mut query_ix = query_len;
let mut buffer_ix = best_buffer_end;
while query_ix > 0 && buffer_ix > 0 {
let current = matrix.get(query_ix, buffer_ix);
match current.direction {
SearchDirection::Diagonal => {
query_ix -= 1;
buffer_ix -= 1;
}
SearchDirection::Up => {
query_ix -= 1;
}
SearchDirection::Left => {
buffer_ix -= 1;
}
}
}
let mut start = buffer.offset_to_point(buffer.clip_offset(buffer_ix, Bias::Left));
start.column = 0;
let mut end = buffer.offset_to_point(buffer.clip_offset(best_buffer_end, Bias::Right));
if end.column > 0 {
end.column = buffer.line_len(end.row);
}
buffer.anchor_after(start)..buffer.anchor_before(end)
}
}
impl AssistantPatch {
pub(crate) async fn resolve(
&self,
project: Model<Project>,
cx: &mut AsyncAppContext,
) -> ResolvedPatch {
let mut resolve_tasks = Vec::new();
for (ix, edit) in self.edits.iter().enumerate() {
if let Ok(edit) = edit.as_ref() {
resolve_tasks.push(
edit.resolve(project.clone(), cx.clone())
.map_err(move |error| (ix, error)),
);
}
}
let edits = future::join_all(resolve_tasks).await;
let mut errors = Vec::new();
let mut edits_by_buffer = HashMap::default();
for entry in edits {
match entry {
Ok((buffer, edit)) => {
edits_by_buffer
.entry(buffer)
.or_insert_with(Vec::new)
.push(edit);
}
Err((edit_ix, error)) => errors.push(AssistantPatchResolutionError {
edit_ix,
message: error.to_string(),
}),
}
}
// Expand the context ranges of each edit and group edits with overlapping context ranges.
let mut edit_groups_by_buffer = HashMap::default();
for (buffer, edits) in edits_by_buffer {
if let Ok(snapshot) = buffer.update(cx, |buffer, _| buffer.text_snapshot()) {
edit_groups_by_buffer.insert(buffer, Self::group_edits(edits, &snapshot));
}
}
ResolvedPatch {
edit_groups: edit_groups_by_buffer,
errors,
}
}
fn group_edits(
mut edits: Vec<ResolvedEdit>,
snapshot: &text::BufferSnapshot,
) -> Vec<ResolvedEditGroup> {
let mut edit_groups = Vec::<ResolvedEditGroup>::new();
// Sort edits by their range so that earlier, larger ranges come first
edits.sort_by(|a, b| a.range.cmp(&b.range, &snapshot));
// Merge overlapping edits
edits.dedup_by(|a, b| b.try_merge(a, &snapshot));
// Create context ranges for each edit
for edit in edits {
let context_range = {
let edit_point_range = edit.range.to_point(&snapshot);
let start_row = edit_point_range.start.row.saturating_sub(5);
let end_row = cmp::min(edit_point_range.end.row + 5, snapshot.max_point().row);
let start = snapshot.anchor_before(Point::new(start_row, 0));
let end = snapshot.anchor_after(Point::new(end_row, snapshot.line_len(end_row)));
start..end
};
if let Some(last_group) = edit_groups.last_mut() {
if last_group
.context_range
.end
.cmp(&context_range.start, &snapshot)
.is_ge()
{
// Merge with the previous group if context ranges overlap
last_group.context_range.end = context_range.end;
last_group.edits.push(edit);
} else {
// Create a new group
edit_groups.push(ResolvedEditGroup {
context_range,
edits: vec![edit],
});
}
} else {
// Create the first group
edit_groups.push(ResolvedEditGroup {
context_range,
edits: vec![edit],
});
}
}
edit_groups
}
pub fn path_count(&self) -> usize {
self.paths().count()
}
pub fn paths(&self) -> impl '_ + Iterator<Item = &str> {
let mut prev_path = None;
self.edits.iter().filter_map(move |edit| {
if let Ok(edit) = edit {
let path = Some(edit.path.as_str());
if path != prev_path {
prev_path = path;
return path;
}
}
None
})
}
}
impl PartialEq for AssistantPatch {
fn eq(&self, other: &Self) -> bool {
self.range == other.range
&& self.title == other.title
&& Arc::ptr_eq(&self.edits, &other.edits)
}
}
impl Eq for AssistantPatch {}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{AppContext, Context};
use language::{
language_settings::AllLanguageSettings, Language, LanguageConfig, LanguageMatcher,
};
use settings::SettingsStore;
use text::{OffsetRangeExt, Point};
use ui::BorrowAppContext;
use unindent::Unindent as _;
#[gpui::test]
fn test_resolve_location(cx: &mut AppContext) {
{
let buffer = cx.new_model(|cx| {
Buffer::local(
concat!(
" Lorem\n",
" ipsum\n",
" dolor sit amet\n",
" consecteur",
),
cx,
)
});
let snapshot = buffer.read(cx).snapshot();
assert_eq!(
AssistantEditKind::resolve_location(&snapshot, "ipsum\ndolor").to_point(&snapshot),
Point::new(1, 0)..Point::new(2, 18)
);
}
{
let buffer = cx.new_model(|cx| {
Buffer::local(
concat!(
"fn foo1(a: usize) -> usize {\n",
" 40\n",
"}\n",
"\n",
"fn foo2(b: usize) -> usize {\n",
" 42\n",
"}\n",
),
cx,
)
});
let snapshot = buffer.read(cx).snapshot();
assert_eq!(
AssistantEditKind::resolve_location(&snapshot, "fn foo1(b: usize) {\n40\n}")
.to_point(&snapshot),
Point::new(0, 0)..Point::new(2, 1)
);
}
{
let buffer = cx.new_model(|cx| {
Buffer::local(
concat!(
"fn main() {\n",
" Foo\n",
" .bar()\n",
" .baz()\n",
" .qux()\n",
"}\n",
"\n",
"fn foo2(b: usize) -> usize {\n",
" 42\n",
"}\n",
),
cx,
)
});
let snapshot = buffer.read(cx).snapshot();
assert_eq!(
AssistantEditKind::resolve_location(&snapshot, "Foo.bar.baz.qux()")
.to_point(&snapshot),
Point::new(1, 0)..Point::new(4, 14)
);
}
}
#[gpui::test]
fn test_resolve_edits(cx: &mut AppContext) {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
cx.update_global::<SettingsStore, _>(|settings, cx| {
settings.update_user_settings::<AllLanguageSettings>(cx, |_| {});
});
assert_edits(
"
/// A person
struct Person {
name: String,
age: usize,
}
/// A dog
struct Dog {
weight: f32,
}
impl Person {
fn name(&self) -> &str {
&self.name
}
}
"
.unindent(),
vec![
AssistantEditKind::Update {
old_text: "
name: String,
"
.unindent(),
new_text: "
first_name: String,
last_name: String,
"
.unindent(),
description: "".into(),
},
AssistantEditKind::Update {
old_text: "
fn name(&self) -> &str {
&self.name
}
"
.unindent(),
new_text: "
fn name(&self) -> String {
format!(\"{} {}\", self.first_name, self.last_name)
}
"
.unindent(),
description: "".into(),
},
],
"
/// A person
struct Person {
first_name: String,
last_name: String,
age: usize,
}
/// A dog
struct Dog {
weight: f32,
}
impl Person {
fn name(&self) -> String {
format!(\"{} {}\", self.first_name, self.last_name)
}
}
"
.unindent(),
cx,
);
// Ensure InsertBefore merges correctly with Update of the same text
assert_edits(
"
fn foo() {
}
"
.unindent(),
vec![
AssistantEditKind::InsertBefore {
old_text: "
fn foo() {"
.unindent(),
new_text: "
fn bar() {
qux();
}"
.unindent(),
description: "implement bar".into(),
},
AssistantEditKind::Update {
old_text: "
fn foo() {
}"
.unindent(),
new_text: "
fn foo() {
bar();
}"
.unindent(),
description: "call bar in foo".into(),
},
AssistantEditKind::InsertAfter {
old_text: "
fn foo() {
}
"
.unindent(),
new_text: "
fn qux() {
// todo
}
"
.unindent(),
description: "implement qux".into(),
},
],
"
fn bar() {
qux();
}
fn foo() {
bar();
}
fn qux() {
// todo
}
"
.unindent(),
cx,
);
}
#[track_caller]
fn assert_edits(
old_text: String,
edits: Vec<AssistantEditKind>,
new_text: String,
cx: &mut AppContext,
) {
let buffer =
cx.new_model(|cx| Buffer::local(old_text, cx).with_language(Arc::new(rust_lang()), cx));
let snapshot = buffer.read(cx).snapshot();
let resolved_edits = edits
.into_iter()
.map(|kind| kind.resolve(&snapshot))
.collect();
let edit_groups = AssistantPatch::group_edits(resolved_edits, &snapshot);
ResolvedPatch::apply_edit_groups(&edit_groups, &buffer, cx);
let actual_new_text = buffer.read(cx).text();
pretty_assertions::assert_eq!(actual_new_text, new_text);
}
fn rust_lang() -> Language {
Language::new(
LanguageConfig {
name: "Rust".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["rs".to_string()],
..Default::default()
},
..Default::default()
},
Some(language::tree_sitter_rust::LANGUAGE.into()),
)
.with_indents_query(
r#"
(call_expression) @indent
(field_expression) @indent
(_ "(" ")" @end) @indent
(_ "{" "}" @end) @indent
"#,
)
.unwrap()
}
}

View File

@@ -45,15 +45,6 @@ pub struct ProjectSlashCommandPromptContext {
pub context_buffer: String,
}
/// Context required to generate a workflow step resolution prompt.
#[derive(Debug, Serialize)]
pub struct StepResolutionContext {
/// The full context, including <step>...</step> tags
pub workflow_context: String,
/// The text of the specific step from the context to resolve
pub step_to_resolve: String,
}
pub struct PromptLoadingParams<'a> {
pub fs: Arc<dyn Fs>,
pub repo_path: Option<PathBuf>,

View File

@@ -1,3 +1,4 @@
use super::create_label_for_command;
use anyhow::{anyhow, Result};
use assistant_slash_command::{
AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandOutput,
@@ -6,9 +7,9 @@ use assistant_slash_command::{
use collections::HashMap;
use context_servers::{
manager::{ContextServer, ContextServerManager},
protocol::PromptInfo,
types::Prompt,
};
use gpui::{Task, WeakView, WindowContext};
use gpui::{AppContext, Task, WeakView, WindowContext};
use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
@@ -18,11 +19,11 @@ use workspace::Workspace;
pub struct ContextServerSlashCommand {
server_id: String,
prompt: PromptInfo,
prompt: Prompt,
}
impl ContextServerSlashCommand {
pub fn new(server: &Arc<ContextServer>, prompt: PromptInfo) -> Self {
pub fn new(server: &Arc<ContextServer>, prompt: Prompt) -> Self {
Self {
server_id: server.id.clone(),
prompt,
@@ -35,12 +36,28 @@ impl SlashCommand for ContextServerSlashCommand {
self.prompt.name.clone()
}
fn label(&self, cx: &AppContext) -> language::CodeLabel {
let mut parts = vec![self.prompt.name.as_str()];
if let Some(args) = &self.prompt.arguments {
if let Some(arg) = args.first() {
parts.push(arg.name.as_str());
}
}
create_label_for_command(&parts[0], &parts[1..], cx)
}
fn description(&self) -> String {
format!("Run context server command: {}", self.prompt.name)
match &self.prompt.description {
Some(desc) => desc.clone(),
None => format!("Run '{}' from {}", self.prompt.name, self.server_id),
}
}
fn menu_text(&self) -> String {
format!("Run '{}' from {}", self.prompt.name, self.server_id)
match &self.prompt.description {
Some(desc) => desc.clone(),
None => format!("Run '{}' from {}", self.prompt.name, self.server_id),
}
}
fn requires_argument(&self) -> bool {
@@ -154,7 +171,7 @@ impl SlashCommand for ContextServerSlashCommand {
}
}
fn completion_argument(prompt: &PromptInfo, arguments: &[String]) -> Result<(String, String)> {
fn completion_argument(prompt: &Prompt, arguments: &[String]) -> Result<(String, String)> {
if arguments.is_empty() {
return Err(anyhow!("No arguments given"));
}
@@ -170,7 +187,7 @@ fn completion_argument(prompt: &PromptInfo, arguments: &[String]) -> Result<(Str
}
}
fn prompt_arguments(prompt: &PromptInfo, arguments: &[String]) -> Result<HashMap<String, String>> {
fn prompt_arguments(prompt: &Prompt, arguments: &[String]) -> Result<HashMap<String, String>> {
match &prompt.arguments {
Some(args) if args.len() > 1 => Err(anyhow!(
"Prompt has more than one argument, which is not supported"
@@ -199,7 +216,7 @@ fn prompt_arguments(prompt: &PromptInfo, arguments: &[String]) -> Result<HashMap
/// MCP servers can return prompts with multiple arguments. Since we only
/// support one argument, we ignore all others. This is the necessary predicate
/// for this.
pub fn acceptable_prompt(prompt: &PromptInfo) -> bool {
pub fn acceptable_prompt(prompt: &Prompt) -> bool {
match &prompt.arguments {
None => true,
Some(args) if args.len() <= 1 => true,

View File

@@ -18,6 +18,8 @@ pub(crate) struct WorkflowSlashCommand {
}
impl WorkflowSlashCommand {
pub const NAME: &'static str = "workflow";
pub fn new(prompt_builder: Arc<PromptBuilder>) -> Self {
Self { prompt_builder }
}
@@ -25,7 +27,7 @@ impl WorkflowSlashCommand {
impl SlashCommand for WorkflowSlashCommand {
fn name(&self) -> String {
"workflow".into()
Self::NAME.into()
}
fn description(&self) -> String {

View File

@@ -1,507 +0,0 @@
use crate::{AssistantPanel, InlineAssistId, InlineAssistant};
use anyhow::{anyhow, Context as _, Result};
use collections::HashMap;
use editor::Editor;
use gpui::AsyncAppContext;
use gpui::{Model, Task, UpdateGlobal as _, View, WeakView, WindowContext};
use language::{Buffer, BufferSnapshot};
use project::{Project, ProjectPath};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{ops::Range, path::Path, sync::Arc};
use text::Bias;
use workspace::Workspace;
#[derive(Debug)]
pub(crate) struct WorkflowStep {
pub range: Range<language::Anchor>,
pub leading_tags_end: text::Anchor,
pub trailing_tag_start: Option<text::Anchor>,
pub edits: Arc<[Result<WorkflowStepEdit>]>,
pub resolution_task: Option<Task<()>>,
pub resolution: Option<Arc<Result<WorkflowStepResolution>>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct WorkflowStepEdit {
pub path: String,
pub kind: WorkflowStepEditKind,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct WorkflowStepResolution {
pub title: String,
pub suggestion_groups: HashMap<Model<Buffer>, Vec<WorkflowSuggestionGroup>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorkflowSuggestionGroup {
pub context_range: Range<language::Anchor>,
pub suggestions: Vec<WorkflowSuggestion>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WorkflowSuggestion {
Update {
range: Range<language::Anchor>,
description: String,
},
CreateFile {
description: String,
},
InsertBefore {
position: language::Anchor,
description: String,
},
InsertAfter {
position: language::Anchor,
description: String,
},
Delete {
range: Range<language::Anchor>,
},
}
impl WorkflowSuggestion {
pub fn range(&self) -> Range<language::Anchor> {
match self {
Self::Update { range, .. } => range.clone(),
Self::CreateFile { .. } => language::Anchor::MIN..language::Anchor::MAX,
Self::InsertBefore { position, .. } | Self::InsertAfter { position, .. } => {
*position..*position
}
Self::Delete { range, .. } => range.clone(),
}
}
pub fn description(&self) -> Option<&str> {
match self {
Self::Update { description, .. }
| Self::CreateFile { description }
| Self::InsertBefore { description, .. }
| Self::InsertAfter { description, .. } => Some(description),
Self::Delete { .. } => None,
}
}
fn description_mut(&mut self) -> Option<&mut String> {
match self {
Self::Update { description, .. }
| Self::CreateFile { description }
| Self::InsertBefore { description, .. }
| Self::InsertAfter { description, .. } => Some(description),
Self::Delete { .. } => None,
}
}
pub fn try_merge(&mut self, other: &Self, buffer: &BufferSnapshot) -> bool {
let range = self.range();
let other_range = other.range();
// Don't merge if we don't contain the other suggestion.
if range.start.cmp(&other_range.start, buffer).is_gt()
|| range.end.cmp(&other_range.end, buffer).is_lt()
{
return false;
}
if let Some(description) = self.description_mut() {
if let Some(other_description) = other.description() {
description.push('\n');
description.push_str(other_description);
}
}
true
}
pub fn show(
&self,
editor: &View<Editor>,
excerpt_id: editor::ExcerptId,
workspace: &WeakView<Workspace>,
assistant_panel: &View<AssistantPanel>,
cx: &mut WindowContext,
) -> Option<InlineAssistId> {
let mut initial_transaction_id = None;
let initial_prompt;
let suggestion_range;
let buffer = editor.read(cx).buffer().clone();
let snapshot = buffer.read(cx).snapshot(cx);
match self {
Self::Update {
range, description, ..
} => {
initial_prompt = description.clone();
suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)?
..snapshot.anchor_in_excerpt(excerpt_id, range.end)?;
}
Self::CreateFile { description } => {
initial_prompt = description.clone();
suggestion_range = editor::Anchor::min()..editor::Anchor::min();
}
Self::InsertBefore {
position,
description,
..
} => {
let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
initial_prompt = description.clone();
suggestion_range = buffer.update(cx, |buffer, cx| {
buffer.start_transaction(cx);
let line_start = buffer.insert_empty_line(position, true, true, cx);
initial_transaction_id = buffer.end_transaction(cx);
buffer.refresh_preview(cx);
let line_start = buffer.read(cx).anchor_before(line_start);
line_start..line_start
});
}
Self::InsertAfter {
position,
description,
..
} => {
let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
initial_prompt = description.clone();
suggestion_range = buffer.update(cx, |buffer, cx| {
buffer.start_transaction(cx);
let line_start = buffer.insert_empty_line(position, true, true, cx);
initial_transaction_id = buffer.end_transaction(cx);
buffer.refresh_preview(cx);
let line_start = buffer.read(cx).anchor_before(line_start);
line_start..line_start
});
}
Self::Delete { range, .. } => {
initial_prompt = "Delete".to_string();
suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)?
..snapshot.anchor_in_excerpt(excerpt_id, range.end)?;
}
}
InlineAssistant::update_global(cx, |inline_assistant, cx| {
Some(inline_assistant.suggest_assist(
editor,
suggestion_range,
initial_prompt,
initial_transaction_id,
false,
Some(workspace.clone()),
Some(assistant_panel),
cx,
))
})
}
}
impl WorkflowStepEdit {
pub fn new(
path: Option<String>,
operation: Option<String>,
search: Option<String>,
description: Option<String>,
) -> Result<Self> {
let path = path.ok_or_else(|| anyhow!("missing path"))?;
let operation = operation.ok_or_else(|| anyhow!("missing operation"))?;
let kind = match operation.as_str() {
"update" => WorkflowStepEditKind::Update {
search: search.ok_or_else(|| anyhow!("missing search"))?,
description: description.ok_or_else(|| anyhow!("missing description"))?,
},
"insert_before" => WorkflowStepEditKind::InsertBefore {
search: search.ok_or_else(|| anyhow!("missing search"))?,
description: description.ok_or_else(|| anyhow!("missing description"))?,
},
"insert_after" => WorkflowStepEditKind::InsertAfter {
search: search.ok_or_else(|| anyhow!("missing search"))?,
description: description.ok_or_else(|| anyhow!("missing description"))?,
},
"delete" => WorkflowStepEditKind::Delete {
search: search.ok_or_else(|| anyhow!("missing search"))?,
},
"create" => WorkflowStepEditKind::Create {
description: description.ok_or_else(|| anyhow!("missing description"))?,
},
_ => Err(anyhow!("unknown operation {operation:?}"))?,
};
Ok(Self { path, kind })
}
pub async fn resolve(
&self,
project: Model<Project>,
mut cx: AsyncAppContext,
) -> Result<(Model<Buffer>, super::WorkflowSuggestion)> {
let path = self.path.clone();
let kind = self.kind.clone();
let buffer = project
.update(&mut cx, |project, cx| {
let project_path = project
.find_project_path(Path::new(&path), cx)
.or_else(|| {
// If we couldn't find a project path for it, put it in the active worktree
// so that when we create the buffer, it can be saved.
let worktree = project
.active_entry()
.and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
.or_else(|| project.worktrees(cx).next())?;
let worktree = worktree.read(cx);
Some(ProjectPath {
worktree_id: worktree.id(),
path: Arc::from(Path::new(&path)),
})
})
.with_context(|| format!("worktree not found for {:?}", path))?;
anyhow::Ok(project.open_buffer(project_path, cx))
})??
.await?;
let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
let suggestion = cx
.background_executor()
.spawn(async move {
match kind {
WorkflowStepEditKind::Update {
search,
description,
} => {
let range = Self::resolve_location(&snapshot, &search);
WorkflowSuggestion::Update { range, description }
}
WorkflowStepEditKind::Create { description } => {
WorkflowSuggestion::CreateFile { description }
}
WorkflowStepEditKind::InsertBefore {
search,
description,
} => {
let range = Self::resolve_location(&snapshot, &search);
WorkflowSuggestion::InsertBefore {
position: range.start,
description,
}
}
WorkflowStepEditKind::InsertAfter {
search,
description,
} => {
let range = Self::resolve_location(&snapshot, &search);
WorkflowSuggestion::InsertAfter {
position: range.end,
description,
}
}
WorkflowStepEditKind::Delete { search } => {
let range = Self::resolve_location(&snapshot, &search);
WorkflowSuggestion::Delete { range }
}
}
})
.await;
Ok((buffer, suggestion))
}
fn resolve_location(buffer: &text::BufferSnapshot, search_query: &str) -> Range<text::Anchor> {
const INSERTION_SCORE: f64 = -1.0;
const DELETION_SCORE: f64 = -1.0;
const REPLACEMENT_SCORE: f64 = -1.0;
const EQUALITY_SCORE: f64 = 5.0;
struct Matrix {
cols: usize,
data: Vec<f64>,
}
impl Matrix {
fn new(rows: usize, cols: usize) -> Self {
Matrix {
cols,
data: vec![0.0; rows * cols],
}
}
fn get(&self, row: usize, col: usize) -> f64 {
self.data[row * self.cols + col]
}
fn set(&mut self, row: usize, col: usize, value: f64) {
self.data[row * self.cols + col] = value;
}
}
let buffer_len = buffer.len();
let query_len = search_query.len();
let mut matrix = Matrix::new(query_len + 1, buffer_len + 1);
for (i, query_byte) in search_query.bytes().enumerate() {
for (j, buffer_byte) in buffer.bytes_in_range(0..buffer.len()).flatten().enumerate() {
let match_score = if query_byte == *buffer_byte {
EQUALITY_SCORE
} else {
REPLACEMENT_SCORE
};
let up = matrix.get(i + 1, j) + DELETION_SCORE;
let left = matrix.get(i, j + 1) + INSERTION_SCORE;
let diagonal = matrix.get(i, j) + match_score;
let score = up.max(left.max(diagonal)).max(0.);
matrix.set(i + 1, j + 1, score);
}
}
// Traceback to find the best match
let mut best_buffer_end = buffer_len;
let mut best_score = 0.0;
for col in 1..=buffer_len {
let score = matrix.get(query_len, col);
if score > best_score {
best_score = score;
best_buffer_end = col;
}
}
let mut query_ix = query_len;
let mut buffer_ix = best_buffer_end;
while query_ix > 0 && buffer_ix > 0 {
let current = matrix.get(query_ix, buffer_ix);
let up = matrix.get(query_ix - 1, buffer_ix);
let left = matrix.get(query_ix, buffer_ix - 1);
if current == left + INSERTION_SCORE {
buffer_ix -= 1;
} else if current == up + DELETION_SCORE {
query_ix -= 1;
} else {
query_ix -= 1;
buffer_ix -= 1;
}
}
let mut start = buffer.offset_to_point(buffer.clip_offset(buffer_ix, Bias::Left));
start.column = 0;
let mut end = buffer.offset_to_point(buffer.clip_offset(best_buffer_end, Bias::Right));
end.column = buffer.line_len(end.row);
buffer.anchor_after(start)..buffer.anchor_before(end)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "operation")]
pub enum WorkflowStepEditKind {
/// Rewrites the specified text entirely based on the given description.
/// This operation completely replaces the given text.
Update {
/// A string in the source text to apply the update to.
search: String,
/// A brief description of the transformation to apply to the symbol.
description: String,
},
/// Creates a new file with the given path based on the provided description.
/// This operation adds a new file to the codebase.
Create {
/// A brief description of the file to be created.
description: String,
},
/// Inserts text before the specified text in the source file.
InsertBefore {
/// A string in the source text to insert text before.
search: String,
/// A brief description of how the new text should be generated.
description: String,
},
/// Inserts text after the specified text in the source file.
InsertAfter {
/// A string in the source text to insert text after.
search: String,
/// A brief description of how the new text should be generated.
description: String,
},
/// Deletes the specified symbol from the containing file.
Delete {
/// A string in the source text to delete.
search: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{AppContext, Context};
use text::{OffsetRangeExt, Point};
#[gpui::test]
fn test_resolve_location(cx: &mut AppContext) {
{
let buffer = cx.new_model(|cx| {
Buffer::local(
concat!(
" Lorem\n",
" ipsum\n",
" dolor sit amet\n",
" consecteur",
),
cx,
)
});
let snapshot = buffer.read(cx).snapshot();
assert_eq!(
WorkflowStepEdit::resolve_location(&snapshot, "ipsum\ndolor").to_point(&snapshot),
Point::new(1, 0)..Point::new(2, 18)
);
}
{
let buffer = cx.new_model(|cx| {
Buffer::local(
concat!(
"fn foo1(a: usize) -> usize {\n",
" 42\n",
"}\n",
"\n",
"fn foo2(b: usize) -> usize {\n",
" 42\n",
"}\n",
),
cx,
)
});
let snapshot = buffer.read(cx).snapshot();
assert_eq!(
WorkflowStepEdit::resolve_location(&snapshot, "fn foo1(b: usize) {\n42\n}")
.to_point(&snapshot),
Point::new(0, 0)..Point::new(2, 1)
);
}
{
let buffer = cx.new_model(|cx| {
Buffer::local(
concat!(
"fn main() {\n",
" Foo\n",
" .bar()\n",
" .baz()\n",
" .qux()\n",
"}\n",
"\n",
"fn foo2(b: usize) -> usize {\n",
" 42\n",
"}\n",
),
cx,
)
});
let snapshot = buffer.read(cx).snapshot();
assert_eq!(
WorkflowStepEdit::resolve_location(&snapshot, "Foo.bar.baz.qux()")
.to_point(&snapshot),
Point::new(1, 0)..Point::new(4, 14)
);
}
}
}

View File

@@ -4,6 +4,7 @@ pub mod test;
mod socks;
pub mod telemetry;
pub mod user;
pub mod zed_urls;
use anyhow::{anyhow, bail, Context as _, Result};
use async_recursion::async_recursion;

View File

@@ -0,0 +1,19 @@
//! Contains helper functions for constructing URLs to various Zed-related pages.
//!
//! These URLs will adapt to the configured server URL in order to construct
//! links appropriate for the environment (e.g., by linking to a local copy of
//! zed.dev in development).
use gpui::AppContext;
use settings::Settings;
use crate::ClientSettings;
fn server_url(cx: &AppContext) -> &str {
&ClientSettings::get_global(cx).server_url
}
/// Returns the URL to the account page on zed.dev.
pub fn account_url(cx: &AppContext) -> String {
format!("{server_url}/account", server_url = server_url(cx))
}

View File

@@ -199,6 +199,12 @@ spec:
secretKeyRef:
name: slack
key: panics_webhook
- name: STRIPE_API_KEY
valueFrom:
secretKeyRef:
name: stripe
key: api_key
optional: true
- name: COMPLETE_WITH_LANGUAGE_MODEL_RATE_LIMIT_PER_HOUR
value: "1000"
- name: SUPERMAVEN_ADMIN_API_KEY

View File

@@ -19,8 +19,8 @@ use stripe::{
};
use util::ResultExt;
use crate::llm::DEFAULT_MAX_MONTHLY_SPEND;
use crate::rpc::ResultExt as _;
use crate::llm::{DEFAULT_MAX_MONTHLY_SPEND, FREE_TIER_MONTHLY_SPENDING_LIMIT};
use crate::rpc::{ResultExt as _, Server};
use crate::{
db::{
billing_customer, BillingSubscriptionId, CreateBillingCustomerParams,
@@ -50,6 +50,7 @@ pub fn router() -> Router {
"/billing/subscriptions/manage",
post(manage_billing_subscription),
)
.route("/billing/monthly_spend", get(get_monthly_spend))
}
#[derive(Debug, Deserialize)]
@@ -404,7 +405,7 @@ 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>) {
pub fn poll_stripe_events_periodically(app: Arc<AppState>, rpc_server: Arc<Server>) {
let Some(stripe_client) = app.stripe_client.clone() else {
log::warn!("failed to retrieve Stripe client");
return;
@@ -415,7 +416,9 @@ pub fn poll_stripe_events_periodically(app: Arc<AppState>) {
let executor = executor.clone();
async move {
loop {
poll_stripe_events(&app, &stripe_client).await.log_err();
poll_stripe_events(&app, &rpc_server, &stripe_client)
.await
.log_err();
executor.sleep(POLL_EVENTS_INTERVAL).await;
}
@@ -425,6 +428,7 @@ pub fn poll_stripe_events_periodically(app: Arc<AppState>) {
async fn poll_stripe_events(
app: &Arc<AppState>,
rpc_server: &Arc<Server>,
stripe_client: &stripe::Client,
) -> anyhow::Result<()> {
fn event_type_to_string(event_type: EventType) -> String {
@@ -449,29 +453,28 @@ async fn poll_stripe_events(
let mut pages_of_already_processed_events = 0;
let mut unprocessed_events = Vec::new();
log::info!(
"Stripe events: starting retrieval for {}",
event_types.join(", ")
);
let mut params = ListEvents::new();
params.types = Some(event_types.clone());
params.limit = Some(EVENTS_LIMIT_PER_PAGE);
let mut event_pages = stripe::Event::list(&stripe_client, &params)
.await?
.paginate(params);
loop {
if pages_of_already_processed_events >= NUMBER_OF_ALREADY_PROCESSED_PAGES_BEFORE_WE_STOP {
log::info!("saw {pages_of_already_processed_events} pages of already-processed events: stopping event retrieval");
break;
}
log::info!("retrieving events from Stripe: {}", event_types.join(", "));
let mut params = ListEvents::new();
params.types = Some(event_types.clone());
params.limit = Some(EVENTS_LIMIT_PER_PAGE);
let events = stripe::Event::list(stripe_client, &params).await?;
let processed_event_ids = {
let event_ids = &events
let event_ids = event_pages
.page
.data
.iter()
.map(|event| event.id.as_str())
.collect::<Vec<_>>();
app.db
.get_processed_stripe_events_by_event_ids(event_ids)
.get_processed_stripe_events_by_event_ids(&event_ids)
.await?
.into_iter()
.map(|event| event.stripe_event_id)
@@ -479,13 +482,13 @@ async fn poll_stripe_events(
};
let mut processed_events_in_page = 0;
let events_in_page = events.data.len();
for event in events.data {
let events_in_page = event_pages.page.data.len();
for event in &event_pages.page.data {
if processed_event_ids.contains(&event.id.to_string()) {
processed_events_in_page += 1;
log::debug!("Stripe event {} already processed: skipping", event.id);
log::debug!("Stripe events: already processed '{}', skipping", event.id);
} else {
unprocessed_events.push(event);
unprocessed_events.push(event.clone());
}
}
@@ -493,15 +496,21 @@ async fn poll_stripe_events(
pages_of_already_processed_events += 1;
}
if !events.has_more {
if event_pages.page.has_more {
if pages_of_already_processed_events >= NUMBER_OF_ALREADY_PROCESSED_PAGES_BEFORE_WE_STOP
{
log::info!("Stripe events: stopping, saw {pages_of_already_processed_events} pages of already-processed events");
break;
} else {
log::info!("Stripe events: retrieving next page");
event_pages = event_pages.next(&stripe_client).await?;
}
} else {
break;
}
}
log::info!(
"unprocessed events from Stripe: {}",
unprocessed_events.len()
);
log::info!("Stripe events: unprocessed {}", unprocessed_events.len());
// Sort all of the unprocessed events in ascending order, so we can handle them in the order they occurred.
unprocessed_events.sort_by(|a, b| a.created.cmp(&b.created).then_with(|| a.id.cmp(&b.id)));
@@ -517,12 +526,12 @@ async fn poll_stripe_events(
// If the event has happened too far in the past, we don't want to
// process it and risk overwriting other more-recent updates.
//
// 1 hour was chosen arbitrarily. This could be made longer or shorter.
let one_hour = Duration::from_secs(60 * 60);
let an_hour_ago = Utc::now() - one_hour;
if an_hour_ago.timestamp() > event.created {
// 1 day was chosen arbitrarily. This could be made longer or shorter.
let one_day = Duration::from_secs(24 * 60 * 60);
let a_day_ago = Utc::now() - one_day;
if a_day_ago.timestamp() > event.created {
log::info!(
"Stripe event {} is more than {one_hour:?} old, marking as processed",
"Stripe events: event '{}' is more than {one_day:?} old, marking as processed",
event_id
);
app.db
@@ -541,7 +550,7 @@ async fn poll_stripe_events(
| EventType::CustomerSubscriptionPaused
| EventType::CustomerSubscriptionResumed
| EventType::CustomerSubscriptionDeleted => {
handle_customer_subscription_event(app, stripe_client, event).await
handle_customer_subscription_event(app, rpc_server, stripe_client, event).await
}
_ => Ok(()),
};
@@ -609,6 +618,7 @@ async fn handle_customer_event(
async fn handle_customer_subscription_event(
app: &Arc<AppState>,
rpc_server: &Arc<Server>,
stripe_client: &stripe::Client,
event: stripe::Event,
) -> anyhow::Result<()> {
@@ -654,9 +664,52 @@ async fn handle_customer_subscription_event(
.await?;
}
// When the user's subscription changes, we want to refresh their LLM tokens
// to either grant/revoke access.
rpc_server
.refresh_llm_tokens_for_user(billing_customer.user_id)
.await;
Ok(())
}
#[derive(Debug, Deserialize)]
struct GetMonthlySpendParams {
github_user_id: i32,
}
#[derive(Debug, Serialize)]
struct GetMonthlySpendResponse {
monthly_spend_in_cents: i32,
}
async fn get_monthly_spend(
Extension(app): Extension<Arc<AppState>>,
Query(params): Query<GetMonthlySpendParams>,
) -> Result<Json<GetMonthlySpendResponse>> {
let user = app
.db
.get_user_by_github_user_id(params.github_user_id)
.await?
.ok_or_else(|| anyhow!("user not found"))?;
let Some(llm_db) = app.llm_db.clone() else {
return Err(Error::http(
StatusCode::NOT_IMPLEMENTED,
"LLM database not available".into(),
));
};
let monthly_spend = llm_db
.get_user_spending_for_month(user.id, Utc::now())
.await?
.saturating_sub(FREE_TIER_MONTHLY_SPENDING_LIMIT);
Ok(Json(GetMonthlySpendResponse {
monthly_spend_in_cents: monthly_spend.0 as i32,
}))
}
impl From<SubscriptionStatus> for StripeSubscriptionStatus {
fn from(value: SubscriptionStatus) -> Self {
match value {
@@ -738,6 +791,7 @@ pub fn sync_llm_usage_with_stripe_periodically(app: Arc<AppState>) {
loop {
sync_with_stripe(&app, &llm_db, &stripe_billing)
.await
.context("failed to sync LLM usage to Stripe")
.trace_err();
executor.sleep(SYNC_LLM_USAGE_WITH_STRIPE_INTERVAL).await;
}

View File

@@ -10,6 +10,8 @@
Copy,
derive_more::Add,
derive_more::AddAssign,
derive_more::Sub,
derive_more::SubAssign,
)]
pub struct Cents(pub u32);

View File

@@ -198,10 +198,6 @@ impl Config {
}
}
pub fn is_llm_billing_enabled(&self) -> bool {
self.stripe_api_key.is_some()
}
#[cfg(test)]
pub fn test() -> Self {
Self {

View File

@@ -21,6 +21,24 @@ use axum::{
use chrono::{DateTime, Duration, Utc};
use collections::HashMap;
use db::TokenUsage;
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ComputeEmbeddingsRequest {
pub model: String,
pub texts: Vec<String>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ComputeEmbeddingsResponse {
pub embeddings: Vec<Embedding>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Embedding {
pub digest: Vec<u8>,
pub dimensions: Vec<f32>,
}
use db::{usage_measure::UsageMeasure, ActiveUserCount, LlmDatabase};
use futures::{Stream, StreamExt as _};
use reqwest_client::ReqwestClient;
@@ -28,6 +46,7 @@ use rpc::{
proto::Plan, LanguageModelProvider, PerformCompletionParams, EXPIRED_LLM_TOKEN_HEADER_NAME,
};
use rpc::{ListModelsResponse, MAX_LLM_MONTHLY_SPEND_REACHED_HEADER_NAME};
use sha2::Sha256;
use std::{
pin::Pin,
sync::Arc,
@@ -113,10 +132,79 @@ impl LlmState {
}
}
async fn compute_embeddings_http(
Extension(state): Extension<Arc<LlmState>>,
Extension(claims): Extension<LlmTokenClaims>,
Json(request): Json<proto::ComputeEmbeddings>,
) -> Result<impl IntoResponse> {
let api_key = state
.config
.openai_api_key
.as_ref()
.context("no OpenAI API key configured on the server")?;
let rate_limit: Box<dyn RateLimit> = match claims.plan {
proto::Plan::ZedPro => Box::new(ZedProComputeEmbeddingsRateLimit),
proto::Plan::Free => Box::new(FreeComputeEmbeddingsRateLimit),
};
state
.app_state
.rate_limiter
.check(&*rate_limit, UserId::from_proto(claims.user_id))
.await?;
let embeddings = match request.model.as_str() {
"openai/text-embedding-3-small" => {
open_ai::embed(
&state.http_client,
OPEN_AI_API_URL,
api_key,
OpenAiEmbeddingModel::TextEmbedding3Small,
request.texts.iter().map(|text| text.as_str()),
)
.await?
}
provider => return Err(anyhow!("unsupported embedding provider {:?}", provider))?,
};
let embeddings = request
.texts
.iter()
.map(|text| {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
let result = hasher.finalize();
result.to_vec()
})
.zip(
embeddings
.data
.into_iter()
.map(|embedding| embedding.embedding),
)
.collect::<HashMap<_, _>>();
state
.db
.save_embeddings(&request.model, &embeddings)
.await
.context("failed to save embeddings")
.trace_err();
Ok(Json(proto::ComputeEmbeddingsResponse {
embeddings: embeddings
.into_iter()
.map(|(digest, dimensions)| proto::Embedding { digest, dimensions })
.collect(),
}))
}
pub fn routes() -> Router<(), Body> {
Router::new()
.route("/models", get(list_models))
.route("/completion", post(perform_completion))
.route("/compute_embeddings", post(compute_embeddings_http))
.layer(middleware::from_fn(validate_api_token))
}
@@ -435,7 +523,7 @@ fn normalize_model_name(known_models: Vec<String>, name: String) -> String {
/// The maximum monthly spending an individual user can reach on the free tier
/// before they have to pay.
pub const FREE_TIER_MONTHLY_SPENDING_LIMIT: Cents = Cents::from_dollars(5);
pub const FREE_TIER_MONTHLY_SPENDING_LIMIT: Cents = Cents::from_dollars(10);
/// The default value to use for maximum spend per month if the user did not
/// explicitly set a maximum spend.
@@ -443,9 +531,6 @@ pub const FREE_TIER_MONTHLY_SPENDING_LIMIT: Cents = Cents::from_dollars(5);
/// Used to prevent surprise bills.
pub const DEFAULT_MAX_MONTHLY_SPEND: Cents = Cents::from_dollars(10);
/// The maximum lifetime spending an individual user can reach before being cut off.
const LIFETIME_SPENDING_LIMIT: Cents = Cents::from_dollars(1_000);
async fn check_usage_limit(
state: &Arc<LlmState>,
provider: LanguageModelProvider,
@@ -463,36 +548,28 @@ async fn check_usage_limit(
)
.await?;
if state.config.is_llm_billing_enabled() {
if usage.spending_this_month >= FREE_TIER_MONTHLY_SPENDING_LIMIT {
if !claims.has_llm_subscription {
return Err(Error::http(
StatusCode::PAYMENT_REQUIRED,
"Maximum spending limit reached for this month.".to_string(),
));
}
if usage.spending_this_month >= Cents(claims.max_monthly_spend_in_cents) {
return Err(Error::Http(
StatusCode::FORBIDDEN,
"Maximum spending limit reached for this month.".to_string(),
[(
HeaderName::from_static(MAX_LLM_MONTHLY_SPEND_REACHED_HEADER_NAME),
HeaderValue::from_static("true"),
)]
.into_iter()
.collect(),
));
}
if usage.spending_this_month >= FREE_TIER_MONTHLY_SPENDING_LIMIT {
if !claims.has_llm_subscription {
return Err(Error::http(
StatusCode::PAYMENT_REQUIRED,
"Maximum spending limit reached for this month.".to_string(),
));
}
}
// TODO: Remove this once we've rolled out monthly spending limits.
if usage.lifetime_spending >= LIFETIME_SPENDING_LIMIT {
return Err(Error::http(
StatusCode::FORBIDDEN,
"Maximum spending limit reached.".to_string(),
));
if (usage.spending_this_month - FREE_TIER_MONTHLY_SPENDING_LIMIT)
>= Cents(claims.max_monthly_spend_in_cents)
{
return Err(Error::Http(
StatusCode::FORBIDDEN,
"Maximum spending limit reached for this month.".to_string(),
[(
HeaderName::from_static(MAX_LLM_MONTHLY_SPEND_REACHED_HEADER_NAME),
HeaderValue::from_static("true"),
)]
.into_iter()
.collect(),
));
}
}
let active_users = state.get_active_user_count(provider, model_name).await?;
@@ -636,7 +713,6 @@ where
impl<S> Drop for TokenCountingStream<S> {
fn drop(&mut self) {
let state = self.state.clone();
let is_llm_billing_enabled = state.config.is_llm_billing_enabled();
let claims = self.claims.clone();
let provider = self.provider;
let model = std::mem::take(&mut self.model);
@@ -650,14 +726,7 @@ impl<S> Drop for TokenCountingStream<S> {
provider,
&model,
tokens,
// We're passing `false` here if LLM billing is not enabled
// so that we don't write any records to the
// `billing_events` table until we're ready to bill users.
if is_llm_billing_enabled {
claims.has_llm_subscription
} else {
false
},
claims.has_llm_subscription,
Cents(claims.max_monthly_spend_in_cents),
Utc::now(),
)

View File

@@ -412,7 +412,7 @@ impl LlmDatabase {
if !is_staff
&& spending_this_month > FREE_TIER_MONTHLY_SPENDING_LIMIT
&& has_llm_subscription
&& spending_this_month <= max_monthly_spend
&& (spending_this_month - FREE_TIER_MONTHLY_SPENDING_LIMIT) <= max_monthly_spend
{
billing_event::ActiveModel {
id: ActiveValue::not_set(),

View File

@@ -1,10 +1,7 @@
use crate::{
db::UserId,
llm::{
db::{
queries::{providers::ModelParams, usages::Usage},
LlmDatabase, TokenUsage,
},
db::{queries::providers::ModelParams, LlmDatabase, TokenUsage},
FREE_TIER_MONTHLY_SPENDING_LIMIT,
},
test_llm_db, Cents,
@@ -45,24 +42,17 @@ async fn test_billing_limit_exceeded(db: &mut LlmDatabase) {
let user_id = UserId::from_proto(123);
let max_monthly_spend = Cents::from_dollars(10);
let max_monthly_spend = Cents::from_dollars(11);
// Record usage that brings us close to the limit but doesn't exceed it
// Let's say we use $9.50 worth of tokens
let tokens_to_use = 190_000_000; // This will cost $9.50 at $0.05 per 1 million tokens
// Let's say we use $10.50 worth of tokens
let tokens_to_use = 210_000_000; // This will cost $10.50 at $0.05 per 1 million tokens
let usage = TokenUsage {
input: tokens_to_use,
input_cache_creation: 0,
input_cache_read: 0,
output: 0,
};
let cost = Cents::new(tokens_to_use as u32 / 1_000_000 * PRICE_PER_MILLION_INPUT_TOKENS as u32);
assert_eq!(
cost,
Cents::new(950),
"expected the cost to be $9.50, based on the inputs, but it wasn't"
);
// Verify that before we record any usage, there are 0 billing events
let billing_events = db.get_billing_events().await.unwrap();
@@ -83,29 +73,9 @@ async fn test_billing_limit_exceeded(db: &mut LlmDatabase) {
// Verify the recorded usage and spending
let recorded_usage = db.get_usage(user_id, provider, model, now).await.unwrap();
// Verify that we exceeded the free tier usage
assert!(
recorded_usage.spending_this_month > FREE_TIER_MONTHLY_SPENDING_LIMIT,
"Expected spending to exceed free tier limit"
);
assert_eq!(
recorded_usage,
Usage {
requests_this_minute: 1,
tokens_this_minute: tokens_to_use,
tokens_this_day: tokens_to_use,
tokens_this_month: TokenUsage {
input: tokens_to_use,
input_cache_creation: 0,
input_cache_read: 0,
output: 0,
},
spending_this_month: Cents::new(950),
lifetime_spending: Cents::new(950),
}
);
assert_eq!(recorded_usage.spending_this_month, Cents::new(1050));
assert!(recorded_usage.spending_this_month > FREE_TIER_MONTHLY_SPENDING_LIMIT);
// Verify that there is one `billing_event` record
let billing_events = db.get_billing_events().await.unwrap();
@@ -118,7 +88,35 @@ async fn test_billing_limit_exceeded(db: &mut LlmDatabase) {
assert_eq!(billing_event.input_cache_read_tokens, 0);
assert_eq!(billing_event.output_tokens, 0);
let tokens_to_exceed = 20_000_000; // This will cost $1.00 more, pushing us from $9.50 to $10.50, which is over the $10 monthly maximum limit
// Record usage that puts us at $20.50
let usage_2 = TokenUsage {
input: 200_000_000, // This will cost $10 more, pushing us from $10.50 to $20.50,
input_cache_creation: 0,
input_cache_read: 0,
output: 0,
};
db.record_usage(
user_id,
false,
provider,
model,
usage_2,
true,
max_monthly_spend,
now,
)
.await
.unwrap();
// Verify the updated usage and spending
let updated_usage = db.get_usage(user_id, provider, model, now).await.unwrap();
assert_eq!(updated_usage.spending_this_month, Cents::new(2050));
// Verify that there are now two billing events
let billing_events = db.get_billing_events().await.unwrap();
assert_eq!(billing_events.len(), 2);
let tokens_to_exceed = 20_000_000; // This will cost $1.00 more, pushing us from $20.50 to $21.50, which is over the $11 monthly maximum limit
let usage_exceeding = TokenUsage {
input: tokens_to_exceed,
input_cache_creation: 0,
@@ -139,27 +137,12 @@ async fn test_billing_limit_exceeded(db: &mut LlmDatabase) {
)
.await
.unwrap();
// Verify that there is still one billing record
let billing_events = db.get_billing_events().await.unwrap();
assert_eq!(billing_events.len(), 1);
// Verify the updated usage and spending
let updated_usage = db.get_usage(user_id, provider, model, now).await.unwrap();
assert_eq!(
updated_usage,
Usage {
requests_this_minute: 2,
tokens_this_minute: tokens_to_use + tokens_to_exceed,
tokens_this_day: tokens_to_use + tokens_to_exceed,
tokens_this_month: TokenUsage {
input: tokens_to_use + tokens_to_exceed,
input_cache_creation: 0,
input_cache_read: 0,
output: 0,
},
spending_this_month: Cents::new(1050),
lifetime_spending: Cents::new(1050),
}
);
assert_eq!(updated_usage.spending_this_month, Cents::new(2150));
// Verify that we never exceed the user max spending for the user
// and avoid charging them.
let billing_events = db.get_billing_events().await.unwrap();
assert_eq!(billing_events.len(), 2);
}

View File

@@ -132,6 +132,8 @@ async fn main() -> Result<()> {
let rpc_server = collab::rpc::Server::new(epoch, state.clone());
rpc_server.start().await?;
poll_stripe_events_periodically(state.clone(), rpc_server.clone());
app = app
.merge(collab::api::routes(rpc_server.clone()))
.merge(collab::rpc::routes(rpc_server.clone()));
@@ -140,7 +142,6 @@ async fn main() -> Result<()> {
}
if mode.is_api() {
poll_stripe_events_periodically(state.clone());
fetch_extensions_from_blob_store_periodically(state.clone());
spawn_user_backfiller(state.clone());

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use crate::{llm, Cents, Result};
use anyhow::Context;
use chrono::Utc;
use chrono::{Datelike, Utc};
use collections::HashMap;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
@@ -45,7 +45,13 @@ impl StripeBilling {
let (meters, prices) = futures::try_join!(
StripeMeter::list(&self.client),
stripe::Price::list(&self.client, &stripe::ListPrices::default())
stripe::Price::list(
&self.client,
&stripe::ListPrices {
limit: Some(100),
..Default::default()
}
)
)?;
for meter in meters.data {
@@ -343,10 +349,20 @@ impl StripeBilling {
model: &StripeModel,
success_url: &str,
) -> Result<String> {
let first_of_next_month = Utc::now()
.checked_add_months(chrono::Months::new(1))
.unwrap()
.with_day(1)
.unwrap();
let mut params = stripe::CreateCheckoutSession::new();
params.mode = Some(stripe::CheckoutSessionMode::Subscription);
params.customer = Some(customer_id);
params.client_reference_id = Some(github_login);
params.subscription_data = Some(stripe::CreateCheckoutSessionSubscriptionData {
billing_cycle_anchor: Some(first_of_next_month.timestamp()),
..Default::default()
});
params.line_items = Some(
[
&model.input_tokens_price.id,
@@ -396,9 +412,12 @@ impl StripeMeter {
pub fn list(client: &stripe::Client) -> stripe::Response<stripe::List<Self>> {
#[derive(Serialize)]
struct Params {}
struct Params {
#[serde(skip_serializing_if = "Option::is_none")]
limit: Option<u64>,
}
client.get_query("/billing/meters", Params {})
client.get_query("/billing/meters", Params { limit: Some(100) })
}
}

View File

@@ -27,6 +27,7 @@ use language::{
use live_kit_client::MacOSDisplay;
use lsp::LanguageServerId;
use parking_lot::Mutex;
use project::lsp_store::FormatTarget;
use project::{
lsp_store::FormatTrigger, search::SearchQuery, search::SearchResult, DiagnosticSummary,
HoverBlockKind, Project, ProjectPath,
@@ -4417,6 +4418,7 @@ async fn test_formatting_buffer(
HashSet::from_iter([buffer_b.clone()]),
true,
FormatTrigger::Save,
FormatTarget::Buffer,
cx,
)
})
@@ -4450,6 +4452,7 @@ async fn test_formatting_buffer(
HashSet::from_iter([buffer_b.clone()]),
true,
FormatTrigger::Save,
FormatTarget::Buffer,
cx,
)
})
@@ -4555,6 +4558,7 @@ async fn test_prettier_formatting_buffer(
HashSet::from_iter([buffer_b.clone()]),
true,
FormatTrigger::Save,
FormatTarget::Buffer,
cx,
)
})
@@ -4574,6 +4578,7 @@ async fn test_prettier_formatting_buffer(
HashSet::from_iter([buffer_a.clone()]),
true,
FormatTrigger::Manual,
FormatTarget::Buffer,
cx,
)
})

View File

@@ -26,7 +26,7 @@ const JSON_RPC_VERSION: &str = "2.0";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
type ResponseHandler = Box<dyn Send + FnOnce(Result<String, Error>)>;
type NotificationHandler = Box<dyn Send + FnMut(RequestId, Value, AsyncAppContext)>;
type NotificationHandler = Box<dyn Send + FnMut(Value, AsyncAppContext)>;
#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(untagged)]
@@ -94,7 +94,6 @@ enum CspResult<T> {
#[derive(Serialize, Deserialize)]
struct Notification<'a, T> {
jsonrpc: &'static str,
id: RequestId,
#[serde(borrow)]
method: &'a str,
params: T,
@@ -103,7 +102,6 @@ struct Notification<'a, T> {
#[derive(Debug, Clone, Deserialize)]
struct AnyNotification<'a> {
jsonrpc: &'a str,
id: RequestId,
method: String,
#[serde(default)]
params: Option<Value>,
@@ -246,11 +244,7 @@ impl Client {
if let Some(handler) =
notification_handlers.get_mut(notification.method.as_str())
{
handler(
notification.id,
notification.params.unwrap_or(Value::Null),
cx.clone(),
);
handler(notification.params.unwrap_or(Value::Null), cx.clone());
}
}
}
@@ -378,10 +372,8 @@ impl Client {
/// Sends a notification to the context server without expecting a response.
/// This function serializes the notification and sends it through the outbound channel.
pub fn notify(&self, method: &str, params: impl Serialize) -> Result<()> {
let id = self.next_id.fetch_add(1, SeqCst);
let notification = serde_json::to_string(&Notification {
jsonrpc: JSON_RPC_VERSION,
id: RequestId::Int(id),
method,
params,
})
@@ -390,13 +382,13 @@ impl Client {
Ok(())
}
pub fn on_notification<F>(&self, method: &'static str, mut f: F)
pub fn on_notification<F>(&self, method: &'static str, f: F)
where
F: 'static + Send + FnMut(Value, AsyncAppContext),
{
self.notification_handlers
.lock()
.insert(method, Box::new(move |_, params, cx| f(params, cx)));
.insert(method, Box::new(f));
}
pub fn name(&self) -> &str {

View File

@@ -85,7 +85,7 @@ impl ContextServer {
)?;
let protocol = crate::protocol::ModelContextProtocol::new(client);
let client_info = types::EntityInfo {
let client_info = types::Implementation {
name: "Zed".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
};

View File

@@ -11,8 +11,6 @@ use collections::HashMap;
use crate::client::Client;
use crate::types;
pub use types::PromptInfo;
const PROTOCOL_VERSION: u32 = 1;
pub struct ModelContextProtocol {
@@ -26,7 +24,7 @@ impl ModelContextProtocol {
pub async fn initialize(
self,
client_info: types::EntityInfo,
client_info: types::Implementation,
) -> Result<InitializedContextServerProtocol> {
let params = types::InitializeParams {
protocol_version: PROTOCOL_VERSION,
@@ -96,7 +94,7 @@ impl InitializedContextServerProtocol {
}
/// List the MCP prompts.
pub async fn list_prompts(&self) -> Result<Vec<types::PromptInfo>> {
pub async fn list_prompts(&self) -> Result<Vec<types::Prompt>> {
self.check_capability(ServerCapability::Prompts)?;
let response: types::PromptsListResponse = self
@@ -107,6 +105,18 @@ impl InitializedContextServerProtocol {
Ok(response.prompts)
}
/// List the MCP resources.
pub async fn list_resources(&self) -> Result<types::ResourcesListResponse> {
self.check_capability(ServerCapability::Resources)?;
let response: types::ResourcesListResponse = self
.inner
.request(types::RequestType::ResourcesList.as_str(), ())
.await?;
Ok(response)
}
/// Executes a prompt with the given arguments and returns the result.
pub async fn run_prompt<P: AsRef<str>>(
&self,

View File

@@ -15,6 +15,7 @@ pub enum RequestType {
PromptsGet,
PromptsList,
CompletionComplete,
Ping,
}
impl RequestType {
@@ -30,6 +31,7 @@ impl RequestType {
RequestType::PromptsGet => "prompts/get",
RequestType::PromptsList => "prompts/list",
RequestType::CompletionComplete => "completion/complete",
RequestType::Ping => "ping",
}
}
}
@@ -39,14 +41,15 @@ impl RequestType {
pub struct InitializeParams {
pub protocol_version: u32,
pub capabilities: ClientCapabilities,
pub client_info: EntityInfo,
pub client_info: Implementation,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CallToolParams {
pub name: String,
pub arguments: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Serialize)]
@@ -77,6 +80,7 @@ pub struct LoggingSetLevelParams {
#[serde(rename_all = "camelCase")]
pub struct PromptsGetParams {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<HashMap<String, String>>,
}
@@ -101,6 +105,13 @@ pub struct PromptReference {
pub name: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResourceReference {
pub r#type: PromptReferenceType,
pub uri: Url,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PromptReferenceType {
@@ -110,13 +121,6 @@ pub enum PromptReferenceType {
Resource,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResourceReference {
pub r#type: String,
pub uri: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompletionArgument {
@@ -129,7 +133,7 @@ pub struct CompletionArgument {
pub struct InitializeResponse {
pub protocol_version: u32,
pub capabilities: ServerCapabilities,
pub server_info: EntityInfo,
pub server_info: Implementation,
}
#[derive(Debug, Deserialize)]
@@ -141,13 +145,39 @@ pub struct ResourcesReadResponse {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResourcesListResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_templates: Option<Vec<ResourceTemplate>>,
pub resources: Vec<Resource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources: Option<Vec<Resource>>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SamplingMessage {
pub role: SamplingRole,
pub content: SamplingContent,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SamplingRole {
User,
Assistant,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SamplingContent {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image")]
Image { data: String, mime_type: String },
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptsGetResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub prompt: String,
}
@@ -155,7 +185,7 @@ pub struct PromptsGetResponse {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptsListResponse {
pub prompts: Vec<PromptInfo>,
pub prompts: Vec<Prompt>,
}
#[derive(Debug, Deserialize)]
@@ -168,61 +198,91 @@ pub struct CompletionCompleteResponse {
#[serde(rename_all = "camelCase")]
pub struct CompletionResult {
pub values: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_more: Option<bool>,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptInfo {
pub struct Prompt {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<Vec<PromptArgument>>,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptArgument {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
}
// Shared Types
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub experimental: Option<HashMap<String, serde_json::Value>>,
pub sampling: Option<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sampling: Option<serde_json::Value>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub experimental: Option<HashMap<String, serde_json::Value>>,
pub logging: Option<HashMap<String, serde_json::Value>>,
pub prompts: Option<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logging: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompts: Option<PromptsCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources: Option<ResourcesCapabilities>,
pub tools: Option<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<ToolsCapabilities>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptsCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub list_changed: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResourcesCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub subscribe: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub list_changed: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolsCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub list_changed: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EntityInfo {
pub struct Implementation {
pub name: String,
pub version: String,
}
@@ -231,6 +291,10 @@ pub struct EntityInfo {
#[serde(rename_all = "camelCase")]
pub struct Resource {
pub uri: Url,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
}
@@ -238,17 +302,23 @@ pub struct Resource {
#[serde(rename_all = "camelCase")]
pub struct ResourceContent {
pub uri: Url,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
pub data: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blob: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResourceTemplate {
pub uri_template: String,
pub name: Option<String>,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -260,13 +330,16 @@ pub enum LoggingLevel {
Error,
}
// Client Notifications
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum NotificationType {
Initialized,
Progress,
Message,
ResourcesUpdated,
ResourcesListChanged,
ToolsListChanged,
PromptsListChanged,
}
impl NotificationType {
@@ -274,6 +347,11 @@ impl NotificationType {
match self {
NotificationType::Initialized => "notifications/initialized",
NotificationType::Progress => "notifications/progress",
NotificationType::Message => "notifications/message",
NotificationType::ResourcesUpdated => "notifications/resources/updated",
NotificationType::ResourcesListChanged => "notifications/resources/list_changed",
NotificationType::ToolsListChanged => "notifications/tools/list_changed",
NotificationType::PromptsListChanged => "notifications/prompts/list_changed",
}
}
}
@@ -288,12 +366,13 @@ pub enum ClientNotification {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProgressParams {
pub progress_token: String,
pub progress_token: ProgressToken,
pub progress: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<f64>,
}
// Helper Types that don't map directly to the protocol
pub type ProgressToken = String;
pub enum CompletionTotal {
Exact(u32),

View File

@@ -962,7 +962,6 @@ fn random_diagnostic(
const FILE_HEADER: &str = "file header";
const EXCERPT_HEADER: &str = "excerpt header";
const EXCERPT_FOOTER: &str = "excerpt footer";
fn editor_blocks(
editor: &View<Editor>,
@@ -998,7 +997,7 @@ fn editor_blocks(
.ok()?
}
Block::ExcerptHeader {
Block::ExcerptBoundary {
starts_new_buffer, ..
} => {
if *starts_new_buffer {
@@ -1007,7 +1006,6 @@ fn editor_blocks(
EXCERPT_HEADER.into()
}
}
Block::ExcerptFooter { .. } => EXCERPT_FOOTER.into(),
};
Some((row, name))

View File

@@ -237,6 +237,7 @@ gpui::actions!(
ToggleFold,
ToggleFoldRecursive,
Format,
FormatSelections,
GoToDeclaration,
GoToDeclarationSplit,
GoToDefinition,

View File

@@ -5,8 +5,8 @@ use super::{
use crate::{EditorStyle, GutterDimensions};
use collections::{Bound, HashMap, HashSet};
use gpui::{AnyElement, EntityId, Pixels, WindowContext};
use language::{BufferSnapshot, Chunk, Patch, Point};
use multi_buffer::{Anchor, ExcerptId, ExcerptRange, MultiBufferRow, ToPoint as _};
use language::{Chunk, Patch, Point};
use multi_buffer::{Anchor, ExcerptId, ExcerptInfo, MultiBufferRow, ToPoint as _};
use parking_lot::Mutex;
use std::{
cell::RefCell,
@@ -128,26 +128,17 @@ pub struct BlockContext<'a, 'b> {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BlockId {
Custom(CustomBlockId),
ExcerptHeader(ExcerptId),
ExcerptFooter(ExcerptId),
}
impl From<BlockId> for EntityId {
fn from(value: BlockId) -> Self {
match value {
BlockId::Custom(CustomBlockId(id)) => EntityId::from(id as u64),
BlockId::ExcerptHeader(id) => id.into(),
BlockId::ExcerptFooter(id) => id.into(),
}
}
ExcerptBoundary(Option<ExcerptId>),
}
impl From<BlockId> for ElementId {
fn from(value: BlockId) -> Self {
match value {
BlockId::Custom(CustomBlockId(id)) => ("Block", id).into(),
BlockId::ExcerptHeader(id) => ("ExcerptHeader", EntityId::from(id)).into(),
BlockId::ExcerptFooter(id) => ("ExcerptFooter", EntityId::from(id)).into(),
BlockId::ExcerptBoundary(next_excerpt) => match next_excerpt {
Some(id) => ("ExcerptBoundary", EntityId::from(id)).into(),
None => "LastExcerptBoundary".into(),
},
}
}
}
@@ -156,8 +147,7 @@ impl std::fmt::Display for BlockId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Custom(id) => write!(f, "Block({id:?})"),
Self::ExcerptHeader(id) => write!(f, "ExcerptHeader({id:?})"),
Self::ExcerptFooter(id) => write!(f, "ExcerptFooter({id:?})"),
Self::ExcerptBoundary(id) => write!(f, "ExcerptHeader({id:?})"),
}
}
}
@@ -177,8 +167,7 @@ struct Transform {
pub(crate) enum BlockType {
Custom(CustomBlockId),
Header,
Footer,
ExcerptBoundary,
}
pub(crate) trait BlockLike {
@@ -191,27 +180,20 @@ pub(crate) trait BlockLike {
#[derive(Clone)]
pub enum Block {
Custom(Arc<CustomBlock>),
ExcerptHeader {
id: ExcerptId,
buffer: BufferSnapshot,
range: ExcerptRange<text::Anchor>,
ExcerptBoundary {
prev_excerpt: Option<ExcerptInfo>,
next_excerpt: Option<ExcerptInfo>,
height: u32,
starts_new_buffer: bool,
show_excerpt_controls: bool,
},
ExcerptFooter {
id: ExcerptId,
disposition: BlockDisposition,
height: u32,
},
}
impl BlockLike for Block {
fn block_type(&self) -> BlockType {
match self {
Block::Custom(block) => BlockType::Custom(block.id),
Block::ExcerptHeader { .. } => BlockType::Header,
Block::ExcerptFooter { .. } => BlockType::Footer,
Block::ExcerptBoundary { .. } => BlockType::ExcerptBoundary,
}
}
@@ -222,8 +204,7 @@ impl BlockLike for Block {
fn priority(&self) -> usize {
match self {
Block::Custom(block) => block.priority,
Block::ExcerptHeader { .. } => usize::MAX,
Block::ExcerptFooter { .. } => 0,
Block::ExcerptBoundary { .. } => usize::MAX,
}
}
}
@@ -232,32 +213,36 @@ impl Block {
pub fn id(&self) -> BlockId {
match self {
Block::Custom(block) => BlockId::Custom(block.id),
Block::ExcerptHeader { id, .. } => BlockId::ExcerptHeader(*id),
Block::ExcerptFooter { id, .. } => BlockId::ExcerptFooter(*id),
Block::ExcerptBoundary { next_excerpt, .. } => {
BlockId::ExcerptBoundary(next_excerpt.as_ref().map(|info| info.id))
}
}
}
fn disposition(&self) -> BlockDisposition {
match self {
Block::Custom(block) => block.disposition,
Block::ExcerptHeader { .. } => BlockDisposition::Above,
Block::ExcerptFooter { disposition, .. } => *disposition,
Block::ExcerptBoundary { next_excerpt, .. } => {
if next_excerpt.is_some() {
BlockDisposition::Above
} else {
BlockDisposition::Below
}
}
}
}
pub fn height(&self) -> u32 {
match self {
Block::Custom(block) => block.height,
Block::ExcerptHeader { height, .. } => *height,
Block::ExcerptFooter { height, .. } => *height,
Block::ExcerptBoundary { height, .. } => *height,
}
}
pub fn style(&self) -> BlockStyle {
match self {
Block::Custom(block) => block.style,
Block::ExcerptHeader { .. } => BlockStyle::Sticky,
Block::ExcerptFooter { .. } => BlockStyle::Sticky,
Block::ExcerptBoundary { .. } => BlockStyle::Sticky,
}
}
}
@@ -266,24 +251,17 @@ impl Debug for Block {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Custom(block) => f.debug_struct("Custom").field("block", block).finish(),
Self::ExcerptHeader {
buffer,
Self::ExcerptBoundary {
starts_new_buffer,
id,
next_excerpt,
prev_excerpt,
..
} => f
.debug_struct("ExcerptHeader")
.field("id", &id)
.field("path", &buffer.file().map(|f| f.path()))
.debug_struct("ExcerptBoundary")
.field("prev_excerpt", &prev_excerpt)
.field("next_excerpt", &next_excerpt)
.field("starts_new_buffer", &starts_new_buffer)
.finish(),
Block::ExcerptFooter {
id, disposition, ..
} => f
.debug_struct("ExcerptFooter")
.field("id", &id)
.field("disposition", &disposition)
.finish(),
}
}
}
@@ -595,66 +573,62 @@ impl BlockMap {
{
buffer
.excerpt_boundaries_in_range(range)
.flat_map(move |excerpt_boundary| {
let mut wrap_row = wrap_snapshot
.make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left)
.row();
[
show_excerpt_controls
.then(|| {
let disposition;
if excerpt_boundary.next.is_some() {
disposition = BlockDisposition::Above;
} else {
wrap_row = wrap_snapshot
.make_wrap_point(
Point::new(
excerpt_boundary.row.0,
buffer.line_len(excerpt_boundary.row),
),
Bias::Left,
)
.row();
disposition = BlockDisposition::Below;
}
excerpt_boundary.prev.as_ref().map(|prev| {
(
wrap_row,
Block::ExcerptFooter {
id: prev.id,
height: excerpt_footer_height,
disposition,
},
)
})
})
.flatten(),
excerpt_boundary.next.map(|next| {
let starts_new_buffer = excerpt_boundary
.prev
.map_or(true, |prev| prev.buffer_id != next.buffer_id);
(
wrap_row,
Block::ExcerptHeader {
id: next.id,
buffer: next.buffer,
range: next.range,
height: if starts_new_buffer {
buffer_header_height
} else {
excerpt_header_height
},
starts_new_buffer,
show_excerpt_controls,
},
.filter_map(move |excerpt_boundary| {
let wrap_row;
if excerpt_boundary.next.is_some() {
wrap_row = wrap_snapshot
.make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left)
.row();
} else {
wrap_row = wrap_snapshot
.make_wrap_point(
Point::new(
excerpt_boundary.row.0,
buffer.line_len(excerpt_boundary.row),
),
Bias::Left,
)
}),
]
.row();
}
let starts_new_buffer = match (&excerpt_boundary.prev, &excerpt_boundary.next) {
(_, None) => false,
(None, Some(_)) => true,
(Some(prev), Some(next)) => prev.buffer_id != next.buffer_id,
};
let mut height = 0;
if excerpt_boundary.prev.is_some() {
if show_excerpt_controls {
height += excerpt_footer_height;
}
}
if excerpt_boundary.next.is_some() {
if starts_new_buffer {
height += buffer_header_height;
if show_excerpt_controls {
height += excerpt_header_height;
}
} else {
height += excerpt_header_height;
}
}
if height == 0 {
return None;
}
Some((
wrap_row,
Block::ExcerptBoundary {
prev_excerpt: excerpt_boundary.prev,
next_excerpt: excerpt_boundary.next,
height,
starts_new_buffer,
show_excerpt_controls,
},
))
})
.flatten()
}
pub(crate) fn sort_blocks<B: BlockLike>(blocks: &mut [(u32, B)]) {
@@ -665,12 +639,9 @@ impl BlockMap {
.disposition()
.cmp(&block_b.disposition())
.then_with(|| match ((block_a.block_type()), (block_b.block_type())) {
(BlockType::Footer, BlockType::Footer) => Ordering::Equal,
(BlockType::Footer, _) => Ordering::Less,
(_, BlockType::Footer) => Ordering::Greater,
(BlockType::Header, BlockType::Header) => Ordering::Equal,
(BlockType::Header, _) => Ordering::Less,
(_, BlockType::Header) => Ordering::Greater,
(BlockType::ExcerptBoundary, BlockType::ExcerptBoundary) => Ordering::Equal,
(BlockType::ExcerptBoundary, _) => Ordering::Less,
(_, BlockType::ExcerptBoundary) => Ordering::Greater,
(BlockType::Custom(a_id), BlockType::Custom(b_id)) => block_b
.priority()
.cmp(&block_a.priority())
@@ -1045,33 +1016,19 @@ impl BlockSnapshot {
let custom_block = self.custom_blocks_by_id.get(&custom_block_id)?;
Some(Block::Custom(custom_block.clone()))
}
BlockId::ExcerptHeader(excerpt_id) => {
let excerpt_range = buffer.range_for_excerpt::<Point>(excerpt_id)?;
let wrap_point = self
.wrap_snapshot
.make_wrap_point(excerpt_range.start, Bias::Left);
let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(&());
cursor.seek(&WrapRow(wrap_point.row()), Bias::Left, &());
while let Some(transform) = cursor.item() {
if let Some(block) = transform.block.as_ref() {
if block.id() == block_id {
return Some(block.clone());
}
} else if cursor.start().0 > WrapRow(wrap_point.row()) {
break;
}
cursor.next(&());
BlockId::ExcerptBoundary(next_excerpt_id) => {
let wrap_point;
if let Some(next_excerpt_id) = next_excerpt_id {
let excerpt_range = buffer.range_for_excerpt::<Point>(next_excerpt_id)?;
wrap_point = self
.wrap_snapshot
.make_wrap_point(excerpt_range.start, Bias::Left);
} else {
wrap_point = self
.wrap_snapshot
.make_wrap_point(buffer.max_point(), Bias::Left);
}
None
}
BlockId::ExcerptFooter(excerpt_id) => {
let excerpt_range = buffer.range_for_excerpt::<Point>(excerpt_id)?;
let wrap_point = self
.wrap_snapshot
.make_wrap_point(excerpt_range.end, Bias::Left);
let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(&());
cursor.seek(&WrapRow(wrap_point.row()), Bias::Left, &());
while let Some(transform) = cursor.item() {
@@ -1468,7 +1425,7 @@ mod tests {
};
use gpui::{div, font, px, AppContext, Context as _, Element};
use language::{Buffer, Capability};
use multi_buffer::MultiBuffer;
use multi_buffer::{ExcerptRange, MultiBuffer};
use rand::prelude::*;
use settings::SettingsStore;
use std::env;
@@ -1724,22 +1681,20 @@ mod tests {
// Each excerpt has a header above and footer below. Excerpts are also *separated* by a newline.
assert_eq!(
snapshot.text(),
"\nBuff\ner 1\n\n\nBuff\ner 2\n\n\nBuff\ner 3\n"
"\n\nBuff\ner 1\n\n\n\nBuff\ner 2\n\n\n\nBuff\ner 3\n"
);
let blocks: Vec<_> = snapshot
.blocks_in_range(0..u32::MAX)
.map(|(row, block)| (row, block.id()))
.map(|(row, block)| (row..row + block.height(), block.id()))
.collect();
assert_eq!(
blocks,
vec![
(0, BlockId::ExcerptHeader(excerpt_ids[0])),
(3, BlockId::ExcerptFooter(excerpt_ids[0])),
(4, BlockId::ExcerptHeader(excerpt_ids[1])),
(7, BlockId::ExcerptFooter(excerpt_ids[1])),
(8, BlockId::ExcerptHeader(excerpt_ids[2])),
(11, BlockId::ExcerptFooter(excerpt_ids[2]))
(0..2, BlockId::ExcerptBoundary(Some(excerpt_ids[0]))), // path, header
(4..7, BlockId::ExcerptBoundary(Some(excerpt_ids[1]))), // footer, path, header
(9..12, BlockId::ExcerptBoundary(Some(excerpt_ids[2]))), // footer, path, header
(14..15, BlockId::ExcerptBoundary(None)), // footer
]
);
}
@@ -2283,13 +2238,10 @@ mod tests {
#[derive(Debug, Eq, PartialEq)]
enum ExpectedBlock {
ExcerptHeader {
ExcerptBoundary {
height: u32,
starts_new_buffer: bool,
},
ExcerptFooter {
height: u32,
disposition: BlockDisposition,
is_last: bool,
},
Custom {
disposition: BlockDisposition,
@@ -2303,8 +2255,7 @@ mod tests {
fn block_type(&self) -> BlockType {
match self {
ExpectedBlock::Custom { id, .. } => BlockType::Custom(*id),
ExpectedBlock::ExcerptHeader { .. } => BlockType::Header,
ExpectedBlock::ExcerptFooter { .. } => BlockType::Footer,
ExpectedBlock::ExcerptBoundary { .. } => BlockType::ExcerptBoundary,
}
}
@@ -2315,8 +2266,7 @@ mod tests {
fn priority(&self) -> usize {
match self {
ExpectedBlock::Custom { priority, .. } => *priority,
ExpectedBlock::ExcerptHeader { .. } => usize::MAX,
ExpectedBlock::ExcerptFooter { .. } => 0,
ExpectedBlock::ExcerptBoundary { .. } => usize::MAX,
}
}
}
@@ -2324,17 +2274,21 @@ mod tests {
impl ExpectedBlock {
fn height(&self) -> u32 {
match self {
ExpectedBlock::ExcerptHeader { height, .. } => *height,
ExpectedBlock::ExcerptBoundary { height, .. } => *height,
ExpectedBlock::Custom { height, .. } => *height,
ExpectedBlock::ExcerptFooter { height, .. } => *height,
}
}
fn disposition(&self) -> BlockDisposition {
match self {
ExpectedBlock::ExcerptHeader { .. } => BlockDisposition::Above,
ExpectedBlock::ExcerptBoundary { is_last, .. } => {
if *is_last {
BlockDisposition::Below
} else {
BlockDisposition::Above
}
}
ExpectedBlock::Custom { disposition, .. } => *disposition,
ExpectedBlock::ExcerptFooter { disposition, .. } => *disposition,
}
}
}
@@ -2348,21 +2302,15 @@ mod tests {
height: block.height,
priority: block.priority,
},
Block::ExcerptHeader {
Block::ExcerptBoundary {
height,
starts_new_buffer,
next_excerpt,
..
} => ExpectedBlock::ExcerptHeader {
} => ExpectedBlock::ExcerptBoundary {
height,
starts_new_buffer,
},
Block::ExcerptFooter {
height,
disposition,
..
} => ExpectedBlock::ExcerptFooter {
height,
disposition,
is_last: next_excerpt.is_none(),
},
}
}
@@ -2380,8 +2328,7 @@ mod tests {
fn as_custom(&self) -> Option<&CustomBlock> {
match self {
Block::Custom(block) => Some(block),
Block::ExcerptHeader { .. } => None,
Block::ExcerptFooter { .. } => None,
Block::ExcerptBoundary { .. } => None,
}
}
}

View File

@@ -48,7 +48,6 @@ mod signature_help;
pub mod test;
use ::git::diff::DiffHunkStatus;
use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
pub(crate) use actions::*;
use aho_corasick::AhoCorasick;
use anyhow::{anyhow, Context as _, Result};
@@ -74,12 +73,12 @@ use git::blame::GitBlame;
use gpui::{
div, impl_actions, point, prelude::*, px, relative, size, uniform_list, Action, AnyElement,
AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardEntry,
ClipboardItem, Context, DispatchPhase, ElementId, EntityId, EventEmitter, FocusHandle,
FocusOutEvent, FocusableView, FontId, FontWeight, HighlightStyle, Hsla, InteractiveText,
KeyContext, ListSizingBehavior, Model, MouseButton, PaintQuad, ParentElement, Pixels, Render,
SharedString, Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, TextStyle,
UTF16Selection, UnderlineStyle, UniformListScrollHandle, View, ViewContext, ViewInputHandler,
VisualContext, WeakFocusHandle, WeakView, WindowContext,
ClipboardItem, Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusOutEvent,
FocusableView, FontId, FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext,
ListSizingBehavior, Model, MouseButton, PaintQuad, ParentElement, Pixels, Render, SharedString,
Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, TextStyle, UTF16Selection,
UnderlineStyle, UniformListScrollHandle, View, ViewContext, ViewInputHandler, VisualContext,
WeakFocusHandle, WeakView, WindowContext,
};
use highlight_matching_bracket::refresh_matching_bracket_highlights;
use hover_popover::{hide_hover, HoverState};
@@ -96,10 +95,12 @@ use language::{
CursorShape, Diagnostic, Documentation, IndentKind, IndentSize, Language, OffsetRangeExt,
Point, Selection, SelectionGoal, TransactionId,
};
use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange};
use language::{
point_to_lsp, BufferRow, CharClassifier, LanguageServerName, Runnable, RunnableRange,
};
use linked_editing_ranges::refresh_linked_ranges;
pub use proposed_changes_editor::{
ProposedChangesBuffer, ProposedChangesEditor, ProposedChangesEditorToolbar,
ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
};
use similar::{ChangeTag, TextDiff};
use task::{ResolvedTask, TaskTemplate, TaskVariables};
@@ -122,7 +123,7 @@ use multi_buffer::{
use ordered_float::OrderedFloat;
use parking_lot::{Mutex, RwLock};
use project::{
lsp_store::FormatTrigger,
lsp_store::{FormatTarget, FormatTrigger},
project_settings::{GitGutterSetting, ProjectSettings},
CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Item, Location,
LocationLink, Project, ProjectPath, ProjectTransaction, TaskSourceKind,
@@ -170,7 +171,7 @@ use workspace::{Item as WorkspaceItem, OpenInTerminal, OpenTerminal, TabBarSetti
use crate::hover_links::find_url;
use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState};
pub const FILE_HEADER_HEIGHT: u32 = 1;
pub const FILE_HEADER_HEIGHT: u32 = 2;
pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1;
pub const MULTI_BUFFER_EXCERPT_FOOTER_HEIGHT: u32 = 1;
pub const DEFAULT_MULTIBUFFER_CONTEXT: u32 = 2;
@@ -639,7 +640,6 @@ pub struct Editor {
tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>,
tasks_update_task: Option<Task<()>>,
previous_search_ranges: Option<Arc<[Range<Anchor>]>>,
file_header_size: u32,
breadcrumb_header: Option<String>,
focused_block: Option<FocusedBlock>,
next_scroll_position: NextScrollCursorCenterTopBottom,
@@ -1845,7 +1845,6 @@ impl Editor {
}),
merge_adjacent: true,
};
let file_header_size = if show_excerpt_controls { 3 } else { 2 };
let display_map = cx.new_model(|cx| {
DisplayMap::new(
buffer.clone(),
@@ -1853,7 +1852,7 @@ impl Editor {
font_size,
None,
show_excerpt_controls,
file_header_size,
FILE_HEADER_HEIGHT,
MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
MULTI_BUFFER_EXCERPT_FOOTER_HEIGHT,
fold_placeholder,
@@ -2037,7 +2036,6 @@ impl Editor {
.restore_unsaved_buffers,
blame: None,
blame_subscription: None,
file_header_size,
tasks: Default::default(),
_subscriptions: vec![
cx.observe(&buffer, Self::on_buffer_changed),
@@ -9893,21 +9891,19 @@ impl Editor {
&self,
lsp_location: lsp::Location,
server_id: LanguageServerId,
cx: &mut ViewContext<Editor>,
cx: &mut ViewContext<Self>,
) -> Task<anyhow::Result<Option<Location>>> {
let Some(project) = self.project.clone() else {
return Task::Ready(Some(Ok(None)));
};
cx.spawn(move |editor, mut cx| async move {
let location_task = editor.update(&mut cx, |editor, cx| {
let location_task = editor.update(&mut cx, |_, cx| {
project.update(cx, |project, cx| {
let language_server_name =
editor.buffer.read(cx).as_singleton().and_then(|buffer| {
project
.language_server_for_buffer(buffer.read(cx), server_id, cx)
.map(|(lsp_adapter, _)| lsp_adapter.name.clone())
});
let language_server_name = project
.language_server_statuses(cx)
.find(|(id, _)| server_id == *id)
.map(|(_, status)| LanguageServerName::from(status.name.as_str()));
language_server_name.map(|language_server_name| {
project.open_local_buffer_via_lsp(
lsp_location.uri.clone(),
@@ -10386,13 +10382,39 @@ impl Editor {
None => return None,
};
Some(self.perform_format(project, FormatTrigger::Manual, cx))
Some(self.perform_format(project, FormatTrigger::Manual, FormatTarget::Buffer, cx))
}
fn format_selections(
&mut self,
_: &FormatSelections,
cx: &mut ViewContext<Self>,
) -> Option<Task<Result<()>>> {
let project = match &self.project {
Some(project) => project.clone(),
None => return None,
};
let selections = self
.selections
.all_adjusted(cx)
.into_iter()
.filter(|s| !s.is_empty())
.collect_vec();
Some(self.perform_format(
project,
FormatTrigger::Manual,
FormatTarget::Ranges(selections),
cx,
))
}
fn perform_format(
&mut self,
project: Model<Project>,
trigger: FormatTrigger,
target: FormatTarget,
cx: &mut ViewContext<Self>,
) -> Task<Result<()>> {
let buffer = self.buffer().clone();
@@ -10402,7 +10424,9 @@ impl Editor {
}
let mut timeout = cx.background_executor().timer(FORMAT_TIMEOUT).fuse();
let format = project.update(cx, |project, cx| project.format(buffers, true, trigger, cx));
let format = project.update(cx, |project, cx| {
project.format(buffers, true, trigger, target, cx)
});
cx.spawn(|_, mut cx| async move {
let transaction = futures::select_biased! {
@@ -11460,11 +11484,8 @@ impl Editor {
snapshot.line_len(buffer_row) == 0
}
fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Result<url::Url> {
let (path, selection, repo) = maybe!({
let project_handle = self.project.as_ref()?.clone();
let project = project_handle.read(cx);
fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<url::Url>> {
let buffer_and_selection = maybe!({
let selection = self.selections.newest::<Point>(cx);
let selection_range = selection.range();
@@ -11488,64 +11509,58 @@ impl Editor {
(buffer.clone(), selection)
};
let path = buffer
.read(cx)
.file()?
.as_local()?
.path()
.to_str()?
.to_string();
let repo = project.get_repo(&buffer.read(cx).project_path(cx)?, cx)?;
Some((path, selection, repo))
Some((buffer, selection))
});
let Some((buffer, selection)) = buffer_and_selection else {
return Task::ready(Err(anyhow!("failed to determine buffer and selection")));
};
let Some(project) = self.project.as_ref() else {
return Task::ready(Err(anyhow!("editor does not have project")));
};
project.update(cx, |project, cx| {
project.get_permalink_to_line(&buffer, selection, cx)
})
.ok_or_else(|| anyhow!("unable to open git repository"))?;
const REMOTE_NAME: &str = "origin";
let origin_url = repo
.remote_url(REMOTE_NAME)
.ok_or_else(|| anyhow!("remote \"{REMOTE_NAME}\" not found"))?;
let sha = repo
.head_sha()
.ok_or_else(|| anyhow!("failed to read HEAD SHA"))?;
let (provider, remote) =
parse_git_remote_url(GitHostingProviderRegistry::default_global(cx), &origin_url)
.ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
Ok(provider.build_permalink(
remote,
BuildPermalinkParams {
sha: &sha,
path: &path,
selection: Some(selection),
},
))
}
pub fn copy_permalink_to_line(&mut self, _: &CopyPermalinkToLine, cx: &mut ViewContext<Self>) {
let permalink = self.get_permalink_to_line(cx);
let permalink_task = self.get_permalink_to_line(cx);
let workspace = self.workspace();
match permalink {
Ok(permalink) => {
cx.write_to_clipboard(ClipboardItem::new_string(permalink.to_string()));
}
Err(err) => {
let message = format!("Failed to copy permalink: {err}");
Err::<(), anyhow::Error>(err).log_err();
if let Some(workspace) = self.workspace() {
workspace.update(cx, |workspace, cx| {
struct CopyPermalinkToLine;
workspace.show_toast(
Toast::new(NotificationId::unique::<CopyPermalinkToLine>(), message),
cx,
)
cx.spawn(|_, mut cx| async move {
match permalink_task.await {
Ok(permalink) => {
cx.update(|cx| {
cx.write_to_clipboard(ClipboardItem::new_string(permalink.to_string()));
})
.ok();
}
Err(err) => {
let message = format!("Failed to copy permalink: {err}");
Err::<(), anyhow::Error>(err).log_err();
if let Some(workspace) = workspace {
workspace
.update(&mut cx, |workspace, cx| {
struct CopyPermalinkToLine;
workspace.show_toast(
Toast::new(
NotificationId::unique::<CopyPermalinkToLine>(),
message,
),
cx,
)
})
.ok();
}
}
}
}
})
.detach();
}
pub fn copy_file_location(&mut self, _: &CopyFileLocation, cx: &mut ViewContext<Self>) {
@@ -11558,29 +11573,41 @@ impl Editor {
}
pub fn open_permalink_to_line(&mut self, _: &OpenPermalinkToLine, cx: &mut ViewContext<Self>) {
let permalink = self.get_permalink_to_line(cx);
let permalink_task = self.get_permalink_to_line(cx);
let workspace = self.workspace();
match permalink {
Ok(permalink) => {
cx.open_url(permalink.as_ref());
}
Err(err) => {
let message = format!("Failed to open permalink: {err}");
Err::<(), anyhow::Error>(err).log_err();
if let Some(workspace) = self.workspace() {
workspace.update(cx, |workspace, cx| {
struct OpenPermalinkToLine;
workspace.show_toast(
Toast::new(NotificationId::unique::<OpenPermalinkToLine>(), message),
cx,
)
cx.spawn(|_, mut cx| async move {
match permalink_task.await {
Ok(permalink) => {
cx.update(|cx| {
cx.open_url(permalink.as_ref());
})
.ok();
}
Err(err) => {
let message = format!("Failed to open permalink: {err}");
Err::<(), anyhow::Error>(err).log_err();
if let Some(workspace) = workspace {
workspace
.update(&mut cx, |workspace, cx| {
struct OpenPermalinkToLine;
workspace.show_toast(
Toast::new(
NotificationId::unique::<OpenPermalinkToLine>(),
message,
),
cx,
)
})
.ok();
}
}
}
}
})
.detach();
}
/// Adds a row highlight for the given range. If a row has multiple highlights, the
@@ -12333,10 +12360,15 @@ impl Editor {
let proposed_changes_buffers = new_selections_by_buffer
.into_iter()
.map(|(buffer, ranges)| ProposedChangesBuffer { buffer, ranges })
.map(|(buffer, ranges)| ProposedChangeLocation { buffer, ranges })
.collect::<Vec<_>>();
let proposed_changes_editor = cx.new_view(|cx| {
ProposedChangesEditor::new(proposed_changes_buffers, self.project.clone(), cx)
ProposedChangesEditor::new(
"Proposed changes",
proposed_changes_buffers,
self.project.clone(),
cx,
)
});
cx.window_context().defer(move |cx| {
@@ -12773,7 +12805,7 @@ impl Editor {
}
pub fn file_header_size(&self) -> u32 {
self.file_header_size
FILE_HEADER_HEIGHT
}
pub fn revert(
@@ -14085,7 +14117,7 @@ pub fn diagnostic_block_renderer(
let multi_line_diagnostic = diagnostic.message.contains('\n');
let buttons = |diagnostic: &Diagnostic, block_id: BlockId| {
let buttons = |diagnostic: &Diagnostic| {
if multi_line_diagnostic {
v_flex()
} else {
@@ -14093,7 +14125,7 @@ pub fn diagnostic_block_renderer(
}
.when(allow_closing, |div| {
div.children(diagnostic.is_primary.then(|| {
IconButton::new(("close-block", EntityId::from(block_id)), IconName::XCircle)
IconButton::new("close-block", IconName::XCircle)
.icon_color(Color::Muted)
.size(ButtonSize::Compact)
.style(ButtonStyle::Transparent)
@@ -14103,7 +14135,7 @@ pub fn diagnostic_block_renderer(
}))
})
.child(
IconButton::new(("copy-block", EntityId::from(block_id)), IconName::Copy)
IconButton::new("copy-block", IconName::Copy)
.icon_color(Color::Muted)
.size(ButtonSize::Compact)
.style(ButtonStyle::Transparent)
@@ -14118,7 +14150,7 @@ pub fn diagnostic_block_renderer(
)
};
let icon_size = buttons(&diagnostic, cx.block_id)
let icon_size = buttons(&diagnostic)
.into_any_element()
.layout_as_root(AvailableSpace::min_size(), cx);
@@ -14135,7 +14167,7 @@ pub fn diagnostic_block_renderer(
.w(cx.anchor_x - cx.gutter_dimensions.width - icon_size.width)
.flex_shrink(),
)
.child(buttons(&diagnostic, cx.block_id))
.child(buttons(&diagnostic))
.child(div().flex().flex_shrink_0().child(
StyledText::new(text_without_backticks.clone()).with_highlights(
&text_style,

View File

@@ -7076,7 +7076,12 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
let format = editor
.update(cx, |editor, cx| {
editor.perform_format(project.clone(), FormatTrigger::Manual, cx)
editor.perform_format(
project.clone(),
FormatTrigger::Manual,
FormatTarget::Buffer,
cx,
)
})
.unwrap();
fake_server
@@ -7112,7 +7117,7 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
});
let format = editor
.update(cx, |editor, cx| {
editor.perform_format(project, FormatTrigger::Manual, cx)
editor.perform_format(project, FormatTrigger::Manual, FormatTarget::Buffer, cx)
})
.unwrap();
cx.executor().advance_clock(super::FORMAT_TIMEOUT);
@@ -10309,7 +10314,12 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) {
editor
.update(cx, |editor, cx| {
editor.perform_format(project.clone(), FormatTrigger::Manual, cx)
editor.perform_format(
project.clone(),
FormatTrigger::Manual,
FormatTarget::Buffer,
cx,
)
})
.unwrap()
.await;
@@ -10323,7 +10333,12 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) {
settings.defaults.formatter = Some(language_settings::SelectedFormatter::Auto)
});
let format = editor.update(cx, |editor, cx| {
editor.perform_format(project.clone(), FormatTrigger::Manual, cx)
editor.perform_format(
project.clone(),
FormatTrigger::Manual,
FormatTarget::Buffer,
cx,
)
});
format.await.unwrap();
assert_eq!(

View File

@@ -21,7 +21,8 @@ use crate::{
EditorSnapshot, EditorStyle, ExpandExcerpts, FocusedBlock, GutterDimensions, HalfPageDown,
HalfPageUp, HandleInput, HoveredCursor, HoveredHunk, LineDown, LineUp, OpenExcerpts, PageDown,
PageUp, Point, RowExt, RowRangeExt, SelectPhase, Selection, SoftWrap, ToPoint,
CURSORS_VISIBLE_FOR, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN,
CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN,
MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
};
use client::ParticipantIndex;
use collections::{BTreeMap, HashMap};
@@ -31,7 +32,7 @@ use gpui::{
anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem,
ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity,
EntityId, FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Length,
FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Length,
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size,
StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View,
@@ -46,7 +47,7 @@ use language::{
ChunkRendererContext,
};
use lsp::DiagnosticSeverity;
use multi_buffer::{Anchor, MultiBufferPoint, MultiBufferRow};
use multi_buffer::{Anchor, ExcerptId, ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow};
use project::{
project_settings::{GitGutterSetting, ProjectSettings},
ProjectPath,
@@ -376,6 +377,13 @@ impl EditorElement {
cx.propagate();
}
});
register_action(view, cx, |editor, action, cx| {
if let Some(task) = editor.format_selections(action, cx) {
task.detach_and_log_err(cx);
} else {
cx.propagate();
}
});
register_action(view, cx, Editor::restart_language_server);
register_action(view, cx, Editor::cancel_language_server_work);
register_action(view, cx, Editor::show_character_palette);
@@ -1625,7 +1633,7 @@ impl EditorElement {
let mut block_offset = 0;
let mut found_excerpt_header = false;
for (_, block) in snapshot.blocks_in_range(prev_line..row_range.start) {
if matches!(block, Block::ExcerptHeader { .. }) {
if matches!(block, Block::ExcerptBoundary { .. }) {
found_excerpt_header = true;
break;
}
@@ -1642,7 +1650,7 @@ impl EditorElement {
let mut block_height = 0;
let mut found_excerpt_header = false;
for (_, block) in snapshot.blocks_in_range(row_range.end..cons_line) {
if matches!(block, Block::ExcerptHeader { .. }) {
if matches!(block, Block::ExcerptBoundary { .. }) {
found_excerpt_header = true;
}
block_height += block.height();
@@ -2093,23 +2101,14 @@ impl EditorElement {
.into_any_element()
}
Block::ExcerptHeader {
buffer,
range,
Block::ExcerptBoundary {
prev_excerpt,
next_excerpt,
show_excerpt_controls,
starts_new_buffer,
height,
id,
show_excerpt_controls,
..
} => {
let include_root = self
.editor
.read(cx)
.project
.as_ref()
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
.unwrap_or_default();
#[derive(Clone)]
struct JumpData {
position: Point,
@@ -2118,233 +2117,227 @@ impl EditorElement {
line_offset_from_top: u32,
}
let jump_data = project::File::from_dyn(buffer.file()).map(|file| {
let jump_path = ProjectPath {
worktree_id: file.worktree_id(cx),
path: file.path.clone(),
};
let jump_anchor = range
.primary
.as_ref()
.map_or(range.context.start, |primary| primary.start);
let excerpt_start = range.context.start;
let jump_position = language::ToPoint::to_point(&jump_anchor, buffer);
let offset_from_excerpt_start = if jump_anchor == excerpt_start {
0
} else {
let excerpt_start_row =
language::ToPoint::to_point(&jump_anchor, buffer).row;
jump_position.row - excerpt_start_row
};
let line_offset_from_top =
block_row_start.0 + *height + offset_from_excerpt_start
- snapshot
.scroll_anchor
.scroll_position(&snapshot.display_snapshot)
.y as u32;
JumpData {
position: jump_position,
anchor: jump_anchor,
path: jump_path,
line_offset_from_top,
}
});
let icon_offset = gutter_dimensions.width
- (gutter_dimensions.left_padding + gutter_dimensions.margin);
let element = if *starts_new_buffer {
let path = buffer.resolve_file_path(cx, include_root);
let mut filename = None;
let mut parent_path = None;
// Can't use .and_then() because `.file_name()` and `.parent()` return references :(
if let Some(path) = path {
filename = path.file_name().map(|f| f.to_string_lossy().to_string());
parent_path = path
.parent()
.map(|p| SharedString::from(p.to_string_lossy().to_string() + "/"));
}
let header_padding = px(6.0);
let header_padding = px(6.0);
let mut result = v_flex().id(block_id).w_full();
v_flex()
.id(("path excerpt header", EntityId::from(block_id)))
.w_full()
.px(header_padding)
.pt(header_padding)
.child(
if let Some(prev_excerpt) = prev_excerpt {
if *show_excerpt_controls {
result = result.child(
h_flex()
.flex_basis(Length::Definite(DefiniteLength::Fraction(0.667)))
.id("path header block")
.h(2. * cx.line_height())
.px(gpui::px(12.))
.rounded_md()
.shadow_md()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_subheader_background)
.justify_between()
.hover(|style| style.bg(cx.theme().colors().element_hover))
.child(
h_flex().gap_3().child(
h_flex()
.gap_2()
.child(
filename
.map(SharedString::from)
.unwrap_or_else(|| "untitled".into()),
)
.when_some(parent_path, |then, path| {
then.child(
div()
.child(path)
.text_color(cx.theme().colors().text_muted),
)
}),
),
)
.when_some(jump_data.clone(), |el, jump_data| {
el.child(Icon::new(IconName::ArrowUpRight))
.cursor_pointer()
.tooltip(|cx| {
Tooltip::for_action("Jump to File", &OpenExcerpts, cx)
})
.on_mouse_down(MouseButton::Left, |_, cx| {
cx.stop_propagation()
})
.on_click(cx.listener_for(&self.editor, {
move |editor, _, cx| {
editor.jump(
jump_data.path.clone(),
jump_data.position,
jump_data.anchor,
jump_data.line_offset_from_top,
cx,
);
}
}))
}),
)
.children(show_excerpt_controls.then(|| {
h_flex()
.flex_basis(Length::Definite(DefiniteLength::Fraction(0.333)))
.h(1. * cx.line_height())
.pt_1()
.justify_end()
.w(icon_offset)
.h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height())
.flex_none()
.w(icon_offset - header_padding)
.child(
ButtonLike::new("expand-icon")
.style(ButtonStyle::Transparent)
.child(
svg()
.path(IconName::ArrowUpFromLine.path())
.size(IconSize::XSmall.rems())
.text_color(cx.theme().colors().editor_line_number)
.group("")
.hover(|style| {
style.text_color(
cx.theme()
.colors()
.editor_active_line_number,
)
}),
)
.on_click(cx.listener_for(&self.editor, {
let id = *id;
move |editor, _, cx| {
editor.expand_excerpt(
id,
multi_buffer::ExpandExcerptDirection::Up,
cx,
);
}
}))
.tooltip({
move |cx| {
Tooltip::for_action(
"Expand Excerpt",
&ExpandExcerpts { lines: 0 },
cx,
)
}
}),
)
}))
} else {
v_flex()
.id(("excerpt header", EntityId::from(block_id)))
.w_full()
.h(snapshot.excerpt_header_height() as f32 * cx.line_height())
.child(
.justify_end()
.child(self.render_expand_excerpt_button(
prev_excerpt.id,
ExpandExcerptDirection::Down,
IconName::ArrowDownFromLine,
cx,
)),
);
}
}
if let Some(next_excerpt) = next_excerpt {
let buffer = &next_excerpt.buffer;
let range = &next_excerpt.range;
let jump_data = project::File::from_dyn(buffer.file()).map(|file| {
let jump_path = ProjectPath {
worktree_id: file.worktree_id(cx),
path: file.path.clone(),
};
let jump_anchor = range
.primary
.as_ref()
.map_or(range.context.start, |primary| primary.start);
let excerpt_start = range.context.start;
let jump_position = language::ToPoint::to_point(&jump_anchor, buffer);
let offset_from_excerpt_start = if jump_anchor == excerpt_start {
0
} else {
let excerpt_start_row =
language::ToPoint::to_point(&jump_anchor, buffer).row;
jump_position.row - excerpt_start_row
};
let line_offset_from_top =
block_row_start.0 + *height + offset_from_excerpt_start
- snapshot
.scroll_anchor
.scroll_position(&snapshot.display_snapshot)
.y as u32;
JumpData {
position: jump_position,
anchor: jump_anchor,
path: jump_path,
line_offset_from_top,
}
});
if *starts_new_buffer {
let include_root = self
.editor
.read(cx)
.project
.as_ref()
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
.unwrap_or_default();
let path = buffer.resolve_file_path(cx, include_root);
let filename = path
.as_ref()
.and_then(|path| Some(path.file_name()?.to_string_lossy().to_string()));
let parent_path = path.as_ref().and_then(|path| {
Some(path.parent()?.to_string_lossy().to_string() + "/")
});
result = result.child(
div()
.flex()
.v_flex()
.px(header_padding)
.pt(header_padding)
.w_full()
.h(FILE_HEADER_HEIGHT as f32 * cx.line_height())
.child(
h_flex()
.id("path header block")
.size_full()
.flex_basis(Length::Definite(DefiniteLength::Fraction(
0.667,
)))
.px(gpui::px(12.))
.rounded_md()
.shadow_md()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_subheader_background)
.justify_between()
.hover(|style| style.bg(cx.theme().colors().element_hover))
.child(
h_flex().gap_3().child(
h_flex()
.gap_2()
.child(
filename
.map(SharedString::from)
.unwrap_or_else(|| "untitled".into()),
)
.when_some(parent_path, |then, path| {
then.child(div().child(path).text_color(
cx.theme().colors().text_muted,
))
}),
),
)
.when_some(jump_data, |el, jump_data| {
el.child(Icon::new(IconName::ArrowUpRight))
.cursor_pointer()
.tooltip(|cx| {
Tooltip::for_action(
"Jump to File",
&OpenExcerpts,
cx,
)
})
.on_mouse_down(MouseButton::Left, |_, cx| {
cx.stop_propagation()
})
.on_click(cx.listener_for(&self.editor, {
move |editor, _, cx| {
editor.jump(
jump_data.path.clone(),
jump_data.position,
jump_data.anchor,
jump_data.line_offset_from_top,
cx,
);
}
}))
}),
),
);
if *show_excerpt_controls {
result = result.child(
h_flex()
.w(icon_offset)
.h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height())
.flex_none()
.justify_end()
.child(self.render_expand_excerpt_button(
next_excerpt.id,
ExpandExcerptDirection::Up,
IconName::ArrowUpFromLine,
cx,
)),
);
}
} else {
result = result.child(
h_flex()
.id("excerpt header block")
.group("excerpt-jump-action")
.justify_start()
.id("jump to collapsed context")
.w(relative(1.0))
.h_full()
.w_full()
.h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height())
.relative()
.child(
div()
.h_px()
.top(px(0.))
.absolute()
.w_full()
.h_px()
.bg(cx.theme().colors().border_variant)
.group_hover("excerpt-jump-action", |style| {
style.bg(cx.theme().colors().border)
}),
),
)
.child(
h_flex()
.justify_end()
.flex_none()
.w(icon_offset)
.h_full()
)
.cursor_pointer()
.when_some(jump_data.clone(), |this, jump_data| {
this.on_click(cx.listener_for(&self.editor, {
let path = jump_data.path.clone();
move |editor, _, cx| {
cx.stop_propagation();
editor.jump(
path.clone(),
jump_data.position,
jump_data.anchor,
jump_data.line_offset_from_top,
cx,
);
}
}))
.tooltip(move |cx| {
Tooltip::for_action(
format!(
"Jump to {}:L{}",
jump_data.path.path.display(),
jump_data.position.row + 1
),
&OpenExcerpts,
cx,
)
})
})
.child(
show_excerpt_controls
.then(|| {
ButtonLike::new("expand-icon")
.style(ButtonStyle::Transparent)
.child(
svg()
.path(IconName::ArrowUpFromLine.path())
.size(IconSize::XSmall.rems())
.text_color(
cx.theme().colors().editor_line_number,
)
.group("")
.hover(|style| {
style.text_color(
cx.theme()
.colors()
.editor_active_line_number,
)
}),
)
.on_click(cx.listener_for(&self.editor, {
let id = *id;
move |editor, _, cx| {
editor.expand_excerpt(
id,
multi_buffer::ExpandExcerptDirection::Up,
cx,
);
}
}))
.tooltip({
move |cx| {
Tooltip::for_action(
"Expand Excerpt",
&ExpandExcerpts { lines: 0 },
cx,
)
}
})
})
.unwrap_or_else(|| {
h_flex()
.w(icon_offset)
.h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32
* cx.line_height())
.flex_none()
.justify_end()
.child(if *show_excerpt_controls {
self.render_expand_excerpt_button(
next_excerpt.id,
ExpandExcerptDirection::Up,
IconName::ArrowUpFromLine,
cx,
)
} else {
ButtonLike::new("jump-icon")
.style(ButtonStyle::Transparent)
.child(
@@ -2354,7 +2347,6 @@ impl EditorElement {
.text_color(
cx.theme().colors().border_variant,
)
.group("excerpt-jump-action")
.group_hover(
"excerpt-jump-action",
|style| {
@@ -2364,118 +2356,13 @@ impl EditorElement {
},
),
)
.when_some(jump_data.clone(), |this, jump_data| {
this.on_click(cx.listener_for(&self.editor, {
let path = jump_data.path.clone();
move |editor, _, cx| {
cx.stop_propagation();
editor.jump(
path.clone(),
jump_data.position,
jump_data.anchor,
jump_data.line_offset_from_top,
cx,
);
}
}))
.tooltip(move |cx| {
Tooltip::for_action(
format!(
"Jump to {}:L{}",
jump_data.path.path.display(),
jump_data.position.row + 1
),
&OpenExcerpts,
cx,
)
})
})
}),
),
)
.group("excerpt-jump-action")
.cursor_pointer()
.when_some(jump_data.clone(), |this, jump_data| {
this.on_click(cx.listener_for(&self.editor, {
let path = jump_data.path.clone();
move |editor, _, cx| {
cx.stop_propagation();
);
}
}
editor.jump(
path.clone(),
jump_data.position,
jump_data.anchor,
jump_data.line_offset_from_top,
cx,
);
}
}))
.tooltip(move |cx| {
Tooltip::for_action(
format!(
"Jump to {}:L{}",
jump_data.path.path.display(),
jump_data.position.row + 1
),
&OpenExcerpts,
cx,
)
})
})
};
element.into_any()
}
Block::ExcerptFooter { id, .. } => {
let element = v_flex()
.id(("excerpt footer", EntityId::from(block_id)))
.w_full()
.h(snapshot.excerpt_footer_height() as f32 * cx.line_height())
.child(
h_flex()
.justify_end()
.flex_none()
.w(gutter_dimensions.width
- (gutter_dimensions.left_padding + gutter_dimensions.margin))
.h_full()
.child(
ButtonLike::new("expand-icon")
.style(ButtonStyle::Transparent)
.child(
svg()
.path(IconName::ArrowDownFromLine.path())
.size(IconSize::XSmall.rems())
.text_color(cx.theme().colors().editor_line_number)
.group("")
.hover(|style| {
style.text_color(
cx.theme().colors().editor_active_line_number,
)
}),
)
.on_click(cx.listener_for(&self.editor, {
let id = *id;
move |editor, _, cx| {
editor.expand_excerpt(
id,
multi_buffer::ExpandExcerptDirection::Down,
cx,
);
}
}))
.tooltip({
move |cx| {
Tooltip::for_action(
"Expand Excerpt",
&ExpandExcerpts { lines: 0 },
cx,
)
}
}),
),
);
element.into_any()
result.into_any()
}
};
@@ -2502,6 +2389,33 @@ impl EditorElement {
(element, final_size)
}
fn render_expand_excerpt_button(
&self,
excerpt_id: ExcerptId,
direction: ExpandExcerptDirection,
icon: IconName,
cx: &mut WindowContext,
) -> ButtonLike {
ButtonLike::new("expand-icon")
.style(ButtonStyle::Transparent)
.child(
svg()
.path(icon.path())
.size(IconSize::XSmall.rems())
.text_color(cx.theme().colors().editor_line_number)
.group("")
.hover(|style| style.text_color(cx.theme().colors().editor_active_line_number)),
)
.on_click(cx.listener_for(&self.editor, {
move |editor, _, cx| {
editor.expand_excerpt(excerpt_id, direction, cx);
}
}))
.tooltip({
move |cx| Tooltip::for_action("Expand Excerpt", &ExpandExcerpts { lines: 0 }, cx)
})
}
#[allow(clippy::too_many_arguments)]
fn render_blocks(
&self,
@@ -3360,7 +3274,7 @@ impl EditorElement {
let end_row_in_current_excerpt = snapshot
.blocks_in_range(start_row..end_row)
.find_map(|(start_row, block)| {
if matches!(block, Block::ExcerptHeader { .. }) {
if matches!(block, Block::ExcerptBoundary { .. }) {
Some(start_row)
} else {
None

View File

@@ -525,7 +525,7 @@ async fn parse_blocks(
font_family: Some(buffer_font_family),
..Default::default()
},
rule_color: Color::Muted.color(cx),
rule_color: cx.theme().colors().border,
block_quote_border_color: Color::Muted.color(cx),
block_quote: TextStyleRefinement {
color: Some(Color::Muted.color(cx)),

View File

@@ -27,6 +27,7 @@ use rpc::proto::{self, update_view, PeerId};
use settings::Settings;
use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
use project::lsp_store::FormatTarget;
use std::{
any::TypeId,
borrow::Cow,
@@ -722,7 +723,12 @@ impl Item for Editor {
cx.spawn(|this, mut cx| async move {
if format {
this.update(&mut cx, |editor, cx| {
editor.perform_format(project.clone(), FormatTrigger::Save, cx)
editor.perform_format(
project.clone(),
FormatTrigger::Save,
FormatTarget::Buffer,
cx,
)
})?
.await?;
}

View File

@@ -1,5 +1,4 @@
use std::ops::Range;
use crate::actions::FormatSelections;
use crate::{
actions::Format, selections_collection::SelectionsCollection, Copy, CopyPermalinkToLine, Cut,
DisplayPoint, DisplaySnapshot, Editor, EditorMode, FindAllReferences, GoToDeclaration,
@@ -8,6 +7,8 @@ use crate::{
};
use gpui::prelude::FluentBuilder;
use gpui::{DismissEvent, Pixels, Point, Subscription, View, ViewContext};
use std::ops::Range;
use text::PointUtf16;
use workspace::OpenInTerminal;
#[derive(Debug)]
@@ -164,6 +165,12 @@ pub fn deploy_context_menu(
} else {
"Reveal in File Manager"
};
let has_selections = editor
.selections
.all::<PointUtf16>(cx)
.into_iter()
.any(|s| !s.is_empty());
ui::ContextMenu::build(cx, |menu, _cx| {
let builder = menu
.on_blur_subscription(Subscription::new(|| {}))
@@ -175,6 +182,9 @@ pub fn deploy_context_menu(
.separator()
.action("Rename Symbol", Box::new(Rename))
.action("Format Buffer", Box::new(Format))
.when(has_selections, |cx| {
cx.action("Format Selections", Box::new(FormatSelections))
})
.action(
"Code Actions",
Box::new(ToggleCodeActions {

View File

@@ -952,7 +952,7 @@ mod tests {
px(14.0),
None,
true,
2,
0,
2,
0,
FoldPlaceholder::test(),

View File

@@ -16,16 +16,24 @@ use workspace::{
pub struct ProposedChangesEditor {
editor: View<Editor>,
_subscriptions: Vec<Subscription>,
multibuffer: Model<MultiBuffer>,
title: SharedString,
buffer_entries: Vec<BufferEntry>,
_recalculate_diffs_task: Task<Option<()>>,
recalculate_diffs_tx: mpsc::UnboundedSender<RecalculateDiff>,
}
pub struct ProposedChangesBuffer<T> {
pub struct ProposedChangeLocation<T> {
pub buffer: Model<Buffer>,
pub ranges: Vec<Range<T>>,
}
struct BufferEntry {
base: Model<Buffer>,
branch: Model<Buffer>,
_subscription: Subscription,
}
pub struct ProposedChangesEditorToolbar {
current_editor: Option<View<ProposedChangesEditor>>,
}
@@ -43,32 +51,14 @@ struct BranchBufferSemanticsProvider(Rc<dyn SemanticsProvider>);
impl ProposedChangesEditor {
pub fn new<T: ToOffset>(
buffers: Vec<ProposedChangesBuffer<T>>,
title: impl Into<SharedString>,
locations: Vec<ProposedChangeLocation<T>>,
project: Option<Model<Project>>,
cx: &mut ViewContext<Self>,
) -> Self {
let mut subscriptions = Vec::new();
let multibuffer = cx.new_model(|_| MultiBuffer::new(Capability::ReadWrite));
for buffer in buffers {
let branch_buffer = buffer.buffer.update(cx, |buffer, cx| buffer.branch(cx));
subscriptions.push(cx.subscribe(&branch_buffer, Self::on_buffer_event));
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.push_excerpts(
branch_buffer,
buffer.ranges.into_iter().map(|range| ExcerptRange {
context: range,
primary: None,
}),
cx,
);
});
}
let (recalculate_diffs_tx, mut recalculate_diffs_rx) = mpsc::unbounded();
Self {
let mut this = Self {
editor: cx.new_view(|cx| {
let mut editor = Editor::for_multibuffer(multibuffer.clone(), project, true, cx);
editor.set_expand_all_diff_hunks();
@@ -81,6 +71,9 @@ impl ProposedChangesEditor {
);
editor
}),
multibuffer,
title: title.into(),
buffer_entries: Vec::new(),
recalculate_diffs_tx,
_recalculate_diffs_task: cx.spawn(|_, mut cx| async move {
let mut buffers_to_diff = HashSet::default();
@@ -112,7 +105,100 @@ impl ProposedChangesEditor {
}
None
}),
_subscriptions: subscriptions,
};
this.reset_locations(locations, cx);
this
}
pub fn branch_buffer_for_base(&self, base_buffer: &Model<Buffer>) -> Option<Model<Buffer>> {
self.buffer_entries.iter().find_map(|entry| {
if &entry.base == base_buffer {
Some(entry.branch.clone())
} else {
None
}
})
}
pub fn set_title(&mut self, title: SharedString, cx: &mut ViewContext<Self>) {
self.title = title;
cx.notify();
}
pub fn reset_locations<T: ToOffset>(
&mut self,
locations: Vec<ProposedChangeLocation<T>>,
cx: &mut ViewContext<Self>,
) {
// Undo all branch changes
for entry in &self.buffer_entries {
let base_version = entry.base.read(cx).version();
entry.branch.update(cx, |buffer, cx| {
let undo_counts = buffer
.operations()
.iter()
.filter_map(|(timestamp, _)| {
if !base_version.observed(*timestamp) {
Some((*timestamp, u32::MAX))
} else {
None
}
})
.collect();
buffer.undo_operations(undo_counts, cx);
});
}
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.clear(cx);
});
let mut buffer_entries = Vec::new();
for location in locations {
let branch_buffer;
if let Some(ix) = self
.buffer_entries
.iter()
.position(|entry| entry.base == location.buffer)
{
let entry = self.buffer_entries.remove(ix);
branch_buffer = entry.branch.clone();
buffer_entries.push(entry);
} else {
branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx));
buffer_entries.push(BufferEntry {
branch: branch_buffer.clone(),
base: location.buffer.clone(),
_subscription: cx.subscribe(&branch_buffer, Self::on_buffer_event),
});
}
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.push_excerpts(
branch_buffer,
location.ranges.into_iter().map(|range| ExcerptRange {
context: range,
primary: None,
}),
cx,
);
});
}
self.buffer_entries = buffer_entries;
self.editor.update(cx, |editor, cx| {
editor.change_selections(None, cx, |selections| selections.refresh())
});
}
pub fn recalculate_all_buffer_diffs(&self) {
for (ix, entry) in self.buffer_entries.iter().enumerate().rev() {
self.recalculate_diffs_tx
.unbounded_send(RecalculateDiff {
buffer: entry.branch.clone(),
debounce: ix > 0,
})
.ok();
}
}
@@ -162,11 +248,11 @@ impl Item for ProposedChangesEditor {
type Event = EditorEvent;
fn tab_icon(&self, _cx: &ui::WindowContext) -> Option<Icon> {
Some(Icon::new(IconName::Pencil))
Some(Icon::new(IconName::Diff))
}
fn tab_content_text(&self, _cx: &WindowContext) -> Option<SharedString> {
Some("Proposed changes".into())
Some(self.title.clone())
}
fn as_searchable(&self, _: &View<Self>) -> Option<Box<dyn SearchableItemHandle>> {

View File

@@ -434,12 +434,10 @@ impl<T> Clone for Model<T> {
impl<T> std::fmt::Debug for Model<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Model {{ entity_id: {:?}, entity_type: {:?} }}",
self.any_model.entity_id,
type_name::<T>()
)
f.debug_struct("Model")
.field("entity_id", &self.any_model.entity_id)
.field("entity_type", &type_name::<T>())
.finish()
}
}
@@ -569,7 +567,10 @@ pub struct WeakModel<T> {
impl<T> std::fmt::Debug for WeakModel<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct(type_name::<WeakModel<T>>()).finish()
f.debug_struct(&type_name::<Self>())
.field("entity_id", &self.any_model.entity_id)
.field("entity_type", &type_name::<T>())
.finish()
}
}

View File

@@ -133,14 +133,7 @@ impl<E: IntoElement + 'static> Element for AnimationElement<E> {
let mut element = (self.animator)(element, delta).into_any_element();
if !done {
let parent_id = cx.parent_view_id();
cx.on_next_frame(move |cx| {
if let Some(parent_id) = parent_id {
cx.notify(parent_id)
} else {
cx.refresh()
}
})
cx.request_animation_frame();
}
((element.request_layout(cx), element), state)

View File

@@ -2575,4 +2575,9 @@ impl ScrollHandle {
pub fn set_logical_scroll_top(&self, ix: usize, px: Pixels) {
self.0.borrow_mut().requested_scroll_top = Some((ix, px));
}
/// Get the count of children for scrollable item.
pub fn children_count(&self) -> usize {
self.0.borrow().child_bounds.len()
}
}

View File

@@ -52,12 +52,13 @@ pub struct WindowsWindowState {
pub display: WindowsDisplay,
fullscreen: Option<StyleAndBounds>,
initial_placement: Option<WINDOWPLACEMENT>,
initial_placement: Option<WindowOpenStatus>,
hwnd: HWND,
}
pub(crate) struct WindowsWindowStatePtr {
hwnd: HWND,
this: Weak<Self>,
pub(crate) state: RefCell<WindowsWindowState>,
pub(crate) handle: AnyWindowHandle,
pub(crate) hide_title_bar: bool,
@@ -222,9 +223,10 @@ impl WindowsWindowStatePtr {
context.display,
)?);
Ok(Rc::new(Self {
state,
Ok(Rc::new_cyclic(|this| Self {
hwnd,
this: this.clone(),
state,
handle: context.handle,
hide_title_bar: context.hide_title_bar,
is_movable: context.is_movable,
@@ -235,11 +237,86 @@ impl WindowsWindowStatePtr {
}))
}
fn toggle_fullscreen(&self) {
let Some(state_ptr) = self.this.upgrade() else {
log::error!("Unable to toggle fullscreen: window has been dropped");
return;
};
self.executor
.spawn(async move {
let mut lock = state_ptr.state.borrow_mut();
let StyleAndBounds {
style,
x,
y,
cx,
cy,
} = if let Some(state) = lock.fullscreen.take() {
state
} else {
let (window_bounds, _) = lock.calculate_window_bounds();
lock.fullscreen_restore_bounds = window_bounds;
let style =
WINDOW_STYLE(unsafe { get_window_long(state_ptr.hwnd, GWL_STYLE) } as _);
let mut rc = RECT::default();
unsafe { GetWindowRect(state_ptr.hwnd, &mut rc) }.log_err();
let _ = lock.fullscreen.insert(StyleAndBounds {
style,
x: rc.left,
y: rc.top,
cx: rc.right - rc.left,
cy: rc.bottom - rc.top,
});
let style = style
& !(WS_THICKFRAME
| WS_SYSMENU
| WS_MAXIMIZEBOX
| WS_MINIMIZEBOX
| WS_CAPTION);
let physical_bounds = lock.display.physical_bounds();
StyleAndBounds {
style,
x: physical_bounds.left().0,
y: physical_bounds.top().0,
cx: physical_bounds.size.width.0,
cy: physical_bounds.size.height.0,
}
};
drop(lock);
unsafe { set_window_long(state_ptr.hwnd, GWL_STYLE, style.0 as isize) };
unsafe {
SetWindowPos(
state_ptr.hwnd,
HWND::default(),
x,
y,
cx,
cy,
SWP_FRAMECHANGED | SWP_NOACTIVATE | SWP_NOZORDER,
)
}
.log_err();
})
.detach();
}
fn set_window_placement(&self) -> Result<()> {
let Some(placement) = self.state.borrow_mut().initial_placement.take() else {
let Some(open_status) = self.state.borrow_mut().initial_placement.take() else {
return Ok(());
};
unsafe { SetWindowPlacement(self.hwnd, &placement)? };
match open_status.state {
WindowOpenState::Maximized => unsafe {
SetWindowPlacement(self.hwnd, &open_status.placement)?;
ShowWindowAsync(self.hwnd, SW_MAXIMIZE).ok()?;
},
WindowOpenState::Fullscreen => {
unsafe { SetWindowPlacement(self.hwnd, &open_status.placement)? };
self.toggle_fullscreen();
}
WindowOpenState::Windowed => unsafe {
SetWindowPlacement(self.hwnd, &open_status.placement)?;
},
}
Ok(())
}
}
@@ -361,7 +438,10 @@ impl WindowsWindow {
if params.show {
unsafe { SetWindowPlacement(hwnd, &placement)? };
} else {
state_ptr.state.borrow_mut().initial_placement = Some(placement);
state_ptr.state.borrow_mut().initial_placement = Some(WindowOpenStatus {
placement,
state: WindowOpenState::Windowed,
});
}
Ok(Self(state_ptr))
@@ -579,68 +659,21 @@ impl PlatformWindow for WindowsWindow {
}
fn zoom(&self) {
unsafe { ShowWindowAsync(self.0.hwnd, SW_MAXIMIZE).ok().log_err() };
unsafe {
if IsWindowVisible(self.0.hwnd).as_bool() {
ShowWindowAsync(self.0.hwnd, SW_MAXIMIZE).ok().log_err();
} else if let Some(status) = self.0.state.borrow_mut().initial_placement.as_mut() {
status.state = WindowOpenState::Maximized;
}
}
}
fn toggle_fullscreen(&self) {
let state_ptr = self.0.clone();
self.0
.executor
.spawn(async move {
let mut lock = state_ptr.state.borrow_mut();
let StyleAndBounds {
style,
x,
y,
cx,
cy,
} = if let Some(state) = lock.fullscreen.take() {
state
} else {
let (window_bounds, _) = lock.calculate_window_bounds();
lock.fullscreen_restore_bounds = window_bounds;
let style =
WINDOW_STYLE(unsafe { get_window_long(state_ptr.hwnd, GWL_STYLE) } as _);
let mut rc = RECT::default();
unsafe { GetWindowRect(state_ptr.hwnd, &mut rc) }.log_err();
let _ = lock.fullscreen.insert(StyleAndBounds {
style,
x: rc.left,
y: rc.top,
cx: rc.right - rc.left,
cy: rc.bottom - rc.top,
});
let style = style
& !(WS_THICKFRAME
| WS_SYSMENU
| WS_MAXIMIZEBOX
| WS_MINIMIZEBOX
| WS_CAPTION);
let physical_bounds = lock.display.physical_bounds();
StyleAndBounds {
style,
x: physical_bounds.left().0,
y: physical_bounds.top().0,
cx: physical_bounds.size.width.0,
cy: physical_bounds.size.height.0,
}
};
drop(lock);
unsafe { set_window_long(state_ptr.hwnd, GWL_STYLE, style.0 as isize) };
unsafe {
SetWindowPos(
state_ptr.hwnd,
HWND::default(),
x,
y,
cx,
cy,
SWP_FRAMECHANGED | SWP_NOACTIVATE | SWP_NOZORDER,
)
}
.log_err();
})
.detach();
if unsafe { IsWindowVisible(self.0.hwnd).as_bool() } {
self.0.toggle_fullscreen();
} else if let Some(status) = self.0.state.borrow_mut().initial_placement.as_mut() {
status.state = WindowOpenState::Fullscreen;
}
}
fn is_fullscreen(&self) -> bool {
@@ -925,6 +958,17 @@ impl WindowBorderOffset {
}
}
struct WindowOpenStatus {
placement: WINDOWPLACEMENT,
state: WindowOpenState,
}
enum WindowOpenState {
Maximized,
Fullscreen,
Windowed,
}
fn register_wnd_class(icon_handle: HICON) -> PCWSTR {
const CLASS_NAME: PCWSTR = w!("Zed::Window");

View File

@@ -36,7 +36,9 @@ impl project::Item for ImageItem {
.path
.extension()
.and_then(OsStr::to_str)
.map(str::to_lowercase)
.unwrap_or_default();
let ext = ext.as_str();
// Only open the item if it's a binary image (no SVGs, etc.)
// Since we do not have a way to toggle to an editor

View File

@@ -20,6 +20,7 @@ use anyhow::{anyhow, Context, Result};
use async_watch as watch;
use clock::Lamport;
pub use clock::ReplicaId;
use collections::HashMap;
use futures::channel::oneshot;
use gpui::{
AnyElement, AppContext, Context as _, EventEmitter, HighlightStyle, Model, ModelContext,
@@ -910,10 +911,8 @@ impl Buffer {
self.apply_ops([operation.clone()], cx);
if let Some(timestamp) = operation_to_undo {
let operation = self
.text
.undo_operations([(timestamp, u32::MAX)].into_iter().collect());
self.send_operation(Operation::Buffer(operation), true, cx);
let counts = [(timestamp, u32::MAX)].into_iter().collect();
self.undo_operations(counts, cx);
}
self.diff_base_version += 1;
@@ -2331,6 +2330,18 @@ impl Buffer {
undone
}
pub fn undo_operations(
&mut self,
counts: HashMap<Lamport, u32>,
cx: &mut ModelContext<Buffer>,
) {
let was_dirty = self.is_dirty();
let operation = self.text.undo_operations(counts);
let old_version = self.version.clone();
self.send_operation(Operation::Buffer(operation), true, cx);
self.did_edit(&old_version, was_dirty, cx);
}
/// Manually redoes a specific transaction in the buffer's redo history.
pub fn redo(&mut self, cx: &mut ModelContext<Self>) -> Option<TransactionId> {
let was_dirty = self.is_dirty();

View File

@@ -8,7 +8,7 @@ use crate::{
use anthropic::AnthropicError;
use anyhow::{anyhow, Result};
use client::{
Client, PerformCompletionParams, UserStore, EXPIRED_LLM_TOKEN_HEADER_NAME,
zed_urls, Client, PerformCompletionParams, UserStore, EXPIRED_LLM_TOKEN_HEADER_NAME,
MAX_LLM_MONTHLY_SPEND_REACHED_HEADER_NAME,
};
use collections::BTreeMap;
@@ -905,7 +905,6 @@ impl ConfigurationView {
impl Render for ConfigurationView {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
const ZED_AI_URL: &str = "https://zed.dev/ai";
const ACCOUNT_SETTINGS_URL: &str = "https://zed.dev/account";
let is_connected = !self.state.read(cx).is_signed_out();
let plan = self.state.read(cx).user_store.read(cx).current_plan();
@@ -922,7 +921,7 @@ impl Render for ConfigurationView {
h_flex().child(
Button::new("manage_settings", "Manage Subscription")
.style(ButtonStyle::Tinted(TintColor::Accent))
.on_click(cx.listener(|_, _, cx| cx.open_url(ACCOUNT_SETTINGS_URL))),
.on_click(cx.listener(|_, _, cx| cx.open_url(&zed_urls::account_url(cx)))),
),
)
} else if cx.has_flag::<ZedPro>() {
@@ -938,7 +937,9 @@ impl Render for ConfigurationView {
Button::new("upgrade", "Upgrade")
.style(ButtonStyle::Subtle)
.color(Color::Accent)
.on_click(cx.listener(|_, _, cx| cx.open_url(ACCOUNT_SETTINGS_URL))),
.on_click(
cx.listener(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))),
),
),
)
} else {

View File

@@ -9,7 +9,9 @@ license = "GPL-3.0-or-later"
workspace = true
[features]
test-support = []
test-support = [
"tree-sitter"
]
load-grammars = [
"tree-sitter-bash",
"tree-sitter-c",
@@ -75,6 +77,7 @@ tree-sitter-yaml = { workspace = true, optional = true }
util.workspace = true
[dev-dependencies]
tree-sitter.workspace = true
text.workspace = true
theme = { workspace = true, features = ["test-support"] }
unindent.workspace = true

View File

@@ -1,6 +1,6 @@
name = "CSS"
grammar = "css"
path_suffixes = ["css"]
path_suffixes = ["css", "postcss"]
autoclose_before = ";:.,=}])>"
brackets = [
{ start = "{", end = "}", close = true, newline = true },

View File

@@ -189,6 +189,7 @@ pub struct MultiBufferSnapshot {
show_headers: bool,
}
#[derive(Clone)]
pub struct ExcerptInfo {
pub id: ExcerptId,
pub buffer: BufferSnapshot,
@@ -201,6 +202,7 @@ impl std::fmt::Debug for ExcerptInfo {
f.debug_struct(type_name::<Self>())
.field("id", &self.id)
.field("buffer_id", &self.buffer_id)
.field("path", &self.buffer.file().map(|f| f.path()))
.field("range", &self.range)
.finish()
}

View File

@@ -101,10 +101,10 @@ pub fn logs_dir() -> &'static PathBuf {
})
}
/// Returns the path to the zed server directory on this ssh host.
/// Returns the path to the Zed server directory on this SSH host.
pub fn remote_server_state_dir() -> &'static PathBuf {
static REMOTE_SERVER_STATE: OnceLock<PathBuf> = OnceLock::new();
REMOTE_SERVER_STATE.get_or_init(|| return support_dir().join("server_state"))
REMOTE_SERVER_STATE.get_or_init(|| support_dir().join("server_state"))
}
/// Returns the path to the `Zed.log` file.

View File

@@ -69,6 +69,7 @@ snippet_provider.workspace = true
terminal.workspace = true
text.workspace = true
util.workspace = true
url.workspace = true
which.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]

View File

@@ -3,6 +3,7 @@ use crate::{
worktree_store::{WorktreeStore, WorktreeStoreEvent},
Item, NoRepositoryError, ProjectPath,
};
use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
use anyhow::{anyhow, Context as _, Result};
use client::Client;
use collections::{hash_map, HashMap, HashSet};
@@ -23,7 +24,7 @@ use language::{
};
use rpc::{proto, AnyProtoClient, ErrorExt as _, TypedEnvelope};
use smol::channel::Receiver;
use std::{io, path::Path, str::FromStr as _, sync::Arc, time::Instant};
use std::{io, ops::Range, path::Path, str::FromStr as _, sync::Arc, time::Instant};
use text::BufferId;
use util::{debug_panic, maybe, ResultExt as _, TryFutureExt};
use worktree::{File, PathChange, ProjectEntryId, UpdatedGitRepositoriesSet, Worktree, WorktreeId};
@@ -971,6 +972,7 @@ impl BufferStore {
client.add_model_request_handler(Self::handle_save_buffer);
client.add_model_request_handler(Self::handle_blame_buffer);
client.add_model_request_handler(Self::handle_reload_buffers);
client.add_model_request_handler(Self::handle_get_permalink_to_line);
}
/// Creates a buffer store, optionally retaining its buffers.
@@ -1170,6 +1172,78 @@ impl BufferStore {
}
}
pub fn get_permalink_to_line(
&self,
buffer: &Model<Buffer>,
selection: Range<u32>,
cx: &AppContext,
) -> Task<Result<url::Url>> {
let buffer = buffer.read(cx);
let Some(file) = File::from_dyn(buffer.file()) else {
return Task::ready(Err(anyhow!("buffer has no file")));
};
match file.worktree.clone().read(cx) {
Worktree::Local(worktree) => {
let Some(repo) = worktree.local_git_repo(file.path()) else {
return Task::ready(Err(anyhow!("no repository for buffer found")));
};
let path = file.path().clone();
cx.spawn(|cx| async move {
const REMOTE_NAME: &str = "origin";
let origin_url = repo
.remote_url(REMOTE_NAME)
.ok_or_else(|| anyhow!("remote \"{REMOTE_NAME}\" not found"))?;
let sha = repo
.head_sha()
.ok_or_else(|| anyhow!("failed to read HEAD SHA"))?;
let provider_registry =
cx.update(GitHostingProviderRegistry::default_global)?;
let (provider, remote) =
parse_git_remote_url(provider_registry, &origin_url)
.ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
let path = path
.to_str()
.context("failed to convert buffer path to string")?;
Ok(provider.build_permalink(
remote,
BuildPermalinkParams {
sha: &sha,
path,
selection: Some(selection),
},
))
})
}
Worktree::Remote(worktree) => {
let buffer_id = buffer.remote_id();
let project_id = worktree.project_id();
let client = worktree.client();
cx.spawn(|_| async move {
let response = client
.request(proto::GetPermalinkToLine {
project_id,
buffer_id: buffer_id.into(),
selection: Some(proto::Range {
start: selection.start as u64,
end: selection.end as u64,
}),
})
.await?;
url::Url::parse(&response.permalink).context("failed to parse permalink")
})
}
}
}
fn add_buffer(&mut self, buffer: Model<Buffer>, cx: &mut ModelContext<Self>) -> Result<()> {
let remote_id = buffer.read(cx).remote_id();
let is_remote = buffer.read(cx).replica_id() != 0;
@@ -1775,6 +1849,31 @@ impl BufferStore {
Ok(serialize_blame_buffer_response(blame))
}
pub async fn handle_get_permalink_to_line(
this: Model<Self>,
envelope: TypedEnvelope<proto::GetPermalinkToLine>,
mut cx: AsyncAppContext,
) -> Result<proto::GetPermalinkToLineResponse> {
let buffer_id = BufferId::new(envelope.payload.buffer_id)?;
// let version = deserialize_version(&envelope.payload.version);
let selection = {
let proto_selection = envelope
.payload
.selection
.context("no selection to get permalink for defined")?;
proto_selection.start as u32..proto_selection.end as u32
};
let buffer = this.read_with(&cx, |this, _| this.get_existing(buffer_id))??;
let permalink = this
.update(&mut cx, |this, cx| {
this.get_permalink_to_line(&buffer, selection, cx)
})?
.await?;
Ok(proto::GetPermalinkToLineResponse {
permalink: permalink.to_string(),
})
}
pub async fn wait_for_loading_buffer(
mut receiver: postage::watch::Receiver<Option<Result<Model<Buffer>, Arc<anyhow::Error>>>>,
) -> Result<Model<Buffer>, Arc<anyhow::Error>> {

View File

@@ -112,8 +112,18 @@ impl ProjectEnvironment {
let worktree = worktree_id.zip(worktree_abs_path);
let cli_environment = self.get_cli_environment();
if cli_environment.is_some() {
Task::ready(cli_environment)
if let Some(environment) = cli_environment {
cx.spawn(|_, _| async move {
let path = environment
.get("PATH")
.map(|path| path.as_str())
.unwrap_or_default();
log::info!(
"using project environment variables from CLI. PATH={:?}",
path
);
Some(environment)
})
} else if let Some((worktree_id, worktree_abs_path)) = worktree {
self.get_worktree_env(worktree_id, worktree_abs_path, cx)
} else {
@@ -143,6 +153,15 @@ impl ProjectEnvironment {
.await;
if let Some(shell_env) = shell_env.as_mut() {
let path = shell_env
.get("PATH")
.map(|path| path.as_str())
.unwrap_or_default();
log::info!(
"using project environment variables shell launched in {:?}. PATH={:?}",
worktree_abs_path,
path
);
this.update(&mut cx, |this, _| {
this.cached_shell_environments
.insert(worktree_id, shell_env.clone());

View File

@@ -2439,15 +2439,13 @@ impl InlayHints {
ResolveState::Resolved => (0, None),
ResolveState::CanResolve(server_id, resolve_data) => (
1,
resolve_data
.map(|json_data| {
Some(proto::resolve_state::LspResolveState {
server_id: server_id.0 as u64,
value: resolve_data.map(|json_data| {
serde_json::to_string(&json_data)
.expect("failed to serialize resolve json data")
})
.map(|value| proto::resolve_state::LspResolveState {
server_id: server_id.0 as u64,
value,
}),
}),
),
ResolveState::Resolving => (2, None),
};
@@ -2515,9 +2513,11 @@ impl InlayHints {
let resolve_state_data = resolve_state
.lsp_resolve_state.as_ref()
.map(|lsp_resolve_state| {
serde_json::from_str::<Option<lsp::LSPAny>>(&lsp_resolve_state.value)
.with_context(|| format!("incorrect proto inlay hint message: non-json resolve state {lsp_resolve_state:?}"))
.map(|state| (LanguageServerId(lsp_resolve_state.server_id as usize), state))
let value = lsp_resolve_state.value.as_deref().map(|value| {
serde_json::from_str::<Option<lsp::LSPAny>>(value)
.with_context(|| format!("incorrect proto inlay hint message: non-json resolve state {lsp_resolve_state:?}"))
}).transpose()?.flatten();
anyhow::Ok((LanguageServerId(lsp_resolve_state.server_id as usize), value))
})
.transpose()?;
let resolve_state = match resolve_state.state {

View File

@@ -72,7 +72,7 @@ use std::{
sync::Arc,
time::{Duration, Instant},
};
use text::{Anchor, BufferId, LineEnding};
use text::{Anchor, BufferId, LineEnding, Point, Selection};
use util::{
debug_panic, defer, maybe, merge_json_value_into, post_inc, ResultExt, TryFutureExt as _,
};
@@ -96,6 +96,20 @@ pub enum FormatTrigger {
Manual,
}
pub enum FormatTarget {
Buffer,
Ranges(Vec<Selection<Point>>),
}
impl FormatTarget {
pub fn as_selections(&self) -> Option<&[Selection<Point>]> {
match self {
FormatTarget::Buffer => None,
FormatTarget::Ranges(selections) => Some(selections.as_slice()),
}
}
}
// Currently, formatting operations are represented differently depending on
// whether they come from a language server or an external command.
#[derive(Debug)]
@@ -161,6 +175,7 @@ impl LocalLspStore {
mut buffers: Vec<FormattableBuffer>,
push_to_history: bool,
trigger: FormatTrigger,
target: FormatTarget,
mut cx: AsyncAppContext,
) -> anyhow::Result<ProjectTransaction> {
// Do not allow multiple concurrent formatting requests for the
@@ -286,6 +301,7 @@ impl LocalLspStore {
if prettier_settings.allowed {
Self::perform_format(
&Formatter::Prettier,
&target,
server_and_buffer,
lsp_store.clone(),
buffer,
@@ -299,6 +315,7 @@ impl LocalLspStore {
} else {
Self::perform_format(
&Formatter::LanguageServer { name: None },
&target,
server_and_buffer,
lsp_store.clone(),
buffer,
@@ -310,9 +327,8 @@ impl LocalLspStore {
)
.await
}
}
.log_err()
.flatten();
}?;
if let Some(op) = diff {
format_operations.push(op);
}
@@ -321,6 +337,7 @@ impl LocalLspStore {
for formatter in formatters.as_ref() {
let diff = Self::perform_format(
formatter,
&target,
server_and_buffer,
lsp_store.clone(),
buffer,
@@ -330,9 +347,7 @@ impl LocalLspStore {
&mut project_transaction,
&mut cx,
)
.await
.log_err()
.flatten();
.await?;
if let Some(op) = diff {
format_operations.push(op);
}
@@ -346,6 +361,7 @@ impl LocalLspStore {
for formatter in formatters.as_ref() {
let diff = Self::perform_format(
formatter,
&target,
server_and_buffer,
lsp_store.clone(),
buffer,
@@ -355,9 +371,7 @@ impl LocalLspStore {
&mut project_transaction,
&mut cx,
)
.await
.log_err()
.flatten();
.await?;
if let Some(op) = diff {
format_operations.push(op);
}
@@ -373,6 +387,7 @@ impl LocalLspStore {
if prettier_settings.allowed {
Self::perform_format(
&Formatter::Prettier,
&target,
server_and_buffer,
lsp_store.clone(),
buffer,
@@ -384,8 +399,14 @@ impl LocalLspStore {
)
.await
} else {
let formatter = Formatter::LanguageServer {
name: primary_language_server
.as_ref()
.map(|server| server.name().to_string()),
};
Self::perform_format(
&Formatter::LanguageServer { name: None },
&formatter,
&target,
server_and_buffer,
lsp_store.clone(),
buffer,
@@ -397,9 +418,7 @@ impl LocalLspStore {
)
.await
}
}
.log_err()
.flatten();
}?;
if let Some(op) = diff {
format_operations.push(op)
@@ -410,6 +429,7 @@ impl LocalLspStore {
// format with formatter
let diff = Self::perform_format(
formatter,
&target,
server_and_buffer,
lsp_store.clone(),
buffer,
@@ -419,9 +439,7 @@ impl LocalLspStore {
&mut project_transaction,
&mut cx,
)
.await
.log_err()
.flatten();
.await?;
if let Some(op) = diff {
format_operations.push(op);
}
@@ -483,6 +501,7 @@ impl LocalLspStore {
#[allow(clippy::too_many_arguments)]
async fn perform_format(
formatter: &Formatter,
format_target: &FormatTarget,
primary_server_and_buffer: Option<(&Arc<LanguageServer>, &PathBuf)>,
lsp_store: WeakModel<LspStore>,
buffer: &FormattableBuffer,
@@ -506,18 +525,33 @@ impl LocalLspStore {
language_server
};
Some(FormatOperation::Lsp(
LspStore::format_via_lsp(
&lsp_store,
&buffer.handle,
buffer_abs_path,
language_server,
settings,
cx,
)
.await
.context("failed to format via language server")?,
))
match format_target {
FormatTarget::Buffer => Some(FormatOperation::Lsp(
LspStore::format_via_lsp(
&lsp_store,
&buffer.handle,
buffer_abs_path,
language_server,
settings,
cx,
)
.await
.context("failed to format via language server")?,
)),
FormatTarget::Ranges(selections) => Some(FormatOperation::Lsp(
LspStore::format_range_via_lsp(
&lsp_store,
&buffer.handle,
selections.as_slice(),
buffer_abs_path,
language_server,
settings,
cx,
)
.await
.context("failed to format ranges via language server")?,
)),
}
} else {
None
}
@@ -1203,16 +1237,19 @@ impl LspStore {
None
};
if let Some(prettier_plugins) = prettier_store::prettier_plugins_for_language(&settings) {
let prettier_store = self.as_local().map(|s| s.prettier_store.clone());
if let Some(prettier_store) = prettier_store {
prettier_store.update(cx, |prettier_store, cx| {
prettier_store.install_default_prettier(
worktree_id,
prettier_plugins.iter().map(|s| Arc::from(s.as_str())),
cx,
)
})
if settings.prettier.allowed {
if let Some(prettier_plugins) = prettier_store::prettier_plugins_for_language(&settings)
{
let prettier_store = self.as_local().map(|s| s.prettier_store.clone());
if let Some(prettier_store) = prettier_store {
prettier_store.update(cx, |prettier_store, cx| {
prettier_store.install_default_prettier(
worktree_id,
prettier_plugins.iter().map(|s| Arc::from(s.as_str())),
cx,
)
})
}
}
}
@@ -1859,10 +1896,9 @@ impl LspStore {
} else if matches!(range_formatting_provider, Some(p) if *p != OneOf::Left(false)) {
let buffer_start = lsp::Position::new(0, 0);
let buffer_end = buffer.update(cx, |b, _| point_to_lsp(b.max_point_utf16()))?;
language_server
.request::<lsp::request::RangeFormatting>(lsp::DocumentRangeFormattingParams {
text_document,
text_document: text_document.clone(),
range: lsp::Range::new(buffer_start, buffer_end),
options: lsp_command::lsp_formatting_options(settings),
work_done_progress_params: Default::default(),
@@ -1878,7 +1914,62 @@ impl LspStore {
})?
.await
} else {
Ok(Vec::new())
Ok(Vec::with_capacity(0))
}
}
pub async fn format_range_via_lsp(
this: &WeakModel<Self>,
buffer: &Model<Buffer>,
selections: &[Selection<Point>],
abs_path: &Path,
language_server: &Arc<LanguageServer>,
settings: &LanguageSettings,
cx: &mut AsyncAppContext,
) -> Result<Vec<(Range<Anchor>, String)>> {
let capabilities = &language_server.capabilities();
let range_formatting_provider = capabilities.document_range_formatting_provider.as_ref();
if range_formatting_provider.map_or(false, |provider| provider == &OneOf::Left(false)) {
return Err(anyhow!(
"{} language server does not support range formatting",
language_server.name()
));
}
let uri = lsp::Url::from_file_path(abs_path)
.map_err(|_| anyhow!("failed to convert abs path to uri"))?;
let text_document = lsp::TextDocumentIdentifier::new(uri);
let lsp_edits = {
let ranges = selections.into_iter().map(|s| {
let start = lsp::Position::new(s.start.row, s.start.column);
let end = lsp::Position::new(s.end.row, s.end.column);
lsp::Range::new(start, end)
});
let mut edits = None;
for range in ranges {
if let Some(mut edit) = language_server
.request::<lsp::request::RangeFormatting>(lsp::DocumentRangeFormattingParams {
text_document: text_document.clone(),
range,
options: lsp_command::lsp_formatting_options(settings),
work_done_progress_params: Default::default(),
})
.await?
{
edits.get_or_insert_with(Vec::new).append(&mut edit);
}
}
edits
};
if let Some(lsp_edits) = lsp_edits {
this.update(cx, |this, cx| {
this.edits_from_lsp(buffer, lsp_edits, language_server.server_id(), None, cx)
})?
.await
} else {
Ok(Vec::with_capacity(0))
}
}
@@ -2648,44 +2739,44 @@ impl LspStore {
};
requests.push(
server
.request::<lsp::request::WorkspaceSymbolRequest>(
lsp::WorkspaceSymbolParams {
query: query.to_string(),
..Default::default()
},
)
.log_err()
.map(move |response| {
let lsp_symbols = response.flatten().map(|symbol_response| match symbol_response {
lsp::WorkspaceSymbolResponse::Flat(flat_responses) => {
flat_responses.into_iter().map(|lsp_symbol| {
(lsp_symbol.name, lsp_symbol.kind, lsp_symbol.location)
}).collect::<Vec<_>>()
}
lsp::WorkspaceSymbolResponse::Nested(nested_responses) => {
nested_responses.into_iter().filter_map(|lsp_symbol| {
let location = match lsp_symbol.location {
OneOf::Left(location) => location,
OneOf::Right(_) => {
log::error!("Unexpected: client capabilities forbid symbol resolutions in workspace.symbol.resolveSupport");
return None
}
};
Some((lsp_symbol.name, lsp_symbol.kind, location))
}).collect::<Vec<_>>()
}
}).unwrap_or_default();
server
.request::<lsp::request::WorkspaceSymbolRequest>(
lsp::WorkspaceSymbolParams {
query: query.to_string(),
..Default::default()
},
)
.log_err()
.map(move |response| {
let lsp_symbols = response.flatten().map(|symbol_response| match symbol_response {
lsp::WorkspaceSymbolResponse::Flat(flat_responses) => {
flat_responses.into_iter().map(|lsp_symbol| {
(lsp_symbol.name, lsp_symbol.kind, lsp_symbol.location)
}).collect::<Vec<_>>()
}
lsp::WorkspaceSymbolResponse::Nested(nested_responses) => {
nested_responses.into_iter().filter_map(|lsp_symbol| {
let location = match lsp_symbol.location {
OneOf::Left(location) => location,
OneOf::Right(_) => {
log::error!("Unexpected: client capabilities forbid symbol resolutions in workspace.symbol.resolveSupport");
return None
}
};
Some((lsp_symbol.name, lsp_symbol.kind, location))
}).collect::<Vec<_>>()
}
}).unwrap_or_default();
WorkspaceSymbolsResult {
lsp_adapter,
language,
worktree: worktree_handle.downgrade(),
worktree_abs_path,
lsp_symbols,
}
}),
);
WorkspaceSymbolsResult {
lsp_adapter,
language,
worktree: worktree_handle.downgrade(),
worktree_abs_path,
lsp_symbols,
}
}),
);
}
cx.spawn(move |this, mut cx| async move {
@@ -3412,7 +3503,7 @@ impl LspStore {
language_server_name: LanguageServerName,
cx: &mut ModelContext<Self>,
) -> Task<Result<Model<Buffer>>> {
cx.spawn(move |this, mut cx| async move {
cx.spawn(move |lsp_store, mut cx| async move {
// Escape percent-encoded string.
let current_scheme = abs_path.scheme().to_owned();
let _ = abs_path.set_scheme("file");
@@ -3421,9 +3512,9 @@ impl LspStore {
.to_file_path()
.map_err(|_| anyhow!("can't convert URI to path"))?;
let p = abs_path.clone();
let yarn_worktree = this
.update(&mut cx, move |this, cx| {
this.as_local().unwrap().yarn.update(cx, |_, cx| {
let yarn_worktree = lsp_store
.update(&mut cx, move |lsp_store, cx| match lsp_store.as_local() {
Some(local_lsp_store) => local_lsp_store.yarn.update(cx, |_, cx| {
cx.spawn(|this, mut cx| async move {
let t = this
.update(&mut cx, |this, cx| {
@@ -3432,7 +3523,8 @@ impl LspStore {
.ok()?;
t.await
})
})
}),
None => Task::ready(None),
})?
.await;
let (worktree_root_target, known_relative_path) =
@@ -3442,8 +3534,8 @@ impl LspStore {
(Arc::<Path>::from(abs_path.as_path()), None)
};
let (worktree, relative_path) = if let Some(result) =
this.update(&mut cx, |this, cx| {
this.worktree_store.update(cx, |worktree_store, cx| {
lsp_store.update(&mut cx, |lsp_store, cx| {
lsp_store.worktree_store.update(cx, |worktree_store, cx| {
worktree_store.find_worktree(&worktree_root_target, cx)
})
})? {
@@ -3451,22 +3543,25 @@ impl LspStore {
known_relative_path.unwrap_or_else(|| Arc::<Path>::from(result.1));
(result.0, relative_path)
} else {
let worktree = this
.update(&mut cx, |this, cx| {
this.worktree_store.update(cx, |worktree_store, cx| {
let worktree = lsp_store
.update(&mut cx, |lsp_store, cx| {
lsp_store.worktree_store.update(cx, |worktree_store, cx| {
worktree_store.create_worktree(&worktree_root_target, false, cx)
})
})?
.await?;
this.update(&mut cx, |this, cx| {
this.register_language_server(
worktree.read(cx).id(),
language_server_name,
language_server_id,
)
})
.ok();
let worktree_root = worktree.update(&mut cx, |this, _| this.abs_path())?;
if worktree.update(&mut cx, |worktree, _| worktree.is_local())? {
lsp_store
.update(&mut cx, |lsp_store, cx| {
lsp_store.register_language_server(
worktree.read(cx).id(),
language_server_name,
language_server_id,
)
})
.ok();
}
let worktree_root = worktree.update(&mut cx, |worktree, _| worktree.abs_path())?;
let relative_path = if let Some(known_path) = known_relative_path {
known_path
} else {
@@ -3478,12 +3573,13 @@ impl LspStore {
worktree_id: worktree.update(&mut cx, |worktree, _| worktree.id())?,
path: relative_path,
};
this.update(&mut cx, |this, cx| {
this.buffer_store().update(cx, |buffer_store, cx| {
buffer_store.open_buffer(project_path, cx)
})
})?
.await
lsp_store
.update(&mut cx, |lsp_store, cx| {
lsp_store.buffer_store().update(cx, |buffer_store, cx| {
buffer_store.open_buffer(project_path, cx)
})
})?
.await
})
}
@@ -4579,16 +4675,16 @@ impl LspStore {
if registrations.remove(registration_id).is_some() {
log::info!(
"language server {}: unregistered workspace/DidChangeWatchedFiles capability with id {}",
language_server_id,
registration_id
);
"language server {}: unregistered workspace/DidChangeWatchedFiles capability with id {}",
language_server_id,
registration_id
);
} else {
log::warn!(
"language server {}: failed to unregister workspace/DidChangeWatchedFiles capability with id {}. not registered.",
language_server_id,
registration_id
);
"language server {}: failed to unregister workspace/DidChangeWatchedFiles capability with id {}. not registered.",
language_server_id,
registration_id
);
}
self.rebuild_watched_paths(language_server_id, cx);
@@ -5078,6 +5174,7 @@ impl LspStore {
buffers: HashSet<Model<Buffer>>,
push_to_history: bool,
trigger: FormatTrigger,
target: FormatTarget,
cx: &mut ModelContext<Self>,
) -> Task<anyhow::Result<ProjectTransaction>> {
if let Some(_) = self.as_local() {
@@ -5114,6 +5211,7 @@ impl LspStore {
formattable_buffers,
push_to_history,
trigger,
target,
cx.clone(),
)
.await;
@@ -5172,7 +5270,7 @@ impl LspStore {
buffers.insert(this.buffer_store.read(cx).get_existing(buffer_id)?);
}
let trigger = FormatTrigger::from_proto(envelope.payload.trigger);
Ok::<_, anyhow::Error>(this.format(buffers, false, trigger, cx))
Ok::<_, anyhow::Error>(this.format(buffers, false, trigger, FormatTarget::Buffer, cx))
})??;
let project_transaction = format.await?;
@@ -5959,6 +6057,16 @@ impl LspStore {
);
})?;
}
"textDocument/rename" => {
this.update(&mut cx, |this, _| {
if let Some(server) = this.language_server_for_id(server_id)
{
server.update_capabilities(|capabilities| {
capabilities.rename_provider = None
})
}
})?;
}
"textDocument/rangeFormatting" => {
this.update(&mut cx, |this, _| {
if let Some(server) = this.language_server_for_id(server_id)
@@ -6485,11 +6593,11 @@ impl LspStore {
})?;
let found_snapshot = snapshots
.binary_search_by_key(&version, |e| e.version)
.map(|ix| snapshots[ix].snapshot.clone())
.map_err(|_| {
anyhow!("snapshot not found for buffer {buffer_id} server {server_id} at version {version}")
})?;
.binary_search_by_key(&version, |e| e.version)
.map(|ix| snapshots[ix].snapshot.clone())
.map_err(|_| {
anyhow!("snapshot not found for buffer {buffer_id} server {server_id} at version {version}")
})?;
snapshots.retain(|snapshot| snapshot.version + OLD_VERSIONS_TO_RETAIN >= version);
Ok(found_snapshot)
@@ -7203,74 +7311,74 @@ impl LanguageServerWatchedPathsBuilder {
let project = cx.weak_model();
cx.new_model(|cx| {
let this_id = cx.entity_id();
const LSP_ABS_PATH_OBSERVE: Duration = Duration::from_millis(100);
let abs_paths = self
.abs_paths
.into_iter()
.map(|(abs_path, globset)| {
let task = cx.spawn({
let abs_path = abs_path.clone();
let fs = fs.clone();
let this_id = cx.entity_id();
const LSP_ABS_PATH_OBSERVE: Duration = Duration::from_millis(100);
let abs_paths = self
.abs_paths
.into_iter()
.map(|(abs_path, globset)| {
let task = cx.spawn({
let abs_path = abs_path.clone();
let fs = fs.clone();
let lsp_store = project.clone();
|_, mut cx| async move {
maybe!(async move {
let mut push_updates =
fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await;
while let Some(update) = push_updates.0.next().await {
let action = lsp_store
.update(&mut cx, |this, cx| {
let Some(local) = this.as_local() else {
return ControlFlow::Break(());
};
let Some(watcher) = local
.language_server_watched_paths
.get(&language_server_id)
else {
return ControlFlow::Break(());
};
if watcher.entity_id() != this_id {
// This watcher is no longer registered on the project, which means that we should
// cease operations.
return ControlFlow::Break(());
}
let (globs, _) = watcher
.read(cx)
.abs_paths
.get(&abs_path)
.expect(
"Watched abs path is not registered with a watcher",
);
let matching_entries = update
.into_iter()
.filter(|event| globs.is_match(&event.path))
.collect::<Vec<_>>();
this.lsp_notify_abs_paths_changed(
language_server_id,
matching_entries,
);
ControlFlow::Continue(())
})
.ok()?;
let lsp_store = project.clone();
|_, mut cx| async move {
maybe!(async move {
let mut push_updates =
fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await;
while let Some(update) = push_updates.0.next().await {
let action = lsp_store
.update(&mut cx, |this, cx| {
let Some(local) = this.as_local() else {
return ControlFlow::Break(());
};
let Some(watcher) = local
.language_server_watched_paths
.get(&language_server_id)
else {
return ControlFlow::Break(());
};
if watcher.entity_id() != this_id {
// This watcher is no longer registered on the project, which means that we should
// cease operations.
return ControlFlow::Break(());
}
let (globs, _) = watcher
.read(cx)
.abs_paths
.get(&abs_path)
.expect(
"Watched abs path is not registered with a watcher",
);
let matching_entries = update
.into_iter()
.filter(|event| globs.is_match(&event.path))
.collect::<Vec<_>>();
this.lsp_notify_abs_paths_changed(
language_server_id,
matching_entries,
);
ControlFlow::Continue(())
})
.ok()?;
if action.is_break() {
break;
if action.is_break() {
break;
}
}
}
Some(())
})
.await;
}
});
(abs_path, (globset, task))
})
.collect();
LanguageServerWatchedPaths {
worktree_paths: self.worktree_paths,
abs_paths,
}
})
Some(())
})
.await;
}
});
(abs_path, (globset, task))
})
.collect();
LanguageServerWatchedPaths {
worktree_paths: self.worktree_paths,
abs_paths,
}
})
}
}

View File

@@ -610,11 +610,13 @@ impl PrettierStore {
) {
let mut prettier_plugins_by_worktree = HashMap::default();
for (worktree, language_settings) in language_formatters_to_check {
if let Some(plugins) = prettier_plugins_for_language(&language_settings) {
prettier_plugins_by_worktree
.entry(worktree)
.or_insert_with(HashSet::default)
.extend(plugins.iter().cloned());
if language_settings.prettier.allowed {
if let Some(plugins) = prettier_plugins_for_language(&language_settings) {
prettier_plugins_by_worktree
.entry(worktree)
.or_insert_with(HashSet::default)
.extend(plugins.iter().cloned());
}
}
}
for (worktree, prettier_plugins) in prettier_plugins_by_worktree {

View File

@@ -2505,10 +2505,11 @@ impl Project {
buffers: HashSet<Model<Buffer>>,
push_to_history: bool,
trigger: lsp_store::FormatTrigger,
target: lsp_store::FormatTarget,
cx: &mut ModelContext<Project>,
) -> Task<anyhow::Result<ProjectTransaction>> {
self.lsp_store.update(cx, |lsp_store, cx| {
lsp_store.format(buffers, push_to_history, trigger, cx)
lsp_store.format(buffers, push_to_history, trigger, target, cx)
})
}
@@ -3462,6 +3463,17 @@ impl Project {
self.buffer_store.read(cx).blame_buffer(buffer, version, cx)
}
pub fn get_permalink_to_line(
&self,
buffer: &Model<Buffer>,
selection: Range<u32>,
cx: &AppContext,
) -> Task<Result<url::Url>> {
self.buffer_store
.read(cx)
.get_permalink_to_line(buffer, selection, cx)
}
// RPC message handlers
async fn handle_unshare_project(
@@ -3978,17 +3990,6 @@ impl Project {
.read(cx)
.language_servers_for_buffer(buffer, cx)
}
pub fn language_server_for_buffer<'a>(
&'a self,
buffer: &'a Buffer,
server_id: LanguageServerId,
cx: &'a AppContext,
) -> Option<(&'a Arc<CachedLspAdapter>, &'a Arc<LanguageServer>)> {
self.lsp_store
.read(cx)
.language_server_for_buffer(buffer, server_id, cx)
}
}
fn deserialize_code_actions(code_actions: &HashMap<String, bool>) -> Vec<lsp::CodeActionKind> {

View File

@@ -6,6 +6,7 @@ use itertools::Itertools;
use settings::{Settings, SettingsLocation};
use smol::channel::bounded;
use std::{
borrow::Cow,
env::{self},
iter,
path::{Path, PathBuf},
@@ -341,10 +342,9 @@ pub fn wrap_for_ssh(
venv_directory: Option<PathBuf>,
) -> (String, Vec<String>) {
let to_run = if let Some((command, args)) = command {
iter::once(command)
.chain(args)
.filter_map(|arg| shlex::try_quote(arg).ok())
.join(" ")
let command = Cow::Borrowed(command.as_str());
let args = args.iter().filter_map(|arg| shlex::try_quote(arg).ok());
iter::once(command).chain(args).join(" ")
} else {
"exec ${SHELL:-sh} -l".to_string()
};
@@ -390,9 +390,7 @@ pub fn wrap_for_ssh(
SshCommand::Direct(ssh_args) => ("ssh".to_string(), ssh_args.clone()),
};
if command.is_none() {
args.push("-t".to_string())
}
args.push("-t".to_string());
args.push(shell_invocation);
(program, args)
}

View File

@@ -247,14 +247,9 @@ impl WorktreeStore {
if abs_path.starts_with("/~") {
abs_path = abs_path[1..].to_string();
}
if abs_path.is_empty() {
if abs_path.is_empty() || abs_path == "/" {
abs_path = "~/".to_string();
}
let root_name = PathBuf::from(abs_path.clone())
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
cx.spawn(|this, mut cx| async move {
let this = this.upgrade().context("Dropped worktree store")?;
@@ -272,6 +267,11 @@ impl WorktreeStore {
return Ok(existing_worktree);
}
let root_name = PathBuf::from(&response.canonicalized_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(response.canonicalized_path.to_string());
let worktree = cx.update(|cx| {
Worktree::remote(
SSH_PROJECT_ID,
@@ -280,7 +280,7 @@ impl WorktreeStore {
id: response.worktree_id,
root_name,
visible,
abs_path,
abs_path: response.canonicalized_path,
},
client,
cx,

View File

@@ -1,8 +1,8 @@
mod project_panel_settings;
mod scrollbar;
use client::{ErrorCode, ErrorExt};
use scrollbar::ProjectPanelScrollbar;
use settings::{Settings, SettingsStore};
use ui::{Scrollbar, ScrollbarState};
use db::kvp::KEY_VALUE_STORE;
use editor::{
@@ -14,16 +14,14 @@ use file_icons::FileIcons;
use anyhow::{anyhow, Context as _, Result};
use collections::{hash_map, BTreeSet, HashMap};
use core::f32;
use git::repository::GitFileStatus;
use gpui::{
actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, DragMoveEvent,
Entity, EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement,
KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton,
MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled,
Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView,
WindowContext,
EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, KeyContext,
ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton, MouseDownEvent,
ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled, Subscription, Task,
UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext,
};
use indexmap::IndexMap;
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
@@ -34,12 +32,11 @@ use project::{
use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
use serde::{Deserialize, Serialize};
use std::{
cell::{Cell, OnceCell},
cell::OnceCell,
collections::HashSet,
ffi::OsStr,
ops::Range,
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
time::Duration,
};
@@ -59,8 +56,8 @@ const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
pub struct ProjectPanel {
project: Model<Project>,
fs: Arc<dyn Fs>,
scroll_handle: UniformListScrollHandle,
focus_handle: FocusHandle,
scroll_handle: UniformListScrollHandle,
visible_entries: Vec<(WorktreeId, Vec<Entry>, OnceCell<HashSet<Arc<Path>>>)>,
/// Maps from leaf project entry ID to the currently selected ancestor.
/// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
@@ -82,8 +79,8 @@ pub struct ProjectPanel {
width: Option<Pixels>,
pending_serialization: Task<Option<()>>,
show_scrollbar: bool,
vertical_scrollbar_drag_thumb_offset: Rc<Cell<Option<f32>>>,
horizontal_scrollbar_drag_thumb_offset: Rc<Cell<Option<f32>>>,
vertical_scrollbar_state: ScrollbarState,
horizontal_scrollbar_state: ScrollbarState,
hide_scrollbar_task: Option<Task<()>>,
max_width_item_index: Option<usize>,
}
@@ -297,10 +294,10 @@ impl ProjectPanel {
})
.detach();
let scroll_handle = UniformListScrollHandle::new();
let mut this = Self {
project: project.clone(),
fs: workspace.app_state().fs.clone(),
scroll_handle: UniformListScrollHandle::new(),
focus_handle,
visible_entries: Default::default(),
ancestors: Default::default(),
@@ -320,9 +317,12 @@ impl ProjectPanel {
pending_serialization: Task::ready(None),
show_scrollbar: !Self::should_autohide_scrollbar(cx),
hide_scrollbar_task: None,
vertical_scrollbar_drag_thumb_offset: Default::default(),
horizontal_scrollbar_drag_thumb_offset: Default::default(),
vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
.parent_view(cx.view()),
horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
.parent_view(cx.view()),
max_width_item_index: None,
scroll_handle,
};
this.update_visible_entries(None, cx);
@@ -2606,37 +2606,11 @@ impl ProjectPanel {
}
fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
if !Self::should_show_scrollbar(cx) {
return None;
}
let scroll_handle = self.scroll_handle.0.borrow();
let total_list_length = scroll_handle
.last_item_size
.filter(|_| {
self.show_scrollbar || self.vertical_scrollbar_drag_thumb_offset.get().is_some()
})?
.contents
.height
.0 as f64;
let current_offset = scroll_handle.base_handle.offset().y.0.min(0.).abs() as f64;
let mut percentage = current_offset / total_list_length;
let end_offset = (current_offset + scroll_handle.base_handle.bounds().size.height.0 as f64)
/ total_list_length;
// Uniform scroll handle might briefly report an offset greater than the length of a list;
// in such case we'll adjust the starting offset as well to keep the scrollbar thumb length stable.
let overshoot = (end_offset - 1.).clamp(0., 1.);
if overshoot > 0. {
percentage -= overshoot;
}
const MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT: f64 = 0.005;
if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT > 1.0 || end_offset > total_list_length
if !Self::should_show_scrollbar(cx)
|| !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
{
return None;
}
if total_list_length < scroll_handle.base_handle.bounds().size.height.0 as f64 {
return None;
}
let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT, 1.);
Some(
div()
.occlude()
@@ -2654,7 +2628,7 @@ impl ProjectPanel {
.on_mouse_up(
MouseButton::Left,
cx.listener(|this, _, cx| {
if this.vertical_scrollbar_drag_thumb_offset.get().is_none()
if !this.vertical_scrollbar_state.is_dragging()
&& !this.focus_handle.contains_focused(cx)
{
this.hide_scrollbar(cx);
@@ -2674,48 +2648,20 @@ impl ProjectPanel {
.bottom_1()
.w(px(12.))
.cursor_default()
.child(ProjectPanelScrollbar::vertical(
percentage as f32..end_offset as f32,
self.scroll_handle.clone(),
self.vertical_scrollbar_drag_thumb_offset.clone(),
cx.view().entity_id(),
.children(Scrollbar::vertical(
// percentage as f32..end_offset as f32,
self.vertical_scrollbar_state.clone(),
)),
)
}
fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
if !Self::should_show_scrollbar(cx) {
return None;
}
let scroll_handle = self.scroll_handle.0.borrow();
let longest_item_width = scroll_handle
.last_item_size
.filter(|_| {
self.show_scrollbar || self.horizontal_scrollbar_drag_thumb_offset.get().is_some()
})
.filter(|size| size.contents.width > size.item.width)?
.contents
.width
.0 as f64;
let current_offset = scroll_handle.base_handle.offset().x.0.min(0.).abs() as f64;
let mut percentage = current_offset / longest_item_width;
let end_offset = (current_offset + scroll_handle.base_handle.bounds().size.width.0 as f64)
/ longest_item_width;
// Uniform scroll handle might briefly report an offset greater than the length of a list;
// in such case we'll adjust the starting offset as well to keep the scrollbar thumb length stable.
let overshoot = (end_offset - 1.).clamp(0., 1.);
if overshoot > 0. {
percentage -= overshoot;
}
const MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH: f64 = 0.005;
if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH > 1.0 || end_offset > longest_item_width
if !Self::should_show_scrollbar(cx)
|| !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
{
return None;
}
if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
return None;
}
let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH, 1.);
Some(
div()
.occlude()
@@ -2733,7 +2679,7 @@ impl ProjectPanel {
.on_mouse_up(
MouseButton::Left,
cx.listener(|this, _, cx| {
if this.horizontal_scrollbar_drag_thumb_offset.get().is_none()
if !this.horizontal_scrollbar_state.is_dragging()
&& !this.focus_handle.contains_focused(cx)
{
this.hide_scrollbar(cx);
@@ -2754,11 +2700,8 @@ impl ProjectPanel {
.h(px(12.))
.cursor_default()
.when(self.width.is_some(), |this| {
this.child(ProjectPanelScrollbar::horizontal(
percentage as f32..end_offset as f32,
self.scroll_handle.clone(),
self.horizontal_scrollbar_drag_thumb_offset.clone(),
cx.view().entity_id(),
this.children(Scrollbar::horizontal(
self.horizontal_scrollbar_state.clone(),
))
}),
)
@@ -2974,7 +2917,9 @@ impl Render for ProjectPanel {
.track_scroll(self.scroll_handle.clone()),
)
.children(self.render_vertical_scrollbar(cx))
.children(self.render_horizontal_scrollbar(cx))
.when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
this.pb_4().child(scrollbar)
})
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
deferred(
anchored()

View File

@@ -1,277 +0,0 @@
use std::{cell::Cell, ops::Range, rc::Rc};
use gpui::{
point, quad, Bounds, ContentMask, Corners, Edges, EntityId, Hitbox, Hsla, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, ScrollWheelEvent, Style, UniformListScrollHandle,
};
use ui::{prelude::*, px, relative, IntoElement};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ScrollbarKind {
Horizontal,
Vertical,
}
pub(crate) struct ProjectPanelScrollbar {
thumb: Range<f32>,
scroll: UniformListScrollHandle,
// If Some(), there's an active drag, offset by percentage from the top of thumb.
scrollbar_drag_state: Rc<Cell<Option<f32>>>,
kind: ScrollbarKind,
parent_id: EntityId,
}
impl ProjectPanelScrollbar {
pub(crate) fn vertical(
thumb: Range<f32>,
scroll: UniformListScrollHandle,
scrollbar_drag_state: Rc<Cell<Option<f32>>>,
parent_id: EntityId,
) -> Self {
Self {
thumb,
scroll,
scrollbar_drag_state,
kind: ScrollbarKind::Vertical,
parent_id,
}
}
pub(crate) fn horizontal(
thumb: Range<f32>,
scroll: UniformListScrollHandle,
scrollbar_drag_state: Rc<Cell<Option<f32>>>,
parent_id: EntityId,
) -> Self {
Self {
thumb,
scroll,
scrollbar_drag_state,
kind: ScrollbarKind::Horizontal,
parent_id,
}
}
}
impl gpui::Element for ProjectPanelScrollbar {
type RequestLayoutState = ();
type PrepaintState = Hitbox;
fn id(&self) -> Option<ui::ElementId> {
None
}
fn request_layout(
&mut self,
_id: Option<&gpui::GlobalElementId>,
cx: &mut ui::WindowContext,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.flex_grow = 1.;
style.flex_shrink = 1.;
if self.kind == ScrollbarKind::Vertical {
style.size.width = px(12.).into();
style.size.height = relative(1.).into();
} else {
style.size.width = relative(1.).into();
style.size.height = px(12.).into();
}
(cx.request_layout(style, None), ())
}
fn prepaint(
&mut self,
_id: Option<&gpui::GlobalElementId>,
bounds: Bounds<ui::Pixels>,
_request_layout: &mut Self::RequestLayoutState,
cx: &mut ui::WindowContext,
) -> Self::PrepaintState {
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
cx.insert_hitbox(bounds, false)
})
}
fn paint(
&mut self,
_id: Option<&gpui::GlobalElementId>,
bounds: Bounds<ui::Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_prepaint: &mut Self::PrepaintState,
cx: &mut ui::WindowContext,
) {
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
let colors = cx.theme().colors();
let thumb_background = colors.scrollbar_thumb_background;
let is_vertical = self.kind == ScrollbarKind::Vertical;
let extra_padding = px(5.0);
let padded_bounds = if is_vertical {
Bounds::from_corners(
bounds.origin + point(Pixels::ZERO, extra_padding),
bounds.lower_right() - point(Pixels::ZERO, extra_padding * 3),
)
} else {
Bounds::from_corners(
bounds.origin + point(extra_padding, Pixels::ZERO),
bounds.lower_right() - point(extra_padding * 3, Pixels::ZERO),
)
};
let mut thumb_bounds = if is_vertical {
let thumb_offset = self.thumb.start * padded_bounds.size.height;
let thumb_end = self.thumb.end * padded_bounds.size.height;
let thumb_upper_left = point(
padded_bounds.origin.x,
padded_bounds.origin.y + thumb_offset,
);
let thumb_lower_right = point(
padded_bounds.origin.x + padded_bounds.size.width,
padded_bounds.origin.y + thumb_end,
);
Bounds::from_corners(thumb_upper_left, thumb_lower_right)
} else {
let thumb_offset = self.thumb.start * padded_bounds.size.width;
let thumb_end = self.thumb.end * padded_bounds.size.width;
let thumb_upper_left = point(
padded_bounds.origin.x + thumb_offset,
padded_bounds.origin.y,
);
let thumb_lower_right = point(
padded_bounds.origin.x + thumb_end,
padded_bounds.origin.y + padded_bounds.size.height,
);
Bounds::from_corners(thumb_upper_left, thumb_lower_right)
};
let corners = if is_vertical {
thumb_bounds.size.width /= 1.5;
Corners::all(thumb_bounds.size.width / 2.0)
} else {
thumb_bounds.size.height /= 1.5;
Corners::all(thumb_bounds.size.height / 2.0)
};
cx.paint_quad(quad(
thumb_bounds,
corners,
thumb_background,
Edges::default(),
Hsla::transparent_black(),
));
let scroll = self.scroll.clone();
let kind = self.kind;
let thumb_percentage_size = self.thumb.end - self.thumb.start;
cx.on_mouse_event({
let scroll = self.scroll.clone();
let is_dragging = self.scrollbar_drag_state.clone();
move |event: &MouseDownEvent, phase, _cx| {
if phase.bubble() && bounds.contains(&event.position) {
if !thumb_bounds.contains(&event.position) {
let scroll = scroll.0.borrow();
if let Some(item_size) = scroll.last_item_size {
match kind {
ScrollbarKind::Horizontal => {
let percentage = (event.position.x - bounds.origin.x)
/ bounds.size.width;
let max_offset = item_size.contents.width;
let percentage = percentage.min(1. - thumb_percentage_size);
scroll.base_handle.set_offset(point(
-max_offset * percentage,
scroll.base_handle.offset().y,
));
}
ScrollbarKind::Vertical => {
let percentage = (event.position.y - bounds.origin.y)
/ bounds.size.height;
let max_offset = item_size.contents.height;
let percentage = percentage.min(1. - thumb_percentage_size);
scroll.base_handle.set_offset(point(
scroll.base_handle.offset().x,
-max_offset * percentage,
));
}
}
}
} else {
let thumb_offset = if is_vertical {
(event.position.y - thumb_bounds.origin.y) / bounds.size.height
} else {
(event.position.x - thumb_bounds.origin.x) / bounds.size.width
};
is_dragging.set(Some(thumb_offset));
}
}
}
});
cx.on_mouse_event({
let scroll = self.scroll.clone();
move |event: &ScrollWheelEvent, phase, cx| {
if phase.bubble() && bounds.contains(&event.position) {
let scroll = scroll.0.borrow_mut();
let current_offset = scroll.base_handle.offset();
scroll
.base_handle
.set_offset(current_offset + event.delta.pixel_delta(cx.line_height()));
}
}
});
let drag_state = self.scrollbar_drag_state.clone();
let view_id = self.parent_id;
let kind = self.kind;
cx.on_mouse_event(move |event: &MouseMoveEvent, _, cx| {
if let Some(drag_state) = drag_state.get().filter(|_| event.dragging()) {
let scroll = scroll.0.borrow();
if let Some(item_size) = scroll.last_item_size {
match kind {
ScrollbarKind::Horizontal => {
let max_offset = item_size.contents.width;
let percentage = (event.position.x - bounds.origin.x)
/ bounds.size.width
- drag_state;
let percentage = percentage.min(1. - thumb_percentage_size);
scroll.base_handle.set_offset(point(
-max_offset * percentage,
scroll.base_handle.offset().y,
));
}
ScrollbarKind::Vertical => {
let max_offset = item_size.contents.height;
let percentage = (event.position.y - bounds.origin.y)
/ bounds.size.height
- drag_state;
let percentage = percentage.min(1. - thumb_percentage_size);
scroll.base_handle.set_offset(point(
scroll.base_handle.offset().x,
-max_offset * percentage,
));
}
};
cx.notify(view_id);
}
} else {
drag_state.set(None);
}
});
let is_dragging = self.scrollbar_drag_state.clone();
cx.on_mouse_event(move |_event: &MouseUpEvent, phase, cx| {
if phase.bubble() {
is_dragging.set(None);
cx.notify(view_id);
}
});
})
}
}
impl IntoElement for ProjectPanelScrollbar {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}

View File

@@ -292,7 +292,10 @@ message Envelope {
Toast toast = 261;
HideToast hide_toast = 262;
OpenServerSettings open_server_settings = 263; // current max
OpenServerSettings open_server_settings = 263;
GetPermalinkToLine get_permalink_to_line = 264;
GetPermalinkToLineResponse get_permalink_to_line_response = 265; // current max
}
reserved 87 to 88;
@@ -1207,7 +1210,7 @@ message ResolveState {
}
message LspResolveState {
string value = 1;
optional string value = 1;
uint64 server_id = 2;
}
}
@@ -2464,6 +2467,7 @@ message AddWorktree {
message AddWorktreeResponse {
uint64 worktree_id = 1;
string canonicalized_path = 2;
}
message UpdateUserSettings {
@@ -2507,3 +2511,13 @@ message HideToast {
message OpenServerSettings {
uint64 project_id = 1;
}
message GetPermalinkToLine {
uint64 project_id = 1;
uint64 buffer_id = 2;
Range selection = 3;
}
message GetPermalinkToLineResponse {
string permalink = 1;
}

View File

@@ -370,6 +370,8 @@ messages!(
(Toast, Background),
(HideToast, Background),
(OpenServerSettings, Foreground),
(GetPermalinkToLine, Foreground),
(GetPermalinkToLineResponse, Foreground),
);
request_messages!(
@@ -494,7 +496,8 @@ request_messages!(
(CheckFileExists, CheckFileExistsResponse),
(ShutdownRemoteServer, Ack),
(RemoveWorktree, Ack),
(OpenServerSettings, OpenBufferResponse)
(OpenServerSettings, OpenBufferResponse),
(GetPermalinkToLine, GetPermalinkToLineResponse),
);
entity_messages!(
@@ -571,7 +574,7 @@ entity_messages!(
Toast,
HideToast,
OpenServerSettings,
GetPermalinkToLine,
);
entity_messages!(

View File

@@ -22,6 +22,7 @@ file_finder.workspace = true
futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
itertools.workspace = true
log.workspace = true
menu.workspace = true
ordered-float.workspace = true

View File

@@ -13,20 +13,19 @@ use futures::channel::oneshot;
use futures::future::Shared;
use futures::FutureExt;
use gpui::canvas;
use gpui::pulsating_between;
use gpui::AsyncWindowContext;
use gpui::ClipboardItem;
use gpui::Subscription;
use gpui::Task;
use gpui::WeakView;
use gpui::{
Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle,
FocusableView, FontWeight, Model, ScrollHandle, View, ViewContext,
AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, FontWeight,
Model, PromptLevel, ScrollHandle, View, ViewContext,
};
use picker::Picker;
use project::terminals::wrap_for_ssh;
use project::terminals::SshCommand;
use project::Project;
use remote::SshConnectionOptions;
use rpc::proto::DevServerStatus;
use settings::update_settings_file;
use settings::Settings;
@@ -34,6 +33,8 @@ use task::HideStrategy;
use task::RevealStrategy;
use task::SpawnInTerminal;
use terminal_view::terminal_panel::TerminalPanel;
use ui::Scrollbar;
use ui::ScrollbarState;
use ui::Section;
use ui::{prelude::*, IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Tooltip};
use util::ResultExt;
@@ -58,16 +59,15 @@ pub struct DevServerProjects {
mode: Mode,
focus_handle: FocusHandle,
scroll_handle: ScrollHandle,
dev_server_store: Model<dev_server_projects::Store>,
workspace: WeakView<Workspace>,
_dev_server_subscription: Subscription,
selectable_items: SelectableItemList,
}
struct CreateDevServer {
address_editor: View<Editor>,
creating: Option<Task<Option<()>>>,
address_error: Option<SharedString>,
ssh_prompt: Option<View<SshPrompt>>,
_creating: Option<Task<Option<()>>>,
}
impl CreateDevServer {
@@ -78,8 +78,9 @@ impl CreateDevServer {
});
Self {
address_editor,
creating: None,
address_error: None,
ssh_prompt: None,
_creating: None,
}
}
}
@@ -301,13 +302,19 @@ impl gpui::Render for ProjectPicker {
}
}
enum Mode {
Default,
Default(ScrollbarState),
ViewServerOptions(usize, SshConnection),
EditNickname(EditNicknameState),
ProjectPicker(View<ProjectPicker>),
CreateDevServer(CreateDevServer),
}
impl Mode {
fn default_mode() -> Self {
let handle = ScrollHandle::new();
Self::Default(ScrollbarState::new(handle))
}
}
impl DevServerProjects {
pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
workspace.register_action(|workspace, _: &OpenRemote, cx| {
@@ -325,11 +332,6 @@ impl DevServerProjects {
pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
let focus_handle = cx.focus_handle();
let dev_server_store = dev_server_projects::Store::global(cx);
let subscription = cx.observe(&dev_server_store, |_, _, cx| {
cx.notify();
});
let mut base_style = cx.text_style();
base_style.refine(&gpui::TextStyleRefinement {
@@ -338,24 +340,22 @@ impl DevServerProjects {
});
Self {
mode: Mode::Default,
mode: Mode::default_mode(),
focus_handle,
scroll_handle: ScrollHandle::new(),
dev_server_store,
workspace,
_dev_server_subscription: subscription,
selectable_items: Default::default(),
}
}
fn next_item(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) {
return;
}
self.selectable_items.next(cx);
}
fn prev_item(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) {
return;
}
self.selectable_items.prev(cx);
@@ -380,34 +380,22 @@ impl DevServerProjects {
}
fn create_ssh_server(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
let host = get_text(&editor, cx);
if host.is_empty() {
let input = get_text(&editor, cx);
if input.is_empty() {
return;
}
let mut host = host.trim_start_matches("ssh ");
let mut username: Option<String> = None;
let mut port: Option<u16> = None;
if let Some((u, rest)) = host.split_once('@') {
host = rest;
username = Some(u.to_string());
}
if let Some((rest, p)) = host.split_once(':') {
host = rest;
port = p.parse().ok()
}
if let Some((rest, p)) = host.split_once(" -p") {
host = rest;
port = p.trim().parse().ok()
}
let connection_options = remote::SshConnectionOptions {
host: host.to_string(),
username: username.clone(),
port,
password: None,
let connection_options = match SshConnectionOptions::parse_command_line(&input) {
Ok(c) => c,
Err(e) => {
self.mode = Mode::CreateDevServer(CreateDevServer {
address_editor: editor,
address_error: Some(format!("could not parse: {:?}", e).into()),
ssh_prompt: None,
_creating: None,
});
return;
}
};
let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
@@ -419,6 +407,7 @@ impl DevServerProjects {
)
.prompt_err("Failed to connect", cx, |_, _| None);
let address_editor = editor.clone();
let creating = cx.spawn(move |this, mut cx| async move {
match connection.await {
Some(_) => this
@@ -431,25 +420,38 @@ impl DevServerProjects {
});
this.add_ssh_server(connection_options, cx);
this.mode = Mode::Default;
this.mode = Mode::default_mode();
this.selectable_items.reset_selection();
cx.notify()
})
.log_err(),
None => this
.update(&mut cx, |this, cx| {
this.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
address_editor.update(cx, |this, _| {
this.set_read_only(false);
});
this.mode = Mode::CreateDevServer(CreateDevServer {
address_editor,
address_error: None,
ssh_prompt: None,
_creating: None,
});
cx.notify()
})
.log_err(),
};
None
});
let mut state = CreateDevServer::new(cx);
state.address_editor = editor;
state.ssh_prompt = Some(ssh_prompt.clone());
state.creating = Some(creating);
self.mode = Mode::CreateDevServer(state);
editor.update(cx, |this, _| {
this.set_read_only(true);
});
self.mode = Mode::CreateDevServer(CreateDevServer {
address_editor: editor,
address_error: None,
ssh_prompt: Some(ssh_prompt.clone()),
_creating: Some(creating),
});
}
fn view_server_options(
@@ -535,7 +537,7 @@ impl DevServerProjects {
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
match &self.mode {
Mode::Default | Mode::ViewServerOptions(_, _) => {
Mode::Default(_) | Mode::ViewServerOptions(_, _) => {
let items = std::mem::take(&mut self.selectable_items);
items.confirm(self, cx);
self.selectable_items = items;
@@ -549,9 +551,6 @@ impl DevServerProjects {
return;
}
state.address_editor.update(cx, |this, _| {
this.set_read_only(true);
});
self.create_ssh_server(state.address_editor.clone(), cx);
}
Mode::EditNickname(state) => {
@@ -566,7 +565,7 @@ impl DevServerProjects {
}
}
});
self.mode = Mode::Default;
self.mode = Mode::default_mode();
self.selectable_items.reset_selection();
self.focus_handle.focus(cx);
}
@@ -575,14 +574,14 @@ impl DevServerProjects {
fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
match &self.mode {
Mode::Default => cx.emit(DismissEvent),
Mode::Default(_) => cx.emit(DismissEvent),
Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
self.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
self.selectable_items.reset_selection();
cx.notify();
}
_ => {
self.mode = Mode::Default;
self.mode = Mode::default_mode();
self.selectable_items.reset_selection();
self.focus_handle(cx).focus(cx);
cx.notify();
@@ -814,6 +813,7 @@ impl DevServerProjects {
port: connection_options.port,
projects: vec![],
nickname: None,
args: connection_options.args.unwrap_or_default(),
})
});
}
@@ -827,10 +827,7 @@ impl DevServerProjects {
state.address_editor.update(cx, |editor, cx| {
if editor.text(cx).is_empty() {
editor.set_placeholder_text(
"Enter the command you use to SSH into this server: e.g., ssh me@my.server",
cx,
);
editor.set_placeholder_text("ssh user@example -p 2222", cx);
}
});
@@ -856,27 +853,38 @@ impl DevServerProjects {
.map(|this| {
if let Some(ssh_prompt) = ssh_prompt {
this.child(h_flex().w_full().child(ssh_prompt))
} else if let Some(address_error) = &state.address_error {
this.child(
h_flex().p_2().w_full().gap_2().child(
Label::new(address_error.clone())
.size(LabelSize::Small)
.color(Color::Error),
),
)
} else {
let color = Color::Muted.color(cx);
this.child(
h_flex()
.p_2()
.w_full()
.items_center()
.justify_center()
.gap_2()
.gap_1()
.child(
div().size_1p5().rounded_full().bg(color).with_animation(
"pulse-ssh-waiting-for-connection",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.2, 0.5)),
move |this, progress| this.bg(color.opacity(progress)),
),
Label::new(
"Enter the command you use to SSH into this server.",
)
.color(Color::Muted)
.size(LabelSize::Small),
)
.child(
Label::new("Waiting for connection")
.size(LabelSize::Small),
Button::new("learn-more", "Learn more")
.label_size(LabelSize::Small)
.size(ButtonSize::None)
.color(Color::Accent)
.style(ButtonStyle::Transparent)
.on_click(|_, cx| {
cx.open_url(
"https://zed.dev/docs/remote-development",
);
}),
),
)
}
@@ -988,46 +996,38 @@ impl DevServerProjects {
.child({
fn remove_ssh_server(
dev_servers: View<DevServerProjects>,
workspace: WeakView<Workspace>,
index: usize,
connection_string: SharedString,
cx: &mut WindowContext<'_>,
) {
workspace
.update(cx, |this, cx| {
struct SshServerRemoval;
let notification = format!(
"Do you really want to remove server `{}`?",
connection_string
);
this.show_toast(
Toast::new(
NotificationId::composite::<SshServerRemoval>(
connection_string.clone(),
),
notification,
)
.on_click(
"Yes, delete it",
move |cx| {
dev_servers.update(cx, |this, cx| {
this.delete_ssh_server(index, cx);
this.mode = Mode::Default;
cx.notify();
})
},
),
cx,
);
})
.ok();
let prompt_message = format!("Remove server `{}`?", connection_string);
let confirmation = cx.prompt(
PromptLevel::Warning,
&prompt_message,
None,
&["Yes, remove it", "No, keep it"],
);
cx.spawn(|mut cx| async move {
if confirmation.await.ok() == Some(0) {
dev_servers
.update(&mut cx, |this, cx| {
this.delete_ssh_server(index, cx);
this.mode = Mode::default_mode();
cx.notify();
})
.ok();
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
self.selectable_items.add_item(Box::new({
let connection_string = connection_string.clone();
move |this, cx| {
move |_, cx| {
remove_ssh_server(
cx.view().clone(),
this.workspace.clone(),
index,
connection_string.clone(),
cx,
@@ -1035,16 +1035,15 @@ impl DevServerProjects {
}
}));
let is_selected = self.selectable_items.is_selected();
ListItem::new("delete-server")
ListItem::new("remove-server")
.selected(is_selected)
.inset(true)
.spacing(ui::ListItemSpacing::Sparse)
.start_slot(Icon::new(IconName::Trash).color(Color::Error))
.child(Label::new("Delete Server").color(Color::Error))
.on_click(cx.listener(move |this, _, cx| {
.child(Label::new("Remove Server").color(Color::Error))
.on_click(cx.listener(move |_, _, cx| {
remove_ssh_server(
cx.view().clone(),
this.workspace.clone(),
index,
connection_string.clone(),
cx,
@@ -1055,7 +1054,7 @@ impl DevServerProjects {
.child({
self.selectable_items.add_item(Box::new({
move |this, cx| {
this.mode = Mode::Default;
this.mode = Mode::default_mode();
cx.notify();
}
}));
@@ -1067,7 +1066,7 @@ impl DevServerProjects {
.start_slot(Icon::new(IconName::ArrowLeft).color(Color::Muted))
.child(Label::new("Go Back"))
.on_click(cx.listener(|this, _, cx| {
this.mode = Mode::Default;
this.mode = Mode::default_mode();
cx.notify()
}))
}),
@@ -1099,8 +1098,12 @@ impl DevServerProjects {
.child(h_flex().p_2().child(state.editor.clone()))
}
fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let dev_servers = self.dev_server_store.read(cx).dev_servers();
fn render_default(
&mut self,
scroll_state: ScrollbarState,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let scroll_state = scroll_state.parent_view(cx.view());
let ssh_connections = SshSettings::get_global(cx)
.ssh_connections()
.collect::<Vec<_>>();
@@ -1124,64 +1127,78 @@ impl DevServerProjects {
cx.notify();
}));
let ui::ScrollableHandle::NonUniform(scroll_handle) = scroll_state.scroll_handle() else {
unreachable!()
};
let mut modal_section = v_flex()
.id("ssh-server-list")
.overflow_y_scroll()
.track_scroll(&scroll_handle)
.size_full()
.child(connect_button)
.child(
List::new()
.empty_message(
v_flex()
.child(ListSeparator)
.child(div().px_3().child(
Label::new("No dev servers registered yet.").color(Color::Muted),
))
.into_any_element(),
)
.children(ssh_connections.iter().cloned().enumerate().map(
|(ix, connection)| {
self.render_ssh_connection(ix, connection, cx)
.into_any_element()
},
)),
h_flex().child(
List::new()
.empty_message(
v_flex()
.child(ListSeparator)
.child(
div().px_3().child(
Label::new("No dev servers registered yet.")
.color(Color::Muted),
),
)
.into_any_element(),
)
.children(ssh_connections.iter().cloned().enumerate().map(
|(ix, connection)| {
self.render_ssh_connection(ix, connection, cx)
.into_any_element()
},
)),
),
)
.into_any_element();
let server_count = format!("Servers: {}", ssh_connections.len() + dev_servers.len());
Modal::new("remote-projects", Some(self.scroll_handle.clone()))
.header(
ModalHeader::new().child(
h_flex()
.items_center()
.justify_between()
.child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
.child(Label::new(server_count).size(LabelSize::Small)),
),
ModalHeader::new()
.child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall)),
)
.section(
Section::new().padded(false).child(
v_flex()
h_flex()
.min_h(rems(20.))
.flex_1()
.size_full()
.child(ListSeparator)
.child(
canvas(
|bounds, cx| {
modal_section.prepaint_as_root(
bounds.origin,
bounds.size.into(),
cx,
);
modal_section
},
|_, mut modal_section, cx| {
modal_section.paint(cx);
},
)
.size_full(),
v_flex().size_full().child(ListSeparator).child(
canvas(
|bounds, cx| {
modal_section.prepaint_as_root(
bounds.origin,
bounds.size.into(),
cx,
);
modal_section
},
|_, mut modal_section, cx| {
modal_section.paint(cx);
},
)
.size_full(),
),
)
.child(
div()
.occlude()
.h_full()
.absolute()
.right_1()
.top_1()
.bottom_1()
.w(px(12.))
.children(Scrollbar::vertical(scroll_state)),
),
),
)
@@ -1217,13 +1234,13 @@ impl Render for DevServerProjects {
this.focus_handle(cx).focus(cx);
}))
.on_mouse_down_out(cx.listener(|this, _, cx| {
if matches!(this.mode, Mode::Default) {
if matches!(this.mode, Mode::Default(_)) {
cx.emit(DismissEvent)
}
}))
.w(rems(34.))
.child(match &self.mode {
Mode::Default => self.render_default(cx).into_any_element(),
Mode::Default(state) => self.render_default(state.clone(), cx).into_any_element(),
Mode::ViewServerOptions(index, connection) => self
.render_view_options(*index, connection.clone(), cx)
.into_any_element(),

View File

@@ -13,6 +13,7 @@ use gpui::{
Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
Subscription, Task, View, ViewContext, WeakView,
};
use itertools::Itertools;
use ordered_float::OrderedFloat;
use picker::{
highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
@@ -21,7 +22,7 @@ use picker::{
use rpc::proto::DevServerStatus;
use serde::Deserialize;
use settings::Settings;
use ssh_connections::SshSettings;
pub use ssh_connections::SshSettings;
use std::{
path::{Path, PathBuf},
sync::Arc,
@@ -247,8 +248,9 @@ impl PickerDelegate for RecentProjectsDelegate {
SerializedWorkspaceLocation::Local(paths, order) => order
.order()
.iter()
.filter_map(|i| paths.paths().get(*i))
.map(|path| path.compact().to_string_lossy().into_owned())
.zip(paths.paths().iter())
.sorted_by_key(|(i, _)| *i)
.map(|(_, path)| path.compact().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(""),
SerializedWorkspaceLocation::DevServer(dev_server_project) => {
@@ -384,11 +386,13 @@ impl PickerDelegate for RecentProjectsDelegate {
..Default::default()
};
let args = SshSettings::get_global(cx).args_for(&ssh_project.host, ssh_project.port, &ssh_project.user);
let connection_options = SshConnectionOptions {
host: ssh_project.host.clone(),
username: ssh_project.user.clone(),
port: ssh_project.port,
password: None,
args,
};
let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
@@ -445,8 +449,9 @@ impl PickerDelegate for RecentProjectsDelegate {
order
.order()
.iter()
.filter_map(|i| paths.paths().get(*i).cloned())
.map(|path| path.compact())
.zip(paths.paths().iter())
.sorted_by_key(|(i, _)| **i)
.map(|(_, path)| path.compact())
.collect(),
),
SerializedWorkspaceLocation::Ssh(ssh_project) => Arc::new(ssh_project.ssh_urls()),

View File

@@ -32,6 +32,23 @@ impl SshSettings {
pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> {
self.ssh_connections.clone().into_iter().flatten()
}
pub fn args_for(
&self,
host: &str,
port: Option<u16>,
user: &Option<String>,
) -> Option<Vec<String>> {
self.ssh_connections()
.filter_map(|conn| {
if conn.host == host && &conn.username == user && conn.port == port {
Some(conn.args)
} else {
None
}
})
.next()
}
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
@@ -45,6 +62,9 @@ pub struct SshConnection {
/// Name to use for this server in UI.
#[serde(skip_serializing_if = "Option::is_none")]
pub nickname: Option<SharedString>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
pub args: Vec<String>,
}
impl From<SshConnection> for SshConnectionOptions {
fn from(val: SshConnection) -> Self {
@@ -53,6 +73,7 @@ impl From<SshConnection> for SshConnectionOptions {
username: val.username,
port: val.port,
password: None,
args: Some(val.args),
}
}
}
@@ -151,12 +172,10 @@ impl Render for SshPrompt {
v_flex()
.key_context("PasswordPrompt")
.size_full()
.justify_center()
.child(
h_flex()
.p_2()
.justify_center()
.flex_wrap()
.flex()
.child(if self.error_message.is_some() {
Icon::new(IconName::XCircle)
.size(IconSize::Medium)
@@ -177,21 +196,17 @@ impl Render for SshPrompt {
.child(
div()
.ml_1()
.child(Label::new("SSH Connection").size(LabelSize::Small)),
)
.child(
div()
.text_ellipsis()
.overflow_x_hidden()
.when_some(self.error_message.as_ref(), |el, error| {
el.child(Label::new(format!("{}", error)).size(LabelSize::Small))
el.child(Label::new(format!("{}", error)).size(LabelSize::Small))
})
.when(
self.error_message.is_none() && self.status_message.is_some(),
|el| {
el.child(
Label::new(format!(
"{}",
"{}",
self.status_message.clone().unwrap()
))
.size(LabelSize::Small),

View File

@@ -29,6 +29,7 @@ prost.workspace = true
rpc = { workspace = true, features = ["gpui"] }
serde.workspace = true
serde_json.workspace = true
shlex.workspace = true
smol.workspace = true
tempfile.workspace = true
thiserror.workspace = true

View File

@@ -2,7 +2,6 @@ use anyhow::Result;
use futures::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use prost::Message as _;
use rpc::proto::Envelope;
use std::mem::size_of;
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
pub struct MessageId(pub u32);
@@ -30,8 +29,10 @@ pub async fn read_message<S: AsyncRead + Unpin>(
) -> Result<Envelope> {
buffer.resize(MESSAGE_LEN_SIZE, 0);
stream.read_exact(buffer).await?;
let len = message_len_from_buffer(buffer);
read_message_with_len(stream, buffer, len).await
let result = read_message_with_len(stream, buffer, len).await;
result
}
pub async fn write_message<S: AsyncWrite + Unpin>(

View File

@@ -13,8 +13,7 @@ use futures::{
oneshot,
},
future::BoxFuture,
select_biased, AsyncReadExt as _, AsyncWriteExt as _, Future, FutureExt as _, SinkExt,
StreamExt as _,
select_biased, AsyncReadExt as _, Future, FutureExt as _, SinkExt, StreamExt as _,
};
use gpui::{
AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, SemanticVersion, Task,
@@ -61,9 +60,89 @@ pub struct SshConnectionOptions {
pub username: Option<String>,
pub port: Option<u16>,
pub password: Option<String>,
pub args: Option<Vec<String>>,
}
impl SshConnectionOptions {
pub fn parse_command_line(input: &str) -> Result<Self> {
let input = input.trim_start_matches("ssh ");
let mut hostname: Option<String> = None;
let mut username: Option<String> = None;
let mut port: Option<u16> = None;
let mut args = Vec::new();
// disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
const ALLOWED_OPTS: &[&str] = &[
"-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
];
const ALLOWED_ARGS: &[&str] = &[
"-B", "-b", "-c", "-D", "-I", "-i", "-J", "-L", "-l", "-m", "-o", "-P", "-p", "-R",
"-w",
];
let mut tokens = shlex::split(input)
.ok_or_else(|| anyhow!("invalid input"))?
.into_iter();
'outer: while let Some(arg) = tokens.next() {
if ALLOWED_OPTS.contains(&(&arg as &str)) {
args.push(arg.to_string());
continue;
}
if arg == "-p" {
port = tokens.next().and_then(|arg| arg.parse().ok());
continue;
} else if let Some(p) = arg.strip_prefix("-p") {
port = p.parse().ok();
continue;
}
if arg == "-l" {
username = tokens.next();
continue;
} else if let Some(l) = arg.strip_prefix("-l") {
username = Some(l.to_string());
continue;
}
for a in ALLOWED_ARGS {
if arg == *a {
args.push(arg);
if let Some(next) = tokens.next() {
args.push(next);
}
continue 'outer;
} else if arg.starts_with(a) {
args.push(arg);
continue 'outer;
}
}
if arg.starts_with("-") || hostname.is_some() {
anyhow::bail!("unsupported argument: {:?}", arg);
}
let mut input = &arg as &str;
if let Some((u, rest)) = input.split_once('@') {
input = rest;
username = Some(u.to_string());
}
if let Some((rest, p)) = input.split_once(':') {
input = rest;
port = p.parse().ok()
}
hostname = Some(input.to_string())
}
let Some(hostname) = hostname else {
anyhow::bail!("missing hostname");
};
Ok(Self {
host: hostname.to_string(),
username: username.clone(),
port,
password: None,
args: Some(args),
})
}
pub fn ssh_url(&self) -> String {
let mut result = String::from("ssh://");
if let Some(username) = &self.username {
@@ -78,6 +157,10 @@ impl SshConnectionOptions {
result
}
pub fn additional_args(&self) -> Option<&Vec<String>> {
self.args.as_ref()
}
fn scp_url(&self) -> String {
if let Some(username) = &self.username {
format!("{}@{}", username, self.host)
@@ -743,8 +826,20 @@ impl SshRemoteClient {
loop {
select_biased! {
_ = connection_activity_rx.next().fuse() => {
result = connection_activity_rx.next().fuse() => {
if result.is_none() {
log::warn!("ssh heartbeat: connection activity channel has been dropped. stopping.");
return Ok(());
}
keepalive_timer.set(cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse());
if missed_heartbeats != 0 {
missed_heartbeats = 0;
this.update(&mut cx, |this, mut cx| {
this.handle_heartbeat_result(missed_heartbeats, &mut cx)
})?;
}
}
_ = keepalive_timer => {
log::debug!("Sending heartbeat to server...");
@@ -757,6 +852,7 @@ impl SshRemoteClient {
ping_result
}
};
if result.is_err() {
missed_heartbeats += 1;
log::warn!(
@@ -825,92 +921,90 @@ impl SshRemoteClient {
let mut child_stdout = ssh_proxy_process.stdout.take().unwrap();
let mut child_stdin = ssh_proxy_process.stdin.take().unwrap();
let io_task = cx.background_executor().spawn(async move {
let mut stdin_buffer = Vec::new();
let mut stdout_buffer = Vec::new();
let mut stderr_buffer = Vec::new();
let mut stderr_offset = 0;
let mut stdin_buffer = Vec::new();
let mut stdout_buffer = Vec::new();
let mut stderr_buffer = Vec::new();
let mut stderr_offset = 0;
loop {
stdout_buffer.resize(MESSAGE_LEN_SIZE, 0);
stderr_buffer.resize(stderr_offset + 1024, 0);
let stdin_task = cx.background_executor().spawn(async move {
while let Some(outgoing) = outgoing_rx.next().await {
write_message(&mut child_stdin, &mut stdin_buffer, outgoing).await?;
}
anyhow::Ok(())
});
select_biased! {
outgoing = outgoing_rx.next().fuse() => {
let Some(outgoing) = outgoing else {
return anyhow::Ok(None);
};
let stdout_task = cx.background_executor().spawn({
let mut connection_activity_tx = connection_activity_tx.clone();
async move {
loop {
stdout_buffer.resize(MESSAGE_LEN_SIZE, 0);
let len = child_stdout.read(&mut stdout_buffer).await?;
write_message(&mut child_stdin, &mut stdin_buffer, outgoing).await?;
if len == 0 {
return anyhow::Ok(());
}
result = child_stdout.read(&mut stdout_buffer).fuse() => {
match result {
Ok(0) => {
child_stdin.close().await?;
outgoing_rx.close();
let status = ssh_proxy_process.status().await?;
// If we don't have a code, we assume process
// has been killed and treat it as non-zero exit
// code
return Ok(status.code().or_else(|| Some(1)));
}
Ok(len) => {
if len < stdout_buffer.len() {
child_stdout.read_exact(&mut stdout_buffer[len..]).await?;
}
let message_len = message_len_from_buffer(&stdout_buffer);
match read_message_with_len(&mut child_stdout, &mut stdout_buffer, message_len).await {
Ok(envelope) => {
connection_activity_tx.try_send(()).ok();
incoming_tx.unbounded_send(envelope).ok();
}
Err(error) => {
log::error!("error decoding message {error:?}");
}
}
}
Err(error) => {
Err(anyhow!("error reading stdout: {error:?}"))?;
}
}
if len < MESSAGE_LEN_SIZE {
child_stdout.read_exact(&mut stdout_buffer[len..]).await?;
}
result = child_stderr.read(&mut stderr_buffer[stderr_offset..]).fuse() => {
match result {
Ok(len) => {
stderr_offset += len;
let mut start_ix = 0;
while let Some(ix) = stderr_buffer[start_ix..stderr_offset].iter().position(|b| b == &b'\n') {
let line_ix = start_ix + ix;
let content = &stderr_buffer[start_ix..line_ix];
start_ix = line_ix + 1;
if let Ok(record) = serde_json::from_slice::<LogRecord>(content) {
record.log(log::logger())
} else {
eprintln!("(remote) {}", String::from_utf8_lossy(content));
}
}
stderr_buffer.drain(0..start_ix);
stderr_offset -= start_ix;
connection_activity_tx.try_send(()).ok();
}
Err(error) => {
Err(anyhow!("error reading stderr: {error:?}"))?;
}
}
}
let message_len = message_len_from_buffer(&stdout_buffer);
let envelope =
read_message_with_len(&mut child_stdout, &mut stdout_buffer, message_len)
.await?;
connection_activity_tx.try_send(()).ok();
incoming_tx.unbounded_send(envelope).ok();
}
}
});
let stderr_task: Task<anyhow::Result<()>> = cx.background_executor().spawn(async move {
loop {
stderr_buffer.resize(stderr_offset + 1024, 0);
let len = child_stderr
.read(&mut stderr_buffer[stderr_offset..])
.await?;
stderr_offset += len;
let mut start_ix = 0;
while let Some(ix) = stderr_buffer[start_ix..stderr_offset]
.iter()
.position(|b| b == &b'\n')
{
let line_ix = start_ix + ix;
let content = &stderr_buffer[start_ix..line_ix];
start_ix = line_ix + 1;
if let Ok(record) = serde_json::from_slice::<LogRecord>(content) {
record.log(log::logger())
} else {
eprintln!("(remote) {}", String::from_utf8_lossy(content));
}
}
stderr_buffer.drain(0..start_ix);
stderr_offset -= start_ix;
connection_activity_tx.try_send(()).ok();
}
});
cx.spawn(|mut cx| async move {
let result = io_task.await;
let result = futures::select! {
result = stdin_task.fuse() => {
result.context("stdin")
}
result = stdout_task.fuse() => {
result.context("stdout")
}
result = stderr_task.fuse() => {
result.context("stderr")
}
};
match result {
Ok(Some(exit_code)) => {
Ok(_) => {
let exit_code = ssh_proxy_process.status().await?.code().unwrap_or(1);
if let Some(error) = ProxyLaunchError::from_exit_code(exit_code) {
match error {
ProxyLaunchError::ServerNotRunning => {
@@ -928,7 +1022,6 @@ impl SshRemoteClient {
})?;
}
}
Ok(None) => {}
Err(error) => {
log::warn!("ssh io task died with error: {:?}. reconnecting...", error);
this.update(&mut cx, |this, cx| {
@@ -936,6 +1029,7 @@ impl SshRemoteClient {
})?;
}
}
Ok(())
})
}
@@ -975,16 +1069,9 @@ impl SshRemoteClient {
SshRemoteConnection::new(connection_options, delegate.clone(), cx).await?;
let platform = ssh_connection.query_platform().await?;
let (local_binary_path, version) = delegate.get_server_binary(platform, cx).await??;
let remote_binary_path = delegate.remote_server_binary_path(platform, cx)?;
ssh_connection
.ensure_server_binary(
&delegate,
&local_binary_path,
&remote_binary_path,
version,
cx,
)
.ensure_server_binary(&delegate, &remote_binary_path, platform, cx)
.await?;
let socket = ssh_connection.socket.clone();
@@ -1116,6 +1203,7 @@ impl SshRemoteConnection {
delegate: Arc<dyn SshClientDelegate>,
cx: &mut AsyncAppContext,
) -> Result<Self> {
use futures::AsyncWriteExt as _;
use futures::{io::BufReader, AsyncBufReadExt as _};
use smol::{fs::unix::PermissionsExt as _, net::unix::UnixListener};
use util::ResultExt as _;
@@ -1182,7 +1270,15 @@ impl SshRemoteConnection {
.stderr(Stdio::piped())
.env("SSH_ASKPASS_REQUIRE", "force")
.env("SSH_ASKPASS", &askpass_script_path)
.args(["-N", "-o", "ControlMaster=yes", "-o"])
.args(connection_options.additional_args().unwrap_or(&Vec::new()))
.args([
"-N",
"-o",
"ControlPersist=no",
"-o",
"ControlMaster=yes",
"-o",
])
.arg(format!("ControlPath={}", socket_path.display()))
.arg(&url)
.spawn()?;
@@ -1223,7 +1319,10 @@ impl SshRemoteConnection {
let mut stderr = master_process.stderr.take().unwrap();
stderr.read_to_end(&mut output).await?;
let error_message = format!("failed to connect: {}", String::from_utf8_lossy(&output));
let error_message = format!(
"failed to connect: {}",
String::from_utf8_lossy(&output).trim()
);
delegate.set_error(error_message.clone(), cx);
Err(anyhow!(error_message))?;
}
@@ -1241,11 +1340,19 @@ impl SshRemoteConnection {
async fn ensure_server_binary(
&self,
delegate: &Arc<dyn SshClientDelegate>,
src_path: &Path,
dst_path: &Path,
version: SemanticVersion,
platform: SshPlatform,
cx: &mut AsyncAppContext,
) -> Result<()> {
if std::env::var("ZED_USE_CACHED_REMOTE_SERVER").is_ok() {
if let Ok(installed_version) =
run_cmd(self.socket.ssh_command(dst_path).arg("version")).await
{
log::info!("using cached server binary version {}", installed_version);
return Ok(());
}
}
let mut dst_path_gz = dst_path.to_path_buf();
dst_path_gz.set_extension("gz");
@@ -1253,8 +1360,10 @@ impl SshRemoteConnection {
run_cmd(self.socket.ssh_command("mkdir").arg("-p").arg(parent)).await?;
}
let (src_path, version) = delegate.get_server_binary(platform, cx).await??;
let mut server_binary_exists = false;
if cfg!(not(debug_assertions)) {
if !server_binary_exists && cfg!(not(debug_assertions)) {
if let Ok(installed_version) =
run_cmd(self.socket.ssh_command(dst_path).arg("version")).await
{
@@ -1269,19 +1378,19 @@ impl SshRemoteConnection {
return Ok(());
}
let src_stat = fs::metadata(src_path).await?;
let src_stat = fs::metadata(&src_path).await?;
let size = src_stat.len();
let server_mode = 0o755;
let t0 = Instant::now();
delegate.set_status(Some("uploading remote development server"), cx);
delegate.set_status(Some("Uploading remote development server"), cx);
log::info!("uploading remote development server ({}kb)", size / 1024);
self.upload_file(src_path, &dst_path_gz)
self.upload_file(&src_path, &dst_path_gz)
.await
.context("failed to upload server binary")?;
log::info!("uploaded remote development server in {:?}", t0.elapsed());
delegate.set_status(Some("extracting remote development server"), cx);
delegate.set_status(Some("Extracting remote development server"), cx);
run_cmd(
self.socket
.ssh_command("gunzip")
@@ -1290,7 +1399,7 @@ impl SshRemoteConnection {
)
.await?;
delegate.set_status(Some("unzipping remote development server"), cx);
delegate.set_status(Some("Marking remote development server executable"), cx);
run_cmd(
self.socket
.ssh_command("chmod")
@@ -1418,19 +1527,16 @@ impl ChannelClient {
cx.clone(),
) {
log::debug!("ssh message received. name:{type_name}");
cx.foreground_executor().spawn(async move {
match future.await {
Ok(_) => {
log::debug!("ssh message handled. name:{type_name}");
}
Err(error) => {
log::error!(
"error handling message. type:{type_name}, error:{error}",
);
}
match future.await {
Ok(_) => {
log::debug!("ssh message handled. name:{type_name}");
}
}).detach();
Err(error) => {
log::error!(
"error handling message. type:{type_name}, error:{error}",
);
}
}
} else {
log::error!("unhandled ssh message name:{type_name}");
}

View File

@@ -30,6 +30,8 @@ client.workspace = true
env_logger.workspace = true
fs.workspace = true
futures.workspace = true
git.workspace = true
git_hosting_providers.workspace = true
gpui.workspace = true
http_client.workspace = true
language.workspace = true

View File

@@ -154,6 +154,7 @@ impl HeadlessProject {
client.add_request_handler(cx.weak_model(), Self::handle_remove_worktree);
client.add_model_request_handler(Self::handle_open_buffer_by_path);
client.add_model_request_handler(Self::handle_open_new_buffer);
client.add_model_request_handler(Self::handle_find_search_candidates);
client.add_model_request_handler(Self::handle_open_server_settings);
@@ -275,7 +276,7 @@ impl HeadlessProject {
let worktree = this
.update(&mut cx.clone(), |this, _| {
Worktree::local(
Arc::from(canonicalized),
Arc::from(canonicalized.as_path()),
message.payload.visible,
this.fs.clone(),
this.next_entry_id.clone(),
@@ -287,6 +288,7 @@ impl HeadlessProject {
let response = this.update(&mut cx, |_, cx| {
worktree.update(cx, |worktree, _| proto::AddWorktreeResponse {
worktree_id: worktree.id().to_proto(),
canonicalized_path: canonicalized.to_string_lossy().to_string(),
})
})?;
@@ -362,6 +364,32 @@ impl HeadlessProject {
})
}
pub async fn handle_open_new_buffer(
this: Model<Self>,
_message: TypedEnvelope<proto::OpenNewBuffer>,
mut cx: AsyncAppContext,
) -> Result<proto::OpenBufferResponse> {
let (buffer_store, buffer) = this.update(&mut cx, |this, cx| {
let buffer_store = this.buffer_store.clone();
let buffer = this
.buffer_store
.update(cx, |buffer_store, cx| buffer_store.create_buffer(cx));
anyhow::Ok((buffer_store, buffer))
})??;
let buffer = buffer.await?;
let buffer_id = buffer.read_with(&cx, |b, _| b.remote_id())?;
buffer_store.update(&mut cx, |buffer_store, cx| {
buffer_store
.create_buffer_for_peer(&buffer, SSH_PEER_ID, cx)
.detach_and_log_err(cx);
})?;
Ok(proto::OpenBufferResponse {
buffer_id: buffer_id.to_proto(),
})
}
pub async fn handle_open_server_settings(
this: Model<Self>,
_: TypedEnvelope<proto::OpenServerSettings>,

View File

@@ -1,6 +1,5 @@
#![cfg_attr(target_os = "windows", allow(unused, dead_code))]
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
@@ -40,13 +39,13 @@ fn main() {
}
#[cfg(not(windows))]
fn main() -> Result<()> {
fn main() {
use remote::proxy::ProxyLaunchError;
use remote_server::unix::{execute_proxy, execute_run};
let cli = Cli::parse();
match cli.command {
let result = match cli.command {
Some(Commands::Run {
log_file,
pid_file,
@@ -74,11 +73,15 @@ fn main() -> Result<()> {
},
Some(Commands::Version) => {
eprintln!("{}", env!("ZED_PKG_VERSION"));
Ok(())
std::process::exit(0);
}
None => {
eprintln!("usage: remote <run|proxy|version>");
std::process::exit(1);
}
};
if let Err(error) = result {
log::error!("exiting due to error: {}", error);
std::process::exit(1);
}
}

View File

@@ -5,12 +5,14 @@ use client::ProxySettings;
use fs::{Fs, RealFs};
use futures::channel::mpsc;
use futures::{select, select_biased, AsyncRead, AsyncWrite, AsyncWriteExt, FutureExt, SinkExt};
use git::GitHostingProviderRegistry;
use gpui::{AppContext, Context as _, ModelContext, UpdateGlobal as _};
use http_client::{read_proxy_from_env, Uri};
use language::LanguageRegistry;
use node_runtime::{NodeBinaryOptions, NodeRuntime};
use paths::logs_dir;
use project::project_settings::ProjectSettings;
use remote::proxy::ProxyLaunchError;
use remote::ssh_session::ChannelClient;
use remote::{
@@ -186,7 +188,6 @@ fn start_server(
log::info!("accepting new connections");
let result = select! {
streams = streams.fuse() => {
log::warn!("stdin {:?}, stdout: {:?}, stderr: {:?}", streams.0, streams.1, streams.2);
let (Some(Ok(stdin_stream)), Some(Ok(stdout_stream)), Some(Ok(stderr_stream))) = streams else {
break;
};
@@ -211,23 +212,29 @@ fn start_server(
break;
};
log::info!("yep! we got connections");
let mut input_buffer = Vec::new();
let mut output_buffer = Vec::new();
let (mut stdin_msg_tx, mut stdin_msg_rx) = mpsc::unbounded::<Envelope>();
cx.background_executor().spawn(async move {
while let Ok(msg) = read_message(&mut stdin_stream, &mut input_buffer).await {
if let Err(_) = stdin_msg_tx.send(msg).await {
break;
}
}
}).detach();
loop {
select_biased! {
_ = app_quit_rx.next().fuse() => {
return anyhow::Ok(());
}
stdin_message = read_message(&mut stdin_stream, &mut input_buffer).fuse() => {
let message = match stdin_message {
Ok(message) => message,
Err(error) => {
log::warn!("error reading message on stdin: {}. exiting.", error);
break;
}
stdin_message = stdin_msg_rx.next().fuse() => {
let Some(message) = stdin_message else {
log::warn!("error reading message on stdin. exiting.");
break;
};
if let Err(error) = incoming_tx.unbounded_send(message) {
log::error!("failed to send message to application: {:?}. exiting.", error);
@@ -253,7 +260,6 @@ fn start_server(
}
}
// // TODO: How do we handle backpressure?
log_message = log_rx.next().fuse() => {
if let Some(log_message) = log_message {
if let Err(error) = stderr_stream.write_all(&log_message).await {
@@ -316,7 +322,9 @@ pub fn execute_run(
let listeners = ServerListeners::new(stdin_socket, stdout_socket, stderr_socket)?;
log::debug!("starting gpui app");
log::info!("starting headless gpui app");
let git_hosting_provider_registry = Arc::new(GitHostingProviderRegistry::new());
gpui::App::headless().run(move |cx| {
settings::init(cx);
HeadlessProject::init(cx);
@@ -326,6 +334,9 @@ pub fn execute_run(
client::init_settings(cx);
GitHostingProviderRegistry::set_global(git_hosting_provider_registry, cx);
git_hosting_providers::init(cx);
let project = cx.new_model(|cx| {
let fs = Arc::new(RealFs::new(Default::default(), None));
let node_settings_rx = initialize_settings(session.clone(), fs.clone(), cx);
@@ -404,7 +415,7 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
init_logging_proxy();
init_panic_hook();
log::debug!("starting up. PID: {}", std::process::id());
log::info!("starting proxy process. PID: {}", std::process::id());
let server_paths = ServerPaths::new(&identifier)?;
@@ -417,7 +428,7 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
}
} else {
if let Some(pid) = server_pid {
log::debug!("found server already running with PID {}. Killing process and cleaning up files...", pid);
log::info!("proxy found server already running with PID {}. Killing process and cleaning up files...", pid);
kill_running_server(pid, &server_paths)?;
}
@@ -443,7 +454,9 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
loop {
match stream.read(&mut stderr_buffer).await {
Ok(0) => {
return anyhow::Ok(());
let error =
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "stderr closed");
Err(anyhow!(error))?;
}
Ok(n) => {
stderr.write_all(&mut stderr_buffer[..n]).await?;
@@ -463,6 +476,12 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
result = stderr_task.fuse() => result,
}
}) {
if let Some(error) = forwarding_result.downcast_ref::<std::io::Error>() {
if error.kind() == std::io::ErrorKind::UnexpectedEof {
log::error!("connection to server closed due to unexpected EOF");
return Err(anyhow!("connection to server closed"));
}
}
log::error!(
"failed to forward messages: {:?}, terminating...",
forwarding_result
@@ -518,7 +537,10 @@ fn spawn_server(paths: &ServerPaths) -> Result<()> {
.arg(&paths.stderr_socket)
.spawn()?;
log::debug!("server started. PID: {:?}", server_process.id());
log::info!(
"proxy spawned server process. PID: {:?}",
server_process.id()
);
let mut total_time_waited = std::time::Duration::from_secs(0);
let wait_duration = std::time::Duration::from_millis(20);

View File

@@ -17,8 +17,7 @@ use editor::{
use futures::io::BufReader;
use futures::{AsyncBufReadExt as _, FutureExt as _, StreamExt as _};
use gpui::{
div, prelude::*, EntityId, EventEmitter, Model, Render, Subscription, Task, View, ViewContext,
WeakView,
div, prelude::*, EventEmitter, Model, Render, Subscription, Task, View, ViewContext, WeakView,
};
use language::Point;
use project::Fs;
@@ -149,23 +148,21 @@ impl EditorBlock {
.w(text_line_height)
.h(text_line_height)
.child(
IconButton::new(
("close_output_area", EntityId::from(cx.block_id)),
IconName::Close,
)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.size(ButtonSize::Compact)
.shape(IconButtonShape::Square)
.tooltip(|cx| Tooltip::text("Close output area", cx))
.on_click(move |_, cx| {
if let BlockId::Custom(block_id) = block_id {
(on_close)(block_id, cx)
}
}),
IconButton::new("close_output_area", IconName::Close)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.size(ButtonSize::Compact)
.shape(IconButtonShape::Square)
.tooltip(|cx| Tooltip::text("Close output area", cx))
.on_click(move |_, cx| {
if let BlockId::Custom(block_id) = block_id {
(on_close)(block_id, cx)
}
}),
);
div()
.id(cx.block_id)
.flex()
.items_start()
.min_h(text_line_height)

View File

@@ -44,8 +44,12 @@ impl ReqwestClient {
let mut client = reqwest::Client::builder()
.use_rustls_tls()
.default_headers(map);
if let Some(proxy) = proxy.clone() {
client = client.proxy(reqwest::Proxy::all(proxy.to_string())?);
if let Some(proxy) = proxy.clone().and_then(|proxy_uri| {
reqwest::Proxy::all(proxy_uri.to_string())
.inspect_err(|e| log::error!("Failed to parse proxy URI {}: {}", proxy_uri, e))
.ok()
}) {
client = client.proxy(proxy);
}
let client = client.build()?;
let mut client: ReqwestClient = client.into();
@@ -232,3 +236,47 @@ impl http_client::HttpClient for ReqwestClient {
.boxed()
}
}
#[cfg(test)]
mod tests {
use http_client::{http, HttpClient};
use crate::ReqwestClient;
#[test]
fn test_proxy_uri() {
let client = ReqwestClient::new();
assert_eq!(client.proxy(), None);
let proxy = http::Uri::from_static("http://localhost:10809");
let client = ReqwestClient::proxy_and_user_agent(Some(proxy.clone()), "test").unwrap();
assert_eq!(client.proxy(), Some(&proxy));
let proxy = http::Uri::from_static("https://localhost:10809");
let client = ReqwestClient::proxy_and_user_agent(Some(proxy.clone()), "test").unwrap();
assert_eq!(client.proxy(), Some(&proxy));
let proxy = http::Uri::from_static("socks4://localhost:10808");
let client = ReqwestClient::proxy_and_user_agent(Some(proxy.clone()), "test").unwrap();
assert_eq!(client.proxy(), Some(&proxy));
let proxy = http::Uri::from_static("socks4a://localhost:10808");
let client = ReqwestClient::proxy_and_user_agent(Some(proxy.clone()), "test").unwrap();
assert_eq!(client.proxy(), Some(&proxy));
let proxy = http::Uri::from_static("socks5://localhost:10808");
let client = ReqwestClient::proxy_and_user_agent(Some(proxy.clone()), "test").unwrap();
assert_eq!(client.proxy(), Some(&proxy));
let proxy = http::Uri::from_static("socks5h://localhost:10808");
let client = ReqwestClient::proxy_and_user_agent(Some(proxy.clone()), "test").unwrap();
assert_eq!(client.proxy(), Some(&proxy));
}
#[test]
#[should_panic]
fn test_invalid_proxy_uri() {
let proxy = http::Uri::from_static("file:///etc/hosts");
ReqwestClient::proxy_and_user_agent(Some(proxy), "test").unwrap();
}
}

View File

@@ -384,13 +384,6 @@ impl PickerDelegate for TasksModalDelegate {
.start_slot::<Icon>(icon)
.end_slot::<AnyElement>(history_run_icon)
.spacing(ListItemSpacing::Sparse)
// .map(|this| {
// if Some(ix) <= self.divider_index {
// this.start_slot(Icon::new(IconName::HistoryRerun).size(IconSize::Small))
// } else {
// this.start_slot(v_flex().flex_none().size(IconSize::Small.rems()))
// }
// })
.when_some(tooltip_label, |list_item, item_label| {
list_item.tooltip(move |_| item_label.clone())
})

View File

@@ -151,7 +151,7 @@ pub struct TerminalSettingsContent {
pub alternate_scroll: Option<AlternateScroll>,
/// Sets whether the option key behaves as the meta key.
///
/// Default: true
/// Default: false
pub option_as_meta: Option<bool>,
/// Whether or not selecting text in the terminal will automatically
/// copy to the system clipboard.

View File

@@ -395,7 +395,21 @@ impl TerminalPanel {
let mut spawn_task = spawn_in_terminal.clone();
// Set up shell args unconditionally, as tasks are always spawned inside of a shell.
let Some((shell, mut user_args)) = (match spawn_in_terminal.shell.clone() {
Shell::System => retrieve_system_shell().map(|shell| (shell, Vec::new())),
Shell::System => {
match self
.workspace
.update(cx, |workspace, cx| workspace.project().read(cx).is_local())
{
Ok(local) => {
if local {
retrieve_system_shell().map(|shell| (shell, Vec::new()))
} else {
Some(("\"${SHELL:-sh}\"".to_string(), Vec::new()))
}
}
Err(_no_window_e) => return,
}
}
Shell::Program(shell) => Some((shell, Vec::new())),
Shell::WithArguments { program, args } => Some((program, args)),
}) else {

View File

@@ -1427,7 +1427,7 @@ impl Buffer {
fn undo_or_redo(&mut self, transaction: Transaction) -> Operation {
let mut counts = HashMap::default();
for edit_id in transaction.edit_ids {
counts.insert(edit_id, self.undo_map.undo_count(edit_id) + 1);
counts.insert(edit_id, self.undo_map.undo_count(edit_id).saturating_add(1));
}
let operation = self.undo_operations(counts);

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