Compare commits

..

55 Commits

Author SHA1 Message Date
Antonio Scandurra
dd2bb8dac6 WIP 2024-10-30 17:23:03 +01:00
renovate[bot]
7bc4cb9868 Update Rust crate hyper to v0.14.31 (#19323)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [hyper](https://hyper.rs)
([source](https://redirect.github.com/hyperium/hyper)) |
workspace.dependencies | patch | `0.14.30` -> `0.14.31` |

---

### Release Notes

<details>
<summary>hyperium/hyper (hyper)</summary>

###
[`v0.14.31`](https://redirect.github.com/hyperium/hyper/releases/tag/v0.14.31)

[Compare
Source](https://redirect.github.com/hyperium/hyper/compare/v0.14.30...v0.14.31)

#### Bug Fixes

- **http1:** improve performance of parsing sequentially partial
messages
([97b595e](97b595e589))

</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:eyJjcmVhdGVkSW5WZXIiOiIzOC4xMjAuMSIsInVwZGF0ZWRJblZlciI6IjM4LjEyMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-30 12:11:07 -04:00
Gherman
f84f3ffeb7 docs: Add linkedProjects section to Rust docs (#19954)
Related to #19897

Adds a section about multi-project workspaces and how to configure
rust-analyzer to diagnose them even if the cargo workspace does not list
them

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
2024-10-30 11:43:44 -04:00
Richard Feldman
c564a4a26c Require /file or /tab when using Suggest Edits (#19960)
Now if you try to do Suggest Edits without a file context, you see this
(and it doesn't run the query).

<img width="635" alt="Screenshot 2024-10-30 at 10 51 24 AM"
src="https://github.com/user-attachments/assets/a3997ba6-98a9-4bfa-81b6-1d8579c26fd7">


Release Notes:

- N/A

---------

Co-authored-by: Antonio <antonio@zed.dev>
2024-10-30 11:38:43 -04:00
Marshall Bowers
515fd7b75f git_hosting_providers: Fix support for GitLab remotes containing subgroups (#19962)
This PR fixes the support for GitLab remote URLs containing subgroups.

Reported in
https://github.com/zed-industries/zed/issues/18012#issuecomment-2446206256.

Release Notes:

- N/A
2024-10-30 11:16:44 -04:00
Peter Tripp
662a4440cc v0.161.x dev 2024-10-30 11:06:39 -04:00
Marshall Bowers
5dee43b05c dart: Extract to zed-extensions/dart repository (#19959)
This PR extracts the Dart extension to the
[zed-extensions/dart](https://github.com/zed-extensions/dart)
repository.

Release Notes:

- N/A
2024-10-30 11:00:06 -04:00
Antonio Scandurra
c8003c0697 Take a mutable context when resolving selections (#19948)
This is a behavior-preserving change, but lays the groundwork for
expanding selections when the cursor lands inside of a "replace" block.

Release Notes:

- N/A
2024-10-30 15:21:51 +01:00
Lukas Geiger
83e2889d63 Fix notebook cell-height when soft-wrapping lines (#19933) 2024-10-30 07:12:32 -07:00
Kirill Bulatov
d49cd0019f Log prettier errors on failures (#19951)
Closes https://github.com/zed-industries/zed/issues/11987

Release Notes:

- Fixed prettier not reporting failures in the status panel on
formatting and installation errors
2024-10-30 14:49:47 +02:00
Thorsten Ball
0ba40bdfb8 remote dev: Always upload binary in development mode (#19953)
Release Notes:

- N/A
2024-10-30 13:41:28 +01:00
Thorsten Ball
f6cd97f6fd remote dev: Allow canceling language server work in editor (#19946)
Release Notes:

- Added ability to cancel language server work in remote development.

Demo:



https://github.com/user-attachments/assets/c9ca91a5-617f-4886-a458-87c563c5a247
2024-10-30 13:27:11 +01:00
Thorsten Ball
774a8bf039 inline blame: Fix default setting for inline blame (#19943)
Follow-up to #19759. Fixes the default value. cc @pjtatlow 😄 

Release Notes:

- N/A
2024-10-30 11:40:04 +01:00
Antonio Scandurra
4431ef1870 Speed up point translation in the Rope (#19913)
This pull request introduces an index of Unicode codepoints, newlines
and UTF-16 codepoints.

Benchmarks worth a thousand words:

```
push/4096               time:   [467.06 µs 470.07 µs 473.24 µs]
                        thrpt:  [8.2543 MiB/s 8.3100 MiB/s 8.3635 MiB/s]
                 change:
                        time:   [-4.1462% -3.0990% -2.0527%] (p = 0.00 < 0.05)
                        thrpt:  [+2.0957% +3.1981% +4.3255%]
                        Performance has improved.
Found 3 outliers among 100 measurements (3.00%)
  1 (1.00%) low mild
  2 (2.00%) high mild
push/65536              time:   [1.4650 ms 1.4796 ms 1.4922 ms]
                        thrpt:  [41.885 MiB/s 42.242 MiB/s 42.664 MiB/s]
                 change:
                        time:   [-3.2871% -2.3489% -1.4555%] (p = 0.00 < 0.05)
                        thrpt:  [+1.4770% +2.4054% +3.3988%]
                        Performance has improved.
Found 6 outliers among 100 measurements (6.00%)
  3 (3.00%) low severe
  3 (3.00%) low mild

append/4096             time:   [729.00 ns 730.57 ns 732.14 ns]
                        thrpt:  [5.2103 GiB/s 5.2215 GiB/s 5.2327 GiB/s]
                 change:
                        time:   [-81.884% -81.836% -81.790%] (p = 0.00 < 0.05)
                        thrpt:  [+449.16% +450.53% +452.01%]
                        Performance has improved.
Found 11 outliers among 100 measurements (11.00%)
  3 (3.00%) low mild
  6 (6.00%) high mild
  2 (2.00%) high severe
append/65536            time:   [504.44 ns 505.58 ns 506.77 ns]
                        thrpt:  [120.44 GiB/s 120.72 GiB/s 121.00 GiB/s]
                 change:
                        time:   [-94.833% -94.807% -94.782%] (p = 0.00 < 0.05)
                        thrpt:  [+1816.3% +1825.8% +1835.5%]
                        Performance has improved.
Found 4 outliers among 100 measurements (4.00%)
  3 (3.00%) high mild
  1 (1.00%) high severe

slice/4096              time:   [29.661 µs 29.733 µs 29.816 µs]
                        thrpt:  [131.01 MiB/s 131.38 MiB/s 131.70 MiB/s]
                 change:
                        time:   [-48.833% -48.533% -48.230%] (p = 0.00 < 0.05)
                        thrpt:  [+93.161% +94.298% +95.440%]
                        Performance has improved.
slice/65536             time:   [588.00 µs 590.22 µs 592.17 µs]
                        thrpt:  [105.54 MiB/s 105.89 MiB/s 106.29 MiB/s]
                 change:
                        time:   [-45.599% -45.347% -45.099%] (p = 0.00 < 0.05)
                        thrpt:  [+82.147% +82.971% +83.821%]
                        Performance has improved.
Found 2 outliers among 100 measurements (2.00%)
  1 (1.00%) low severe
  1 (1.00%) high mild

bytes_in_range/4096     time:   [3.8630 µs 3.8811 µs 3.8994 µs]
                        thrpt:  [1001.8 MiB/s 1006.5 MiB/s 1011.2 MiB/s]
                 change:
                        time:   [+0.0600% +0.6000% +1.1833%] (p = 0.03 < 0.05)
                        thrpt:  [-1.1695% -0.5964% -0.0600%]
                        Change within noise threshold.
bytes_in_range/65536    time:   [98.178 µs 98.545 µs 98.931 µs]
                        thrpt:  [631.75 MiB/s 634.23 MiB/s 636.60 MiB/s]
                 change:
                        time:   [-0.6513% +0.7537% +2.2265%] (p = 0.30 > 0.05)
                        thrpt:  [-2.1780% -0.7481% +0.6555%]
                        No change in performance detected.
Found 11 outliers among 100 measurements (11.00%)
  8 (8.00%) high mild
  3 (3.00%) high severe

chars/4096              time:   [878.91 ns 879.45 ns 880.06 ns]
                        thrpt:  [4.3346 GiB/s 4.3376 GiB/s 4.3403 GiB/s]
                 change:
                        time:   [+9.1679% +9.4000% +9.6304%] (p = 0.00 < 0.05)
                        thrpt:  [-8.7844% -8.5923% -8.3979%]
                        Performance has regressed.
Found 8 outliers among 100 measurements (8.00%)
  1 (1.00%) low severe
  1 (1.00%) low mild
  3 (3.00%) high mild
  3 (3.00%) high severe
chars/65536             time:   [15.615 µs 15.691 µs 15.757 µs]
                        thrpt:  [3.8735 GiB/s 3.8899 GiB/s 3.9087 GiB/s]
                 change:
                        time:   [+5.4902% +5.9345% +6.4044%] (p = 0.00 < 0.05)
                        thrpt:  [-6.0190% -5.6021% -5.2045%]
                        Performance has regressed.
Found 2 outliers among 100 measurements (2.00%)
  2 (2.00%) low mild

clip_point/4096         time:   [29.677 µs 29.835 µs 30.019 µs]
                        thrpt:  [130.13 MiB/s 130.93 MiB/s 131.63 MiB/s]
                 change:
                        time:   [-46.306% -45.866% -45.436%] (p = 0.00 < 0.05)
                        thrpt:  [+83.272% +84.728% +86.240%]
                        Performance has improved.
Found 11 outliers among 100 measurements (11.00%)
  3 (3.00%) high mild
  8 (8.00%) high severe
clip_point/65536        time:   [1.5933 ms 1.6116 ms 1.6311 ms]
                        thrpt:  [38.318 MiB/s 38.782 MiB/s 39.226 MiB/s]
                 change:
                        time:   [-30.388% -29.598% -28.717%] (p = 0.00 < 0.05)
                        thrpt:  [+40.286% +42.040% +43.653%]
                        Performance has improved.
Found 3 outliers among 100 measurements (3.00%)
  3 (3.00%) high mild


running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 7 filtered out; finished in 0.00s

point_to_offset/4096    time:   [14.493 µs 14.591 µs 14.707 µs]
                        thrpt:  [265.61 MiB/s 267.72 MiB/s 269.52 MiB/s]
                 change:
                        time:   [-71.990% -71.787% -71.588%] (p = 0.00 < 0.05)
                        thrpt:  [+251.96% +254.45% +257.01%]
                        Performance has improved.
Found 9 outliers among 100 measurements (9.00%)
  5 (5.00%) high mild
  4 (4.00%) high severe
point_to_offset/65536   time:   [700.72 µs 713.75 µs 727.26 µs]
                        thrpt:  [85.939 MiB/s 87.566 MiB/s 89.194 MiB/s]
                 change:
                        time:   [-61.778% -61.015% -60.256%] (p = 0.00 < 0.05)
                        thrpt:  [+151.61% +156.51% +161.63%]
                        Performance has improved.
```

Calling `Rope::chars` got slightly slower but I don't think it's a big
issue (we don't really call `chars` for an entire `Rope`).

In a future pull request, I want to use the tab index (which we're not
yet using) and the char index to make `TabMap` a lot faster.

Release Notes:

- N/A
2024-10-30 10:59:03 +01:00
Mikayla Maki
b3f0ba1430 Implement panic reporting saving and uploads (#19932)
TODO: 
- [x] check that the app version is well formatted for zed.dev

Release Notes:

- N/A

---------

Co-authored-by: Trace <violet.white.batt@gmail.com>
2024-10-29 23:54:00 -07:00
Nate Butler
a5f52f0f04 Use theme families to refine user themes (#19936)
This PR changes the way we load user themes into the ThemeRegistry. 

Rather than directly pass a theme family's themes to
`insert_user_themes`, instead we use the new `refine_theme_family ` and
`ThemeFamily::refine_theme`.

This PR should have net zero change to themes today, but sets up
enabling theme variables. We need to do it this way so each theme has
access to it's family when it is refined.

Release Notes:

- N/A
2024-10-29 22:30:58 -04:00
Nate Butler
63524a2354 Add missing full-size styles for panes (#19935)
As we don't use scrolling flex layouts directly in panes that often, the
methods that would normally be applied to containers that should fill
the space weren't applied here.

Should help un-stuck #19872's layout issue, but I'm merging this change
separately in case it creates some other layout issue in panes.

Release Notes:

- N/A
2024-10-29 21:16:45 -04:00
Marshall Bowers
90edb7189f git_hosting_providers: Clean up tests (#19927)
This PR cleans up the tests for the various Git hosting providers.

These tests had rotted a bit over time, to the point that some of them
weren't even testing what they claimed anymore.

Release Notes:

- N/A
2024-10-29 17:24:32 -04:00
Max Brunsfeld
518f6b529b Fix missing diagnostic and text highlights after blocks (#19920)
Release Notes:

- Fixed an issue where diagnostic underlines and certain text highlights
were not rendered correctly below block decorations such as the inline
assistant prompt.

Co-authored-by: Antonio <antonio@zed.dev>
Co-authored-by: Richard <richard@zed.dev>
2024-10-29 13:47:43 -07:00
Conrad Irwin
fb97e462de Better handle interrupted connections for shared SSH (#19925)
Co-Authored-By: Mikayla <mikayla@zed.dev>
2024-10-29 16:43:34 -04:00
Marshall Bowers
5b7fa05a87 Make Git remote URL parsing more robust (#19924)
This PR improves the parsing of Git remote URLs in order to make
features that depend on them more robust.

Previously we were just treating these as plain strings and doing
one-off shotgun parsing to massage them into the right format. This
meant that we weren't accounting for edge cases in URL structure.

One of these cases was HTTPS Git URLs containing a username, which can
arise when using GitHub Enterprise (see
https://github.com/zed-industries/zed/issues/11160).

We now have a `RemoteUrl` typed to represent a parsed Git remote URL and
use the `Url` parser to parse it.

Release Notes:

- Improved the parsing of Git remote URLs to support additional
scenarios.
2024-10-29 16:19:05 -04:00
Conrad Irwin
d310a1269f SSH Remoting: Fix diagnostic summary syncing (#19923)
Co-Authored-By: Mikayla <mikayla@zed.dev>

Release Notes:

- SSH Remoting: Fix diagnostics summary over collab

Co-authored-by: Mikayla <mikayla@zed.dev>
2024-10-29 13:02:32 -07:00
Mikayla Maki
9818835c9d Fix the log spam from the BlameBuffer request (#19921)
Release Notes:

- N/A
2024-10-29 13:02:21 -07:00
Mikayla Maki
f3b7f5944d Fix a rare crash on startup (#19922)
Release Notes:

- Fixed a rare crash that could happen when certain SQL statements are
prepared
2024-10-29 12:30:55 -07:00
Conrad Irwin
fc5cde9434 Fix quotes in Rust (#19914)
Release Notes:

- (preview only) Fixed quote-autoclose in Rust
2024-10-29 12:35:34 -06:00
Kyle Kelley
6ea4662326 Initial Notebook UI structure (#19756)
This is the start of a notebook UI for Zed. 

`🔔 Note: This won't be useable yet when it is merged! Read below. 🔔`

This is going to be behind a feature flag so that we can merge this
initial PR and then make follow up PRs. Release notes will be produced
in a future PR.

Minimum checklist for merging this:

* [x] All functionality behind the `notebooks` feature flag (with env
var opt out)
* [x] Open notebook files in the workspace
* [x] Remove the "Open Notebook" button from title bar
* [x] Incorporate text style refinements for cell editors
* [x] Rely on `nbformat` crate for parsing the notebook into our
in-memory format
* [x] Move notebook to a `gpui::List`
* [x] Hook up output rendering


Release Notes:

- N/A

---------

Co-authored-by: Nate Butler <iamnbutler@gmail.com>
Co-authored-by: Thorsten Ball <mrnugget@gmail.com>
2024-10-29 11:30:07 -07:00
Joseph T. Lyons
9d12308d06 Fix Julia icon extension lookup (#19916)
Release Notes:

- Fixed a bug where the Julia icon was not displayed for Julia files.
2024-10-29 14:07:54 -04:00
Richard Feldman
21137d2ba7 Delete /workflow (#19900)
This a separate PR from https://github.com/zed-industries/zed/pull/19705
so we can revert it more easily if we want it back later.

Release Notes:

- Added "Suggest Edit" button to the assistant panel if
`"enable_experimental_live_diffs": true` is set in the `"assistant"`
section of `settings.json`. This button takes the place of the previous
`/workflow` command, but it is experimental and may change!

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
2024-10-29 13:57:33 -04:00
Conrad Irwin
273cb1921f Fix wrong UpdateWorktree chunk size being used in release mode (#19912)
Release Notes:

- Fixed slowness when collaborating

Co-authored-by: Thorsten <thorsten@zed.dev>
2024-10-29 11:22:41 -06:00
Nathan Sobo
cfa20ff221 Sketch in assistant edit button (#19705)
Add an edit button to the assistant. This is totally hacked in for now,
just to see how this would feel rendered simply in the UI.

![CleanShot 2024-10-24 at 16 26
14@2x](https://github.com/user-attachments/assets/e630d078-78b7-42d7-93f1-cf61c00bd20e)

cc @as-cii @danilo-leal 

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
2024-10-29 13:21:10 -04:00
Mikayla Maki
759d136fe6 Update a few doc comments (#19911)
Release Notes:

- N/A
2024-10-29 10:09:49 -07:00
Marshall Bowers
322aa41ad6 Add support for self-hosted GitLab instances for Git permalinks (#19909)
This PR adds support for self-hosted GitLab instances when generating
Git permalinks.

If the `origin` Git remote contains `gitlab` in the URL hostname we will
then attempt to register it as a self-hosted GitLab instance.

A note on this: I don't think relying on specific keywords is going to
be a suitable long-term solution to detection. In reality the
self-hosted instance could be hosted anywhere (e.g.,
`vcs.my-company.com`), so we will ultimately need a way to have the user
indicate which Git provider they are using (perhaps via a setting).

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

Release Notes:

- Added support for self-hosted GitLab instances when generating Git
permalinks.
- The instance URL must have `gitlab` somewhere in the host in order to
be recognized.
2024-10-29 12:31:51 -04:00
Max Brunsfeld
3e2f1d733c Fix horizontal scroll caused by diagnostic block width error (#19856)
Previously, when scrolling the diagnostics view with the mouse, we'd get
a spurious horizontal scroll (even if the content was not overflowing
horizontally) due to an error in the widths of the diagnostic blocks.

Release Notes:

- Fixed an issue where the project diagnostics view spuriously allowed
horizontal scrolling by a small amount.
2024-10-29 09:16:38 -07:00
Michael Sloan
3fed738d2f Use same logic for skipping single instance check on Linux as on Mac/Win (#19446)
Release Notes:

- Linux: Now skips check which exits with "zed is already running" when
in development mode or when run with `zed-local`, matching the behavior
on Mac and Windows

Co-authored-by: Nathan Sobo <nathan@zed.dev>
2024-10-29 09:12:34 -07:00
Kirill Bulatov
5893e85708 Ensure shared ssh project propagates buffer changes to all participants (#19907)
Fixed the bug when shared ssh project did not account for client
changing things in their buffers.
Also ensures Prettier formatting workflow works for both ssh project
owner and ssh project clients.

Release Notes:

- N/A

---------

Co-authored-by: Conrad Irwin <conrad@zed.dev>
2024-10-29 17:24:10 +02:00
Joseph T. Lyons
1356665ed3 Update community links page url (#19899)
See:

- https://github.com/zed-industries/zed.dev/pull/786

Release Notes:

- N/A
2024-10-29 10:13:26 -04:00
Jen Stehlik
9739da8de3 Add Gleam icon (#19887)
I took a shot at creating an icon version of the Gleam logo in response
to https://github.com/zed-industries/zed/pull/19529

Release Notes:

- Added an icon for Gleam files.


![image](https://github.com/user-attachments/assets/97432ded-342f-4d87-8eb2-dc9145513d8c)

<img width="231" alt="Screenshot 2024-10-29 at 9 46 33 AM"
src="https://github.com/user-attachments/assets/c957c98f-3da0-4b92-bc21-2a5adca1daa3">

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
2024-10-29 09:54:21 -04:00
Joseph T. Lyons
249c8a4d96 Remove community content from docs and point to zed.dev (#19895)
The community content now lives on zed.dev, discoverable via the navbar
`resources` menu.

See:

- https://github.com/zed-industries/zed.dev/pull/783

Release Notes:

- N/A
2024-10-29 09:44:58 -04:00
Thorsten Ball
f919fa92de remote servers: Fix title from alpha to beta (#19889)
Discussed this in Slack yesterday. We use `beta` because that's what we
use in the docs as well.

Release Notes:

- N/A
2024-10-29 13:38:30 +01:00
Bennet Bo Fenner
21b58643fa vsc menu: Fix issue when switching branch while non-visible worktree is open (#19888)
Fixes a regression introduced in #19755

<img width="935" alt="Screenshot 2024-10-29 at 12 13 04"
src="https://github.com/user-attachments/assets/7699b8da-631d-4932-89a8-bc5d7f2546f1">

Co-Authored-by: Thorsten <thorsten@zed.dev>

Release Notes:

- Fixed an issue where the branch switcher would show an error, when
opening a file outside of the project

Co-authored-by: Thorsten <thorsten@zed.dev>
2024-10-29 12:27:30 +01:00
Bennet Bo Fenner
6a0bcca9ec ssh remoting: Hide share button while connecting to project (#19885)
Co-Authored-by: Thorsten <thorsten@zed.dev>

Release Notes:

- N/A

Co-authored-by: Thorsten <thorsten@zed.dev>
2024-10-29 12:13:21 +01:00
PJ Tatlow
84328c303b Include commit summary in inline Git blame (#19759)
Closes #19758

Release Notes:

- Added feature to show commit summary as part of the inline Git blame

---------

Co-authored-by: Thorsten Ball <mrnugget@gmail.com>
2024-10-29 11:35:31 +01:00
Bennet Bo Fenner
f7b2b41df9 ssh remoting: Check nightly version correctly by comparing commit SHA (#19884)
This ensures that we detect if a new nightly version of the remote
server is available.
Previously we would always mark a version as matching if they had the
same semantic version.
However, for nightly versions we also need to check if they have the
same commit SHA.

Co-Authored-by: Thorsten <thorsten@zed.dev>

Release Notes:

- N/A

---------

Co-authored-by: Thorsten <thorsten@zed.dev>
2024-10-29 11:32:55 +01:00
Bennet Bo Fenner
7a6b6435c4 languages: Enable grammar loading when compiling with test feature (#19881)
This ensures that `cargo tests -p languages` will not fail with a
confusing error message.

Follow up to #19821

We opted to check the `test` feature flag instead of defining a runtime
flag, because we only want to include the `tree-sitter-*` dependencies
in some cases, which is not possible with a runtime flag.

Co-Authored-by: Thorsten <thorsten@zed.dev>

Release Notes:

- N/A

Co-authored-by: Thorsten <thorsten@zed.dev>
2024-10-29 11:00:44 +01:00
Bennet Bo Fenner
bdb54decdc ssh remoting: Show the host's GitHub name in the titlebar when sharing an SSH project (#19844)
The name (GitHub name) of the host was not displayed when sharing an ssh
project.

Previously we assumed that the a collaborator is a host if the
`replica_id` of the collaborator was `0`,
but for ssh project the `replica_id` is actually `1`.

<img width="329" alt="Screenshot 2024-10-28 at 18 16 30"
src="https://github.com/user-attachments/assets/c0151e12-a96f-4f38-aec1-4ed5475a9eaf">


Co-Authored-by: Thorsten <thorsten@zed.dev>

Release Notes:

- N/A

---------

Co-authored-by: Thorsten <thorsten@zed.dev>
2024-10-29 09:52:54 +01:00
Bennet Bo Fenner
b5c41eeb98 Future-proof indent guides settings for panels (#19878)
This PR ensures that we do not have to break the indent guides settings
for the project/outline panel. In the future we might want to have a
more granular way to control when to show indent guides, or control
other indent guide properties, like its width.

Release Notes:

- N/A
2024-10-29 09:52:36 +01:00
Conrad Irwin
719a7f7890 Fix block cursor on graphemes (#19867)
Release Notes:

- Fixed block cursor rendering only first char of multii-char graphemes.
2024-10-28 21:05:24 -06:00
Joseph T. Lyons
1b84fee708 restore editor::UnfoldRecursive binding (#19865) 2024-10-28 22:32:59 -04:00
Conrad Irwin
58e5d4ff02 Reland invisibles (#19846)
Release Notes:

- Show invisibles in the editor

Relands #19298 

Trying to quantify a performance impact, it doesn't seem to impact much
visible in Instruments or in a micro-benchmark of Editor#layout_lines.
We're still taking a few hundred micro-seconds (+/- a lot) every time.
The ascii file has just ascii, where as the cc file has one control
character per line.

<img width="1055" alt="Screenshot 2024-10-28 at 12 14 53"
src="https://github.com/user-attachments/assets/1c382063-bb19-4e92-bbba-ed5e7c02309f">
<img width="1020" alt="Screenshot 2024-10-28 at 12 15 07"
src="https://github.com/user-attachments/assets/1789f65e-5f83-4c32-be47-7748c62c3703">
2024-10-28 20:27:09 -06:00
Mikayla Maki
85ff03cde0 Add more context to the save new file path picker (#19863)
Release Notes:

- N/A

Co-authored-by: Conrad <conrad@zed.dev>
2024-10-28 17:21:41 -07:00
Conrad Irwin
a3f0bb4547 SSH Remoting: Document manual binary management (#19862)
Release Notes:

- N/A
2024-10-28 17:10:08 -06:00
Marshall Bowers
93b20008e0 Add support for Doxygen doc comments in C++ (#19858)
This PR adds support for Doxygen-style doc comments in C++.

<img width="962" alt="Screenshot 2024-10-28 at 5 38 34 PM"
src="https://github.com/user-attachments/assets/57d0fa4b-07c1-4b71-899a-fba78e822e8f">

https://www.doxygen.nl/manual/docblocks.html

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

Release Notes:

- C++: Added support for Doxygen-style doc comments starting with `/// `
or `//! `.
2024-10-28 17:44:23 -04:00
ClanEver
188a893fd0 python: Enhance syntax highlighting for type hints (#18185)
Release Notes:

- Python: Improved syntax highlighting for type hints.

# Before

![image](https://github.com/user-attachments/assets/876a69ab-a572-4d1b-af99-e6f85f249ea6)

# After

![image](https://github.com/user-attachments/assets/4fb98a9b-bc5d-4799-b535-057047884383)

---
Why manual recursion?
- Due to tree-sitter grammar not supporting recursion in query
(https://github.com/tree-sitter-grammars/tree-sitter-lua/issues/24),
currently only manual recursion is possible (refer to
https://github.com/projekt0n/github-nvim-theme/pull/250/files).

<br/>

Unable to highlight when simple structures appear before complex
structures, example:
```python
def t() -> str | dict[int, dict[int, dict[int, str]]]:
    pass
```
Because complex structures are parsed as `subscript` rather than
`generic_type` by tree-sitter in this case ☹

<br/>

Related:

- https://github.com/zed-industries/zed/issues/14715
- [Union Type (Python
Doc)](https://docs.python.org/3/library/stdtypes.html#union-type)
- [Type parameter lists (Python
Doc)](https://docs.python.org/3/reference/compound_stmts.html#type-parameter-lists)

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
2024-10-28 16:56:59 -04:00
Piotr Osiewicz
052b746fbd language_selector: Fix debug_assert firing off on context menu creation for LSP view (#19854)
Closes #ISSUE

Release Notes:

- N/A
2024-10-28 21:55:38 +01:00
Mikayla Maki
80f89059aa Fix mouse clicks on remote-open-folder UI (#19851)
Also change Zed's standard style to use
`.track_focus(&self.focus_handle(cx))`, instead of
`.track_focus(&self.focus_handle)`, to catch these kinds of errors more
easily in the future.

Release Notes:

- N/A

---------

Co-authored-by: Conrad <conrad@zed.dev>
2024-10-28 12:55:55 -07:00
156 changed files with 6018 additions and 2637 deletions

View File

@@ -1,3 +1,3 @@
# Code of Conduct
The Code of Conduct for this repository can be found online at [zed.dev/docs/code-of-conduct](https://zed.dev/docs/code-of-conduct).
The Code of Conduct for this repository can be found online at [zed.dev/code-of-conduct](https://zed.dev/code-of-conduct).

View File

@@ -2,7 +2,7 @@
Thanks for your interest in contributing to Zed, the collaborative platform that is also a code editor!
All activity in Zed forums is subject to our [Code of Conduct](https://zed.dev/docs/code-of-conduct). Additionally, contributors must sign our [Contributor License Agreement](https://zed.dev/cla) before their contributions can be merged.
All activity in Zed forums is subject to our [Code of Conduct](https://zed.dev/code-of-conduct). Additionally, contributors must sign our [Contributor License Agreement](https://zed.dev/cla) before their contributions can be merged.
## Contribution ideas

84
Cargo.lock generated
View File

@@ -16,6 +16,7 @@ dependencies = [
"project",
"smallvec",
"ui",
"util",
"workspace",
]
@@ -853,7 +854,7 @@ dependencies = [
"chrono",
"futures-util",
"http-types",
"hyper 0.14.30",
"hyper 0.14.31",
"hyper-rustls 0.24.2",
"serde",
"serde_json",
@@ -1349,7 +1350,7 @@ dependencies = [
"http-body 0.4.6",
"http-body 1.0.1",
"httparse",
"hyper 0.14.30",
"hyper 0.14.31",
"hyper-rustls 0.24.2",
"once_cell",
"pin-project-lite",
@@ -1440,7 +1441,7 @@ dependencies = [
"headers",
"http 0.2.12",
"http-body 0.4.6",
"hyper 0.14.30",
"hyper 0.14.31",
"itoa",
"matchit",
"memchr",
@@ -1586,7 +1587,7 @@ dependencies = [
"bitflags 2.6.0",
"cexpr",
"clang-sys",
"itertools 0.10.5",
"itertools 0.12.1",
"lazy_static",
"lazycell",
"proc-macro2",
@@ -2365,7 +2366,7 @@ dependencies = [
"clickhouse-derive",
"clickhouse-rs-cityhash-sys",
"futures 0.3.30",
"hyper 0.14.30",
"hyper 0.14.31",
"hyper-tls",
"lz4",
"sealed",
@@ -2568,7 +2569,7 @@ dependencies = [
"gpui",
"hex",
"http_client",
"hyper 0.14.30",
"hyper 0.14.31",
"indoc",
"jsonwebtoken",
"language",
@@ -3717,6 +3718,7 @@ dependencies = [
"tree-sitter-rust",
"tree-sitter-typescript",
"ui",
"unicode-segmentation",
"unindent",
"url",
"util",
@@ -4908,12 +4910,13 @@ dependencies = [
"git",
"gpui",
"http_client",
"indoc",
"pretty_assertions",
"regex",
"serde",
"serde_json",
"unindent",
"url",
"util",
]
[[package]]
@@ -5567,9 +5570,9 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
name = "hyper"
version = "0.14.30"
version = "0.14.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9"
checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85"
dependencies = [
"bytes 1.7.2",
"futures-channel",
@@ -5617,7 +5620,7 @@ checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [
"futures-util",
"http 0.2.12",
"hyper 0.14.30",
"hyper 0.14.31",
"log",
"rustls 0.21.12",
"rustls-native-certs 0.6.3",
@@ -5650,7 +5653,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes 1.7.2",
"hyper 0.14.30",
"hyper 0.14.31",
"native-tls",
"tokio",
"tokio-native-tls",
@@ -6152,6 +6155,20 @@ dependencies = [
"simple_asn1",
]
[[package]]
name = "jupyter-serde"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a444fb3f87ee6885eb316028cc998c7d84811663ef95d78c419419423d5a054"
dependencies = [
"anyhow",
"chrono",
"serde",
"serde_json",
"thiserror",
"uuid",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -7135,6 +7152,21 @@ dependencies = [
"tempfile",
]
[[package]]
name = "nbformat"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146074ad45cab20f5d98ccded164826158471f21d04f96e40b9872529e10979d"
dependencies = [
"anyhow",
"chrono",
"jupyter-serde",
"serde",
"serde_json",
"thiserror",
"uuid",
]
[[package]]
name = "ndk"
version = "0.8.0"
@@ -9522,6 +9554,7 @@ dependencies = [
"async-watch",
"backtrace",
"cargo_toml",
"chrono",
"clap",
"client",
"clock",
@@ -9541,6 +9574,8 @@ dependencies = [
"node_runtime",
"paths",
"project",
"proto",
"release_channel",
"remote",
"reqwest_client",
"rpc",
@@ -9550,6 +9585,7 @@ dependencies = [
"settings",
"shellexpand 2.1.2",
"smol",
"telemetry_events",
"toml 0.8.19",
"util",
"worktree",
@@ -9577,6 +9613,7 @@ dependencies = [
"command_palette_hooks",
"editor",
"env_logger 0.11.5",
"feature_flags",
"futures 0.3.30",
"gpui",
"http_client",
@@ -9586,7 +9623,9 @@ dependencies = [
"languages",
"log",
"markdown_preview",
"menu",
"multi_buffer",
"nbformat",
"project",
"runtimelib",
"schemars",
@@ -9621,7 +9660,7 @@ dependencies = [
"h2 0.3.26",
"http 0.2.12",
"http-body 0.4.6",
"hyper 0.14.30",
"hyper 0.14.31",
"hyper-tls",
"ipnet",
"js-sys",
@@ -9860,6 +9899,7 @@ dependencies = [
"gpui",
"log",
"rand 0.8.5",
"rayon",
"smallvec",
"sum_tree",
"unicode-segmentation",
@@ -9925,9 +9965,9 @@ dependencies = [
[[package]]
name = "runtimelib"
version = "0.15.0"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7d76d28b882a7b889ebb04e79bc2b160b3061821ea596ff0f4a838fc7a76db0"
checksum = "263588fe9593333c4bfde258c9021fc64e766ea434e070c6b67c7100536d6499"
dependencies = [
"anyhow",
"async-dispatcher",
@@ -9939,6 +9979,7 @@ dependencies = [
"dirs 5.0.1",
"futures 0.3.30",
"glob",
"jupyter-serde",
"rand 0.8.5",
"ring 0.17.8",
"serde",
@@ -13411,7 +13452,7 @@ dependencies = [
"futures-util",
"headers",
"http 0.2.12",
"hyper 0.14.30",
"hyper 0.14.31",
"log",
"mime",
"mime_guess",
@@ -14124,7 +14165,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -14729,6 +14770,7 @@ dependencies = [
"fuzzy",
"git",
"git2",
"git_hosting_providers",
"gpui",
"http_client",
"ignore",
@@ -14991,7 +15033,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.159.2"
version = "0.161.0"
dependencies = [
"activity_indicator",
"anyhow",
@@ -15056,6 +15098,7 @@ dependencies = [
"project",
"project_panel",
"project_symbols",
"proto",
"quick_action_bar",
"recent_projects",
"release_channel",
@@ -15130,13 +15173,6 @@ dependencies = [
"zed_extension_api 0.1.0",
]
[[package]]
name = "zed_dart"
version = "0.1.2"
dependencies = [
"zed_extension_api 0.1.0",
]
[[package]]
name = "zed_deno"
version = "0.0.2"

View File

@@ -138,7 +138,6 @@ members = [
"extensions/astro",
"extensions/clojure",
"extensions/csharp",
"extensions/dart",
"extensions/deno",
"extensions/elixir",
"extensions/elm",
@@ -371,6 +370,7 @@ linkify = "0.10.0"
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
markup5ever_rcdom = "0.3.0"
nanoid = "0.4"
nbformat = "0.3.1"
nix = "0.29"
num-format = "0.4.4"
once_cell = "1.19.0"
@@ -391,6 +391,7 @@ prost-build = "0.9"
prost-types = "0.9"
pulldown-cmark = { version = "0.12.0", default-features = false }
rand = "0.8.5"
rayon = "1.8"
regex = "1.5"
repair_json = "0.1.0"
reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "fd110f6998da16bbca97b6dddda9be7827c50e29", default-features = false, features = [
@@ -402,7 +403,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "fd110f
"stream",
] }
rsa = "0.9.6"
runtimelib = { version = "0.15", default-features = false, features = [
runtimelib = { version = "0.16.0", default-features = false, features = [
"async-dispatcher-runtime",
] }
rustc-demangle = "0.1.23"

View File

@@ -58,6 +58,7 @@
"gitignore": "vcs",
"gitkeep": "vcs",
"gitmodules": "vcs",
"gleam": "gleam",
"go": "go",
"gql": "graphql",
"graphql": "graphql",
@@ -83,6 +84,7 @@
"j2k": "image",
"java": "java",
"jfif": "image",
"jl": "julia",
"jp2": "image",
"jpeg": "image",
"jpg": "image",
@@ -90,7 +92,6 @@
"json": "storage",
"jsonc": "storage",
"jsx": "react",
"julia": "julia",
"jxl": "image",
"kt": "kotlin",
"ldf": "storage",
@@ -264,6 +265,9 @@
"fsharp": {
"icon": "icons/file_icons/fsharp.svg"
},
"gleam": {
"icon": "icons/file_icons/gleam.svg"
},
"go": {
"icon": "icons/file_icons/go.svg"
},

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" fill="black" d="M 3.828125 14.601562 C 3.894531 15.726562 5.183594 16.375 6.132812 15.785156 L 6.136719 15.785156 L 8.988281 13.824219 C 8.996094 13.816406 9.007812 13.8125 9.015625 13.804688 C 9.203125 13.675781 9.4375 13.636719 9.65625 13.691406 L 12.988281 14.550781 C 14.105469 14.839844 15.140625 13.769531 14.8125 12.667969 L 13.832031 9.386719 C 13.769531 9.167969 13.800781 8.9375 13.921875 8.75 C 13.921875 8.746094 13.925781 8.746094 13.925781 8.746094 L 15.777344 5.863281 L 15.777344 5.859375 C 15.78125 5.851562 15.785156 5.84375 15.789062 5.835938 L 15.792969 5.835938 C 16.382812 4.871094 15.6875 3.582031 14.542969 3.554688 L 11.109375 3.472656 C 10.878906 3.464844 10.664062 3.359375 10.519531 3.183594 L 8.339844 0.542969 C 8.019531 0.152344 7.550781 -0.015625 7.105469 0.0078125 L 7.101562 0.0078125 C 7.039062 0.0117188 6.976562 0.0195312 6.914062 0.0273438 C 6.414062 0.117188 5.945312 0.453125 5.75 1 L 4.609375 4.222656 C 4.535156 4.4375 4.367188 4.613281 4.152344 4.695312 L 0.957031 5.945312 C -0.121094 6.363281 -0.328125 7.835938 0.589844 8.535156 L 3.316406 10.609375 C 3.5 10.75 3.609375 10.960938 3.625 11.191406 Z M 7.515625 1.847656 C 7.421875 1.730469 7.296875 1.695312 7.183594 1.714844 C 7.066406 1.734375 6.960938 1.8125 6.914062 1.953125 L 5.867188 4.902344 C 5.699219 5.382812 5.328125 5.765625 4.851562 5.949219 L 1.925781 7.09375 C 1.785156 7.148438 1.710938 7.253906 1.695312 7.371094 C 1.679688 7.484375 1.71875 7.605469 1.839844 7.695312 L 4.335938 9.597656 C 4.742188 9.90625 4.992188 10.375 5.023438 10.882812 L 5.207031 14.003906 C 5.214844 14.152344 5.296875 14.253906 5.398438 14.304688 C 5.503906 14.355469 5.632812 14.355469 5.757812 14.269531 L 8.347656 12.492188 C 8.765625 12.207031 9.292969 12.113281 9.785156 12.242188 L 12.824219 13.027344 C 12.972656 13.066406 13.09375 13.023438 13.175781 12.9375 C 13.257812 12.855469 13.296875 12.734375 13.253906 12.589844 L 12.355469 9.589844 C 12.210938 9.105469 12.285156 8.578125 12.558594 8.148438 L 14.253906 5.511719 C 14.335938 5.386719 14.332031 5.257812 14.277344 5.15625 C 14.222656 5.054688 14.117188 4.980469 13.964844 4.976562 L 10.824219 4.902344 C 10.316406 4.886719 9.835938 4.65625 9.511719 4.261719 Z M 7.515625 1.847656 "/>
<path fill="black" d="M 5.71875 7.257812 C 5.671875 7.25 5.628906 7.246094 5.582031 7.246094 C 5.09375 7.246094 4.695312 7.644531 4.695312 8.128906 C 4.695312 8.613281 5.09375 9.011719 5.582031 9.011719 C 6.070312 9.011719 6.46875 8.613281 6.46875 8.128906 C 6.46875 7.6875 6.140625 7.320312 5.71875 7.257812 Z M 5.71875 7.257812 "/>
<path fill="black" d="M 11.019531 7.953125 C 10.976562 7.957031 10.929688 7.960938 10.886719 7.960938 C 10.398438 7.960938 10 7.5625 10 7.078125 C 10 6.59375 10.398438 6.195312 10.886719 6.195312 C 11.371094 6.195312 11.773438 6.59375 11.773438 7.078125 C 11.773438 7.519531 11.445312 7.886719 11.019531 7.953125 Z M 11.019531 7.953125 "/>
<path fill="black" d="M 7.269531 9.089844 C 7.53125 8.988281 7.828125 9.113281 7.933594 9.375 C 8.125 9.859375 8.503906 9.996094 8.796875 9.949219 C 9.082031 9.898438 9.378906 9.664062 9.378906 9.136719 C 9.378906 8.855469 9.605469 8.628906 9.886719 8.628906 C 10.167969 8.628906 10.398438 8.855469 10.398438 9.136719 C 10.398438 10.140625 9.757812 10.816406 8.96875 10.949219 C 8.1875 11.078125 7.351562 10.664062 6.988281 9.75 C 6.882812 9.488281 7.011719 9.195312 7.269531 9.089844 Z M 7.269531 9.089844 "/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

7
assets/icons/list_x.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.33333 8H3" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.6667 4H3" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.6667 12H3" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.6667 6.66663L11 9.33329" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 6.66663L13.6667 9.33329" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 579 B

View File

@@ -532,6 +532,7 @@
"context": "ContextEditor > Editor",
"bindings": {
"ctrl-enter": "assistant::Assist",
"ctrl-shift-enter": "assistant::Edit",
"ctrl-s": "workspace::Save",
"ctrl->": "assistant::QuoteSelection",
"ctrl-<": "assistant::InsertIntoEditor",

View File

@@ -201,6 +201,7 @@
"context": "ContextEditor > Editor",
"bindings": {
"cmd-enter": "assistant::Assist",
"cmd-shift-enter": "assistant::Edit",
"cmd-s": "workspace::Save",
"cmd->": "assistant::QuoteSelection",
"cmd-<": "assistant::InsertIntoEditor",
@@ -349,6 +350,7 @@
"alt-cmd-]": "editor::UnfoldLines",
"cmd-k cmd-l": "editor::ToggleFold",
"cmd-k cmd-[": "editor::FoldRecursive",
"cmd-k cmd-]": "editor::UnfoldRecursive",
"cmd-k cmd-1": ["editor::FoldAtLevel", { "level": 1 }],
"cmd-k cmd-2": ["editor::FoldAtLevel", { "level": 2 }],
"cmd-k cmd-3": ["editor::FoldAtLevel", { "level": 3 }],

View File

@@ -346,8 +346,6 @@
"git_status": true,
// Amount of indentation for nested items.
"indent_size": 20,
// Whether to show indent guides in the project panel.
"indent_guides": true,
// Whether to reveal it in the project panel automatically,
// when a corresponding project entry becomes active.
// Gitignored entries are never auto revealed.
@@ -371,6 +369,17 @@
/// 5. Never show the scrollbar:
/// "never"
"show": null
},
// Settings related to indent guides in the project panel.
"indent_guides": {
// When to show indent guides in the project panel.
// This setting can take two values:
//
// 1. Always show indent guides:
// "always"
// 2. Never show indent guides:
// "never"
"show": "always"
}
},
"outline_panel": {
@@ -388,15 +397,24 @@
"git_status": true,
// Amount of indentation for nested items.
"indent_size": 20,
// Whether to show indent guides in the outline panel.
"indent_guides": true,
// Whether to reveal it in the outline panel automatically,
// when a corresponding outline entry becomes active.
// Gitignored entries are never auto revealed.
"auto_reveal_entries": true,
/// Whether to fold directories automatically
/// when a directory has only one directory inside.
"auto_fold_dirs": true
"auto_fold_dirs": true,
// Settings related to indent guides in the outline panel.
"indent_guides": {
// When to show indent guides in the outline panel.
// This setting can take two values:
//
// 1. Always show indent guides:
// "always"
// 2. Never show indent guides:
// "never"
"show": "always"
}
},
"collaboration_panel": {
// Whether to show the collaboration panel button in the status bar.

View File

@@ -23,6 +23,7 @@ language.workspace = true
project.workspace = true
smallvec.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
[dev-dependencies]

View File

@@ -13,7 +13,8 @@ use language::{
use project::{EnvironmentErrorMessage, LanguageServerProgress, Project, WorktreeId};
use smallvec::SmallVec;
use std::{cmp::Reverse, fmt::Write, sync::Arc, time::Duration};
use ui::{prelude::*, ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle};
use ui::{prelude::*, ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip};
use util::truncate_and_trailoff;
use workspace::{item::ItemHandle, StatusItemView, Workspace};
actions!(activity_indicator, [ShowErrorMessage]);
@@ -446,6 +447,8 @@ impl ActivityIndicator {
impl EventEmitter<Event> for ActivityIndicator {}
const MAX_MESSAGE_LEN: usize = 50;
impl Render for ActivityIndicator {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let result = h_flex()
@@ -456,6 +459,7 @@ impl Render for ActivityIndicator {
return result;
};
let this = cx.view().downgrade();
let truncate_content = content.message.len() > MAX_MESSAGE_LEN;
result.gap_2().child(
PopoverMenu::new("activity-indicator-popover")
.trigger(
@@ -464,7 +468,21 @@ impl Render for ActivityIndicator {
.id("activity-indicator-status")
.gap_2()
.children(content.icon)
.child(Label::new(content.message).size(LabelSize::Small))
.map(|button| {
if truncate_content {
button
.child(
Label::new(truncate_and_trailoff(
&content.message,
MAX_MESSAGE_LEN,
))
.size(LabelSize::Small),
)
.tooltip(move |cx| Tooltip::text(&content.message, cx))
} else {
button.child(Label::new(content.message).size(LabelSize::Small))
}
})
.when_some(content.on_click, |this, handler| {
this.on_click(cx.listener(move |this, _, cx| {
handler(this, cx);

View File

@@ -41,12 +41,10 @@ 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,
prompt_command, search_command, symbols_command, tab_command, terminal_command,
workflow_command,
};
use std::path::PathBuf;
use std::sync::Arc;
@@ -59,6 +57,7 @@ actions!(
assistant,
[
Assist,
Edit,
Split,
CopyCode,
CycleMessageRole,
@@ -444,22 +443,6 @@ fn register_slash_commands(prompt_builder: Option<Arc<PromptBuilder>>, cx: &mut
slash_command_registry.register_command(fetch_command::FetchSlashCommand, false);
if let Some(prompt_builder) = prompt_builder {
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| {

View File

@@ -13,10 +13,11 @@ use crate::{
terminal_inline_assistant::TerminalInlineAssistant,
Assist, AssistantPatch, AssistantPatchStatus, CacheStatus, ConfirmCommand, Content, Context,
ContextEvent, ContextId, ContextStore, ContextStoreEvent, CopyCode, CycleMessageRole,
DeployHistory, DeployPromptLibrary, InlineAssistant, InsertDraggedFiles, InsertIntoEditor,
Message, MessageId, MessageMetadata, MessageStatus, ModelPickerDelegate, ModelSelector,
NewContext, PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection,
RemoteContextMetadata, SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector,
DeployHistory, DeployPromptLibrary, Edit, InlineAssistant, InsertDraggedFiles,
InsertIntoEditor, Message, MessageId, MessageMetadata, MessageStatus, ModelPickerDelegate,
ModelSelector, NewContext, PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection,
RemoteContextMetadata, RequestType, SavedContextMetadata, Split, ToggleFocus,
ToggleModelSelector,
};
use anyhow::Result;
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection};
@@ -1461,6 +1462,7 @@ type MessageHeader = MessageMetadata;
#[derive(Clone)]
enum AssistError {
FileRequired,
PaymentRequired,
MaxMonthlySpendReached,
Message(SharedString),
@@ -1588,23 +1590,11 @@ impl ContextEditor {
}
fn assist(&mut self, _: &Assist, cx: &mut ViewContext<Self>) {
let provider = LanguageModelRegistry::read_global(cx).active_provider();
if provider
.as_ref()
.map_or(false, |provider| provider.must_accept_terms(cx))
{
self.show_accept_terms = true;
cx.notify();
return;
}
self.send_to_model(RequestType::Chat, cx);
}
if self.focus_active_patch(cx) {
return;
}
self.last_error = None;
self.send_to_model(cx);
cx.notify();
fn edit(&mut self, _: &Edit, cx: &mut ViewContext<Self>) {
self.send_to_model(RequestType::SuggestEdits, cx);
}
fn focus_active_patch(&mut self, cx: &mut ViewContext<Self>) -> bool {
@@ -1622,8 +1612,30 @@ impl ContextEditor {
false
}
fn send_to_model(&mut self, cx: &mut ViewContext<Self>) {
if let Some(user_message) = self.context.update(cx, |context, cx| context.assist(cx)) {
fn send_to_model(&mut self, request_type: RequestType, cx: &mut ViewContext<Self>) {
let provider = LanguageModelRegistry::read_global(cx).active_provider();
if provider
.as_ref()
.map_or(false, |provider| provider.must_accept_terms(cx))
{
self.show_accept_terms = true;
cx.notify();
return;
}
if self.focus_active_patch(cx) {
return;
}
self.last_error = None;
if request_type == RequestType::SuggestEdits && !self.context.read(cx).contains_files(cx) {
self.last_error = Some(AssistError::FileRequired);
cx.notify();
} else if let Some(user_message) = self
.context
.update(cx, |context, cx| context.assist(request_type, cx))
{
let new_selection = {
let cursor = user_message
.start
@@ -1640,6 +1652,8 @@ impl ContextEditor {
// Avoid scrolling to the new cursor position so the assistant's output is stable.
cx.defer(|this, _| this.scroll_position = None);
}
cx.notify();
}
fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext<Self>) {
@@ -1667,8 +1681,10 @@ impl ContextEditor {
});
}
fn cursors(&self, cx: &AppContext) -> Vec<usize> {
let selections = self.editor.read(cx).selections.all::<usize>(cx);
fn cursors(&self, cx: &mut WindowContext) -> Vec<usize> {
let selections = self
.editor
.update(cx, |editor, cx| editor.selections.all::<usize>(cx));
selections
.into_iter()
.map(|selection| selection.head())
@@ -2375,7 +2391,9 @@ impl ContextEditor {
}
fn update_active_patch(&mut self, cx: &mut ViewContext<Self>) {
let newest_cursor = self.editor.read(cx).selections.newest::<Point>(cx).head();
let newest_cursor = self.editor.update(cx, |editor, cx| {
editor.selections.newest::<Point>(cx).head()
});
let context = self.context.read(cx);
let new_patch = context.patch_containing(newest_cursor, cx).cloned();
@@ -2782,39 +2800,40 @@ impl ContextEditor {
) -> Option<(String, bool)> {
const CODE_FENCE_DELIMITER: &'static str = "```";
let context_editor = context_editor_view.read(cx).editor.read(cx);
let context_editor = context_editor_view.read(cx).editor.clone();
context_editor.update(cx, |context_editor, cx| {
if context_editor.selections.newest::<Point>(cx).is_empty() {
let snapshot = context_editor.buffer().read(cx).snapshot(cx);
let (_, _, snapshot) = snapshot.as_singleton()?;
if context_editor.selections.newest::<Point>(cx).is_empty() {
let snapshot = context_editor.buffer().read(cx).snapshot(cx);
let (_, _, snapshot) = snapshot.as_singleton()?;
let head = context_editor.selections.newest::<Point>(cx).head();
let offset = snapshot.point_to_offset(head);
let head = context_editor.selections.newest::<Point>(cx).head();
let offset = snapshot.point_to_offset(head);
let surrounding_code_block_range = find_surrounding_code_block(snapshot, offset)?;
let mut text = snapshot
.text_for_range(surrounding_code_block_range)
.collect::<String>();
let surrounding_code_block_range = find_surrounding_code_block(snapshot, offset)?;
let mut text = snapshot
.text_for_range(surrounding_code_block_range)
.collect::<String>();
// If there is no newline trailing the closing three-backticks, then
// tree-sitter-md extends the range of the content node to include
// the backticks.
if text.ends_with(CODE_FENCE_DELIMITER) {
text.drain((text.len() - CODE_FENCE_DELIMITER.len())..);
}
// If there is no newline trailing the closing three-backticks, then
// tree-sitter-md extends the range of the content node to include
// the backticks.
if text.ends_with(CODE_FENCE_DELIMITER) {
text.drain((text.len() - CODE_FENCE_DELIMITER.len())..);
(!text.is_empty()).then_some((text, true))
} else {
let anchor = context_editor.selections.newest_anchor();
let text = context_editor
.buffer()
.read(cx)
.read(cx)
.text_for_range(anchor.range())
.collect::<String>();
(!text.is_empty()).then_some((text, false))
}
(!text.is_empty()).then_some((text, true))
} else {
let anchor = context_editor.selections.newest_anchor();
let text = context_editor
.buffer()
.read(cx)
.read(cx)
.text_for_range(anchor.range())
.collect::<String>();
(!text.is_empty()).then_some((text, false))
}
})
}
fn insert_selection(
@@ -3644,7 +3663,13 @@ impl ContextEditor {
button.tooltip(move |_| tooltip.clone())
})
.layer(ElevationIndex::ModalSurface)
.child(Label::new("Send"))
.child(Label::new(
if AssistantSettings::get_global(cx).are_live_diffs_enabled(cx) {
"Chat"
} else {
"Send"
},
))
.children(
KeyBinding::for_action_in(&Assist, &focus_handle, cx)
.map(|binding| binding.into_any_element()),
@@ -3654,6 +3679,57 @@ impl ContextEditor {
})
}
fn render_edit_button(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle(cx).clone();
let (style, tooltip) = match token_state(&self.context, cx) {
Some(TokenState::NoTokensLeft { .. }) => (
ButtonStyle::Tinted(TintColor::Negative),
Some(Tooltip::text("Token limit reached", cx)),
),
Some(TokenState::HasMoreTokens {
over_warn_threshold,
..
}) => {
let (style, tooltip) = if over_warn_threshold {
(
ButtonStyle::Tinted(TintColor::Warning),
Some(Tooltip::text("Token limit is close to exhaustion", cx)),
)
} else {
(ButtonStyle::Filled, None)
};
(style, tooltip)
}
None => (ButtonStyle::Filled, None),
};
let provider = LanguageModelRegistry::read_global(cx).active_provider();
let has_configuration_error = configuration_error(cx).is_some();
let needs_to_accept_terms = self.show_accept_terms
&& provider
.as_ref()
.map_or(false, |provider| provider.must_accept_terms(cx));
let disabled = has_configuration_error || needs_to_accept_terms;
ButtonLike::new("edit_button")
.disabled(disabled)
.style(style)
.when_some(tooltip, |button, tooltip| {
button.tooltip(move |_| tooltip.clone())
})
.layer(ElevationIndex::ModalSurface)
.child(Label::new("Suggest Edits"))
.children(
KeyBinding::for_action_in(&Edit, &focus_handle, cx)
.map(|binding| binding.into_any_element()),
)
.on_click(move |_event, cx| {
focus_handle.dispatch_action(&Edit, cx);
})
}
fn render_last_error(&self, cx: &mut ViewContext<Self>) -> Option<AnyElement> {
let last_error = self.last_error.as_ref()?;
@@ -3668,6 +3744,7 @@ impl ContextEditor {
.elevation_2(cx)
.occlude()
.child(match last_error {
AssistError::FileRequired => self.render_file_required_error(cx),
AssistError::PaymentRequired => self.render_payment_required_error(cx),
AssistError::MaxMonthlySpendReached => {
self.render_max_monthly_spend_reached_error(cx)
@@ -3680,6 +3757,41 @@ impl ContextEditor {
)
}
fn render_file_required_error(&self, cx: &mut ViewContext<Self>) -> AnyElement {
v_flex()
.gap_0p5()
.child(
h_flex()
.gap_1p5()
.items_center()
.child(Icon::new(IconName::Warning).color(Color::Warning))
.child(
Label::new("Suggest Edits needs a file to edit").weight(FontWeight::MEDIUM),
),
)
.child(
div()
.id("error-message")
.max_h_24()
.overflow_y_scroll()
.child(Label::new(
"To include files, type /file or /tab in your prompt.",
)),
)
.child(
h_flex()
.justify_end()
.mt_1()
.child(Button::new("dismiss", "Dismiss").on_click(cx.listener(
|this, _, cx| {
this.last_error = None;
cx.notify();
},
))),
)
.into_any()
}
fn render_payment_required_error(&self, cx: &mut ViewContext<Self>) -> AnyElement {
const ERROR_MESSAGE: &str = "Free tier exceeded. Subscribe and add payment to continue using Zed LLMs. You'll be billed at cost for tokens used.";
@@ -3910,6 +4022,7 @@ impl Render for ContextEditor {
.capture_action(cx.listener(ContextEditor::paste))
.capture_action(cx.listener(ContextEditor::cycle_message_role))
.capture_action(cx.listener(ContextEditor::confirm_command))
.on_action(cx.listener(ContextEditor::edit))
.on_action(cx.listener(ContextEditor::assist))
.on_action(cx.listener(ContextEditor::split))
.size_full()
@@ -3974,7 +4087,21 @@ impl Render for ContextEditor {
h_flex()
.w_full()
.justify_end()
.child(div().child(self.render_send_button(cx))),
.when(
AssistantSettings::get_global(cx).are_live_diffs_enabled(cx),
|buttons| {
buttons
.items_center()
.gap_1p5()
.child(self.render_edit_button(cx))
.child(
Label::new("or")
.size(LabelSize::Small)
.color(Color::Muted),
)
},
)
.child(self.render_send_button(cx)),
),
),
)

View File

@@ -2,8 +2,9 @@
mod context_tests;
use crate::{
prompts::PromptBuilder, slash_command::SlashCommandLine, AssistantEdit, AssistantPatch,
AssistantPatchStatus, MessageId, MessageStatus,
prompts::PromptBuilder,
slash_command::{file_command::FileCommandMetadata, SlashCommandLine},
AssistantEdit, AssistantPatch, AssistantPatchStatus, MessageId, MessageStatus,
};
use anyhow::{anyhow, Context as _, Result};
use assistant_slash_command::{
@@ -66,6 +67,14 @@ impl ContextId {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RequestType {
/// Request a normal chat response from the model.
Chat,
/// Add a preamble to the message, which tells the model to return a structured response that suggests edits.
SuggestEdits,
}
#[derive(Clone, Debug)]
pub enum ContextOperation {
InsertMessage {
@@ -981,6 +990,20 @@ impl Context {
&self.slash_command_output_sections
}
pub fn contains_files(&self, cx: &AppContext) -> bool {
let buffer = self.buffer.read(cx);
self.slash_command_output_sections.iter().any(|section| {
section.is_valid(buffer)
&& section
.metadata
.as_ref()
.and_then(|metadata| {
serde_json::from_value::<FileCommandMetadata>(metadata.clone()).ok()
})
.is_some()
})
}
pub fn pending_tool_uses(&self) -> Vec<&PendingToolUse> {
self.pending_tool_uses_by_id.values().collect()
}
@@ -1028,7 +1051,7 @@ impl Context {
}
pub(crate) fn count_remaining_tokens(&mut self, cx: &mut ModelContext<Self>) {
let request = self.to_completion_request(cx);
let request = self.to_completion_request(RequestType::SuggestEdits, cx); // Conservatively assume SuggestEdits, since it takes more tokens.
let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
return;
};
@@ -1171,7 +1194,7 @@ impl Context {
}
let request = {
let mut req = self.to_completion_request(cx);
let mut req = self.to_completion_request(RequestType::Chat, cx);
// Skip the last message because it's likely to change and
// therefore would be a waste to cache.
req.messages.pop();
@@ -1859,7 +1882,11 @@ impl Context {
})
}
pub fn assist(&mut self, cx: &mut ModelContext<Self>) -> Option<MessageAnchor> {
pub fn assist(
&mut self,
request_type: RequestType,
cx: &mut ModelContext<Self>,
) -> Option<MessageAnchor> {
let model_registry = LanguageModelRegistry::read_global(cx);
let provider = model_registry.active_provider()?;
let model = model_registry.active_model()?;
@@ -1872,7 +1899,7 @@ impl Context {
// Compute which messages to cache, including the last one.
self.mark_cache_anchors(&model.cache_configuration(), false, cx);
let mut request = self.to_completion_request(cx);
let mut request = self.to_completion_request(request_type, cx);
if cx.has_flag::<ToolUseFeatureFlag>() {
let tool_registry = ToolRegistry::global(cx);
@@ -2074,7 +2101,11 @@ impl Context {
Some(user_message)
}
pub fn to_completion_request(&self, cx: &AppContext) -> LanguageModelRequest {
pub fn to_completion_request(
&self,
request_type: RequestType,
cx: &AppContext,
) -> LanguageModelRequest {
let buffer = self.buffer.read(cx);
let mut contents = self.contents(cx).peekable();
@@ -2163,6 +2194,25 @@ impl Context {
completion_request.messages.push(request_message);
}
if let RequestType::SuggestEdits = request_type {
if let Ok(preamble) = self.prompt_builder.generate_workflow_prompt() {
let last_elem_index = completion_request.messages.len();
completion_request
.messages
.push(LanguageModelRequestMessage {
role: Role::User,
content: vec![MessageContent::Text(preamble)],
cache: false,
});
// The preamble message should be sent right before the last actual user message.
completion_request
.messages
.swap(last_elem_index, last_elem_index.saturating_sub(1));
}
}
completion_request
}
@@ -2477,7 +2527,7 @@ impl Context {
return;
}
let mut request = self.to_completion_request(cx);
let mut request = self.to_completion_request(RequestType::Chat, cx);
request.messages.push(LanguageModelRequestMessage {
role: Role::User,
content: vec![

View File

@@ -1,7 +1,7 @@
use crate::{
assistant_settings::AssistantSettings, humanize_token_count, prompts::PromptBuilder,
AssistantPanel, AssistantPanelEvent, CharOperation, CycleNextInlineAssist,
CyclePreviousInlineAssist, LineDiff, LineOperation, ModelSelector, StreamingDiff,
CyclePreviousInlineAssist, LineDiff, LineOperation, ModelSelector, RequestType, StreamingDiff,
};
use anyhow::{anyhow, Context as _, Result};
use client::{telemetry::Telemetry, ErrorExt};
@@ -189,11 +189,16 @@ impl InlineAssistant {
initial_prompt: Option<String>,
cx: &mut WindowContext,
) {
let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
let (snapshot, initial_selections) = editor.update(cx, |editor, cx| {
(
editor.buffer().read(cx).snapshot(cx),
editor.selections.all::<Point>(cx),
)
});
let mut selections = Vec::<Selection<Point>>::new();
let mut newest_selection = None;
for mut selection in editor.read(cx).selections.all::<Point>(cx) {
for mut selection in initial_selections {
if selection.end > selection.start {
selection.start.column = 0;
// If the selection ends at the start of the line, we don't want to include it.
@@ -566,10 +571,13 @@ impl InlineAssistant {
return;
};
let editor = editor.read(cx);
if editor.selections.count() == 1 {
let selection = editor.selections.newest::<usize>(cx);
let buffer = editor.buffer().read(cx).snapshot(cx);
if editor.read(cx).selections.count() == 1 {
let (selection, buffer) = editor.update(cx, |editor, cx| {
(
editor.selections.newest::<usize>(cx),
editor.buffer().read(cx).snapshot(cx),
)
});
for assist_id in &editor_assists.assist_ids {
let assist = &self.assists[assist_id];
let assist_range = assist.range.to_offset(&buffer);
@@ -594,10 +602,13 @@ impl InlineAssistant {
return;
};
let editor = editor.read(cx);
if editor.selections.count() == 1 {
let selection = editor.selections.newest::<usize>(cx);
let buffer = editor.buffer().read(cx).snapshot(cx);
if editor.read(cx).selections.count() == 1 {
let (selection, buffer) = editor.update(cx, |editor, cx| {
(
editor.selections.newest::<usize>(cx),
editor.buffer().read(cx).snapshot(cx),
)
});
let mut closest_assist_fallback = None;
for assist_id in &editor_assists.assist_ids {
let assist = &self.assists[assist_id];
@@ -2234,7 +2245,7 @@ impl InlineAssist {
.read(cx)
.active_context(cx)?
.read(cx)
.to_completion_request(cx),
.to_completion_request(RequestType::Chat, cx),
)
} else {
None

View File

@@ -311,7 +311,7 @@ impl PromptBuilder {
}
pub fn generate_workflow_prompt(&self) -> Result<String, RenderError> {
self.handlebars.lock().render("edit_workflow", &())
self.handlebars.lock().render("suggest_edits", &())
}
pub fn generate_project_slash_command_prompt(

View File

@@ -34,7 +34,6 @@ pub mod search_command;
pub mod symbols_command;
pub mod tab_command;
pub mod terminal_command;
pub mod workflow_command;
pub(crate) struct SlashCommandCompletionProvider {
cancel_flag: Mutex<Arc<AtomicBool>>,

View File

@@ -1,82 +0,0 @@
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use anyhow::Result;
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use gpui::{Task, WeakView};
use language::{BufferSnapshot, LspAdapterDelegate};
use ui::prelude::*;
use workspace::Workspace;
use crate::prompts::PromptBuilder;
pub(crate) struct WorkflowSlashCommand {
prompt_builder: Arc<PromptBuilder>,
}
impl WorkflowSlashCommand {
pub const NAME: &'static str = "workflow";
pub fn new(prompt_builder: Arc<PromptBuilder>) -> Self {
Self { prompt_builder }
}
}
impl SlashCommand for WorkflowSlashCommand {
fn name(&self) -> String {
Self::NAME.into()
}
fn description(&self) -> String {
"Insert prompt to opt into the edit workflow".into()
}
fn menu_text(&self) -> String {
self.description()
}
fn requires_argument(&self) -> bool {
false
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancel: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Ok(Vec::new()))
}
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let prompt_builder = self.prompt_builder.clone();
cx.spawn(|_cx| async move {
let text = prompt_builder.generate_workflow_prompt()?;
let range = 0..text.len();
Ok(SlashCommandOutput {
text,
sections: vec![SlashCommandOutputSection {
range,
icon: IconName::Route,
label: "Workflow".into(),
metadata: None,
}],
run_commands_in_text: false,
}
.to_event_stream())
})
}
}

View File

@@ -1,6 +1,6 @@
use crate::{
humanize_token_count, prompts::PromptBuilder, AssistantPanel, AssistantPanelEvent,
ModelSelector, DEFAULT_CONTEXT_LINES,
ModelSelector, RequestType, DEFAULT_CONTEXT_LINES,
};
use anyhow::{Context as _, Result};
use client::telemetry::Telemetry;
@@ -251,7 +251,7 @@ impl TerminalInlineAssistant {
.read(cx)
.active_context(cx)?
.read(cx)
.to_completion_request(cx),
.to_completion_request(RequestType::Chat, cx),
)
})
} else {

View File

@@ -84,9 +84,9 @@ pub struct AutoUpdater {
}
#[derive(Deserialize)]
struct JsonRelease {
version: String,
url: String,
pub struct JsonRelease {
pub version: String,
pub url: String,
}
struct MacOsUnmounter {
@@ -482,7 +482,7 @@ impl AutoUpdater {
release_channel: ReleaseChannel,
version: Option<SemanticVersion>,
cx: &mut AsyncAppContext,
) -> Result<(String, String)> {
) -> Result<(JsonRelease, String)> {
let this = cx.update(|cx| {
cx.default_global::<GlobalAutoUpdate>()
.0
@@ -504,7 +504,7 @@ impl AutoUpdater {
let update_request_body = build_remote_server_update_request_body(cx)?;
let body = serde_json::to_string(&update_request_body)?;
Ok((release.url, body))
Ok((release, body))
}
async fn get_release(

View File

@@ -48,6 +48,7 @@ pub struct Collaborator {
pub peer_id: proto::PeerId,
pub replica_id: ReplicaId,
pub user_id: UserId,
pub is_host: bool,
}
impl PartialOrd for User {
@@ -824,6 +825,7 @@ impl Collaborator {
peer_id: message.peer_id.ok_or_else(|| anyhow!("invalid peer id"))?,
replica_id: message.replica_id as ReplicaId,
user_id: message.user_id as UserId,
is_host: message.is_host,
})
}
}

View File

@@ -740,6 +740,7 @@ impl ProjectCollaborator {
peer_id: Some(self.connection_id.into()),
replica_id: self.replica_id.0 as u32,
user_id: self.user_id.to_proto(),
is_host: self.is_host,
}
}
}

View File

@@ -116,6 +116,7 @@ impl Database {
peer_id: Some(collaborator.connection().into()),
user_id: collaborator.user_id.to_proto(),
replica_id: collaborator.replica_id.0 as u32,
is_host: false,
})
.collect(),
})
@@ -222,6 +223,7 @@ impl Database {
peer_id: Some(collaborator.connection().into()),
user_id: collaborator.user_id.to_proto(),
replica_id: collaborator.replica_id.0 as u32,
is_host: false,
})
.collect(),
},
@@ -257,6 +259,7 @@ impl Database {
peer_id: Some(db_collaborator.connection().into()),
replica_id: db_collaborator.replica_id.0 as u32,
user_id: db_collaborator.user_id.to_proto(),
is_host: false,
})
} else {
collaborator_ids_to_remove.push(db_collaborator.id);
@@ -385,6 +388,7 @@ impl Database {
peer_id: Some(connection.into()),
replica_id: row.replica_id.0 as u32,
user_id: row.user_id.to_proto(),
is_host: false,
});
}

View File

@@ -121,11 +121,13 @@ async fn test_channel_buffers(db: &Arc<Database>) {
user_id: a_id.to_proto(),
peer_id: Some(rpc::proto::PeerId { id: 1, owner_id }),
replica_id: 0,
is_host: false,
},
rpc::proto::Collaborator {
user_id: b_id.to_proto(),
peer_id: Some(rpc::proto::PeerId { id: 2, owner_id }),
replica_id: 1,
is_host: false,
}
]
);

View File

@@ -1827,6 +1827,7 @@ fn join_project_internal(
peer_id: Some(session.connection_id.into()),
replica_id: replica_id.0 as u32,
user_id: guest_user_id.to_proto(),
is_host: false,
}),
};

View File

@@ -1978,6 +1978,7 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
enabled: false,
delay_ms: None,
min_column: None,
show_commit_summary: false,
});
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {

View File

@@ -1957,9 +1957,10 @@ async fn test_following_to_channel_notes_without_a_shared_project(
});
channel_notes_1_b.update(cx_b, |notes, cx| {
assert_eq!(notes.channel(cx).unwrap().name, "channel-1");
let editor = notes.editor.read(cx);
assert_eq!(editor.text(cx), "Hello from A.");
assert_eq!(editor.selections.ranges::<usize>(cx), &[3..4]);
notes.editor.update(cx, |editor, cx| {
assert_eq!(editor.text(cx), "Hello from A.");
assert_eq!(editor.selections.ranges::<usize>(cx), &[3..4]);
})
});
// Client A opens the notes for channel 2.

View File

@@ -21,8 +21,8 @@ use language::{
language_settings::{
AllLanguageSettings, Formatter, FormatterList, PrettierSettings, SelectedFormatter,
},
tree_sitter_rust, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language, LanguageConfig,
LanguageMatcher, LineEnding, OffsetRangeExt, Point, Rope,
tree_sitter_rust, tree_sitter_typescript, Diagnostic, DiagnosticEntry, FakeLspAdapter,
Language, LanguageConfig, LanguageMatcher, LineEnding, OffsetRangeExt, Point, Rope,
};
use live_kit_client::MacOSDisplay;
use lsp::LanguageServerId;
@@ -4461,7 +4461,7 @@ async fn test_prettier_formatting_buffer(
},
..Default::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
)));
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"TypeScript",

View File

@@ -1,14 +1,27 @@
use crate::tests::TestServer;
use call::ActiveCall;
use collections::HashSet;
use fs::{FakeFs, Fs as _};
use gpui::{BackgroundExecutor, Context as _, TestAppContext};
use futures::StreamExt as _;
use gpui::{BackgroundExecutor, Context as _, TestAppContext, UpdateGlobal as _};
use http_client::BlockedHttpClient;
use language::{language_settings::language_settings, LanguageRegistry};
use language::{
language_settings::{
language_settings, AllLanguageSettings, Formatter, FormatterList, PrettierSettings,
SelectedFormatter,
},
tree_sitter_typescript, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher,
LanguageRegistry,
};
use node_runtime::NodeRuntime;
use project::ProjectPath;
use project::{
lsp_store::{FormatTarget, FormatTrigger},
ProjectPath,
};
use remote::SshRemoteClient;
use remote_server::{HeadlessAppState, HeadlessProject};
use serde_json::json;
use settings::SettingsStore;
use std::{path::Path, sync::Arc};
#[gpui::test(iterations = 10)]
@@ -304,3 +317,181 @@ async fn test_ssh_collaboration_git_branches(
assert_eq!(server_branch.as_ref(), "totally-new-branch");
}
#[gpui::test]
async fn test_ssh_collaboration_formatting_with_prettier(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
server_cx: &mut TestAppContext,
) {
cx_a.set_name("a");
cx_b.set_name("b");
server_cx.set_name("server");
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
let remote_fs = FakeFs::new(server_cx.executor());
let buffer_text = "let one = \"two\"";
let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX;
remote_fs
.insert_tree("/project", serde_json::json!({ "a.ts": buffer_text }))
.await;
let test_plugin = "test_plugin";
let ts_lang = Arc::new(Language::new(
LanguageConfig {
name: "TypeScript".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["ts".to_string()],
..LanguageMatcher::default()
},
..LanguageConfig::default()
},
Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
));
client_a.language_registry().add(ts_lang.clone());
client_b.language_registry().add(ts_lang.clone());
let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
let mut fake_language_servers = languages.register_fake_lsp(
"TypeScript",
FakeLspAdapter {
prettier_plugins: vec![test_plugin],
..Default::default()
},
);
// User A connects to the remote project via SSH.
server_cx.update(HeadlessProject::init);
let remote_http_client = Arc::new(BlockedHttpClient);
let _headless_project = server_cx.new_model(|cx| {
client::init_settings(cx);
HeadlessProject::new(
HeadlessAppState {
session: server_ssh,
fs: remote_fs.clone(),
http_client: remote_http_client,
node_runtime: NodeRuntime::unavailable(),
languages,
},
cx,
)
});
let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
let (project_a, worktree_id) = client_a
.build_ssh_project("/project", client_ssh, cx_a)
.await;
// While the SSH worktree is being scanned, user A shares the remote project.
let active_call_a = cx_a.read(ActiveCall::global);
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
// User B joins the project.
let project_b = client_b.join_remote_project(project_id, cx_b).await;
executor.run_until_parked();
// Opens the buffer and formats it
let buffer_b = project_b
.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.ts"), cx))
.await
.expect("user B opens buffer for formatting");
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<AllLanguageSettings>(cx, |file| {
file.defaults.formatter = Some(SelectedFormatter::Auto);
file.defaults.prettier = Some(PrettierSettings {
allowed: true,
..PrettierSettings::default()
});
});
});
});
cx_b.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<AllLanguageSettings>(cx, |file| {
file.defaults.formatter = Some(SelectedFormatter::List(FormatterList(
vec![Formatter::LanguageServer { name: None }].into(),
)));
file.defaults.prettier = Some(PrettierSettings {
allowed: true,
..PrettierSettings::default()
});
});
});
});
let fake_language_server = fake_language_servers.next().await.unwrap();
fake_language_server.handle_request::<lsp::request::Formatting, _, _>(|_, _| async move {
panic!(
"Unexpected: prettier should be preferred since it's enabled and language supports it"
)
});
project_b
.update(cx_b, |project, cx| {
project.format(
HashSet::from_iter([buffer_b.clone()]),
true,
FormatTrigger::Save,
FormatTarget::Buffer,
cx,
)
})
.await
.unwrap();
executor.run_until_parked();
assert_eq!(
buffer_b.read_with(cx_b, |buffer, _| buffer.text()),
buffer_text.to_string() + "\n" + prettier_format_suffix,
"Prettier formatting was not applied to client buffer after client's request"
);
// User A opens and formats the same buffer too
let buffer_a = project_a
.update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.ts"), cx))
.await
.expect("user A opens buffer for formatting");
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<AllLanguageSettings>(cx, |file| {
file.defaults.formatter = Some(SelectedFormatter::Auto);
file.defaults.prettier = Some(PrettierSettings {
allowed: true,
..PrettierSettings::default()
});
});
});
});
project_a
.update(cx_a, |project, cx| {
project.format(
HashSet::from_iter([buffer_a.clone()]),
true,
FormatTrigger::Manual,
FormatTarget::Buffer,
cx,
)
})
.await
.unwrap();
executor.run_until_parked();
assert_eq!(
buffer_b.read_with(cx_b, |buffer, _| buffer.text()),
buffer_text.to_string() + "\n" + prettier_format_suffix + "\n" + prettier_format_suffix,
"Prettier formatting was not applied to client buffer after host's request"
);
}

View File

@@ -136,11 +136,12 @@ impl DiagnosticIndicator {
}
fn update(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
let editor = editor.read(cx);
let buffer = editor.buffer().read(cx);
let cursor_position = editor.selections.newest::<usize>(cx).head();
let (buffer, cursor_position) = editor.update(cx, |editor, cx| {
let buffer = editor.buffer().read(cx).snapshot(cx);
let cursor_position = editor.selections.newest::<usize>(cx).head();
(buffer, cursor_position)
});
let new_diagnostic = buffer
.snapshot(cx)
.diagnostics_in_range::<_, usize>(cursor_position..cursor_position, false)
.filter(|entry| !entry.range.is_empty())
.min_by_key(|entry| (entry.diagnostic.severity, entry.range.len()))

View File

@@ -76,6 +76,7 @@ theme.workspace = true
tree-sitter-html = { workspace = true, optional = true }
tree-sitter-rust = { workspace = true, optional = true }
tree-sitter-typescript = { workspace = true, optional = true }
unicode-segmentation.workspace = true
unindent = { workspace = true, optional = true }
ui.workspace = true
url.workspace = true

View File

@@ -21,6 +21,7 @@ mod block_map;
mod crease_map;
mod fold_map;
mod inlay_map;
pub(crate) mod invisibles;
mod tab_map;
mod wrap_map;
@@ -42,6 +43,7 @@ use gpui::{
pub(crate) use inlay_map::Inlay;
use inlay_map::{InlayMap, InlaySnapshot};
pub use inlay_map::{InlayOffset, InlayPoint};
use invisibles::{is_invisible, replacement};
use language::{
language_settings::language_settings, ChunkRenderer, OffsetUtf16, Point,
Subscription as BufferSubscription,
@@ -56,6 +58,7 @@ use std::{
any::TypeId,
borrow::Cow,
fmt::Debug,
iter,
num::NonZeroU32,
ops::{Add, Range, Sub},
sync::Arc,
@@ -63,7 +66,8 @@ use std::{
use sum_tree::{Bias, TreeMap};
use tab_map::{TabMap, TabSnapshot};
use text::LineIndent;
use ui::WindowContext;
use ui::{div, px, IntoElement, ParentElement, SharedString, Styled, WindowContext};
use unicode_segmentation::UnicodeSegmentation;
use wrap_map::{WrapMap, WrapSnapshot};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
@@ -461,6 +465,98 @@ pub struct HighlightedChunk<'a> {
pub renderer: Option<ChunkRenderer>,
}
impl<'a> HighlightedChunk<'a> {
fn highlight_invisibles(
self,
editor_style: &'a EditorStyle,
) -> impl Iterator<Item = Self> + 'a {
let mut chars = self.text.chars().peekable();
let mut text = self.text;
let style = self.style;
let is_tab = self.is_tab;
let renderer = self.renderer;
iter::from_fn(move || {
let mut prefix_len = 0;
while let Some(&ch) = chars.peek() {
if !is_invisible(ch) {
prefix_len += ch.len_utf8();
chars.next();
continue;
}
if prefix_len > 0 {
let (prefix, suffix) = text.split_at(prefix_len);
text = suffix;
return Some(HighlightedChunk {
text: prefix,
style,
is_tab,
renderer: renderer.clone(),
});
}
chars.next();
let (prefix, suffix) = text.split_at(ch.len_utf8());
text = suffix;
if let Some(replacement) = replacement(ch) {
let background = editor_style.status.hint_background;
let underline = editor_style.status.hint;
return Some(HighlightedChunk {
text: prefix,
style: None,
is_tab: false,
renderer: Some(ChunkRenderer {
render: Arc::new(move |_| {
div()
.child(replacement)
.bg(background)
.text_decoration_1()
.text_decoration_color(underline)
.into_any_element()
}),
constrain_width: false,
}),
});
} else {
let invisible_highlight = HighlightStyle {
background_color: Some(editor_style.status.hint_background),
underline: Some(UnderlineStyle {
color: Some(editor_style.status.hint),
thickness: px(1.),
wavy: false,
}),
..Default::default()
};
let invisible_style = if let Some(mut style) = style {
style.highlight(invisible_highlight);
style
} else {
invisible_highlight
};
return Some(HighlightedChunk {
text: prefix,
style: Some(invisible_style),
is_tab: false,
renderer: renderer.clone(),
});
}
}
if !text.is_empty() {
let remainder = text;
text = "";
Some(HighlightedChunk {
text: remainder,
style,
is_tab,
renderer: renderer.clone(),
})
} else {
None
}
})
}
}
#[derive(Clone)]
pub struct DisplaySnapshot {
pub buffer_snapshot: MultiBufferSnapshot,
@@ -564,6 +660,28 @@ impl DisplaySnapshot {
new_start..new_end
}
pub fn clip_buffer_points(
&self,
points: impl IntoIterator<Item = (MultiBufferPoint, Bias)>,
) -> impl Iterator<Item = MultiBufferPoint> {
let block_points = self.block_snapshot.to_block_points(
self.wrap_snapshot.to_wrap_points(
self.tab_snapshot.to_tab_points(
self.fold_snapshot
.to_fold_points(self.inlay_snapshot.to_inlay_points(points)),
),
),
);
self.inlay_snapshot.to_buffer_points(
self.fold_snapshot.to_inlay_points(
self.tab_snapshot.to_fold_points(
self.wrap_snapshot
.to_tab_points(self.block_snapshot.to_wrap_points(block_points)),
),
),
)
}
fn point_to_display_point(&self, point: MultiBufferPoint, bias: Bias) -> DisplayPoint {
let inlay_point = self.inlay_snapshot.to_inlay_point(point);
let fold_point = self.fold_snapshot.to_fold_point(inlay_point, bias);
@@ -675,7 +793,7 @@ impl DisplaySnapshot {
suggestion: Some(editor_style.suggestions_style),
},
)
.map(|chunk| {
.flat_map(|chunk| {
let mut highlight_style = chunk
.syntax_highlight_id
.and_then(|id| id.style(&editor_style.syntax));
@@ -718,6 +836,7 @@ impl DisplaySnapshot {
is_tab: chunk.is_tab,
renderer: chunk.renderer,
}
.highlight_invisibles(editor_style)
})
}
@@ -784,12 +903,10 @@ impl DisplaySnapshot {
layout_line.closest_index_for_x(x) as u32
}
pub fn display_chars_at(
&self,
mut point: DisplayPoint,
) -> impl Iterator<Item = (char, DisplayPoint)> + '_ {
pub fn grapheme_at(&self, mut point: DisplayPoint) -> Option<SharedString> {
point = DisplayPoint(self.block_snapshot.clip_point(point.0, Bias::Left));
self.text_chunks(point.row())
let chars = self
.text_chunks(point.row())
.flat_map(str::chars)
.skip_while({
let mut column = 0;
@@ -799,16 +916,24 @@ impl DisplaySnapshot {
!at_point
}
})
.map(move |ch| {
let result = (ch, point);
if ch == '\n' {
*point.row_mut() += 1;
*point.column_mut() = 0;
} else {
*point.column_mut() += ch.len_utf8() as u32;
.take_while({
let mut prev = false;
move |char| {
let now = char.is_ascii();
let end = char.is_ascii() && (char.is_ascii_whitespace() || prev);
prev = now;
!end
}
result
})
});
chars.collect::<String>().graphemes(true).next().map(|s| {
if let Some(invisible) = s.chars().next().filter(|&c| is_invisible(c)) {
replacement(invisible).unwrap_or(s).to_owned().into()
} else if s == "\n" {
" ".into()
} else {
s.to_owned().into()
}
})
}
pub fn buffer_chars_at(&self, mut offset: usize) -> impl Iterator<Item = (char, usize)> + '_ {
@@ -1157,16 +1282,21 @@ pub mod tests {
use super::*;
use crate::{movement, test::marked_display_snapshot};
use block_map::BlockPlacement;
use gpui::{div, font, observe, px, AppContext, BorrowAppContext, Context, Element, Hsla};
use gpui::{
div, font, observe, px, AppContext, BorrowAppContext, Context, Element, Hsla, Rgba,
};
use language::{
language_settings::{AllLanguageSettings, AllLanguageSettingsContent},
Buffer, Language, LanguageConfig, LanguageMatcher,
Buffer, Diagnostic, DiagnosticEntry, DiagnosticSet, Language, LanguageConfig,
LanguageMatcher,
};
use lsp::LanguageServerId;
use project::Project;
use rand::{prelude::*, Rng};
use settings::SettingsStore;
use smol::stream::StreamExt;
use std::{env, sync::Arc};
use text::PointUtf16;
use theme::{LoadThemes, SyntaxTheme};
use unindent::Unindent as _;
use util::test::{marked_text_ranges, sample_text};
@@ -1821,6 +1951,125 @@ pub mod tests {
);
}
#[gpui::test]
async fn test_chunks_with_diagnostics_across_blocks(cx: &mut gpui::TestAppContext) {
cx.background_executor
.set_block_on_ticks(usize::MAX..=usize::MAX);
let text = r#"
struct A {
b: usize;
}
const c: usize = 1;
"#
.unindent();
cx.update(|cx| init_test(cx, |_| {}));
let buffer = cx.new_model(|cx| Buffer::local(text, cx));
buffer.update(cx, |buffer, cx| {
buffer.update_diagnostics(
LanguageServerId(0),
DiagnosticSet::new(
[DiagnosticEntry {
range: PointUtf16::new(0, 0)..PointUtf16::new(2, 1),
diagnostic: Diagnostic {
severity: DiagnosticSeverity::ERROR,
group_id: 1,
message: "hi".into(),
..Default::default()
},
}],
buffer,
),
cx,
)
});
let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx));
let map = cx.new_model(|cx| {
DisplayMap::new(
buffer,
font("Courier"),
px(16.0),
None,
true,
1,
1,
0,
FoldPlaceholder::test(),
cx,
)
});
let black = gpui::black().to_rgb();
let red = gpui::red().to_rgb();
// Insert a block in the middle of a multi-line diagnostic.
map.update(cx, |map, cx| {
map.highlight_text(
TypeId::of::<usize>(),
vec![
buffer_snapshot.anchor_before(Point::new(3, 9))
..buffer_snapshot.anchor_after(Point::new(3, 14)),
buffer_snapshot.anchor_before(Point::new(3, 17))
..buffer_snapshot.anchor_after(Point::new(3, 18)),
],
red.into(),
);
map.insert_blocks(
[BlockProperties {
placement: BlockPlacement::Below(
buffer_snapshot.anchor_before(Point::new(1, 0)),
),
height: 1,
style: BlockStyle::Sticky,
render: Box::new(|_| div().into_any()),
priority: 0,
}],
cx,
)
});
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
let mut chunks = Vec::<(String, Option<DiagnosticSeverity>, Rgba)>::new();
for chunk in snapshot.chunks(DisplayRow(0)..DisplayRow(5), true, Default::default()) {
let color = chunk
.highlight_style
.and_then(|style| style.color)
.map_or(black, |color| color.to_rgb());
if let Some((last_chunk, last_severity, last_color)) = chunks.last_mut() {
if *last_severity == chunk.diagnostic_severity && *last_color == color {
last_chunk.push_str(chunk.text);
continue;
}
}
chunks.push((chunk.text.to_string(), chunk.diagnostic_severity, color));
}
assert_eq!(
chunks,
[
(
"struct A {\n b: usize;\n".into(),
Some(DiagnosticSeverity::ERROR),
black
),
("\n".into(), None, black),
("}".into(), Some(DiagnosticSeverity::ERROR), black),
("\nconst c: ".into(), None, black),
("usize".into(), None, red),
(" = ".into(), None, black),
("1".into(), None, red),
(";\n".into(), None, black),
]
);
}
// todo(linux) fails due to pixel differences in text rendering
#[cfg(target_os = "macos")]
#[gpui::test]

View File

@@ -79,10 +79,7 @@ impl FoldPoint {
}
pub fn to_inlay_point(self, snapshot: &FoldSnapshot) -> InlayPoint {
let mut cursor = snapshot.transforms.cursor::<(FoldPoint, InlayPoint)>(&());
cursor.seek(&self, Bias::Right, &());
let overshoot = self.0 - cursor.start().0 .0;
InlayPoint(cursor.start().1 .0 + overshoot)
snapshot.to_inlay_point(self)
}
pub fn to_offset(self, snapshot: &FoldSnapshot) -> FoldOffset {
@@ -618,6 +615,48 @@ impl FoldSnapshot {
}
}
pub fn to_fold_points<'a>(
&'a self,
points: impl 'a + IntoIterator<Item = (InlayPoint, Bias)>,
) -> impl 'a + Iterator<Item = FoldPoint> {
let mut cursor = self.transforms.cursor::<(InlayPoint, FoldPoint)>(&());
points.into_iter().map(move |(point, bias)| {
cursor.seek_forward(&point, Bias::Right, &());
if cursor.item().map_or(false, |t| t.is_fold()) {
if bias == Bias::Left || point == cursor.start().0 {
cursor.start().1
} else {
cursor.end(&()).1
}
} else {
let overshoot = point.0 - cursor.start().0 .0;
FoldPoint(cmp::min(
cursor.start().1 .0 + overshoot,
cursor.end(&()).1 .0,
))
}
})
}
pub fn to_inlay_point(&self, point: FoldPoint) -> InlayPoint {
let mut cursor = self.transforms.cursor::<(FoldPoint, InlayPoint)>(&());
cursor.seek(&point, Bias::Right, &());
let overshoot = point.0 - cursor.start().0 .0;
InlayPoint(cursor.start().1 .0 + overshoot)
}
pub fn to_inlay_points<'a>(
&'a self,
points: impl 'a + IntoIterator<Item = FoldPoint>,
) -> impl 'a + Iterator<Item = InlayPoint> {
let mut cursor = self.transforms.cursor::<(FoldPoint, InlayPoint)>(&());
points.into_iter().map(move |point| {
cursor.seek(&point, Bias::Right, &());
let overshoot = point.0 - cursor.start().0 .0;
InlayPoint(cursor.start().1 .0 + overshoot)
})
}
pub fn len(&self) -> FoldOffset {
FoldOffset(self.transforms.summary().output.len)
}

View File

@@ -255,6 +255,22 @@ impl<'a> InlayChunks<'a> {
self.buffer_chunk = None;
self.output_offset = new_range.start;
self.max_output_offset = new_range.end;
let mut highlight_endpoints = Vec::new();
if let Some(text_highlights) = self.highlights.text_highlights {
if !text_highlights.is_empty() {
self.snapshot.apply_text_highlights(
&mut self.transforms,
&new_range,
text_highlights,
&mut highlight_endpoints,
);
self.transforms.seek(&new_range.start, Bias::Right, &());
highlight_endpoints.sort();
}
}
self.highlight_endpoints = highlight_endpoints.into_iter().peekable();
self.active_highlights.clear();
}
pub fn offset(&self) -> InlayOffset {
@@ -773,6 +789,25 @@ impl InlaySnapshot {
None => self.buffer.max_point(),
}
}
pub fn to_buffer_points<'a>(
&'a self,
points: impl 'a + IntoIterator<Item = InlayPoint>,
) -> impl 'a + Iterator<Item = Point> {
let mut cursor = self.transforms.cursor::<(InlayPoint, Point)>(&());
points.into_iter().map(move |point| {
cursor.seek_forward(&point, Bias::Right, &());
match cursor.item() {
Some(Transform::Isomorphic(_)) => {
let overshoot = point.0 - cursor.start().0 .0;
cursor.start().1 + overshoot
}
Some(Transform::Inlay(_)) => cursor.start().1,
None => self.buffer.max_point(),
}
})
}
pub fn to_buffer_offset(&self, offset: InlayOffset) -> usize {
let mut cursor = self.transforms.cursor::<(InlayOffset, usize)>(&());
cursor.seek(&offset, Bias::Right, &());
@@ -819,6 +854,7 @@ impl InlaySnapshot {
}
}
}
pub fn to_inlay_point(&self, point: Point) -> InlayPoint {
let mut cursor = self.transforms.cursor::<(Point, InlayPoint)>(&());
cursor.seek(&point, Bias::Left, &());
@@ -853,6 +889,45 @@ impl InlaySnapshot {
}
}
pub fn to_inlay_points<'a>(
&'a self,
points: impl 'a + IntoIterator<Item = Point>,
) -> impl 'a + Iterator<Item = InlayPoint> {
let mut cursor = self.transforms.cursor::<(Point, InlayPoint)>(&());
points.into_iter().map(move |point| {
cursor.seek_forward(&point, Bias::Left, &());
loop {
match cursor.item() {
Some(Transform::Isomorphic(_)) => {
if point == cursor.end(&()).0 {
while let Some(Transform::Inlay(inlay)) = cursor.next_item() {
if inlay.position.bias() == Bias::Right {
break;
} else {
cursor.next(&());
}
}
return cursor.end(&()).1;
} else {
let overshoot = point - cursor.start().0;
return InlayPoint(cursor.start().1 .0 + overshoot);
}
}
Some(Transform::Inlay(inlay)) => {
if inlay.position.bias() == Bias::Left {
cursor.next(&());
} else {
return cursor.start().1;
}
}
None => {
return self.max_point();
}
}
}
})
}
pub fn clip_point(&self, mut point: InlayPoint, mut bias: Bias) -> InlayPoint {
let mut cursor = self.transforms.cursor::<(InlayPoint, Point)>(&());
cursor.seek(&point, Bias::Left, &());

View File

@@ -0,0 +1,129 @@
// Invisibility in a Unicode context is not well defined, so we have to guess.
//
// We highlight all ASCII control codes, and unicode whitespace because they are likely
// confused with an ASCII space in a programming context (U+0020).
//
// We also highlight the handful of blank non-space characters:
// U+2800 BRAILLE PATTERN BLANK - Category: So
// U+115F HANGUL CHOSEONG FILLER - Category: Lo
// U+1160 HANGUL CHOSEONG FILLER - Category: Lo
// U+3164 HANGUL FILLER - Category: Lo
// U+FFA0 HALFWIDTH HANGUL FILLER - Category: Lo
// U+FFFC OBJECT REPLACEMENT CHARACTER - Category: So
//
// For the rest of Unicode, invisibility happens for two reasons:
// * A Format character (like a byte order mark or right-to-left override)
// * An invisible Nonspacing Mark character (like U+034F, or variation selectors)
//
// We don't consider unassigned codepoints invisible as the font renderer already shows
// a replacement character in that case (and there are a *lot* of them)
//
// Control characters are mostly fine to highlight; except:
// * U+E0020..=U+E007F are used in emoji flags. We don't highlight them right now, but we could if we tightened our heuristics.
// * U+200D is used to join characters. We highlight this but don't replace it. As our font system ignores mid-glyph highlights this mostly works to highlight unexpected uses.
//
// Nonspacing marks are handled like U+200D. This means that mid-glyph we ignore them, but
// probably causes issues with end-of-glyph usage.
//
// ref: https://invisible-characters.com
// ref: https://www.compart.com/en/unicode/category/Cf
// ref: https://gist.github.com/ConradIrwin/f759e1fc29267143c4c7895aa495dca5?h=1
// ref: https://unicode.org/Public/emoji/13.0/emoji-test.txt
// https://github.com/bits/UTF-8-Unicode-Test-Documents/blob/master/UTF-8_sequence_separated/utf8_sequence_0-0x10ffff_assigned_including-unprintable-asis.txt
pub fn is_invisible(c: char) -> bool {
if c <= '\u{1f}' {
c != '\t' && c != '\n' && c != '\r'
} else if c >= '\u{7f}' {
c <= '\u{9f}'
|| (c.is_whitespace() && c != IDEOGRAPHIC_SPACE)
|| contains(c, &FORMAT)
|| contains(c, &OTHER)
} else {
false
}
}
// ASCII control characters have fancy unicode glyphs, everything else
// is replaced by a space - unless it is used in combining characters in
// which case we need to leave it in the string.
pub(crate) fn replacement(c: char) -> Option<&'static str> {
if c <= '\x1f' {
Some(C0_SYMBOLS[c as usize])
} else if c == '\x7f' {
Some(DEL)
} else if contains(c, &PRESERVE) {
None
} else {
Some("\u{2007}") // fixed width space
}
}
// IDEOGRAPHIC SPACE is common alongside Chinese and other wide character sets.
// We don't highlight this for now (as it already shows up wide in the editor),
// but could if we tracked state in the classifier.
const IDEOGRAPHIC_SPACE: char = '\u{3000}';
const C0_SYMBOLS: &'static [&'static str] = &[
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "",
];
const DEL: &'static str = "";
// generated using ucd-generate: ucd-generate general-category --include Format --chars ucd-16.0.0
pub const FORMAT: &'static [(char, char)] = &[
('\u{ad}', '\u{ad}'),
('\u{600}', '\u{605}'),
('\u{61c}', '\u{61c}'),
('\u{6dd}', '\u{6dd}'),
('\u{70f}', '\u{70f}'),
('\u{890}', '\u{891}'),
('\u{8e2}', '\u{8e2}'),
('\u{180e}', '\u{180e}'),
('\u{200b}', '\u{200f}'),
('\u{202a}', '\u{202e}'),
('\u{2060}', '\u{2064}'),
('\u{2066}', '\u{206f}'),
('\u{feff}', '\u{feff}'),
('\u{fff9}', '\u{fffb}'),
('\u{110bd}', '\u{110bd}'),
('\u{110cd}', '\u{110cd}'),
('\u{13430}', '\u{1343f}'),
('\u{1bca0}', '\u{1bca3}'),
('\u{1d173}', '\u{1d17a}'),
('\u{e0001}', '\u{e0001}'),
('\u{e0020}', '\u{e007f}'),
];
// hand-made base on https://invisible-characters.com (Excluding Cf)
pub const OTHER: &'static [(char, char)] = &[
('\u{034f}', '\u{034f}'),
('\u{115F}', '\u{1160}'),
('\u{17b4}', '\u{17b5}'),
('\u{180b}', '\u{180d}'),
('\u{2800}', '\u{2800}'),
('\u{3164}', '\u{3164}'),
('\u{fe00}', '\u{fe0d}'),
('\u{ffa0}', '\u{ffa0}'),
('\u{fffc}', '\u{fffc}'),
('\u{e0100}', '\u{e01ef}'),
];
// a subset of FORMAT/OTHER that may appear within glyphs
const PRESERVE: &'static [(char, char)] = &[
('\u{034f}', '\u{034f}'),
('\u{200d}', '\u{200d}'),
('\u{17b4}', '\u{17b5}'),
('\u{180b}', '\u{180d}'),
('\u{e0061}', '\u{e007a}'),
('\u{e007f}', '\u{e007f}'),
];
fn contains(c: char, list: &[(char, char)]) -> bool {
for (start, end) in list {
if c < *start {
return false;
}
if c <= *end {
return true;
}
}
false
}

View File

@@ -304,6 +304,14 @@ impl TabSnapshot {
TabPoint::new(input.row(), expanded)
}
pub fn to_tab_points<'a>(
&'a self,
points: impl 'a + IntoIterator<Item = FoldPoint>,
) -> impl 'a + Iterator<Item = TabPoint> {
// todo!("make this efficient")
points.into_iter().map(|point| self.to_tab_point(point))
}
pub fn to_fold_point(&self, output: TabPoint, bias: Bias) -> (FoldPoint, u32, u32) {
let chars = self.fold_snapshot.chars_at(FoldPoint::new(output.row(), 0));
let expanded = output.column();
@@ -316,6 +324,16 @@ impl TabSnapshot {
)
}
pub fn to_fold_points<'a>(
&'a self,
points: impl 'a + IntoIterator<Item = (TabPoint, Bias)>,
) -> impl 'a + Iterator<Item = FoldPoint> {
// todo!("make this efficient")
points
.into_iter()
.map(|(point, bias)| self.to_fold_point(point, bias).0)
}
pub fn make_tab_point(&self, point: Point, bias: Bias) -> TabPoint {
let inlay_point = self.fold_snapshot.inlay_snapshot.to_inlay_point(point);
let fold_point = self.fold_snapshot.to_fold_point(inlay_point, bias);

View File

@@ -761,6 +761,12 @@ impl WrapSnapshot {
WrapPoint(cursor.start().1 .0 + (point.0 - cursor.start().0 .0))
}
pub fn tab_points_to_wrap_points(
&self,
points: impl IntoIterator<Item = TabPoint>,
) -> impl Iterator<Item = WrapPoint> {
}
pub fn clip_point(&self, mut point: WrapPoint, bias: Bias) -> WrapPoint {
if bias == Bias::Left {
let mut cursor = self.transforms.cursor::<WrapPoint>(&());

View File

@@ -3244,9 +3244,21 @@ impl Editor {
}
if enabled && pair.start.ends_with(text.as_ref()) {
bracket_pair = Some(pair.clone());
is_bracket_pair_start = true;
break;
let prefix_len = pair.start.len() - text.len();
let preceding_text_matches_prefix = prefix_len == 0
|| (selection.start.column >= (prefix_len as u32)
&& snapshot.contains_str_at(
Point::new(
selection.start.row,
selection.start.column - (prefix_len as u32),
),
&pair.start[..prefix_len],
));
if preceding_text_matches_prefix {
bracket_pair = Some(pair.clone());
is_bracket_pair_start = true;
break;
}
}
if pair.end.as_str() == text.as_ref() {
bracket_pair = Some(pair.clone());
@@ -3263,8 +3275,6 @@ impl Editor {
self.use_auto_surround && snapshot_settings.use_auto_surround;
if selection.is_empty() {
if is_bracket_pair_start {
let prefix_len = bracket_pair.start.len() - text.len();
// If the inserted text is a suffix of an opening bracket and the
// selection is preceded by the rest of the opening bracket, then
// insert the closing bracket.
@@ -3272,15 +3282,6 @@ impl Editor {
.chars_at(selection.start)
.next()
.map_or(true, |c| scope.should_autoclose_before(c));
let preceding_text_matches_prefix = prefix_len == 0
|| (selection.start.column >= (prefix_len as u32)
&& snapshot.contains_str_at(
Point::new(
selection.start.row,
selection.start.column - (prefix_len as u32),
),
&bracket_pair.start[..prefix_len],
));
let is_closing_quote = if bracket_pair.end == bracket_pair.start
&& bracket_pair.start.len() == 1
@@ -3299,7 +3300,6 @@ impl Editor {
if autoclose
&& bracket_pair.close
&& following_text_allows_autoclose
&& preceding_text_matches_prefix
&& !is_closing_quote
{
let anchor = snapshot.anchor_before(selection.end);
@@ -9629,8 +9629,8 @@ impl Editor {
let Some(provider) = self.semantics_provider.clone() else {
return Task::ready(Ok(Navigated::No));
};
let buffer = self.buffer.read(cx);
let head = self.selections.newest::<usize>(cx).head();
let buffer = self.buffer.read(cx);
let (buffer, head) = if let Some(text_anchor) = buffer.text_anchor_for_position(head, cx) {
text_anchor
} else {
@@ -9937,8 +9937,8 @@ impl Editor {
_: &FindAllReferences,
cx: &mut ViewContext<Self>,
) -> Option<Task<Result<Navigated>>> {
let multi_buffer = self.buffer.read(cx);
let selection = self.selections.newest::<usize>(cx);
let multi_buffer = self.buffer.read(cx);
let head = selection.head();
let multi_buffer_snapshot = multi_buffer.snapshot(cx);
@@ -10345,8 +10345,9 @@ impl Editor {
self.show_local_selections = true;
if moving_cursor {
let rename_editor = rename.editor.read(cx);
let cursor_in_rename_editor = rename_editor.selections.newest::<usize>(cx).head();
let cursor_in_rename_editor = rename.editor.update(cx, |editor, cx| {
editor.selections.newest::<usize>(cx).head()
});
// Update the selection to match the position of the selection inside
// the rename editor.
@@ -10460,7 +10461,7 @@ impl Editor {
fn cancel_language_server_work(
&mut self,
_: &CancelLanguageServerWork,
_: &actions::CancelLanguageServerWork,
cx: &mut ViewContext<Self>,
) {
if let Some(project) = self.project.clone() {
@@ -11592,9 +11593,9 @@ impl Editor {
}
pub fn copy_file_location(&mut self, _: &CopyFileLocation, cx: &mut ViewContext<Self>) {
let selection = self.selections.newest::<Point>(cx).start.row + 1;
if let Some(file) = self.target_file(cx) {
if let Some(path) = file.path().to_str() {
let selection = self.selections.newest::<Point>(cx).start.row + 1;
cx.write_to_clipboard(ClipboardItem::new_string(format!("{path}:{selection}")));
}
}
@@ -12370,9 +12371,10 @@ impl Editor {
return;
};
let selections = self.selections.all::<usize>(cx);
let buffer = self.buffer.read(cx);
let mut new_selections_by_buffer = HashMap::default();
for selection in self.selections.all::<usize>(cx) {
for selection in selections {
for (buffer, range, _) in
buffer.range_to_buffer_ranges(selection.start..selection.end, cx)
{
@@ -12417,6 +12419,7 @@ impl Editor {
}
fn open_excerpts_common(&mut self, split: bool, cx: &mut ViewContext<Self>) {
let selections = self.selections.all::<usize>(cx);
let buffer = self.buffer.read(cx);
if buffer.is_singleton() {
cx.propagate();
@@ -12429,7 +12432,7 @@ impl Editor {
};
let mut new_selections_by_buffer = HashMap::default();
for selection in self.selections.all::<usize>(cx) {
for selection in selections {
for (mut buffer_handle, mut range, _) in
buffer.range_to_buffer_ranges(selection.range(), cx)
{
@@ -12545,7 +12548,7 @@ impl Editor {
fn selection_replacement_ranges(
&self,
range: Range<OffsetUtf16>,
cx: &AppContext,
cx: &mut AppContext,
) -> Vec<Range<OffsetUtf16>> {
let selections = self.selections.all::<OffsetUtf16>(cx);
let newest_selection = selections
@@ -14188,7 +14191,7 @@ pub fn diagnostic_block_renderer(
.relative()
.size_full()
.pl(cx.gutter_dimensions.width)
.w(cx.max_width + cx.gutter_dimensions.width)
.w(cx.max_width - cx.gutter_dimensions.full_width())
.child(
div()
.flex()

View File

@@ -68,6 +68,7 @@ use sum_tree::Bias;
use theme::{ActiveTheme, Appearance, PlayerColor};
use ui::prelude::*;
use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip};
use unicode_segmentation::UnicodeSegmentation;
use util::RangeExt;
use util::ResultExt;
use workspace::{item::Item, Workspace};
@@ -823,129 +824,131 @@ impl EditorElement {
let mut selections: Vec<(PlayerColor, Vec<SelectionLayout>)> = Vec::new();
let mut active_rows = BTreeMap::new();
let mut newest_selection_head = None;
let editor = self.editor.read(cx);
self.editor.update(cx, |editor, cx| {
if editor.show_local_selections {
let mut local_selections: Vec<Selection<Point>> = editor
.selections
.disjoint_in_range(start_anchor..end_anchor, cx);
local_selections.extend(editor.selections.pending(cx));
let mut layouts = Vec::new();
let newest = editor.selections.newest(cx);
for selection in local_selections.drain(..) {
let is_empty = selection.start == selection.end;
let is_newest = selection == newest;
if editor.show_local_selections {
let mut local_selections: Vec<Selection<Point>> = editor
.selections
.disjoint_in_range(start_anchor..end_anchor, cx);
local_selections.extend(editor.selections.pending(cx));
let mut layouts = Vec::new();
let newest = editor.selections.newest(cx);
for selection in local_selections.drain(..) {
let is_empty = selection.start == selection.end;
let is_newest = selection == newest;
let layout = SelectionLayout::new(
selection,
editor.selections.line_mode,
editor.cursor_shape,
&snapshot.display_snapshot,
is_newest,
editor.leader_peer_id.is_none(),
None,
);
if is_newest {
newest_selection_head = Some(layout.head);
}
let layout = SelectionLayout::new(
selection,
editor.selections.line_mode,
editor.cursor_shape,
&snapshot.display_snapshot,
is_newest,
editor.leader_peer_id.is_none(),
None,
);
if is_newest {
newest_selection_head = Some(layout.head);
for row in cmp::max(layout.active_rows.start.0, start_row.0)
..=cmp::min(layout.active_rows.end.0, end_row.0)
{
let contains_non_empty_selection =
active_rows.entry(DisplayRow(row)).or_insert(!is_empty);
*contains_non_empty_selection |= !is_empty;
}
layouts.push(layout);
}
for row in cmp::max(layout.active_rows.start.0, start_row.0)
..=cmp::min(layout.active_rows.end.0, end_row.0)
{
let contains_non_empty_selection =
active_rows.entry(DisplayRow(row)).or_insert(!is_empty);
*contains_non_empty_selection |= !is_empty;
}
layouts.push(layout);
let player = if editor.read_only(cx) {
cx.theme().players().read_only()
} else {
self.style.local_player
};
selections.push((player, layouts));
}
let player = if editor.read_only(cx) {
cx.theme().players().read_only()
} else {
self.style.local_player
};
selections.push((player, layouts));
}
if let Some(collaboration_hub) = &editor.collaboration_hub {
// When following someone, render the local selections in their color.
if let Some(leader_id) = editor.leader_peer_id {
if let Some(collaborator) = collaboration_hub.collaborators(cx).get(&leader_id) {
if let Some(participant_index) = collaboration_hub
.user_participant_indices(cx)
.get(&collaborator.user_id)
if let Some(collaboration_hub) = &editor.collaboration_hub {
// When following someone, render the local selections in their color.
if let Some(leader_id) = editor.leader_peer_id {
if let Some(collaborator) = collaboration_hub.collaborators(cx).get(&leader_id)
{
if let Some((local_selection_style, _)) = selections.first_mut() {
*local_selection_style = cx
.theme()
.players()
.color_for_participant(participant_index.0);
if let Some(participant_index) = collaboration_hub
.user_participant_indices(cx)
.get(&collaborator.user_id)
{
if let Some((local_selection_style, _)) = selections.first_mut() {
*local_selection_style = cx
.theme()
.players()
.color_for_participant(participant_index.0);
}
}
}
}
}
let mut remote_selections = HashMap::default();
for selection in snapshot.remote_selections_in_range(
&(start_anchor..end_anchor),
collaboration_hub.as_ref(),
cx,
) {
let selection_style = Self::get_participant_color(selection.participant_index, cx);
let mut remote_selections = HashMap::default();
for selection in snapshot.remote_selections_in_range(
&(start_anchor..end_anchor),
collaboration_hub.as_ref(),
cx,
) {
let selection_style =
Self::get_participant_color(selection.participant_index, cx);
// Don't re-render the leader's selections, since the local selections
// match theirs.
if Some(selection.peer_id) == editor.leader_peer_id {
continue;
// Don't re-render the leader's selections, since the local selections
// match theirs.
if Some(selection.peer_id) == editor.leader_peer_id {
continue;
}
let key = HoveredCursor {
replica_id: selection.replica_id,
selection_id: selection.selection.id,
};
let is_shown =
editor.show_cursor_names || editor.hovered_cursors.contains_key(&key);
remote_selections
.entry(selection.replica_id)
.or_insert((selection_style, Vec::new()))
.1
.push(SelectionLayout::new(
selection.selection,
selection.line_mode,
selection.cursor_shape,
&snapshot.display_snapshot,
false,
false,
if is_shown { selection.user_name } else { None },
));
}
let key = HoveredCursor {
replica_id: selection.replica_id,
selection_id: selection.selection.id,
selections.extend(remote_selections.into_values());
} else if !editor.is_focused(cx) && editor.show_cursor_when_unfocused {
let player = if editor.read_only(cx) {
cx.theme().players().read_only()
} else {
self.style.local_player
};
let is_shown =
editor.show_cursor_names || editor.hovered_cursors.contains_key(&key);
remote_selections
.entry(selection.replica_id)
.or_insert((selection_style, Vec::new()))
.1
.push(SelectionLayout::new(
selection.selection,
selection.line_mode,
selection.cursor_shape,
&snapshot.display_snapshot,
false,
false,
if is_shown { selection.user_name } else { None },
));
let layouts = snapshot
.buffer_snapshot
.selections_in_range(&(start_anchor..end_anchor), true)
.map(move |(_, line_mode, cursor_shape, selection)| {
SelectionLayout::new(
selection,
line_mode,
cursor_shape,
&snapshot.display_snapshot,
false,
false,
None,
)
})
.collect::<Vec<_>>();
selections.push((player, layouts));
}
selections.extend(remote_selections.into_values());
} else if !editor.is_focused(cx) && editor.show_cursor_when_unfocused {
let player = if editor.read_only(cx) {
cx.theme().players().read_only()
} else {
self.style.local_player
};
let layouts = snapshot
.buffer_snapshot
.selections_in_range(&(start_anchor..end_anchor), true)
.map(move |(_, line_mode, cursor_shape, selection)| {
SelectionLayout::new(
selection,
line_mode,
cursor_shape,
&snapshot.display_snapshot,
false,
false,
None,
)
})
.collect::<Vec<_>>();
selections.push((player, layouts));
}
});
(selections, active_rows, newest_selection_head)
}
@@ -1027,24 +1030,17 @@ impl EditorElement {
}
let block_text = if let CursorShape::Block = selection.cursor_shape {
snapshot
.display_chars_at(cursor_position)
.next()
.grapheme_at(cursor_position)
.or_else(|| {
if cursor_column == 0 {
snapshot
.placeholder_text()
.and_then(|s| s.chars().next())
.map(|c| (c, cursor_position))
snapshot.placeholder_text().and_then(|s| {
s.graphemes(true).next().map(|s| s.to_string().into())
})
} else {
None
}
})
.and_then(|(character, _)| {
let text = if character == '\n' {
SharedString::from(" ")
} else {
SharedString::from(character.to_string())
};
.and_then(|text| {
let len = text.len();
let font = cursor_row_layout
@@ -1854,23 +1850,25 @@ impl EditorElement {
return Vec::new();
}
let editor = self.editor.read(cx);
let newest_selection_head = newest_selection_head.unwrap_or_else(|| {
let newest = editor.selections.newest::<Point>(cx);
SelectionLayout::new(
newest,
editor.selections.line_mode,
editor.cursor_shape,
&snapshot.display_snapshot,
true,
true,
None,
)
.head
let (newest_selection_head, is_relative) = self.editor.update(cx, |editor, cx| {
let newest_selection_head = newest_selection_head.unwrap_or_else(|| {
let newest = editor.selections.newest::<Point>(cx);
SelectionLayout::new(
newest,
editor.selections.line_mode,
editor.cursor_shape,
&snapshot.display_snapshot,
true,
true,
None,
)
.head
});
let is_relative = editor.should_use_relative_line_numbers(cx);
(newest_selection_head, is_relative)
});
let font_size = self.style.text.font_size.to_pixels(cx.rem_size());
let is_relative = editor.should_use_relative_line_numbers(cx);
let relative_to = if is_relative {
Some(newest_selection_head.row())
} else {
@@ -4159,7 +4157,16 @@ fn render_inline_blame_entry(
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry);
let author = blame_entry.author.as_deref().unwrap_or_default();
let text = format!("{}, {}", author, relative_timestamp);
let summary_enabled = ProjectSettings::get_global(cx)
.git
.show_inline_commit_summary();
let text = match blame_entry.summary.as_ref() {
Some(summary) if summary_enabled => {
format!("{}, {} - {}", author, relative_timestamp, summary)
}
_ => format!("{}, {}", author, relative_timestamp),
};
let details = blame.read(cx).details_for_entry(&blame_entry);

View File

@@ -368,12 +368,15 @@ impl GitBlame {
.spawn({
let snapshot = snapshot.clone();
async move {
let Blame {
let Some(Blame {
entries,
permalinks,
messages,
remote_url,
} = blame.await?;
}) = blame.await?
else {
return Ok(None);
};
let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
let commit_details = parse_commit_messages(
@@ -385,13 +388,16 @@ impl GitBlame {
)
.await;
anyhow::Ok((entries, commit_details))
anyhow::Ok(Some((entries, commit_details)))
}
})
.await;
this.update(&mut cx, |this, cx| match result {
Ok((entries, commit_details)) => {
Ok(None) => {
// Nothing to do, e.g. no repository found
}
Ok(Some((entries, commit_details))) => {
this.buffer_edits = buffer_edits;
this.buffer_snapshot = snapshot;
this.entries = entries;
@@ -410,11 +416,7 @@ impl GitBlame {
} else {
// If we weren't triggered by a user, we just log errors in the background, instead of sending
// notifications.
// Except for `NoRepositoryError`, which can happen often if a user has inline-blame turned on
// and opens a non-git file.
if error.downcast_ref::<project::NoRepositoryError>().is_none() {
log::error!("failed to get git blame data: {error:?}");
}
log::error!("failed to get git blame data: {error:?}");
}
}),
})

View File

@@ -1,5 +1,5 @@
use crate::{
display_map::{InlayOffset, ToDisplayPoint},
display_map::{invisibles::is_invisible, InlayOffset, ToDisplayPoint},
hover_links::{InlayHighlight, RangeInEditor},
scroll::ScrollAmount,
Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings, EditorSnapshot,
@@ -11,7 +11,7 @@ use gpui::{
StyleRefinement, Styled, Task, TextStyleRefinement, View, ViewContext,
};
use itertools::Itertools;
use language::{DiagnosticEntry, Language, LanguageRegistry};
use language::{Diagnostic, DiagnosticEntry, Language, LanguageRegistry};
use lsp::DiagnosticSeverity;
use markdown::{Markdown, MarkdownStyle};
use multi_buffer::ToOffset;
@@ -259,7 +259,7 @@ fn show_hover(
}
// If there's a diagnostic, assign it on the hover state and notify
let local_diagnostic = snapshot
let mut local_diagnostic = snapshot
.buffer_snapshot
.diagnostics_in_range::<_, usize>(anchor..anchor, false)
// Find the entry with the most specific range
@@ -280,6 +280,41 @@ fn show_hover(
range: entry.range.to_anchors(&snapshot.buffer_snapshot),
})
});
if let Some(invisible) = snapshot
.buffer_snapshot
.chars_at(anchor)
.next()
.filter(|&c| is_invisible(c))
{
let after = snapshot.buffer_snapshot.anchor_after(
anchor.to_offset(&snapshot.buffer_snapshot) + invisible.len_utf8(),
);
local_diagnostic = Some(DiagnosticEntry {
diagnostic: Diagnostic {
severity: DiagnosticSeverity::HINT,
message: format!("Unicode character U+{:02X}", invisible as u32),
..Default::default()
},
range: anchor..after,
})
} else if let Some(invisible) = snapshot
.buffer_snapshot
.reversed_chars_at(anchor)
.next()
.filter(|&c| is_invisible(c))
{
let before = snapshot.buffer_snapshot.anchor_before(
anchor.to_offset(&snapshot.buffer_snapshot) - invisible.len_utf8(),
);
local_diagnostic = Some(DiagnosticEntry {
diagnostic: Diagnostic {
severity: DiagnosticSeverity::HINT,
message: format!("Unicode character U+{:02X}", invisible as u32),
..Default::default()
},
range: before..anchor,
})
}
let diagnostic_popover = if let Some(local_diagnostic) = local_diagnostic {
let text = match local_diagnostic.diagnostic.source {

View File

@@ -41,9 +41,9 @@ pub(super) fn refresh_linked_ranges(this: &mut Editor, cx: &mut ViewContext<Edit
return None;
}
let project = this.project.clone()?;
let selections = this.selections.all::<usize>(cx);
let buffer = this.buffer.read(cx);
let mut applicable_selections = vec![];
let selections = this.selections.all::<usize>(cx);
let snapshot = buffer.snapshot(cx);
for selection in selections {
let cursor_position = selection.head();

View File

@@ -8,14 +8,14 @@ use std::{
use collections::HashMap;
use gpui::{AppContext, Model, Pixels};
use itertools::Itertools;
use language::{Bias, Point, Selection, SelectionGoal, TextDimension, ToPoint};
use language::{Bias, Point, Selection, SelectionGoal, TextDimension};
use util::post_inc;
use crate::{
display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint},
movement::TextLayoutDetails,
Anchor, DisplayPoint, DisplayRow, ExcerptId, MultiBuffer, MultiBufferSnapshot, SelectMode,
ToOffset,
ToOffset, ToPoint,
};
#[derive(Debug, Clone)]
@@ -96,7 +96,7 @@ impl SelectionsCollection {
pub fn pending<D: TextDimension + Ord + Sub<D, Output = D>>(
&self,
cx: &AppContext,
cx: &mut AppContext,
) -> Option<Selection<D>> {
self.pending_anchor()
.as_ref()
@@ -107,7 +107,7 @@ impl SelectionsCollection {
self.pending.as_ref().map(|pending| pending.mode.clone())
}
pub fn all<'a, D>(&self, cx: &AppContext) -> Vec<Selection<D>>
pub fn all<'a, D>(&self, cx: &mut AppContext) -> Vec<Selection<D>>
where
D: 'a + TextDimension + Ord + Sub<D, Output = D>,
{
@@ -194,7 +194,7 @@ impl SelectionsCollection {
pub fn disjoint_in_range<'a, D>(
&self,
range: Range<Anchor>,
cx: &AppContext,
cx: &mut AppContext,
) -> Vec<Selection<D>>
where
D: 'a + TextDimension + Ord + Sub<D, Output = D> + std::fmt::Debug,
@@ -239,9 +239,10 @@ impl SelectionsCollection {
pub fn newest<D: TextDimension + Ord + Sub<D, Output = D>>(
&self,
cx: &AppContext,
cx: &mut AppContext,
) -> Selection<D> {
resolve(self.newest_anchor(), &self.buffer(cx))
let buffer = self.buffer(cx);
self.newest_anchor().map(|p| p.summary::<D>(&buffer))
}
pub fn newest_display(&self, cx: &mut AppContext) -> Selection<DisplayPoint> {
@@ -262,9 +263,10 @@ impl SelectionsCollection {
pub fn oldest<D: TextDimension + Ord + Sub<D, Output = D>>(
&self,
cx: &AppContext,
cx: &mut AppContext,
) -> Selection<D> {
resolve(self.oldest_anchor(), &self.buffer(cx))
let buffer = self.buffer(cx);
self.oldest_anchor().map(|p| p.summary::<D>(&buffer))
}
pub fn first_anchor(&self) -> Selection<Anchor> {
@@ -276,14 +278,14 @@ impl SelectionsCollection {
pub fn first<D: TextDimension + Ord + Sub<D, Output = D>>(
&self,
cx: &AppContext,
cx: &mut AppContext,
) -> Selection<D> {
self.all(cx).first().unwrap().clone()
}
pub fn last<D: TextDimension + Ord + Sub<D, Output = D>>(
&self,
cx: &AppContext,
cx: &mut AppContext,
) -> Selection<D> {
self.all(cx).last().unwrap().clone()
}
@@ -298,7 +300,7 @@ impl SelectionsCollection {
#[cfg(any(test, feature = "test-support"))]
pub fn ranges<D: TextDimension + Ord + Sub<D, Output = D> + std::fmt::Debug>(
&self,
cx: &AppContext,
cx: &mut AppContext,
) -> Vec<Range<D>> {
self.all::<D>(cx)
.iter()
@@ -475,7 +477,7 @@ impl<'a> MutableSelectionsCollection<'a> {
where
T: 'a + ToOffset + ToPoint + TextDimension + Ord + Sub<T, Output = T> + std::marker::Copy,
{
let mut selections = self.all(self.cx);
let mut selections = self.collection.all(self.cx);
let mut start = range.start.to_offset(&self.buffer());
let mut end = range.end.to_offset(&self.buffer());
let reversed = if start > end {
@@ -649,6 +651,7 @@ impl<'a> MutableSelectionsCollection<'a> {
let mut changed = false;
let display_map = self.display_map();
let selections = self
.collection
.all::<Point>(self.cx)
.into_iter()
.map(|selection| {
@@ -676,6 +679,7 @@ impl<'a> MutableSelectionsCollection<'a> {
let mut changed = false;
let snapshot = self.buffer().clone();
let selections = self
.collection
.all::<usize>(self.cx)
.into_iter()
.map(|selection| {
@@ -869,10 +873,3 @@ where
goal: s.goal,
})
}
fn resolve<D: TextDimension + Ord + Sub<D, Output = D>>(
selection: &Selection<Anchor>,
buffer: &MultiBufferSnapshot,
) -> Selection<D> {
selection.map(|p| p.summary::<D>(buffer))
}

View File

@@ -59,6 +59,12 @@ impl FeatureFlag for ZedPro {
const NAME: &'static str = "zed-pro";
}
pub struct NotebookFeatureFlag;
impl FeatureFlag for NotebookFeatureFlag {
const NAME: &'static str = "notebooks";
}
pub struct AutoCommand {}
impl FeatureFlag for AutoCommand {
const NAME: &'static str = "auto-command";

View File

@@ -4,7 +4,7 @@ use gpui::{HighlightStyle, Model, StyledText};
use picker::{Picker, PickerDelegate};
use project::{Entry, PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
use std::{
path::PathBuf,
path::{Path, PathBuf},
sync::{
atomic::{self, AtomicBool},
Arc,
@@ -254,6 +254,7 @@ impl PickerDelegate for NewPathDelegate {
.trim()
.trim_start_matches("./")
.trim_start_matches('/');
let (dir, suffix) = if let Some(index) = query.rfind('/') {
let suffix = if index + 1 < query.len() {
Some(query[index + 1..].to_string())
@@ -317,6 +318,14 @@ impl PickerDelegate for NewPathDelegate {
})
}
fn confirm_completion(
&mut self,
_: String,
cx: &mut ViewContext<Picker<Self>>,
) -> Option<String> {
self.confirm_update_query(cx)
}
fn confirm_update_query(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<String> {
let m = self.matches.get(self.selected_index)?;
if m.is_dir(self.project.read(cx), cx) {
@@ -422,7 +431,32 @@ impl NewPathDelegate {
) {
cx.notify();
if query.is_empty() {
self.matches = vec![];
self.matches = self
.project
.read(cx)
.worktrees(cx)
.flat_map(|worktree| {
let worktree_id = worktree.read(cx).id();
worktree
.read(cx)
.child_entries(Path::new(""))
.filter_map(move |entry| {
entry.is_dir().then(|| Match {
path_match: Some(PathMatch {
score: 1.0,
positions: Default::default(),
worktree_id: worktree_id.to_usize(),
path: entry.path.clone(),
path_prefix: "".into(),
is_dir: entry.is_dir(),
distance_to_relative_ancestor: 0,
}),
suffix: None,
})
})
})
.collect();
return;
}

View File

@@ -220,7 +220,11 @@ impl PickerDelegate for OpenPathDelegate {
})
}
fn confirm_completion(&self, query: String) -> Option<String> {
fn confirm_completion(
&mut self,
query: String,
_: &mut ViewContext<Picker<Self>>,
) -> Option<String> {
Some(
maybe!({
let m = self.matches.get(self.selected_index)?;

View File

@@ -1,4 +1,10 @@
pub mod blame;
pub mod commit;
pub mod diff;
mod hosting_provider;
mod remote;
pub mod repository;
pub mod status;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
@@ -7,15 +13,9 @@ use std::fmt;
use std::str::FromStr;
use std::sync::LazyLock;
pub use git2 as libgit;
pub use crate::hosting_provider::*;
pub mod blame;
pub mod commit;
pub mod diff;
pub mod repository;
pub mod status;
pub use crate::remote::*;
pub use git2 as libgit;
pub static DOT_GIT: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new(".git"));
pub static COOKIES: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new("cookies"));

View File

@@ -69,7 +69,7 @@ pub trait GitHostingProvider {
/// Returns a formatted range of line numbers to be placed in a permalink URL.
fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String;
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>>;
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote>;
fn extract_pull_request(
&self,
@@ -111,6 +111,12 @@ impl GitHostingProviderRegistry {
cx.global::<GlobalGitHostingProviderRegistry>().0.clone()
}
/// Returns the global [`GitHostingProviderRegistry`], if one is set.
pub fn try_global(cx: &AppContext) -> Option<Arc<Self>> {
cx.try_global::<GlobalGitHostingProviderRegistry>()
.map(|registry| registry.0.clone())
}
/// Returns the global [`GitHostingProviderRegistry`].
///
/// Inserts a default [`GitHostingProviderRegistry`] if one does not yet exist.
@@ -153,10 +159,10 @@ impl GitHostingProviderRegistry {
}
}
#[derive(Debug)]
pub struct ParsedGitRemote<'a> {
pub owner: &'a str,
pub repo: &'a str,
#[derive(Debug, PartialEq)]
pub struct ParsedGitRemote {
pub owner: Arc<str>,
pub repo: Arc<str>,
}
pub fn parse_git_remote_url(

85
crates/git/src/remote.rs Normal file
View File

@@ -0,0 +1,85 @@
use derive_more::Deref;
use url::Url;
/// The URL to a Git remote.
#[derive(Debug, PartialEq, Eq, Clone, Deref)]
pub struct RemoteUrl(Url);
impl std::str::FromStr for RemoteUrl {
type Err = url::ParseError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
if input.starts_with("git@") {
// Rewrite remote URLs like `git@github.com:user/repo.git` to `ssh://git@github.com/user/repo.git`
let ssh_url = input.replacen(':', "/", 1).replace("git@", "ssh://git@");
Ok(RemoteUrl(Url::parse(&ssh_url)?))
} else {
Ok(RemoteUrl(Url::parse(input)?))
}
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_parsing_valid_remote_urls() {
let valid_urls = vec![
(
"https://github.com/octocat/zed.git",
"https",
"github.com",
"/octocat/zed.git",
),
(
"git@github.com:octocat/zed.git",
"ssh",
"github.com",
"/octocat/zed.git",
),
(
"ssh://git@github.com/octocat/zed.git",
"ssh",
"github.com",
"/octocat/zed.git",
),
(
"file:///path/to/local/zed",
"file",
"",
"/path/to/local/zed",
),
];
for (input, expected_scheme, expected_host, expected_path) in valid_urls {
let parsed = input.parse::<RemoteUrl>().expect("failed to parse URL");
let url = parsed.0;
assert_eq!(
url.scheme(),
expected_scheme,
"unexpected scheme for {input:?}",
);
assert_eq!(
url.host_str().unwrap_or(""),
expected_host,
"unexpected host for {input:?}",
);
assert_eq!(url.path(), expected_path, "unexpected path for {input:?}");
}
}
#[test]
fn test_parsing_invalid_remote_urls() {
let invalid_urls = vec!["not_a_url", "http://"];
for url in invalid_urls {
assert!(
url.parse::<RemoteUrl>().is_err(),
"expected \"{url}\" to not parse as a Git remote URL",
);
}
}
}

View File

@@ -22,8 +22,9 @@ regex.workspace = true
serde.workspace = true
serde_json.workspace = true
url.workspace = true
util.workspace = true
[dev-dependencies]
unindent.workspace = true
indoc.workspace = true
serde_json.workspace = true
pretty_assertions.workspace = true

View File

@@ -2,6 +2,7 @@ mod providers;
use std::sync::Arc;
use git::repository::GitRepository;
use git::GitHostingProviderRegistry;
use gpui::AppContext;
@@ -10,17 +11,27 @@ pub use crate::providers::*;
/// Initializes the Git hosting providers.
pub fn init(cx: &AppContext) {
let provider_registry = GitHostingProviderRegistry::global(cx);
// The providers are stored in a `BTreeMap`, so insertion order matters.
// GitHub comes first.
provider_registry.register_hosting_provider(Arc::new(Github));
// Then GitLab.
provider_registry.register_hosting_provider(Arc::new(Gitlab));
// Then the other providers, in the order they were added.
provider_registry.register_hosting_provider(Arc::new(Gitee));
provider_registry.register_hosting_provider(Arc::new(Bitbucket));
provider_registry.register_hosting_provider(Arc::new(Sourcehut));
provider_registry.register_hosting_provider(Arc::new(Codeberg));
provider_registry.register_hosting_provider(Arc::new(Gitee));
provider_registry.register_hosting_provider(Arc::new(Github));
provider_registry.register_hosting_provider(Arc::new(Gitlab::new()));
provider_registry.register_hosting_provider(Arc::new(Sourcehut));
}
/// Registers additional Git hosting providers.
///
/// These require information from the Git repository to construct, so their
/// registration is deferred until we have a Git repository initialized.
pub fn register_additional_providers(
provider_registry: Arc<GitHostingProviderRegistry>,
repository: Arc<dyn GitRepository>,
) {
let Some(origin_url) = repository.remote_url("origin") else {
return;
};
if let Ok(gitlab_self_hosted) = Gitlab::from_remote_url(&origin_url) {
provider_registry.register_hosting_provider(Arc::new(gitlab_self_hosted));
}
}

View File

@@ -1,6 +1,11 @@
use std::str::FromStr;
use url::Url;
use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
use git::{
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
RemoteUrl,
};
pub struct Bitbucket;
@@ -25,18 +30,22 @@ impl GitHostingProvider for Bitbucket {
format!("lines-{start_line}:{end_line}")
}
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
if url.contains("bitbucket.org") {
let (_, repo_with_owner) = url.trim_end_matches(".git").split_once("bitbucket.org")?;
let (owner, repo) = repo_with_owner
.trim_start_matches('/')
.trim_start_matches(':')
.split_once('/')?;
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
let url = RemoteUrl::from_str(url).ok()?;
return Some(ParsedGitRemote { owner, repo });
let host = url.host_str()?;
if host != "bitbucket.org" {
return None;
}
None
let mut path_segments = url.path_segments()?;
let owner = path_segments.next()?;
let repo = path_segments.next()?.trim_end_matches(".git");
Some(ParsedGitRemote {
owner: owner.into(),
repo: repo.into(),
})
}
fn build_commit_permalink(
@@ -75,53 +84,62 @@ impl GitHostingProvider for Bitbucket {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use git::{parse_git_remote_url, GitHostingProviderRegistry};
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_parse_git_remote_url_bitbucket_https_with_username() {
let provider_registry = Arc::new(GitHostingProviderRegistry::new());
provider_registry.register_hosting_provider(Arc::new(Bitbucket));
let url = "https://thorstenballzed@bitbucket.org/thorstenzed/testingrepo.git";
let (provider, parsed) = parse_git_remote_url(provider_registry, url).unwrap();
assert_eq!(provider.name(), "Bitbucket");
assert_eq!(parsed.owner, "thorstenzed");
assert_eq!(parsed.repo, "testingrepo");
fn test_parse_remote_url_given_ssh_url() {
let parsed_remote = Bitbucket
.parse_remote_url("git@bitbucket.org:zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_git_remote_url_bitbucket_https_without_username() {
let provider_registry = Arc::new(GitHostingProviderRegistry::new());
provider_registry.register_hosting_provider(Arc::new(Bitbucket));
let url = "https://bitbucket.org/thorstenzed/testingrepo.git";
let (provider, parsed) = parse_git_remote_url(provider_registry, url).unwrap();
assert_eq!(provider.name(), "Bitbucket");
assert_eq!(parsed.owner, "thorstenzed");
assert_eq!(parsed.repo, "testingrepo");
fn test_parse_remote_url_given_https_url() {
let parsed_remote = Bitbucket
.parse_remote_url("https://bitbucket.org/zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_git_remote_url_bitbucket_git() {
let provider_registry = Arc::new(GitHostingProviderRegistry::new());
provider_registry.register_hosting_provider(Arc::new(Bitbucket));
let url = "git@bitbucket.org:thorstenzed/testingrepo.git";
let (provider, parsed) = parse_git_remote_url(provider_registry, url).unwrap();
assert_eq!(provider.name(), "Bitbucket");
assert_eq!(parsed.owner, "thorstenzed");
assert_eq!(parsed.repo, "testingrepo");
fn test_parse_remote_url_given_https_url_with_username() {
let parsed_remote = Bitbucket
.parse_remote_url("https://thorstenballzed@bitbucket.org/zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_build_bitbucket_permalink_from_ssh_url() {
let remote = ParsedGitRemote {
owner: "thorstenzed",
repo: "testingrepo",
};
fn test_build_bitbucket_permalink() {
let permalink = Bitbucket.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "f00b4r",
path: "main.rs",
@@ -129,18 +147,17 @@ mod tests {
},
);
let expected_url = "https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs";
let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_bitbucket_permalink_from_ssh_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "thorstenzed",
repo: "testingrepo",
};
fn test_build_bitbucket_permalink_with_single_line_selection() {
let permalink = Bitbucket.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "f00b4r",
path: "main.rs",
@@ -148,19 +165,17 @@ mod tests {
},
);
let expected_url =
"https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs#lines-7";
let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_bitbucket_permalink_from_ssh_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "thorstenzed",
repo: "testingrepo",
};
fn test_build_bitbucket_permalink_with_multi_line_selection() {
let permalink = Bitbucket.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "f00b4r",
path: "main.rs",
@@ -169,7 +184,7 @@ mod tests {
);
let expected_url =
"https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs#lines-24:48";
"https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
}

View File

@@ -1,3 +1,4 @@
use std::str::FromStr;
use std::sync::Arc;
use anyhow::{bail, Context, Result};
@@ -9,6 +10,7 @@ use url::Url;
use git::{
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
RemoteUrl,
};
#[derive(Debug, Deserialize)]
@@ -103,19 +105,22 @@ impl GitHostingProvider for Codeberg {
format!("L{start_line}-L{end_line}")
}
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
if url.starts_with("git@codeberg.org:") || url.starts_with("https://codeberg.org/") {
let repo_with_owner = url
.trim_start_matches("git@codeberg.org:")
.trim_start_matches("https://codeberg.org/")
.trim_end_matches(".git");
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
let url = RemoteUrl::from_str(url).ok()?;
let (owner, repo) = repo_with_owner.split_once('/')?;
return Some(ParsedGitRemote { owner, repo });
let host = url.host_str()?;
if host != "codeberg.org" {
return None;
}
None
let mut path_segments = url.path_segments()?;
let owner = path_segments.next()?;
let repo = path_segments.next()?.trim_end_matches(".git");
Some(ParsedGitRemote {
owner: owner.into(),
repo: repo.into(),
})
}
fn build_commit_permalink(
@@ -170,16 +175,47 @@ impl GitHostingProvider for Codeberg {
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_build_codeberg_permalink_from_ssh_url() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed",
};
fn test_parse_remote_url_given_ssh_url() {
let parsed_remote = Codeberg
.parse_remote_url("git@codeberg.org:zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_https_url() {
let parsed_remote = Codeberg
.parse_remote_url("https://codeberg.org/zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_build_codeberg_permalink() {
let permalink = Codeberg.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/editor/src/git/permalink.rs",
@@ -187,18 +223,17 @@ mod tests {
},
);
let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_codeberg_permalink_from_ssh_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed",
};
fn test_build_codeberg_permalink_with_single_line_selection() {
let permalink = Codeberg.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/editor/src/git/permalink.rs",
@@ -206,18 +241,17 @@ mod tests {
},
);
let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_codeberg_permalink_from_ssh_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed",
};
fn test_build_codeberg_permalink_with_multi_line_selection() {
let permalink = Codeberg.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/editor/src/git/permalink.rs",
@@ -225,64 +259,7 @@ mod tests {
},
);
let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_codeberg_permalink_from_https_url() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed",
};
let permalink = Codeberg.build_permalink(
remote,
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/zed/src/main.rs",
selection: None,
},
);
let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_codeberg_permalink_from_https_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed",
};
let permalink = Codeberg.build_permalink(
remote,
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/zed/src/main.rs",
selection: Some(6..6),
},
);
let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_codeberg_permalink_from_https_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed",
};
let permalink = Codeberg.build_permalink(
remote,
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/zed/src/main.rs",
selection: Some(23..47),
},
);
let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs#L24-L48";
let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
}

View File

@@ -1,6 +1,11 @@
use std::str::FromStr;
use url::Url;
use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
use git::{
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
RemoteUrl,
};
pub struct Gitee;
@@ -25,19 +30,22 @@ impl GitHostingProvider for Gitee {
format!("L{start_line}-{end_line}")
}
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
if url.starts_with("git@gitee.com:") || url.starts_with("https://gitee.com/") {
let repo_with_owner = url
.trim_start_matches("git@gitee.com:")
.trim_start_matches("https://gitee.com/")
.trim_end_matches(".git");
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
let url = RemoteUrl::from_str(url).ok()?;
let (owner, repo) = repo_with_owner.split_once('/')?;
return Some(ParsedGitRemote { owner, repo });
let host = url.host_str()?;
if host != "gitee.com" {
return None;
}
None
let mut path_segments = url.path_segments()?;
let owner = path_segments.next()?;
let repo = path_segments.next()?.trim_end_matches(".git");
Some(ParsedGitRemote {
owner: owner.into(),
repo: repo.into(),
})
}
fn build_commit_permalink(
@@ -76,16 +84,47 @@ impl GitHostingProvider for Gitee {
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_build_gitee_permalink_from_ssh_url() {
let remote = ParsedGitRemote {
owner: "libkitten",
repo: "zed",
};
fn test_parse_remote_url_given_ssh_url() {
let parsed_remote = Gitee
.parse_remote_url("git@gitee.com:zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_https_url() {
let parsed_remote = Gitee
.parse_remote_url("https://gitee.com/zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_build_gitee_permalink() {
let permalink = Gitee.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
path: "crates/editor/src/git/permalink.rs",
@@ -93,18 +132,17 @@ mod tests {
},
);
let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs";
let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_gitee_permalink_from_ssh_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "libkitten",
repo: "zed",
};
fn test_build_gitee_permalink_with_single_line_selection() {
let permalink = Gitee.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
path: "crates/editor/src/git/permalink.rs",
@@ -112,18 +150,17 @@ mod tests {
},
);
let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L7";
let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_gitee_permalink_from_ssh_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "libkitten",
repo: "zed",
};
fn test_build_gitee_permalink_with_multi_line_selection() {
let permalink = Gitee.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
path: "crates/editor/src/git/permalink.rs",
@@ -131,64 +168,7 @@ mod tests {
},
);
let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L24-48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_gitee_permalink_from_https_url() {
let remote = ParsedGitRemote {
owner: "libkitten",
repo: "zed",
};
let permalink = Gitee.build_permalink(
remote,
BuildPermalinkParams {
sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
path: "crates/zed/src/main.rs",
selection: None,
},
);
let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_gitee_permalink_from_https_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "libkitten",
repo: "zed",
};
let permalink = Gitee.build_permalink(
remote,
BuildPermalinkParams {
sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
path: "crates/zed/src/main.rs",
selection: Some(6..6),
},
);
let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_gitee_permalink_from_https_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "libkitten",
repo: "zed",
};
let permalink = Gitee.build_permalink(
remote,
BuildPermalinkParams {
sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
path: "crates/zed/src/main.rs",
selection: Some(23..47),
},
);
let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs#L24-48";
let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L24-48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
}

View File

@@ -1,3 +1,4 @@
use std::str::FromStr;
use std::sync::{Arc, OnceLock};
use anyhow::{bail, Context, Result};
@@ -10,7 +11,7 @@ use url::Url;
use git::{
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
PullRequest,
PullRequest, RemoteUrl,
};
fn pull_request_number_regex() -> &'static Regex {
@@ -107,19 +108,22 @@ impl GitHostingProvider for Github {
format!("L{start_line}-L{end_line}")
}
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
if url.starts_with("git@github.com:") || url.starts_with("https://github.com/") {
let repo_with_owner = url
.trim_start_matches("git@github.com:")
.trim_start_matches("https://github.com/")
.trim_end_matches(".git");
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
let url = RemoteUrl::from_str(url).ok()?;
let (owner, repo) = repo_with_owner.split_once('/')?;
return Some(ParsedGitRemote { owner, repo });
let host = url.host_str()?;
if host != "github.com" {
return None;
}
None
let mut path_segments = url.path_segments()?;
let owner = path_segments.next()?;
let repo = path_segments.next()?.trim_end_matches(".git");
Some(ParsedGitRemote {
owner: owner.into(),
repo: repo.into(),
})
}
fn build_commit_permalink(
@@ -193,16 +197,61 @@ impl GitHostingProvider for Github {
#[cfg(test)]
mod tests {
// TODO: Replace with `indoc`.
use unindent::Unindent;
use indoc::indoc;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_parse_remote_url_given_ssh_url() {
let parsed_remote = Github
.parse_remote_url("git@github.com:zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_https_url() {
let parsed_remote = Github
.parse_remote_url("https://github.com/zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_https_url_with_username() {
let parsed_remote = Github
.parse_remote_url("https://jlannister@github.com/some-org/some-repo.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "some-org".into(),
repo: "some-repo".into(),
}
);
}
#[test]
fn test_build_github_permalink_from_ssh_url() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
owner: "zed-industries".into(),
repo: "zed".into(),
};
let permalink = Github.build_permalink(
remote,
@@ -218,51 +267,12 @@ mod tests {
}
#[test]
fn test_build_github_permalink_from_ssh_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
fn test_build_github_permalink() {
let permalink = Github.build_permalink(
remote,
BuildPermalinkParams {
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
path: "crates/editor/src/git/permalink.rs",
selection: Some(6..6),
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
);
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_github_permalink_from_ssh_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Github.build_permalink(
remote,
BuildPermalinkParams {
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
path: "crates/editor/src/git/permalink.rs",
selection: Some(23..47),
},
);
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_github_permalink_from_https_url() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Github.build_permalink(
remote,
BuildPermalinkParams {
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
path: "crates/zed/src/main.rs",
@@ -275,55 +285,53 @@ mod tests {
}
#[test]
fn test_build_github_permalink_from_https_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
fn test_build_github_permalink_with_single_line_selection() {
let permalink = Github.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
path: "crates/zed/src/main.rs",
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
path: "crates/editor/src/git/permalink.rs",
selection: Some(6..6),
},
);
let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_github_permalink_from_https_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
fn test_build_github_permalink_with_multi_line_selection() {
let permalink = Github.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
path: "crates/zed/src/main.rs",
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
path: "crates/editor/src/git/permalink.rs",
selection: Some(23..47),
},
);
let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-L48";
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_github_pull_requests() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
owner: "zed-industries".into(),
repo: "zed".into(),
};
let message = "This does not contain a pull request";
assert!(Github.extract_pull_request(&remote, message).is_none());
// Pull request number at end of first line
let message = r#"
let message = indoc! {r#"
project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
Fixes #10597
@@ -332,7 +340,7 @@ mod tests {
- Fixed "project panel: collapse all entries" expanding collapsed worktrees.
"#
.unindent();
};
assert_eq!(
Github
@@ -344,12 +352,12 @@ mod tests {
);
// Pull request number in middle of line, which we want to ignore
let message = r#"
let message = indoc! {r#"
Follow-up to #10687 to fix problems
See the original PR, this is a fix.
"#
.unindent();
};
assert_eq!(Github.extract_pull_request(&remote, &message), None);
}
}

View File

@@ -1,16 +1,60 @@
use std::str::FromStr;
use anyhow::{anyhow, bail, Result};
use url::Url;
use util::maybe;
use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
use git::{
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
RemoteUrl,
};
pub struct Gitlab;
#[derive(Debug)]
pub struct Gitlab {
name: String,
base_url: Url,
}
impl Gitlab {
pub fn new() -> Self {
Self {
name: "GitLab".to_string(),
base_url: Url::parse("https://gitlab.com").unwrap(),
}
}
pub fn from_remote_url(remote_url: &str) -> Result<Self> {
let host = maybe!({
if let Some(remote_url) = remote_url.strip_prefix("git@") {
if let Some((host, _)) = remote_url.trim_start_matches("git@").split_once(':') {
return Some(host.to_string());
}
}
Url::parse(&remote_url)
.ok()
.and_then(|remote_url| remote_url.host_str().map(|host| host.to_string()))
})
.ok_or_else(|| anyhow!("URL has no host"))?;
if !host.contains("gitlab") {
bail!("not a GitLab URL");
}
Ok(Self {
name: "GitLab Self-Hosted".to_string(),
base_url: Url::parse(&format!("https://{}", host))?,
})
}
}
impl GitHostingProvider for Gitlab {
fn name(&self) -> String {
"GitLab".to_string()
self.name.clone()
}
fn base_url(&self) -> Url {
Url::parse("https://gitlab.com").unwrap()
self.base_url.clone()
}
fn supports_avatars(&self) -> bool {
@@ -25,19 +69,22 @@ impl GitHostingProvider for Gitlab {
format!("L{start_line}-{end_line}")
}
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
if url.starts_with("git@gitlab.com:") || url.starts_with("https://gitlab.com/") {
let repo_with_owner = url
.trim_start_matches("git@gitlab.com:")
.trim_start_matches("https://gitlab.com/")
.trim_end_matches(".git");
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
let url = RemoteUrl::from_str(url).ok()?;
let (owner, repo) = repo_with_owner.split_once('/')?;
return Some(ParsedGitRemote { owner, repo });
let host = url.host_str()?;
if host != self.base_url.host_str()? {
return None;
}
None
let mut path_segments = url.path_segments()?.collect::<Vec<_>>();
let repo = path_segments.pop()?.trim_end_matches(".git");
let owner = path_segments.join("/");
Some(ParsedGitRemote {
owner: owner.into(),
repo: repo.into(),
})
}
fn build_commit_permalink(
@@ -79,16 +126,82 @@ impl GitHostingProvider for Gitlab {
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_build_gitlab_permalink_from_ssh_url() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Gitlab.build_permalink(
remote,
fn test_parse_remote_url_given_ssh_url() {
let parsed_remote = Gitlab::new()
.parse_remote_url("git@gitlab.com:zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_https_url() {
let parsed_remote = Gitlab::new()
.parse_remote_url("https://gitlab.com/zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_self_hosted_ssh_url() {
let remote_url = "git@gitlab.my-enterprise.com:zed-industries/zed.git";
let parsed_remote = Gitlab::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_self_hosted_https_url_with_subgroup() {
let remote_url = "https://gitlab.my-enterprise.com/group/subgroup/zed.git";
let parsed_remote = Gitlab::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "group/subgroup".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_build_gitlab_permalink() {
let permalink = Gitlab::new().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
path: "crates/editor/src/git/permalink.rs",
@@ -101,13 +214,12 @@ mod tests {
}
#[test]
fn test_build_gitlab_permalink_from_ssh_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Gitlab.build_permalink(
remote,
fn test_build_gitlab_permalink_with_single_line_selection() {
let permalink = Gitlab::new().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
path: "crates/editor/src/git/permalink.rs",
@@ -120,13 +232,12 @@ mod tests {
}
#[test]
fn test_build_gitlab_permalink_from_ssh_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Gitlab.build_permalink(
remote,
fn test_build_gitlab_permalink_with_multi_line_selection() {
let permalink = Gitlab::new().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
path: "crates/editor/src/git/permalink.rs",
@@ -139,13 +250,36 @@ mod tests {
}
#[test]
fn test_build_gitlab_permalink_from_https_url() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Gitlab.build_permalink(
remote,
fn test_build_gitlab_self_hosted_permalink_from_ssh_url() {
let gitlab =
Gitlab::from_remote_url("git@gitlab.some-enterprise.com:zed-industries/zed.git")
.unwrap();
let permalink = gitlab.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
path: "crates/editor/src/git/permalink.rs",
selection: None,
},
);
let expected_url = "https://gitlab.some-enterprise.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_gitlab_self_hosted_permalink_from_https_url() {
let gitlab =
Gitlab::from_remote_url("https://gitlab-instance.big-co.com/zed-industries/zed.git")
.unwrap();
let permalink = gitlab.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
path: "crates/zed/src/main.rs",
@@ -153,45 +287,7 @@ mod tests {
},
);
let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_gitlab_permalink_from_https_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Gitlab.build_permalink(
remote,
BuildPermalinkParams {
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
path: "crates/zed/src/main.rs",
selection: Some(6..6),
},
);
let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_gitlab_permalink_from_https_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Gitlab.build_permalink(
remote,
BuildPermalinkParams {
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
path: "crates/zed/src/main.rs",
selection: Some(23..47),
},
);
let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-48";
let expected_url = "https://gitlab-instance.big-co.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
}

View File

@@ -1,6 +1,11 @@
use std::str::FromStr;
use url::Url;
use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
use git::{
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
RemoteUrl,
};
pub struct Sourcehut;
@@ -25,21 +30,27 @@ impl GitHostingProvider for Sourcehut {
format!("L{start_line}-{end_line}")
}
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
if url.starts_with("git@git.sr.ht:") || url.starts_with("https://git.sr.ht/") {
// sourcehut indicates a repo with '.git' suffix as a separate repo.
// For example, "git@git.sr.ht:~username/repo" and "git@git.sr.ht:~username/repo.git"
// are two distinct repositories.
let repo_with_owner = url
.trim_start_matches("git@git.sr.ht:~")
.trim_start_matches("https://git.sr.ht/~");
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
let url = RemoteUrl::from_str(url).ok()?;
let (owner, repo) = repo_with_owner.split_once('/')?;
return Some(ParsedGitRemote { owner, repo });
let host = url.host_str()?;
if host != "git.sr.ht" {
return None;
}
None
let mut path_segments = url.path_segments()?;
let owner = path_segments.next()?.trim_start_matches('~');
// We don't trim the `.git` suffix here like we do elsewhere, as
// sourcehut treats a repo with `.git` suffix as a separate repo.
//
// For example, `git@git.sr.ht:~username/repo` and `git@git.sr.ht:~username/repo.git`
// are two distinct repositories.
let repo = path_segments.next()?;
Some(ParsedGitRemote {
owner: owner.into(),
repo: repo.into(),
})
}
fn build_commit_permalink(
@@ -78,16 +89,62 @@ impl GitHostingProvider for Sourcehut {
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_build_sourcehut_permalink_from_ssh_url() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed",
};
fn test_parse_remote_url_given_ssh_url() {
let parsed_remote = Sourcehut
.parse_remote_url("git@git.sr.ht:~zed-industries/zed")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_ssh_url_with_git_suffix() {
let parsed_remote = Sourcehut
.parse_remote_url("git@git.sr.ht:~zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed.git".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_https_url() {
let parsed_remote = Sourcehut
.parse_remote_url("https://git.sr.ht/~zed-industries/zed")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_build_sourcehut_permalink() {
let permalink = Sourcehut.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/editor/src/git/permalink.rs",
@@ -95,18 +152,17 @@ mod tests {
},
);
let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_permalink_from_ssh_url_with_git_prefix() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed.git",
};
fn test_build_sourcehut_permalink_with_git_suffix() {
let permalink = Sourcehut.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed.git".into(),
},
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/editor/src/git/permalink.rs",
@@ -114,18 +170,17 @@ mod tests {
},
);
let expected_url = "https://git.sr.ht/~rajveermalviya/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
let expected_url = "https://git.sr.ht/~zed-industries/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_permalink_from_ssh_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed",
};
fn test_build_sourcehut_permalink_with_single_line_selection() {
let permalink = Sourcehut.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/editor/src/git/permalink.rs",
@@ -133,18 +188,17 @@ mod tests {
},
);
let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7";
let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_permalink_from_ssh_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed",
};
fn test_build_sourcehut_permalink_with_multi_line_selection() {
let permalink = Sourcehut.build_permalink(
remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/editor/src/git/permalink.rs",
@@ -152,64 +206,7 @@ mod tests {
},
);
let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_permalink_from_https_url() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed",
};
let permalink = Sourcehut.build_permalink(
remote,
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/zed/src/main.rs",
selection: None,
},
);
let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/zed/src/main.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_permalink_from_https_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed",
};
let permalink = Sourcehut.build_permalink(
remote,
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/zed/src/main.rs",
selection: Some(6..6),
},
);
let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/zed/src/main.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_permalink_from_https_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "rajveermalviya",
repo: "zed",
};
let permalink = Sourcehut.build_permalink(
remote,
BuildPermalinkParams {
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
path: "crates/zed/src/main.rs",
selection: Some(23..47),
},
);
let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/zed/src/main.rs#L24-48";
let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
}

View File

@@ -37,34 +37,34 @@ impl CursorPosition {
}
fn update_position(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
let editor = editor.read(cx);
let buffer = editor.buffer().read(cx).snapshot(cx);
editor.update(cx, |editor, cx| {
let buffer = editor.buffer().read(cx).snapshot(cx);
self.selected_count = Default::default();
self.selected_count.selections = editor.selections.count();
let mut last_selection: Option<Selection<usize>> = None;
for selection in editor.selections.all::<usize>(cx) {
self.selected_count.characters += buffer
.text_for_range(selection.start..selection.end)
.map(|t| t.chars().count())
.sum::<usize>();
if last_selection
.as_ref()
.map_or(true, |last_selection| selection.id > last_selection.id)
{
last_selection = Some(selection);
}
}
for selection in editor.selections.all::<Point>(cx) {
if selection.end != selection.start {
self.selected_count.lines += (selection.end.row - selection.start.row) as usize;
if selection.end.column != 0 {
self.selected_count.lines += 1;
self.selected_count = Default::default();
self.selected_count.selections = editor.selections.count();
let mut last_selection: Option<Selection<usize>> = None;
for selection in editor.selections.all::<usize>(cx) {
self.selected_count.characters += buffer
.text_for_range(selection.start..selection.end)
.map(|t| t.chars().count())
.sum::<usize>();
if last_selection
.as_ref()
.map_or(true, |last_selection| selection.id > last_selection.id)
{
last_selection = Some(selection);
}
}
}
self.position = last_selection.map(|s| s.head().to_point(&buffer));
for selection in editor.selections.all::<Point>(cx) {
if selection.end != selection.start {
self.selected_count.lines += (selection.end.row - selection.start.row) as usize;
if selection.end.column != 0 {
self.selected_count.lines += 1;
}
}
}
self.position = last_selection.map(|s| s.head().to_point(&buffer));
});
cx.notify();
}

View File

@@ -56,8 +56,8 @@ impl GoToLine {
}
pub fn new(active_editor: View<Editor>, cx: &mut ViewContext<Self>) -> Self {
let editor = active_editor.read(cx);
let cursor = editor.selections.last::<Point>(cx).head();
let cursor =
active_editor.update(cx, |editor, cx| editor.selections.last::<Point>(cx).head());
let line = cursor.row + 1;
let column = cursor.column + 1;

View File

@@ -217,6 +217,7 @@ pub(crate) type KeystrokeObserver =
type QuitHandler = Box<dyn FnOnce(&mut AppContext) -> LocalBoxFuture<'static, ()> + 'static>;
type ReleaseListener = Box<dyn FnOnce(&mut dyn Any, &mut AppContext) + 'static>;
type NewViewListener = Box<dyn FnMut(AnyView, &mut WindowContext) + 'static>;
type NewModelListener = Box<dyn FnMut(AnyModel, &mut AppContext) + 'static>;
/// Contains the state of the full application, and passed as a reference to a variety of callbacks.
/// Other contexts such as [ModelContext], [WindowContext], and [ViewContext] deref to this type, making it the most general context type.
@@ -237,6 +238,7 @@ pub struct AppContext {
http_client: Arc<dyn HttpClient>,
pub(crate) globals_by_type: FxHashMap<TypeId, Box<dyn Any>>,
pub(crate) entities: EntityMap,
pub(crate) new_model_observers: SubscriberSet<TypeId, NewModelListener>,
pub(crate) new_view_observers: SubscriberSet<TypeId, NewViewListener>,
pub(crate) windows: SlotMap<WindowId, Option<Window>>,
pub(crate) window_handles: FxHashMap<WindowId, AnyWindowHandle>,
@@ -296,6 +298,7 @@ impl AppContext {
globals_by_type: FxHashMap::default(),
entities,
new_view_observers: SubscriberSet::new(),
new_model_observers: SubscriberSet::new(),
window_handles: FxHashMap::default(),
windows: SlotMap::with_key(),
keymap: Rc::new(RefCell::new(Keymap::default())),
@@ -1016,6 +1019,7 @@ impl AppContext {
activate();
subscription
}
/// Arrange for the given function to be invoked whenever a view of the specified type is created.
/// The function will be passed a mutable reference to the view along with an appropriate context.
pub fn observe_new_views<V: 'static>(
@@ -1035,6 +1039,31 @@ impl AppContext {
)
}
pub(crate) fn new_model_observer(&self, key: TypeId, value: NewModelListener) -> Subscription {
let (subscription, activate) = self.new_model_observers.insert(key, value);
activate();
subscription
}
/// Arrange for the given function to be invoked whenever a view of the specified type is created.
/// The function will be passed a mutable reference to the view along with an appropriate context.
pub fn observe_new_models<T: 'static>(
&self,
on_new: impl 'static + Fn(&mut T, &mut ModelContext<T>),
) -> Subscription {
self.new_model_observer(
TypeId::of::<T>(),
Box::new(move |any_model: AnyModel, cx: &mut AppContext| {
any_model
.downcast::<T>()
.unwrap()
.update(cx, |model_state, cx| {
on_new(model_state, cx);
})
}),
)
}
/// Observe the release of a model or view. The callback is invoked after the model or view
/// has no more strong references but before it has been dropped.
pub fn observe_release<E, T>(
@@ -1346,8 +1375,21 @@ impl Context for AppContext {
) -> Model<T> {
self.update(|cx| {
let slot = cx.entities.reserve();
let model = slot.clone();
let entity = build_model(&mut ModelContext::new(cx, slot.downgrade()));
cx.entities.insert(slot, entity)
cx.entities.insert(slot, entity);
// Non-generic part to avoid leaking SubscriberSet to invokers of `new_view`.
fn notify_observers(cx: &mut AppContext, tid: TypeId, model: AnyModel) {
cx.new_model_observers.clone().retain(&tid, |observer| {
let any_model = model.clone();
(observer)(any_model, cx);
true
});
}
notify_observers(cx, TypeId::of::<T>(), AnyModel::from(model.clone()));
model
})
}

View File

@@ -1,8 +1,9 @@
//! A list element that can be used to render a large number of differently sized elements
//! efficiently. Clients of this API need to ensure that elements outside of the scrolled
//! area do not change their height for this element to function correctly. In order to minimize
//! re-renders, this element's state is stored intrusively on your own views, so that your code
//! can coordinate directly with the list element's cached state.
//! area do not change their height for this element to function correctly. If your elements
//! do change height, notify the list element via [`ListState::splice`] or [`ListState::reset`].
//! In order to minimize re-renders, this element's state is stored intrusively
//! on your own views, so that your code can coordinate directly with the list element's cached state.
//!
//! If all of your elements are the same height, see [`UniformList`] for a simpler API

View File

@@ -1,6 +1,7 @@
use crate::{
black, fill, point, px, size, Bounds, Hsla, LineLayout, Pixels, Point, Result, SharedString,
StrikethroughStyle, UnderlineStyle, WindowContext, WrapBoundary, WrappedLineLayout,
black, fill, point, px, size, Bounds, Half, Hsla, LineLayout, Pixels, Point, Result,
SharedString, StrikethroughStyle, UnderlineStyle, WindowContext, WrapBoundary,
WrappedLineLayout,
};
use derive_more::{Deref, DerefMut};
use smallvec::SmallVec;
@@ -129,8 +130,9 @@ fn paint_line(
let text_system = cx.text_system().clone();
let mut glyph_origin = origin;
let mut prev_glyph_position = Point::default();
let mut max_glyph_size = size(px(0.), px(0.));
for (run_ix, run) in layout.runs.iter().enumerate() {
let max_glyph_size = text_system.bounding_box(run.font_id, layout.font_size).size;
max_glyph_size = text_system.bounding_box(run.font_id, layout.font_size).size;
for (glyph_ix, glyph) in run.glyphs.iter().enumerate() {
glyph_origin.x += glyph.position.x - prev_glyph_position.x;
@@ -139,6 +141,9 @@ fn paint_line(
wraps.next();
if let Some((background_origin, background_color)) = current_background.as_mut()
{
if glyph_origin.x == background_origin.x {
background_origin.x -= max_glyph_size.width.half()
}
cx.paint_quad(fill(
Bounds {
origin: *background_origin,
@@ -150,6 +155,9 @@ fn paint_line(
background_origin.y += line_height;
}
if let Some((underline_origin, underline_style)) = current_underline.as_mut() {
if glyph_origin.x == underline_origin.x {
underline_origin.x -= max_glyph_size.width.half();
};
cx.paint_underline(
*underline_origin,
glyph_origin.x - underline_origin.x,
@@ -161,6 +169,9 @@ fn paint_line(
if let Some((strikethrough_origin, strikethrough_style)) =
current_strikethrough.as_mut()
{
if glyph_origin.x == strikethrough_origin.x {
strikethrough_origin.x -= max_glyph_size.width.half();
};
cx.paint_strikethrough(
*strikethrough_origin,
glyph_origin.x - strikethrough_origin.x,
@@ -179,7 +190,18 @@ fn paint_line(
let mut finished_underline: Option<(Point<Pixels>, UnderlineStyle)> = None;
let mut finished_strikethrough: Option<(Point<Pixels>, StrikethroughStyle)> = None;
if glyph.index >= run_end {
if let Some(style_run) = decoration_runs.next() {
let mut style_run = decoration_runs.next();
// ignore style runs that apply to a partial glyph
while let Some(run) = style_run {
if glyph.index < run_end + (run.len as usize) {
break;
}
run_end += run.len as usize;
style_run = decoration_runs.next();
}
if let Some(style_run) = style_run {
if let Some((_, background_color)) = &mut current_background {
if style_run.background_color.as_ref() != Some(background_color) {
finished_background = current_background.take();
@@ -239,17 +261,24 @@ fn paint_line(
}
}
if let Some((background_origin, background_color)) = finished_background {
if let Some((mut background_origin, background_color)) = finished_background {
let mut width = glyph_origin.x - background_origin.x;
if background_origin.x == glyph_origin.x {
background_origin.x -= max_glyph_size.width.half();
};
cx.paint_quad(fill(
Bounds {
origin: background_origin,
size: size(glyph_origin.x - background_origin.x, line_height),
size: size(width, line_height),
},
background_color,
));
}
if let Some((underline_origin, underline_style)) = finished_underline {
if let Some((mut underline_origin, underline_style)) = finished_underline {
if underline_origin.x == glyph_origin.x {
underline_origin.x -= max_glyph_size.width.half();
};
cx.paint_underline(
underline_origin,
glyph_origin.x - underline_origin.x,
@@ -257,7 +286,12 @@ fn paint_line(
);
}
if let Some((strikethrough_origin, strikethrough_style)) = finished_strikethrough {
if let Some((mut strikethrough_origin, strikethrough_style)) =
finished_strikethrough
{
if strikethrough_origin.x == glyph_origin.x {
strikethrough_origin.x -= max_glyph_size.width.half();
};
cx.paint_strikethrough(
strikethrough_origin,
glyph_origin.x - strikethrough_origin.x,
@@ -299,7 +333,10 @@ fn paint_line(
last_line_end_x -= glyph.position.x;
}
if let Some((background_origin, background_color)) = current_background.take() {
if let Some((mut background_origin, background_color)) = current_background.take() {
if last_line_end_x == background_origin.x {
background_origin.x -= max_glyph_size.width.half()
};
cx.paint_quad(fill(
Bounds {
origin: background_origin,
@@ -309,7 +346,10 @@ fn paint_line(
));
}
if let Some((underline_start, underline_style)) = current_underline.take() {
if let Some((mut underline_start, underline_style)) = current_underline.take() {
if last_line_end_x == underline_start.x {
underline_start.x -= max_glyph_size.width.half()
};
cx.paint_underline(
underline_start,
last_line_end_x - underline_start.x,
@@ -317,7 +357,10 @@ fn paint_line(
);
}
if let Some((strikethrough_start, strikethrough_style)) = current_strikethrough.take() {
if let Some((mut strikethrough_start, strikethrough_style)) = current_strikethrough.take() {
if last_line_end_x == strikethrough_start.x {
strikethrough_start.x -= max_glyph_size.width.half()
};
cx.paint_strikethrough(
strikethrough_start,
last_line_end_x - strikethrough_start.x,

View File

@@ -4103,6 +4103,10 @@ impl<'a> BufferChunks<'a> {
diagnostic_endpoints
.sort_unstable_by_key(|endpoint| (endpoint.offset, !endpoint.is_start));
*diagnostics = diagnostic_endpoints.into_iter().peekable();
self.hint_depth = 0;
self.error_depth = 0;
self.warning_depth = 0;
self.information_depth = 0;
}
}
}

View File

@@ -38,7 +38,7 @@ menu.workspace = true
ollama = { workspace = true, features = ["schemars"] }
open_ai = { workspace = true, features = ["schemars"] }
parking_lot.workspace = true
proto = { workspace = true, features = ["test-support"] }
proto.workspace = true
project.workspace = true
schemars.workspace = true
serde.workspace = true
@@ -62,6 +62,7 @@ env_logger.workspace = true
language = { workspace = true, features = ["test-support"] }
log.workspace = true
project = { workspace = true, features = ["test-support"] }
proto = { workspace = true, features = ["test-support"] }
rand.workspace = true
text = { workspace = true, features = ["test-support"] }
unindent.workspace = true

View File

@@ -1237,6 +1237,22 @@ impl Render for LspLogToolbarItemView {
view.show_rpc_trace_for_server(row.server_id, cx);
}),
);
if server_selected && row.selected_entry == LogKind::Rpc {
let selected_ix = menu.select_last();
// Each language server has:
// 1. A title.
// 2. Server logs.
// 3. Server trace.
// 4. RPC messages.
// 5. Server capabilities
// Thus, if nth server's RPC is selected, the index of selected entry should match this formula
let _expected_index = ix * 5 + 3;
debug_assert_eq!(
Some(_expected_index),
selected_ix,
"Could not scroll to a just added LSP menu item"
);
}
menu = menu.entry(
SERVER_CAPABILITIES,
None,
@@ -1244,14 +1260,6 @@ impl Render for LspLogToolbarItemView {
view.show_capabilities_for_server(row.server_id, cx);
}),
);
if server_selected && row.selected_entry == LogKind::Rpc {
let selected_ix = menu.select_last();
debug_assert_eq!(
Some(ix * 4 + 3),
selected_ix,
"Could not scroll to a just added LSP menu item"
);
}
}
menu
})

View File

@@ -128,12 +128,14 @@ impl SyntaxTreeView {
fn editor_updated(&mut self, did_reparse: bool, cx: &mut ViewContext<Self>) -> Option<()> {
// Find which excerpt the cursor is in, and the position within that excerpted buffer.
let editor_state = self.editor.as_mut()?;
let editor = &editor_state.editor.read(cx);
let selection_range = editor.selections.last::<usize>(cx).range();
let multibuffer = editor.buffer().read(cx);
let (buffer, range, excerpt_id) = multibuffer
.range_to_buffer_ranges(selection_range, cx)
.pop()?;
let (buffer, range, excerpt_id) = editor_state.editor.update(cx, |editor, cx| {
let selection_range = editor.selections.last::<usize>(cx).range();
editor
.buffer()
.read(cx)
.range_to_buffer_ranges(selection_range, cx)
.pop()
})?;
// If the cursor has moved into a different excerpt, retrieve a new syntax layer
// from that buffer.

View File

@@ -1,7 +1,7 @@
name = "C++"
grammar = "cpp"
path_suffixes = ["cc", "hh", "cpp", "h", "hpp", "cxx", "hxx", "c++", "ipp", "inl", "cu", "cuh"]
line_comments = ["// "]
line_comments = ["// ", "/// ", "//! "]
autoclose_before = ";:.,=}])>"
brackets = [
{ start = "{", end = "}", close = true, newline = true },

View File

@@ -325,7 +325,7 @@ fn load_config(name: &str) -> LanguageConfig {
.with_context(|| format!("failed to load config.toml for language {name:?}"))
.unwrap();
#[cfg(not(feature = "load-grammars"))]
#[cfg(not(any(feature = "load-grammars", test)))]
{
config = LanguageConfig {
name: config.name,

View File

@@ -1,5 +1,14 @@
(attribute attribute: (identifier) @property)
(type (identifier) @type)
(generic_type (identifier) @type)
; Type alias
(type_alias_statement "type" @keyword)
; TypeVar with constraints in type parameters
(type
(tuple (identifier) @type)
)
; Function calls

View File

@@ -5,9 +5,9 @@ line_comments = ["// ", "/// ", "//! "]
autoclose_before = ";:.,=}])>"
brackets = [
{ start = "{", end = "}", close = true, newline = true },
{ start = "r#\"", end = "\"#", close = true, newline = true },
{ start = "r##\"", end = "\"##", close = true, newline = true },
{ start = "r###\"", end = "\"###", close = true, newline = true },
{ start = "r#\"", end = "\"#", close = true, newline = true, not_in = ["string", "comment"] },
{ start = "r##\"", end = "\"##", close = true, newline = true, not_in = ["string", "comment"] },
{ start = "r###\"", end = "\"###", close = true, newline = true, not_in = ["string", "comment"] },
{ start = "[", end = "]", close = true, newline = true },
{ start = "(", end = ")", close = true, newline = true },
{ start = "<", end = ">", close = false, newline = true, not_in = ["string", "comment"] },

View File

@@ -301,8 +301,8 @@ impl MarkdownPreviewView {
this.parse_markdown_from_active_editor(true, cx);
}
EditorEvent::SelectionsChanged { .. } => {
let editor = editor.read(cx);
let selection_range = editor.selections.last::<usize>(cx).range();
let selection_range =
editor.update(cx, |editor, cx| editor.selections.last::<usize>(cx).range());
this.selected_block = this.get_block_index_under_cursor(selection_range);
this.list_state.scroll_to_reveal_item(this.selected_block);
cx.notify();

View File

@@ -194,9 +194,11 @@ impl PickerDelegate for OutlineViewDelegate {
})
.collect();
let editor = self.active_editor.read(cx);
let cursor_offset = editor.selections.newest::<usize>(cx).head();
let buffer = editor.buffer().read(cx).snapshot(cx);
let (buffer, cursor_offset) = self.active_editor.update(cx, |editor, cx| {
let buffer = editor.buffer().read(cx).snapshot(cx);
let cursor_offset = editor.selections.newest::<usize>(cx).head();
(buffer, cursor_offset)
});
selected_index = self
.outline
.items

View File

@@ -35,7 +35,7 @@ use itertools::Itertools;
use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrev};
use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings};
use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides};
use project::{File, Fs, Item, Project};
use search::{BufferSearchBar, ProjectSearchView};
use serde::{Deserialize, Serialize};
@@ -2410,11 +2410,9 @@ impl OutlinePanel {
editor: &View<Editor>,
cx: &mut ViewContext<Self>,
) -> Option<PanelEntry> {
let selection = editor
.read(cx)
.selections
.newest::<language::Point>(cx)
.head();
let selection = editor.update(cx, |editor, cx| {
editor.selections.newest::<language::Point>(cx).head()
});
let editor_snapshot = editor.update(cx, |editor, cx| editor.snapshot(cx));
let multi_buffer = editor.read(cx).buffer();
let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
@@ -3748,7 +3746,7 @@ impl Render for OutlinePanel {
let pinned = self.pinned;
let settings = OutlinePanelSettings::get_global(cx);
let indent_size = settings.indent_size;
let show_indent_guides = settings.indent_guides;
let show_indent_guides = settings.indent_guides.show == ShowIndentGuides::Always;
let outline_panel = v_flex()
.id("outline-panel")

View File

@@ -10,6 +10,13 @@ pub enum OutlinePanelDockPosition {
Right,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ShowIndentGuides {
Always,
Never,
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
pub struct OutlinePanelSettings {
pub button: bool,
@@ -19,11 +26,22 @@ pub struct OutlinePanelSettings {
pub folder_icons: bool,
pub git_status: bool,
pub indent_size: f32,
pub indent_guides: bool,
pub indent_guides: IndentGuidesSettings,
pub auto_reveal_entries: bool,
pub auto_fold_dirs: bool,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct IndentGuidesSettings {
pub show: ShowIndentGuides,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct IndentGuidesSettingsContent {
/// When to show the scrollbar in the outline panel.
pub show: Option<ShowIndentGuides>,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
pub struct OutlinePanelSettingsContent {
/// Whether to show the outline panel button in the status bar.
@@ -54,10 +72,6 @@ pub struct OutlinePanelSettingsContent {
///
/// Default: 20
pub indent_size: Option<f32>,
/// Whether to show indent guides in the outline panel.
///
/// Default: true
pub indent_guides: Option<bool>,
/// Whether to reveal it in the outline panel automatically,
/// when a corresponding project entry becomes active.
/// Gitignored entries are never auto revealed.
@@ -69,6 +83,8 @@ pub struct OutlinePanelSettingsContent {
///
/// Default: true
pub auto_fold_dirs: Option<bool>,
/// Settings related to indent guides in the outline panel.
pub indent_guides: Option<IndentGuidesSettingsContent>,
}
impl Settings for OutlinePanelSettings {

View File

@@ -108,7 +108,11 @@ pub trait PickerDelegate: Sized + 'static {
fn should_dismiss(&self) -> bool {
true
}
fn confirm_completion(&self, _query: String) -> Option<String> {
fn confirm_completion(
&mut self,
_query: String,
_: &mut ViewContext<Picker<Self>>,
) -> Option<String> {
None
}
@@ -370,7 +374,7 @@ impl<D: PickerDelegate> Picker<D> {
}
fn confirm_completion(&mut self, _: &ConfirmCompletion, cx: &mut ViewContext<Self>) {
if let Some(new_query) = self.delegate.confirm_completion(self.query(cx)) {
if let Some(new_query) = self.delegate.confirm_completion(self.query(cx), cx) {
self.set_query(new_query, cx);
} else {
cx.propagate()

View File

@@ -14,14 +14,14 @@ use std::{
};
use util::paths::PathMatcher;
#[derive(Clone)]
#[derive(Debug, Clone)]
pub enum Prettier {
Real(RealPrettier),
#[cfg(any(test, feature = "test-support"))]
Test(TestPrettier),
}
#[derive(Clone)]
#[derive(Debug, Clone)]
pub struct RealPrettier {
default: bool,
prettier_dir: PathBuf,
@@ -29,7 +29,7 @@ pub struct RealPrettier {
}
#[cfg(any(test, feature = "test-support"))]
#[derive(Clone)]
#[derive(Debug, Clone)]
pub struct TestPrettier {
prettier_dir: PathBuf,
default: bool,
@@ -329,11 +329,7 @@ impl Prettier {
})?
.context("prettier params calculation")?;
let response = local
.server
.request::<Format>(params)
.await
.context("prettier format request")?;
let response = local.server.request::<Format>(params).await?;
let diff_task = buffer.update(cx, |buffer, cx| buffer.diff(response.text, cx))?;
Ok(diff_task.await)
}

View File

@@ -1,7 +1,7 @@
use crate::{
search::SearchQuery,
worktree_store::{WorktreeStore, WorktreeStoreEvent},
Item, NoRepositoryError, ProjectPath,
Item, ProjectPath,
};
use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
use anyhow::{anyhow, Context as _, Result};
@@ -1118,7 +1118,7 @@ impl BufferStore {
buffer: &Model<Buffer>,
version: Option<clock::Global>,
cx: &AppContext,
) -> Task<Result<Blame>> {
) -> Task<Result<Option<Blame>>> {
let buffer = buffer.read(cx);
let Some(file) = File::from_dyn(buffer.file()) else {
return Task::ready(Err(anyhow!("buffer has no file")));
@@ -1130,7 +1130,7 @@ impl BufferStore {
let blame_params = maybe!({
let (repo_entry, local_repo_entry) = match worktree.repo_for_path(&file.path) {
Some(repo_for_path) => repo_for_path,
None => anyhow::bail!(NoRepositoryError {}),
None => return Ok(None),
};
let relative_path = repo_entry
@@ -1144,13 +1144,16 @@ impl BufferStore {
None => buffer.as_rope().clone(),
};
anyhow::Ok((repo, relative_path, content))
anyhow::Ok(Some((repo, relative_path, content)))
});
cx.background_executor().spawn(async move {
let (repo, relative_path, content) = blame_params?;
let Some((repo, relative_path, content)) = blame_params? else {
return Ok(None);
};
repo.blame(&relative_path, content)
.with_context(|| format!("Failed to blame {:?}", relative_path.0))
.map(Some)
})
}
Worktree::Remote(worktree) => {
@@ -2112,7 +2115,13 @@ fn is_not_found_error(error: &anyhow::Error) -> bool {
.is_some_and(|err| err.kind() == io::ErrorKind::NotFound)
}
fn serialize_blame_buffer_response(blame: git::blame::Blame) -> proto::BlameBufferResponse {
fn serialize_blame_buffer_response(blame: Option<git::blame::Blame>) -> proto::BlameBufferResponse {
let Some(blame) = blame else {
return proto::BlameBufferResponse {
blame_response: None,
};
};
let entries = blame
.entries
.into_iter()
@@ -2154,14 +2163,19 @@ fn serialize_blame_buffer_response(blame: git::blame::Blame) -> proto::BlameBuff
.collect::<Vec<_>>();
proto::BlameBufferResponse {
entries,
messages,
permalinks,
remote_url: blame.remote_url,
blame_response: Some(proto::blame_buffer_response::BlameResponse {
entries,
messages,
permalinks,
remote_url: blame.remote_url,
}),
}
}
fn deserialize_blame_buffer_response(response: proto::BlameBufferResponse) -> git::blame::Blame {
fn deserialize_blame_buffer_response(
response: proto::BlameBufferResponse,
) -> Option<git::blame::Blame> {
let response = response.blame_response?;
let entries = response
.entries
.into_iter()
@@ -2202,10 +2216,10 @@ fn deserialize_blame_buffer_response(response: proto::BlameBufferResponse) -> gi
})
.collect::<HashMap<_, _>>();
Blame {
Some(Blame {
entries,
permalinks,
messages,
remote_url: response.remote_url,
}
})
}

View File

@@ -29,6 +29,7 @@ use gpui::{
Task, WeakModel,
};
use http_client::HttpClient;
use itertools::Itertools as _;
use language::{
language_settings::{
language_settings, FormatOnSave, Formatter, LanguageSettings, SelectedFormatter,
@@ -144,7 +145,6 @@ pub struct LocalLspStore {
HashMap<LanguageServerId, (LanguageServerName, Arc<LanguageServer>)>,
prettier_store: Model<PrettierStore>,
current_lsp_settings: HashMap<LanguageServerName, LspSettings>,
last_formatting_failure: Option<String>,
_subscription: gpui::Subscription,
}
@@ -563,9 +563,7 @@ impl LocalLspStore {
})?;
prettier_store::format_with_prettier(&prettier, &buffer.handle, cx)
.await
.transpose()
.ok()
.flatten()
.transpose()?
}
Formatter::External { command, arguments } => {
Self::format_via_external_command(buffer, command, arguments.as_deref(), cx)
@@ -675,6 +673,7 @@ impl LocalLspStore {
}
}
#[derive(Debug)]
pub struct FormattableBuffer {
handle: Model<Buffer>,
abs_path: Option<PathBuf>,
@@ -704,6 +703,7 @@ impl LspStoreMode {
pub struct LspStore {
mode: LspStoreMode,
last_formatting_failure: Option<String>,
downstream_client: Option<(AnyProtoClient, u64)>,
nonce: u128,
buffer_store: Model<BufferStore>,
@@ -786,6 +786,7 @@ impl LspStore {
pub fn init(client: &AnyProtoClient) {
client.add_model_request_handler(Self::handle_multi_lsp_query);
client.add_model_request_handler(Self::handle_restart_language_servers);
client.add_model_request_handler(Self::handle_cancel_language_server_work);
client.add_model_message_handler(Self::handle_start_language_server);
client.add_model_message_handler(Self::handle_update_language_server);
client.add_model_message_handler(Self::handle_language_server_log);
@@ -905,7 +906,6 @@ impl LspStore {
language_server_watcher_registrations: Default::default(),
current_lsp_settings: ProjectSettings::get_global(cx).lsp.clone(),
buffers_being_formatted: Default::default(),
last_formatting_failure: None,
prettier_store,
environment,
http_client,
@@ -915,6 +915,7 @@ impl LspStore {
this.as_local_mut().unwrap().shutdown_language_servers(cx)
}),
}),
last_formatting_failure: None,
downstream_client: None,
buffer_store,
worktree_store,
@@ -975,6 +976,7 @@ impl LspStore {
upstream_project_id: project_id,
}),
downstream_client: None,
last_formatting_failure: None,
buffer_store,
worktree_store,
languages: languages.clone(),
@@ -4043,6 +4045,20 @@ impl LspStore {
.or_default()
.insert(server_id, summary);
}
if let Some((downstream_client, project_id)) = &this.downstream_client {
downstream_client
.send(proto::UpdateDiagnosticSummary {
project_id: *project_id,
worktree_id: worktree_id.to_proto(),
summary: Some(proto::DiagnosticSummary {
path: project_path.path.to_string_lossy().to_string(),
language_server_id: server_id.0 as u64,
error_count: summary.error_count as u32,
warning_count: summary.warning_count as u32,
}),
})
.log_err();
}
cx.emit(LspStoreEvent::DiagnosticsUpdated {
language_server_id: LanguageServerId(message.language_server_id as usize),
path: project_path,
@@ -4103,7 +4119,7 @@ impl LspStore {
LanguageServerProgress {
title: payload.title,
is_disk_based_diagnostics_progress: false,
is_cancellable: false,
is_cancellable: payload.is_cancellable.unwrap_or(false),
message: payload.message,
percentage: payload.percentage.map(|p| p as usize),
last_update_at: cx.background_executor().now(),
@@ -4119,7 +4135,7 @@ impl LspStore {
LanguageServerProgress {
title: None,
is_disk_based_diagnostics_progress: false,
is_cancellable: false,
is_cancellable: payload.is_cancellable.unwrap_or(false),
message: payload.message,
percentage: payload.percentage.map(|p| p as usize),
last_update_at: cx.background_executor().now(),
@@ -4620,6 +4636,7 @@ impl LspStore {
token,
message: report.message,
percentage: report.percentage,
is_cancellable: report.cancellable,
},
),
})
@@ -4653,6 +4670,7 @@ impl LspStore {
title: progress.title,
message: progress.message,
percentage: progress.percentage.map(|p| p as u32),
is_cancellable: Some(progress.is_cancellable),
}),
})
}
@@ -4683,6 +4701,9 @@ impl LspStore {
if progress.percentage.is_some() {
entry.percentage = progress.percentage;
}
if progress.is_cancellable != entry.is_cancellable {
entry.is_cancellable = progress.is_cancellable;
}
cx.notify();
return true;
}
@@ -5153,22 +5174,52 @@ impl LspStore {
mut cx: AsyncAppContext,
) -> Result<proto::Ack> {
this.update(&mut cx, |this, cx| {
let buffers: Vec<_> = envelope
.payload
.buffer_ids
.into_iter()
.flat_map(|buffer_id| {
this.buffer_store
.read(cx)
.get(BufferId::new(buffer_id).log_err()?)
})
.collect();
this.restart_language_servers_for_buffers(buffers, cx)
let buffers = this.buffer_ids_to_buffers(envelope.payload.buffer_ids.into_iter(), cx);
this.restart_language_servers_for_buffers(buffers, cx);
})?;
Ok(proto::Ack {})
}
pub async fn handle_cancel_language_server_work(
this: Model<Self>,
envelope: TypedEnvelope<proto::CancelLanguageServerWork>,
mut cx: AsyncAppContext,
) -> Result<proto::Ack> {
this.update(&mut cx, |this, cx| {
if let Some(work) = envelope.payload.work {
match work {
proto::cancel_language_server_work::Work::Buffers(buffers) => {
let buffers =
this.buffer_ids_to_buffers(buffers.buffer_ids.into_iter(), cx);
this.cancel_language_server_work_for_buffers(buffers, cx);
}
proto::cancel_language_server_work::Work::LanguageServerWork(work) => {
let server_id = LanguageServerId::from_proto(work.language_server_id);
this.cancel_language_server_work(server_id, work.token, cx);
}
}
}
})?;
Ok(proto::Ack {})
}
fn buffer_ids_to_buffers(
&mut self,
buffer_ids: impl Iterator<Item = u64>,
cx: &mut ModelContext<Self>,
) -> Vec<Model<Buffer>> {
buffer_ids
.into_iter()
.flat_map(|buffer_id| {
self.buffer_store
.read(cx)
.get(BufferId::new(buffer_id).log_err()?)
})
.collect::<Vec<_>>()
}
async fn handle_apply_additional_edits_for_completion(
this: Model<Self>,
envelope: TypedEnvelope<proto::ApplyCompletionAdditionalEdits>,
@@ -5214,9 +5265,9 @@ impl LspStore {
.map(language::proto::serialize_transaction),
})
}
pub fn last_formatting_failure(&self) -> Option<&str> {
self.as_local()
.and_then(|local| local.last_formatting_failure.as_deref())
self.last_formatting_failure.as_deref()
}
pub fn environment_for_buffer(
@@ -5287,23 +5338,16 @@ impl LspStore {
cx.clone(),
)
.await;
lsp_store.update(&mut cx, |lsp_store, _| {
let local = lsp_store.as_local_mut().unwrap();
match &result {
Ok(_) => local.last_formatting_failure = None,
Err(error) => {
local.last_formatting_failure.replace(error.to_string());
}
}
lsp_store.update_last_formatting_failure(&result);
})?;
result
})
} else if let Some((client, project_id)) = self.upstream_client() {
let buffer_store = self.buffer_store();
cx.spawn(move |_, mut cx| async move {
let response = client
cx.spawn(move |lsp_store, mut cx| async move {
let result = client
.request(proto::FormatBuffers {
project_id,
trigger: trigger as i32,
@@ -5314,13 +5358,21 @@ impl LspStore {
})
.collect::<Result<_>>()?,
})
.await?
.transaction
.ok_or_else(|| anyhow!("missing transaction"))?;
.await
.and_then(|result| result.transaction.context("missing transaction"));
lsp_store.update(&mut cx, |lsp_store, _| {
lsp_store.update_last_formatting_failure(&result);
})?;
let transaction_response = result?;
buffer_store
.update(&mut cx, |buffer_store, cx| {
buffer_store.deserialize_project_transaction(response, push_to_history, cx)
buffer_store.deserialize_project_transaction(
transaction_response,
push_to_history,
cx,
)
})?
.await
})
@@ -5342,7 +5394,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, FormatTarget::Buffer, cx))
anyhow::Ok(this.format(buffers, false, trigger, FormatTarget::Buffer, cx))
})??;
let project_transaction = format.await?;
@@ -5914,7 +5966,6 @@ impl LspStore {
let adapter = adapter.clone();
if let Some(this) = this.upgrade() {
adapter.process_diagnostics(&mut params);
// Everything else has to be on the server, Can we make it on the client?
this.update(&mut cx, |this, cx| {
this.update_diagnostics(
server_id,
@@ -6714,16 +6765,89 @@ impl LspStore {
buffers: impl IntoIterator<Item = Model<Buffer>>,
cx: &mut ModelContext<Self>,
) {
let servers = buffers
.into_iter()
.flat_map(|buffer| {
self.language_server_ids_for_buffer(buffer.read(cx), cx)
.into_iter()
})
.collect::<HashSet<_>>();
if let Some((client, project_id)) = self.upstream_client() {
let request = client.request(proto::CancelLanguageServerWork {
project_id,
work: Some(proto::cancel_language_server_work::Work::Buffers(
proto::cancel_language_server_work::Buffers {
buffer_ids: buffers
.into_iter()
.map(|b| b.read(cx).remote_id().to_proto())
.collect(),
},
)),
});
cx.background_executor()
.spawn(request)
.detach_and_log_err(cx);
} else {
let servers = buffers
.into_iter()
.flat_map(|buffer| {
self.language_server_ids_for_buffer(buffer.read(cx), cx)
.into_iter()
})
.collect::<HashSet<_>>();
for server_id in servers {
self.cancel_language_server_work(server_id, None, cx);
for server_id in servers {
self.cancel_language_server_work(server_id, None, cx);
}
}
}
pub(crate) fn cancel_language_server_work(
&mut self,
server_id: LanguageServerId,
token_to_cancel: Option<String>,
cx: &mut ModelContext<Self>,
) {
if let Some(local) = self.as_local() {
let status = self.language_server_statuses.get(&server_id);
let server = local.language_servers.get(&server_id);
if let Some((LanguageServerState::Running { server, .. }, status)) = server.zip(status)
{
for (token, progress) in &status.pending_work {
if let Some(token_to_cancel) = token_to_cancel.as_ref() {
if token != token_to_cancel {
continue;
}
}
if progress.is_cancellable {
server
.notify::<lsp::notification::WorkDoneProgressCancel>(
WorkDoneProgressCancelParams {
token: lsp::NumberOrString::String(token.clone()),
},
)
.ok();
}
if progress.is_cancellable {
server
.notify::<lsp::notification::WorkDoneProgressCancel>(
WorkDoneProgressCancelParams {
token: lsp::NumberOrString::String(token.clone()),
},
)
.ok();
}
}
}
} else if let Some((client, project_id)) = self.upstream_client() {
let request = client.request(proto::CancelLanguageServerWork {
project_id,
work: Some(
proto::cancel_language_server_work::Work::LanguageServerWork(
proto::cancel_language_server_work::LanguageServerWork {
language_server_id: server_id.to_proto(),
token: token_to_cancel,
},
),
),
});
cx.background_executor()
.spawn(request)
.detach_and_log_err(cx);
}
}
@@ -6854,47 +6978,6 @@ impl LspStore {
}
}
pub(crate) fn cancel_language_server_work(
&mut self,
server_id: LanguageServerId,
token_to_cancel: Option<String>,
_cx: &mut ModelContext<Self>,
) {
let Some(local) = self.as_local() else {
return;
};
let status = self.language_server_statuses.get(&server_id);
let server = local.language_servers.get(&server_id);
if let Some((LanguageServerState::Running { server, .. }, status)) = server.zip(status) {
for (token, progress) in &status.pending_work {
if let Some(token_to_cancel) = token_to_cancel.as_ref() {
if token != token_to_cancel {
continue;
}
}
if progress.is_cancellable {
server
.notify::<lsp::notification::WorkDoneProgressCancel>(
WorkDoneProgressCancelParams {
token: lsp::NumberOrString::String(token.clone()),
},
)
.ok();
}
if progress.is_cancellable {
server
.notify::<lsp::notification::WorkDoneProgressCancel>(
WorkDoneProgressCancelParams {
token: lsp::NumberOrString::String(token.clone()),
},
)
.ok();
}
}
}
}
pub fn wait_for_remote_buffer(
&mut self,
id: BufferId,
@@ -7284,6 +7367,18 @@ impl LspStore {
lsp_action,
})
}
fn update_last_formatting_failure<T>(&mut self, formatting_result: &anyhow::Result<T>) {
match &formatting_result {
Ok(_) => self.last_formatting_failure = None,
Err(error) => {
let error_string = format!("{error:#}");
log::error!("Formatting failed: {error_string}");
self.last_formatting_failure
.replace(error_string.lines().join(" "));
}
}
}
}
impl EventEmitter<LspStoreEvent> for LspStore {}

View File

@@ -827,7 +827,7 @@ impl Project {
ssh_proto.add_model_message_handler(Self::handle_toast);
ssh_proto.add_model_request_handler(Self::handle_language_server_prompt_request);
ssh_proto.add_model_message_handler(Self::handle_hide_toast);
ssh_proto.add_model_request_handler(BufferStore::handle_update_buffer);
ssh_proto.add_model_request_handler(Self::handle_update_buffer_from_ssh);
BufferStore::init(&ssh_proto);
LspStore::init(&ssh_proto);
SettingsObserver::init(&ssh_proto);
@@ -1333,7 +1333,7 @@ impl Project {
}
pub fn host(&self) -> Option<&Collaborator> {
self.collaborators.values().find(|c| c.replica_id == 0)
self.collaborators.values().find(|c| c.is_host)
}
pub fn set_worktrees_reordered(&mut self, worktrees_reordered: bool, cx: &mut AppContext) {
@@ -3420,7 +3420,7 @@ impl Project {
buffer: &Model<Buffer>,
version: Option<clock::Global>,
cx: &AppContext,
) -> Task<Result<Blame>> {
) -> Task<Result<Option<Blame>>> {
self.buffer_store.read(cx).blame_buffer(buffer, version, cx)
}
@@ -3495,7 +3495,7 @@ impl Project {
.collaborators
.remove(&old_peer_id)
.ok_or_else(|| anyhow!("received UpdateProjectCollaborator for unknown peer"))?;
let is_host = collaborator.replica_id == 0;
let is_host = collaborator.is_host;
this.collaborators.insert(new_peer_id, collaborator);
log::info!("peer {} became {}", old_peer_id, new_peer_id,);
@@ -3653,6 +3653,24 @@ impl Project {
})?
}
async fn handle_update_buffer_from_ssh(
this: Model<Self>,
envelope: TypedEnvelope<proto::UpdateBuffer>,
cx: AsyncAppContext,
) -> Result<proto::Ack> {
let buffer_store = this.read_with(&cx, |this, cx| {
if let Some(remote_id) = this.remote_id() {
let mut payload = envelope.payload.clone();
payload.project_id = remote_id;
cx.background_executor()
.spawn(this.client.request(payload))
.detach_and_log_err(cx);
}
this.buffer_store.clone()
})?;
BufferStore::handle_update_buffer(buffer_store, envelope, cx).await
}
async fn handle_update_buffer(
this: Model<Self>,
envelope: TypedEnvelope<proto::UpdateBuffer>,
@@ -4255,17 +4273,6 @@ impl Completion {
}
}
#[derive(Debug)]
pub struct NoRepositoryError {}
impl std::fmt::Display for NoRepositoryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "no git repository for worktree found")
}
}
impl std::error::Error for NoRepositoryError {}
pub fn sort_worktree_entries(entries: &mut [Entry]) {
entries.sort_by(|entry_a, entry_b| {
compare_paths(

View File

@@ -111,6 +111,16 @@ impl GitSettings {
_ => None,
}
}
pub fn show_inline_commit_summary(&self) -> bool {
match self.inline_blame {
Some(InlineBlameSettings {
show_commit_summary,
..
}) => show_commit_summary,
_ => false,
}
}
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
@@ -141,12 +151,21 @@ pub struct InlineBlameSettings {
///
/// Default: 0
pub min_column: Option<u32>,
/// Whether to show commit summary as part of the inline blame.
///
/// Default: false
#[serde(default = "false_value")]
pub show_commit_summary: bool,
}
const fn true_value() -> bool {
true
}
const fn false_value() -> bool {
false
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
pub struct BinarySettings {
pub path: Option<String>,

View File

@@ -30,7 +30,7 @@ use project::{
relativize_path, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree,
WorktreeId,
};
use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings, ShowIndentGuides};
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
use std::{
@@ -3043,7 +3043,8 @@ impl Render for ProjectPanel {
let has_worktree = !self.visible_entries.is_empty();
let project = self.project.read(cx);
let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
let indent_guides = ProjectPanelSettings::get_global(cx).indent_guides;
let show_indent_guides =
ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
let is_local = project.is_local();
if has_worktree {
@@ -3147,7 +3148,7 @@ impl Render for ProjectPanel {
items
}
})
.when(indent_guides, |list| {
.when(show_indent_guides, |list| {
list.with_decoration(
ui::indent_guides(
cx.view().clone(),

View File

@@ -11,6 +11,13 @@ pub enum ProjectPanelDockPosition {
Right,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ShowIndentGuides {
Always,
Never,
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
pub struct ProjectPanelSettings {
pub button: bool,
@@ -20,12 +27,23 @@ pub struct ProjectPanelSettings {
pub folder_icons: bool,
pub git_status: bool,
pub indent_size: f32,
pub indent_guides: bool,
pub indent_guides: IndentGuidesSettings,
pub auto_reveal_entries: bool,
pub auto_fold_dirs: bool,
pub scrollbar: ScrollbarSettings,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct IndentGuidesSettings {
pub show: ShowIndentGuides,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct IndentGuidesSettingsContent {
/// When to show the scrollbar in the project panel.
pub show: Option<ShowIndentGuides>,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ScrollbarSettings {
/// When to show the scrollbar in the project panel.
@@ -72,10 +90,6 @@ pub struct ProjectPanelSettingsContent {
///
/// Default: 20
pub indent_size: Option<f32>,
/// Whether to show indent guides in the project panel.
///
/// Default: true
pub indent_guides: Option<bool>,
/// Whether to reveal it in the project panel automatically,
/// when a corresponding project entry becomes active.
/// Gitignored entries are never auto revealed.
@@ -89,6 +103,8 @@ pub struct ProjectPanelSettingsContent {
pub auto_fold_dirs: Option<bool>,
/// Scrollbar-related settings
pub scrollbar: Option<ScrollbarSettingsContent>,
/// Settings related to indent guides in the project panel.
pub indent_guides: Option<IndentGuidesSettingsContent>,
}
impl Settings for ProjectPanelSettings {

View File

@@ -289,7 +289,12 @@ message Envelope {
ActiveToolchainResponse active_toolchain_response = 277;
GetPathMetadata get_path_metadata = 278;
GetPathMetadataResponse get_path_metadata_response = 279; // current max
GetPathMetadataResponse get_path_metadata_response = 279;
GetPanicFiles get_panic_files = 280;
GetPanicFilesResponse get_panic_files_response = 281;
CancelLanguageServerWork cancel_language_server_work = 282; // current max
}
reserved 87 to 88;
@@ -1254,12 +1259,14 @@ message LspWorkStart {
optional string title = 4;
optional string message = 2;
optional uint32 percentage = 3;
optional bool is_cancellable = 5;
}
message LspWorkProgress {
string token = 1;
optional string message = 2;
optional uint32 percentage = 3;
optional bool is_cancellable = 4;
}
message LspWorkEnd {
@@ -1721,6 +1728,7 @@ message Collaborator {
PeerId peer_id = 1;
uint32 replica_id = 2;
uint64 user_id = 3;
bool is_host = 4;
}
message User {
@@ -2116,10 +2124,16 @@ message CommitPermalink {
}
message BlameBufferResponse {
repeated BlameEntry entries = 1;
repeated CommitMessage messages = 2;
repeated CommitPermalink permalinks = 3;
optional string remote_url = 4;
message BlameResponse {
repeated BlameEntry entries = 1;
repeated CommitMessage messages = 2;
repeated CommitPermalink permalinks = 3;
optional string remote_url = 4;
}
optional BlameResponse blame_response = 5;
reserved 1 to 4;
}
message MultiLspQuery {
@@ -2482,5 +2496,29 @@ message UpdateGitBranch {
uint64 project_id = 1;
string branch_name = 2;
ProjectPath repository = 3;
}
message GetPanicFiles {
}
message GetPanicFilesResponse {
repeated string file_contents = 2;
}
message CancelLanguageServerWork {
uint64 project_id = 1;
oneof work {
Buffers buffers = 2;
LanguageServerWork language_server_work = 3;
}
message Buffers {
repeated uint64 buffer_ids = 2;
}
message LanguageServerWork {
uint64 language_server_id = 1;
optional string token = 2;
}
}

View File

@@ -104,7 +104,19 @@ impl ErrorExt for anyhow::Error {
if let Some(rpc_error) = self.downcast_ref::<RpcError>() {
rpc_error.to_proto()
} else {
ErrorCode::Internal.message(format!("{}", self)).to_proto()
ErrorCode::Internal
.message(
format!("{self:#}")
.lines()
.fold(String::new(), |mut message, line| {
if !message.is_empty() {
message.push(' ');
}
message.push_str(line);
message
}),
)
.to_proto()
}
}

View File

@@ -363,7 +363,10 @@ messages!(
(ActiveToolchain, Foreground),
(ActiveToolchainResponse, Foreground),
(GetPathMetadata, Background),
(GetPathMetadataResponse, Background)
(GetPathMetadataResponse, Background),
(GetPanicFiles, Background),
(GetPanicFilesResponse, Background),
(CancelLanguageServerWork, Foreground),
);
request_messages!(
@@ -483,7 +486,9 @@ request_messages!(
(ListToolchains, ListToolchainsResponse),
(ActivateToolchain, Ack),
(ActiveToolchain, ActiveToolchainResponse),
(GetPathMetadata, GetPathMetadataResponse)
(GetPathMetadata, GetPathMetadataResponse),
(GetPanicFiles, GetPanicFilesResponse),
(CancelLanguageServerWork, Ack),
);
entity_messages!(
@@ -566,7 +571,8 @@ entity_messages!(
ListToolchains,
ActivateToolchain,
ActiveToolchain,
GetPathMetadata
GetPathMetadata,
CancelLanguageServerWork,
);
entity_messages!(

View File

@@ -1,7 +1,7 @@
pub mod disconnected_overlay;
mod remote_servers;
mod ssh_connections;
pub use ssh_connections::open_ssh_project;
pub use ssh_connections::{is_connecting_over_ssh, open_ssh_project};
use disconnected_overlay::DisconnectedOverlay;
use fuzzy::{StringMatch, StringMatchCandidate};

View File

@@ -1204,7 +1204,7 @@ impl RemoteServerProjects {
Modal::new("remote-projects", Some(self.scroll_handle.clone()))
.header(
ModalHeader::new()
.child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall)),
.child(Headline::new("Remote Projects (beta)").size(HeadlineSize::XSmall)),
)
.section(
Section::new().padded(false).child(

View File

@@ -14,7 +14,7 @@ use gpui::{AppContext, Model};
use language::CursorShape;
use markdown::{Markdown, MarkdownStyle};
use release_channel::{AppVersion, ReleaseChannel};
use remote::ssh_session::ServerBinary;
use remote::ssh_session::{ServerBinary, ServerVersion};
use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -446,7 +446,7 @@ impl remote::SshClientDelegate for SshClientDelegate {
platform: SshPlatform,
upload_binary_over_ssh: bool,
cx: &mut AsyncAppContext,
) -> oneshot::Receiver<Result<(ServerBinary, SemanticVersion)>> {
) -> oneshot::Receiver<Result<(ServerBinary, ServerVersion)>> {
let (tx, rx) = oneshot::channel();
let this = self.clone();
cx.spawn(|mut cx| async move {
@@ -491,7 +491,7 @@ impl SshClientDelegate {
platform: SshPlatform,
upload_binary_via_ssh: bool,
cx: &mut AsyncAppContext,
) -> Result<(ServerBinary, SemanticVersion)> {
) -> Result<(ServerBinary, ServerVersion)> {
let (version, release_channel) = cx.update(|cx| {
let version = AppVersion::global(cx);
let channel = ReleaseChannel::global(cx);
@@ -505,7 +505,10 @@ impl SshClientDelegate {
let result = self.build_local(cx, platform, version).await?;
// Fall through to a remote binary if we're not able to compile a local binary
if let Some((path, version)) = result {
return Ok((ServerBinary::LocalBinary(path), version));
return Ok((
ServerBinary::LocalBinary(path),
ServerVersion::Semantic(version),
));
}
}
@@ -540,9 +543,12 @@ impl SshClientDelegate {
)
})?;
Ok((ServerBinary::LocalBinary(binary_path), version))
Ok((
ServerBinary::LocalBinary(binary_path),
ServerVersion::Semantic(version),
))
} else {
let (request_url, request_body) = AutoUpdater::get_remote_server_release_url(
let (release, request_body) = AutoUpdater::get_remote_server_release_url(
platform.os,
platform.arch,
release_channel,
@@ -560,9 +566,14 @@ impl SshClientDelegate {
)
})?;
let version = release
.version
.parse::<SemanticVersion>()
.map(ServerVersion::Semantic)
.unwrap_or_else(|_| ServerVersion::Commit(release.version));
Ok((
ServerBinary::ReleaseUrl {
url: request_url,
url: release.url,
body: request_body,
},
version,
@@ -678,6 +689,10 @@ impl SshClientDelegate {
}
}
pub fn is_connecting_over_ssh(workspace: &Workspace, cx: &AppContext) -> bool {
workspace.active_modal::<SshConnectionModal>(cx).is_some()
}
pub fn connect_over_ssh(
unique_identifier: String,
connection_options: SshConnectionOptions,

View File

@@ -227,6 +227,20 @@ pub enum ServerBinary {
ReleaseUrl { url: String, body: String },
}
pub enum ServerVersion {
Semantic(SemanticVersion),
Commit(String),
}
impl std::fmt::Display for ServerVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Semantic(version) => write!(f, "{}", version),
Self::Commit(commit) => write!(f, "{}", commit),
}
}
}
pub trait SshClientDelegate: Send + Sync {
fn ask_password(
&self,
@@ -243,7 +257,7 @@ pub trait SshClientDelegate: Send + Sync {
platform: SshPlatform,
upload_binary_over_ssh: bool,
cx: &mut AsyncAppContext,
) -> oneshot::Receiver<Result<(ServerBinary, SemanticVersion)>>;
) -> oneshot::Receiver<Result<(ServerBinary, ServerVersion)>>;
fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext);
}
@@ -1009,7 +1023,7 @@ impl SshRemoteClient {
server_cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server"));
let connection: Arc<dyn RemoteConnection> = Arc::new(fake::FakeRemoteConnection {
connection_options: opts.clone(),
server_cx: fake::SendableCx::new(server_cx.to_async()),
server_cx: fake::SendableCx::new(server_cx),
server_channel: server_client.clone(),
});
@@ -1274,6 +1288,7 @@ impl SshRemoteConnection {
) -> Result<Self> {
use futures::AsyncWriteExt as _;
use futures::{io::BufReader, AsyncBufReadExt as _};
use smol::net::unix::UnixStream;
use smol::{fs::unix::PermissionsExt as _, net::unix::UnixListener};
use util::ResultExt as _;
@@ -1290,6 +1305,9 @@ impl SshRemoteConnection {
let listener =
UnixListener::bind(&askpass_socket).context("failed to create askpass socket")?;
let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<UnixStream>();
let mut kill_tx = Some(askpass_kill_master_tx);
let askpass_task = cx.spawn({
let delegate = delegate.clone();
|mut cx| async move {
@@ -1313,6 +1331,11 @@ impl SshRemoteConnection {
.log_err()
{
stream.write_all(password.as_bytes()).await.log_err();
} else {
if let Some(kill_tx) = kill_tx.take() {
kill_tx.send(stream).log_err();
break;
}
}
}
}
@@ -1333,6 +1356,7 @@ impl SshRemoteConnection {
// the connection and keep it open, allowing other ssh commands to reuse it
// via a control socket.
let socket_path = temp_dir.path().join("ssh.sock");
let mut master_process = process::Command::new("ssh")
.stdin(Stdio::null())
.stdout(Stdio::piped())
@@ -1355,20 +1379,28 @@ impl SshRemoteConnection {
// Wait for this ssh process to close its stdout, indicating that authentication
// has completed.
let stdout = master_process.stdout.as_mut().unwrap();
let mut stdout = master_process.stdout.take().unwrap();
let mut output = Vec::new();
let connection_timeout = Duration::from_secs(10);
let result = select_biased! {
_ = askpass_opened_rx.fuse() => {
// If the askpass script has opened, that means the user is typing
// their password, in which case we don't want to timeout anymore,
// since we know a connection has been established.
stdout.read_to_end(&mut output).await?;
Ok(())
select_biased! {
stream = askpass_kill_master_rx.fuse() => {
master_process.kill().ok();
drop(stream);
Err(anyhow!("SSH connection canceled"))
}
// If the askpass script has opened, that means the user is typing
// their password, in which case we don't want to timeout anymore,
// since we know a connection has been established.
result = stdout.read_to_end(&mut output).fuse() => {
result?;
Ok(())
}
}
}
result = stdout.read_to_end(&mut output).fuse() => {
result?;
_ = stdout.read_to_end(&mut output).fuse() => {
Ok(())
}
_ = futures::FutureExt::fuse(smol::Timer::after(connection_timeout)) => {
@@ -1704,44 +1736,63 @@ impl SshRemoteConnection {
}
}
if self.is_binary_in_use(dst_path).await? {
log::info!("server binary is opened by another process. not updating");
delegate.set_status(
Some("Skipping update of remote development server, since it's still in use"),
cx,
);
return Ok(());
if cfg!(not(debug_assertions)) {
// When we're not in dev mode, we don't want to switch out the binary if it's
// still open.
// In dev mode, that's fine, since we often kill Zed processes with Ctrl-C and want
// to still replace the binary.
if self.is_binary_in_use(dst_path).await? {
log::info!("server binary is opened by another process. not updating");
delegate.set_status(
Some("Skipping update of remote development server, since it's still in use"),
cx,
);
return Ok(());
}
}
let upload_binary_over_ssh = self.socket.connection_options.upload_binary_over_ssh;
let (binary, version) = delegate
let (binary, new_server_version) = delegate
.get_server_binary(platform, upload_binary_over_ssh, cx)
.await??;
let mut remote_version = None;
if cfg!(not(debug_assertions)) {
if let Ok(installed_version) =
let installed_version = if let Ok(version_output) =
run_cmd(self.socket.ssh_command(dst_path).arg("version")).await
{
if let Ok(version) = installed_version.trim().parse::<SemanticVersion>() {
remote_version = Some(version);
if let Ok(version) = version_output.trim().parse::<SemanticVersion>() {
Some(ServerVersion::Semantic(version))
} else {
log::warn!("failed to parse version of remote server: {installed_version:?}",);
Some(ServerVersion::Commit(version_output.trim().to_string()))
}
}
} else {
None
};
if let Some(remote_version) = remote_version {
if remote_version == version {
log::info!("remote development server present and matching client version");
return Ok(());
} else if remote_version > version {
let error = anyhow!("The version of the remote server ({}) is newer than the Zed version ({}). Please update Zed.", remote_version, version);
return Err(error);
} else {
log::info!(
"remote development server has older version: {}. updating...",
remote_version
);
if let Some(installed_version) = installed_version {
use ServerVersion::*;
match (installed_version, new_server_version) {
(Semantic(installed), Semantic(new)) if installed == new => {
log::info!("remote development server present and matching client version");
return Ok(());
}
(Semantic(installed), Semantic(new)) if installed > new => {
let error = anyhow!("The version of the remote server ({}) is newer than the Zed version ({}). Please update Zed.", installed, new);
return Err(error);
}
(Commit(installed), Commit(new)) if installed == new => {
log::info!(
"remote development server present and matching client version {}",
installed
);
return Ok(());
}
(installed, _) => {
log::info!(
"remote development server has version: {}. updating...",
installed
);
}
}
}
}
@@ -1966,77 +2017,97 @@ impl ChannelClient {
mut incoming_rx: mpsc::UnboundedReceiver<Envelope>,
cx: &AsyncAppContext,
) -> Task<Result<()>> {
cx.spawn(|cx| {
async move {
let peer_id = PeerId { owner_id: 0, id: 0 };
while let Some(incoming) = incoming_rx.next().await {
let Some(this) = this.upgrade() else {
return anyhow::Ok(());
};
if let Some(ack_id) = incoming.ack_id {
let mut buffer = this.buffer.lock();
while buffer.front().is_some_and(|msg| msg.id <= ack_id) {
buffer.pop_front();
cx.spawn(|cx| async move {
let peer_id = PeerId { owner_id: 0, id: 0 };
while let Some(incoming) = incoming_rx.next().await {
let Some(this) = this.upgrade() else {
return anyhow::Ok(());
};
if let Some(ack_id) = incoming.ack_id {
let mut buffer = this.buffer.lock();
while buffer.front().is_some_and(|msg| msg.id <= ack_id) {
buffer.pop_front();
}
}
if let Some(proto::envelope::Payload::FlushBufferedMessages(_)) = &incoming.payload
{
log::debug!(
"{}:ssh message received. name:FlushBufferedMessages",
this.name
);
{
let buffer = this.buffer.lock();
for envelope in buffer.iter() {
this.outgoing_tx
.lock()
.unbounded_send(envelope.clone())
.ok();
}
}
if let Some(proto::envelope::Payload::FlushBufferedMessages(_)) =
&incoming.payload
{
log::debug!("{}:ssh message received. name:FlushBufferedMessages", this.name);
{
let buffer = this.buffer.lock();
for envelope in buffer.iter() {
this.outgoing_tx.lock().unbounded_send(envelope.clone()).ok();
}
let mut envelope = proto::Ack {}.into_envelope(0, Some(incoming.id), None);
envelope.id = this.next_message_id.fetch_add(1, SeqCst);
this.outgoing_tx.lock().unbounded_send(envelope).ok();
continue;
}
this.max_received.store(incoming.id, SeqCst);
if let Some(request_id) = incoming.responding_to {
let request_id = MessageId(request_id);
let sender = this.response_channels.lock().remove(&request_id);
if let Some(sender) = sender {
let (tx, rx) = oneshot::channel();
if incoming.payload.is_some() {
sender.send((incoming, tx)).ok();
}
let mut envelope = proto::Ack{}.into_envelope(0, Some(incoming.id), None);
envelope.id = this.next_message_id.fetch_add(1, SeqCst);
this.outgoing_tx.lock().unbounded_send(envelope).ok();
continue;
rx.await.ok();
}
this.max_received.store(incoming.id, SeqCst);
if let Some(request_id) = incoming.responding_to {
let request_id = MessageId(request_id);
let sender = this.response_channels.lock().remove(&request_id);
if let Some(sender) = sender {
let (tx, rx) = oneshot::channel();
if incoming.payload.is_some() {
sender.send((incoming, tx)).ok();
}
rx.await.ok();
}
} else if let Some(envelope) =
build_typed_envelope(peer_id, Instant::now(), incoming)
{
let type_name = envelope.payload_type_name();
if let Some(future) = ProtoMessageHandlerSet::handle_message(
&this.message_handlers,
envelope,
this.clone().into(),
cx.clone(),
) {
log::debug!("{}:ssh message received. name:{type_name}", this.name);
cx.foreground_executor().spawn(async move {
} else if let Some(envelope) =
build_typed_envelope(peer_id, Instant::now(), incoming)
{
let type_name = envelope.payload_type_name();
if let Some(future) = ProtoMessageHandlerSet::handle_message(
&this.message_handlers,
envelope,
this.clone().into(),
cx.clone(),
) {
log::debug!("{}:ssh message received. name:{type_name}", this.name);
cx.foreground_executor()
.spawn(async move {
match future.await {
Ok(_) => {
log::debug!("{}:ssh message handled. name:{type_name}", this.name);
log::debug!(
"{}:ssh message handled. name:{type_name}",
this.name
);
}
Err(error) => {
log::error!(
"{}:error handling message. type:{type_name}, error:{error}", this.name,
"{}:error handling message. type:{}, error:{}",
this.name,
type_name,
format!("{error:#}").lines().fold(
String::new(),
|mut message, line| {
if !message.is_empty() {
message.push(' ');
}
message.push_str(line);
message
}
)
);
}
}
}).detach()
} else {
log::error!("{}:unhandled ssh message name:{type_name}", this.name);
}
})
.detach()
} else {
log::error!("{}:unhandled ssh message name:{type_name}", this.name);
}
}
anyhow::Ok(())
}
anyhow::Ok(())
})
}
@@ -2224,12 +2295,12 @@ mod fake {
},
select_biased, FutureExt, SinkExt, StreamExt,
};
use gpui::{AsyncAppContext, SemanticVersion, Task};
use gpui::{AsyncAppContext, Task, TestAppContext};
use rpc::proto::Envelope;
use super::{
ChannelClient, RemoteConnection, ServerBinary, SshClientDelegate, SshConnectionOptions,
SshPlatform,
ChannelClient, RemoteConnection, ServerBinary, ServerVersion, SshClientDelegate,
SshConnectionOptions, SshPlatform,
};
pub(super) struct FakeRemoteConnection {
@@ -2239,15 +2310,19 @@ mod fake {
}
pub(super) struct SendableCx(AsyncAppContext);
// safety: you can only get the other cx on the main thread.
impl SendableCx {
pub(super) fn new(cx: AsyncAppContext) -> Self {
Self(cx)
// SAFETY: When run in test mode, GPUI is always single threaded.
pub(super) fn new(cx: &TestAppContext) -> Self {
Self(cx.to_async())
}
// SAFETY: Enforce that we're on the main thread by requiring a valid AsyncAppContext
fn get(&self, _: &AsyncAppContext) -> AsyncAppContext {
self.0.clone()
}
}
// SAFETY: There is no way to access a SendableCx from a different thread, see [`SendableCx::new`] and [`SendableCx::get`]
unsafe impl Send for SendableCx {}
unsafe impl Sync for SendableCx {}
@@ -2349,7 +2424,7 @@ mod fake {
_: SshPlatform,
_: bool,
_: &mut AsyncAppContext,
) -> oneshot::Receiver<Result<(ServerBinary, SemanticVersion)>> {
) -> oneshot::Receiver<Result<(ServerBinary, ServerVersion)>> {
unreachable!()
}

View File

@@ -22,9 +22,10 @@ debug-embed = ["dep:rust-embed"]
test-support = ["fs/test-support"]
[dependencies]
async-watch.workspace = true
anyhow.workspace = true
async-watch.workspace = true
backtrace = "0.3"
chrono.workspace = true
clap.workspace = true
client.workspace = true
env_logger.workspace = true
@@ -39,8 +40,10 @@ languages.workspace = true
log.workspace = true
lsp.workspace = true
node_runtime.workspace = true
project.workspace = true
paths = { workspace = true }
project.workspace = true
proto.workspace = true
release_channel.workspace = true
remote.workspace = true
reqwest_client.workspace = true
rpc.workspace = true
@@ -50,6 +53,7 @@ serde_json.workspace = true
settings.workspace = true
shellexpand.workspace = true
smol.workspace = true
telemetry_events.workspace = true
util.workspace = true
worktree.workspace = true

View File

@@ -1,3 +1,5 @@
use std::process::Command;
const ZED_MANIFEST: &str = include_str!("../zed/Cargo.toml");
fn main() {
@@ -7,4 +9,23 @@ fn main() {
"cargo:rustc-env=ZED_PKG_VERSION={}",
zed_cargo_toml.package.unwrap().version.unwrap()
);
// If we're building this for nightly, we want to set the ZED_COMMIT_SHA
if let Some(release_channel) = std::env::var("ZED_RELEASE_CHANNEL").ok() {
if release_channel.as_str() == "nightly" {
// Populate git sha environment variable if git is available
println!("cargo:rerun-if-changed=../../.git/logs/HEAD");
if let Some(output) = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
{
let git_sha = String::from_utf8_lossy(&output.stdout);
let git_sha = git_sha.trim();
println!("cargo:rustc-env=ZED_COMMIT_SHA={git_sha}");
}
}
}
}

View File

@@ -72,7 +72,12 @@ fn main() {
}
},
Some(Commands::Version) => {
println!("{}", env!("ZED_PKG_VERSION"));
if let Some(build_sha) = option_env!("ZED_COMMIT_SHA") {
println!("{}", build_sha);
} else {
println!("{}", env!("ZED_PKG_VERSION"));
}
std::process::exit(0);
}
None => {

View File

@@ -528,6 +528,172 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
})
}
#[gpui::test]
async fn test_remote_cancel_language_server_work(
cx: &mut TestAppContext,
server_cx: &mut TestAppContext,
) {
let fs = FakeFs::new(server_cx.executor());
fs.insert_tree(
"/code",
json!({
"project1": {
".git": {},
"README.md": "# project 1",
"src": {
"lib.rs": "fn one() -> usize { 1 }"
}
},
}),
)
.await;
let (project, headless) = init_test(&fs, cx, server_cx).await;
fs.insert_tree(
"/code/project1/.zed",
json!({
"settings.json": r#"
{
"languages": {"Rust":{"language_servers":["rust-analyzer"]}},
"lsp": {
"rust-analyzer": {
"binary": {
"path": "~/.cargo/bin/rust-analyzer"
}
}
}
}"#
}),
)
.await;
cx.update_model(&project, |project, _| {
project.languages().register_test_language(LanguageConfig {
name: "Rust".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["rs".into()],
..Default::default()
},
..Default::default()
});
project.languages().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
name: "rust-analyzer",
..Default::default()
},
)
});
let mut fake_lsp = server_cx.update(|cx| {
headless.read(cx).languages.register_fake_language_server(
LanguageServerName("rust-analyzer".into()),
Default::default(),
None,
)
});
cx.run_until_parked();
let worktree_id = project
.update(cx, |project, cx| {
project.find_or_create_worktree("/code/project1", true, cx)
})
.await
.unwrap()
.0
.read_with(cx, |worktree, _| worktree.id());
cx.run_until_parked();
let buffer = project
.update(cx, |project, cx| {
project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
})
.await
.unwrap();
cx.run_until_parked();
let mut fake_lsp = fake_lsp.next().await.unwrap();
// Cancelling all language server work for a given buffer
{
// Two operations, one cancellable and one not.
fake_lsp
.start_progress_with(
"another-token",
lsp::WorkDoneProgressBegin {
cancellable: Some(false),
..Default::default()
},
)
.await;
let progress_token = "the-progress-token";
fake_lsp
.start_progress_with(
progress_token,
lsp::WorkDoneProgressBegin {
cancellable: Some(true),
..Default::default()
},
)
.await;
cx.executor().run_until_parked();
project.update(cx, |project, cx| {
project.cancel_language_server_work_for_buffers([buffer.clone()], cx)
});
cx.executor().run_until_parked();
// Verify the cancellation was received on the server side
let cancel_notification = fake_lsp
.receive_notification::<lsp::notification::WorkDoneProgressCancel>()
.await;
assert_eq!(
cancel_notification.token,
lsp::NumberOrString::String(progress_token.into())
);
}
// Cancelling work by server_id and token
{
let server_id = fake_lsp.server.server_id();
let progress_token = "the-progress-token";
fake_lsp
.start_progress_with(
progress_token,
lsp::WorkDoneProgressBegin {
cancellable: Some(true),
..Default::default()
},
)
.await;
cx.executor().run_until_parked();
project.update(cx, |project, cx| {
project.cancel_language_server_work(server_id, Some(progress_token.into()), cx)
});
cx.executor().run_until_parked();
// Verify the cancellation was received on the server side
let cancel_notification = fake_lsp
.receive_notification::<lsp::notification::WorkDoneProgressCancel>()
.await;
assert_eq!(
cancel_notification.token,
lsp::NumberOrString::String(progress_token.into())
);
}
}
#[gpui::test]
async fn test_remote_reload(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
let fs = FakeFs::new(server_cx.executor());

View File

@@ -1,12 +1,13 @@
use crate::headless_project::HeadlessAppState;
use crate::HeadlessProject;
use anyhow::{anyhow, Context, Result};
use client::ProxySettings;
use chrono::Utc;
use client::{telemetry, 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 gpui::{AppContext, Context as _, Model, ModelContext, UpdateGlobal as _};
use http_client::{read_proxy_from_env, Uri};
use language::LanguageRegistry;
use node_runtime::{NodeBinaryOptions, NodeRuntime};
@@ -21,19 +22,23 @@ use remote::{
};
use reqwest_client::ReqwestClient;
use rpc::proto::{self, Envelope, SSH_PROJECT_ID};
use rpc::{AnyProtoClient, TypedEnvelope};
use settings::{watch_config_file, Settings, SettingsStore};
use smol::channel::{Receiver, Sender};
use smol::io::AsyncReadExt;
use smol::Async;
use smol::{net::unix::UnixListener, stream::StreamExt as _};
use std::ffi::OsStr;
use std::ops::ControlFlow;
use std::{env, thread};
use std::{
io::Write,
mem,
path::{Path, PathBuf},
sync::Arc,
};
use telemetry_events::LocationData;
use util::ResultExt;
fn init_logging_proxy() {
@@ -131,16 +136,97 @@ fn init_panic_hook() {
backtrace.drain(0..=ix);
}
let thread = thread::current();
let thread_name = thread.name().unwrap_or("<unnamed>");
log::error!(
"panic occurred: {}\nBacktrace:\n{}",
payload,
backtrace.join("\n")
&payload,
(&backtrace).join("\n")
);
let panic_data = telemetry_events::Panic {
thread: thread_name.into(),
payload: payload.clone(),
location_data: info.location().map(|location| LocationData {
file: location.file().into(),
line: location.line(),
}),
app_version: format!(
"remote-server-{}",
option_env!("ZED_COMMIT_SHA").unwrap_or(&env!("ZED_PKG_VERSION"))
),
release_channel: release_channel::RELEASE_CHANNEL.display_name().into(),
os_name: telemetry::os_name(),
os_version: Some(telemetry::os_version()),
architecture: env::consts::ARCH.into(),
panicked_on: Utc::now().timestamp_millis(),
backtrace,
system_id: None, // Set on SSH client
installation_id: None, // Set on SSH client
session_id: "".to_string(), // Set on SSH client
};
if let Some(panic_data_json) = serde_json::to_string(&panic_data).log_err() {
let timestamp = chrono::Utc::now().format("%Y_%m_%d %H_%M_%S").to_string();
let panic_file_path = paths::logs_dir().join(format!("zed-{timestamp}.panic"));
let panic_file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&panic_file_path)
.log_err();
if let Some(mut panic_file) = panic_file {
writeln!(&mut panic_file, "{panic_data_json}").log_err();
panic_file.flush().log_err();
}
}
std::process::abort();
}));
}
fn handle_panic_requests(project: &Model<HeadlessProject>, client: &Arc<ChannelClient>) {
let client: AnyProtoClient = client.clone().into();
client.add_request_handler(
project.downgrade(),
|_, _: TypedEnvelope<proto::GetPanicFiles>, _cx| async move {
let mut children = smol::fs::read_dir(paths::logs_dir()).await?;
let mut panic_files = Vec::new();
while let Some(child) = children.next().await {
let child = child?;
let child_path = child.path();
if child_path.extension() != Some(OsStr::new("panic")) {
continue;
}
let filename = if let Some(filename) = child_path.file_name() {
filename.to_string_lossy()
} else {
continue;
};
if !filename.starts_with("zed") {
continue;
}
let file_contents = smol::fs::read_to_string(&child_path)
.await
.context("error reading panic file")?;
panic_files.push(file_contents);
// We've done what we can, delete the file
std::fs::remove_file(child_path)
.context("error removing panic")
.log_err();
}
anyhow::Ok(proto::GetPanicFilesResponse {
file_contents: panic_files,
})
},
);
}
struct ServerListeners {
stdin: UnixListener,
stdout: UnixListener,
@@ -368,7 +454,7 @@ pub fn execute_run(
HeadlessProject::new(
HeadlessAppState {
session,
session: session.clone(),
fs,
http_client,
node_runtime,
@@ -378,6 +464,8 @@ pub fn execute_run(
)
});
handle_panic_requests(&project, &session);
mem::forget(project);
});
log::info!("gpui app is shut down. quitting.");

View File

@@ -21,13 +21,16 @@ client.workspace = true
collections.workspace = true
command_palette_hooks.workspace = true
editor.workspace = true
feature_flags.workspace = true
futures.workspace = true
gpui.workspace = true
image.workspace = true
language.workspace = true
log.workspace = true
markdown_preview.workspace = true
menu.workspace = true
multi_buffer.workspace = true
nbformat.workspace = true
project.workspace = true
runtimelib.workspace = true
schemars.workspace = true

View File

@@ -0,0 +1,4 @@
mod cell;
mod notebook_ui;
pub use cell::*;
pub use notebook_ui::*;

View File

@@ -0,0 +1,730 @@
#![allow(unused, dead_code)]
use std::sync::Arc;
use editor::{Editor, EditorMode, MultiBuffer};
use futures::future::Shared;
use gpui::{prelude::*, AppContext, Hsla, Task, TextStyleRefinement, View};
use language::{Buffer, Language, LanguageRegistry};
use markdown_preview::{markdown_parser::parse_markdown, markdown_renderer::render_markdown_block};
use nbformat::v4::{CellId, CellMetadata, CellType};
use settings::Settings as _;
use theme::ThemeSettings;
use ui::{prelude::*, IconButtonShape};
use util::ResultExt;
use crate::{
notebook::{CODE_BLOCK_INSET, GUTTER_WIDTH},
outputs::{plain::TerminalOutput, user_error::ErrorView, Output},
};
#[derive(Copy, Clone, PartialEq, PartialOrd)]
pub enum CellPosition {
First,
Middle,
Last,
}
pub enum CellControlType {
RunCell,
RerunCell,
ClearCell,
CellOptions,
CollapseCell,
ExpandCell,
}
impl CellControlType {
fn icon_name(&self) -> IconName {
match self {
CellControlType::RunCell => IconName::Play,
CellControlType::RerunCell => IconName::ArrowCircle,
CellControlType::ClearCell => IconName::ListX,
CellControlType::CellOptions => IconName::Ellipsis,
CellControlType::CollapseCell => IconName::ChevronDown,
CellControlType::ExpandCell => IconName::ChevronRight,
}
}
}
pub struct CellControl {
button: IconButton,
}
impl CellControl {
fn new(id: impl Into<SharedString>, control_type: CellControlType) -> Self {
let icon_name = control_type.icon_name();
let id = id.into();
let button = IconButton::new(id, icon_name)
.icon_size(IconSize::Small)
.shape(IconButtonShape::Square);
Self { button }
}
}
impl Clickable for CellControl {
fn on_click(self, handler: impl Fn(&gpui::ClickEvent, &mut WindowContext) + 'static) -> Self {
let button = self.button.on_click(handler);
Self { button }
}
fn cursor_style(self, _cursor_style: gpui::CursorStyle) -> Self {
self
}
}
/// A notebook cell
#[derive(Clone)]
pub enum Cell {
Code(View<CodeCell>),
Markdown(View<MarkdownCell>),
Raw(View<RawCell>),
}
fn convert_outputs(outputs: &Vec<nbformat::v4::Output>, cx: &mut WindowContext) -> Vec<Output> {
outputs
.into_iter()
.map(|output| match output {
nbformat::v4::Output::Stream { text, .. } => Output::Stream {
content: cx.new_view(|cx| TerminalOutput::from(&text.0, cx)),
},
nbformat::v4::Output::DisplayData(display_data) => {
Output::new(&display_data.data, None, cx)
}
nbformat::v4::Output::ExecuteResult(execute_result) => {
Output::new(&execute_result.data, None, cx)
}
nbformat::v4::Output::Error(error) => Output::ErrorOutput(ErrorView {
ename: error.ename.clone(),
evalue: error.evalue.clone(),
traceback: cx.new_view(|cx| TerminalOutput::from(&error.traceback.join("\n"), cx)),
}),
})
.collect()
}
impl Cell {
pub fn load(
cell: &nbformat::v4::Cell,
languages: &Arc<LanguageRegistry>,
notebook_language: Shared<Task<Option<Arc<Language>>>>,
cx: &mut WindowContext,
) -> Self {
match cell {
nbformat::v4::Cell::Markdown {
id,
metadata,
source,
attachments: _,
} => {
let source = source.join("");
let view = cx.new_view(|cx| {
let markdown_parsing_task = {
let languages = languages.clone();
let source = source.clone();
cx.spawn(|this, mut cx| async move {
let parsed_markdown = cx
.background_executor()
.spawn(async move {
parse_markdown(&source, None, Some(languages)).await
})
.await;
this.update(&mut cx, |cell: &mut MarkdownCell, _| {
cell.parsed_markdown = Some(parsed_markdown);
})
.log_err();
})
};
MarkdownCell {
markdown_parsing_task,
languages: languages.clone(),
id: id.clone(),
metadata: metadata.clone(),
source: source.clone(),
parsed_markdown: None,
selected: false,
cell_position: None,
}
});
Cell::Markdown(view)
}
nbformat::v4::Cell::Code {
id,
metadata,
execution_count,
source,
outputs,
} => Cell::Code(cx.new_view(|cx| {
let text = source.join("");
let buffer = cx.new_model(|cx| Buffer::local(text.clone(), cx));
let multi_buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer.clone(), cx));
let editor_view = cx.new_view(|cx| {
let mut editor = Editor::new(
EditorMode::AutoHeight { max_lines: 1024 },
multi_buffer,
None,
false,
cx,
);
let theme = ThemeSettings::get_global(cx);
let refinement = TextStyleRefinement {
font_family: Some(theme.buffer_font.family.clone()),
font_size: Some(theme.buffer_font_size.into()),
color: Some(cx.theme().colors().editor_foreground),
background_color: Some(gpui::transparent_black()),
..Default::default()
};
editor.set_text(text, cx);
editor.set_show_gutter(false, cx);
editor.set_text_style_refinement(refinement);
// editor.set_read_only(true);
editor
});
let buffer = buffer.clone();
let language_task = cx.spawn(|this, mut cx| async move {
let language = notebook_language.await;
buffer.update(&mut cx, |buffer, cx| {
buffer.set_language(language.clone(), cx);
});
});
CodeCell {
id: id.clone(),
metadata: metadata.clone(),
execution_count: *execution_count,
source: source.join(""),
editor: editor_view,
outputs: convert_outputs(outputs, cx),
selected: false,
language_task,
cell_position: None,
}
})),
nbformat::v4::Cell::Raw {
id,
metadata,
source,
} => Cell::Raw(cx.new_view(|_| RawCell {
id: id.clone(),
metadata: metadata.clone(),
source: source.join(""),
selected: false,
cell_position: None,
})),
}
}
}
pub trait RenderableCell: Render {
const CELL_TYPE: CellType;
fn id(&self) -> &CellId;
fn cell_type(&self) -> CellType;
fn metadata(&self) -> &CellMetadata;
fn source(&self) -> &String;
fn selected(&self) -> bool;
fn set_selected(&mut self, selected: bool) -> &mut Self;
fn selected_bg_color(&self, cx: &ViewContext<Self>) -> Hsla {
if self.selected() {
let mut color = cx.theme().colors().icon_accent;
color.fade_out(0.9);
color
} else {
// TODO: this is wrong
cx.theme().colors().tab_bar_background
}
}
fn control(&self, _cx: &ViewContext<Self>) -> Option<CellControl> {
None
}
fn cell_position_spacer(
&self,
is_first: bool,
cx: &ViewContext<Self>,
) -> Option<impl IntoElement> {
let cell_position = self.cell_position();
if (cell_position == Some(&CellPosition::First) && is_first)
|| (cell_position == Some(&CellPosition::Last) && !is_first)
{
Some(div().flex().w_full().h(Spacing::XLarge.px(cx)))
} else {
None
}
}
fn gutter(&self, cx: &ViewContext<Self>) -> impl IntoElement {
let is_selected = self.selected();
div()
.relative()
.h_full()
.w(px(GUTTER_WIDTH))
.child(
div()
.w(px(GUTTER_WIDTH))
.flex()
.flex_none()
.justify_center()
.h_full()
.child(
div()
.flex_none()
.w(px(1.))
.h_full()
.when(is_selected, |this| this.bg(cx.theme().colors().icon_accent))
.when(!is_selected, |this| this.bg(cx.theme().colors().border)),
),
)
.when_some(self.control(cx), |this, control| {
this.child(
div()
.absolute()
.top(px(CODE_BLOCK_INSET - 2.0))
.left_0()
.flex()
.flex_none()
.w(px(GUTTER_WIDTH))
.h(px(GUTTER_WIDTH + 12.0))
.items_center()
.justify_center()
.bg(cx.theme().colors().tab_bar_background)
.child(control.button),
)
})
}
fn cell_position(&self) -> Option<&CellPosition>;
fn set_cell_position(&mut self, position: CellPosition) -> &mut Self;
}
pub trait RunnableCell: RenderableCell {
fn execution_count(&self) -> Option<i32>;
fn set_execution_count(&mut self, count: i32) -> &mut Self;
fn run(&mut self, cx: &mut ViewContext<Self>) -> ();
}
pub struct MarkdownCell {
id: CellId,
metadata: CellMetadata,
source: String,
parsed_markdown: Option<markdown_preview::markdown_elements::ParsedMarkdown>,
markdown_parsing_task: Task<()>,
selected: bool,
cell_position: Option<CellPosition>,
languages: Arc<LanguageRegistry>,
}
impl RenderableCell for MarkdownCell {
const CELL_TYPE: CellType = CellType::Markdown;
fn id(&self) -> &CellId {
&self.id
}
fn cell_type(&self) -> CellType {
CellType::Markdown
}
fn metadata(&self) -> &CellMetadata {
&self.metadata
}
fn source(&self) -> &String {
&self.source
}
fn selected(&self) -> bool {
self.selected
}
fn set_selected(&mut self, selected: bool) -> &mut Self {
self.selected = selected;
self
}
fn control(&self, _: &ViewContext<Self>) -> Option<CellControl> {
None
}
fn cell_position(&self) -> Option<&CellPosition> {
self.cell_position.as_ref()
}
fn set_cell_position(&mut self, cell_position: CellPosition) -> &mut Self {
self.cell_position = Some(cell_position);
self
}
}
impl Render for MarkdownCell {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let Some(parsed) = self.parsed_markdown.as_ref() else {
return div();
};
let mut markdown_render_context =
markdown_preview::markdown_renderer::RenderContext::new(None, cx);
v_flex()
.size_full()
// TODO: Move base cell render into trait impl so we don't have to repeat this
.children(self.cell_position_spacer(true, cx))
.child(
h_flex()
.w_full()
.pr_6()
.rounded_sm()
.items_start()
.gap(Spacing::Large.rems(cx))
.bg(self.selected_bg_color(cx))
.child(self.gutter(cx))
.child(
v_flex()
.size_full()
.flex_1()
.p_3()
.font_ui(cx)
.text_size(TextSize::Default.rems(cx))
//
.children(parsed.children.iter().map(|child| {
div().relative().child(div().relative().child(
render_markdown_block(child, &mut markdown_render_context),
))
})),
),
)
// TODO: Move base cell render into trait impl so we don't have to repeat this
.children(self.cell_position_spacer(false, cx))
}
}
pub struct CodeCell {
id: CellId,
metadata: CellMetadata,
execution_count: Option<i32>,
source: String,
editor: View<editor::Editor>,
outputs: Vec<Output>,
selected: bool,
cell_position: Option<CellPosition>,
language_task: Task<()>,
}
impl CodeCell {
pub fn is_dirty(&self, cx: &AppContext) -> bool {
self.editor.read(cx).buffer().read(cx).is_dirty(cx)
}
pub fn has_outputs(&self) -> bool {
!self.outputs.is_empty()
}
pub fn clear_outputs(&mut self) {
self.outputs.clear();
}
fn output_control(&self) -> Option<CellControlType> {
if self.has_outputs() {
Some(CellControlType::ClearCell)
} else {
None
}
}
pub fn gutter_output(&self, cx: &ViewContext<Self>) -> impl IntoElement {
let is_selected = self.selected();
div()
.relative()
.h_full()
.w(px(GUTTER_WIDTH))
.child(
div()
.w(px(GUTTER_WIDTH))
.flex()
.flex_none()
.justify_center()
.h_full()
.child(
div()
.flex_none()
.w(px(1.))
.h_full()
.when(is_selected, |this| this.bg(cx.theme().colors().icon_accent))
.when(!is_selected, |this| this.bg(cx.theme().colors().border)),
),
)
.when(self.has_outputs(), |this| {
this.child(
div()
.absolute()
.top(px(CODE_BLOCK_INSET - 2.0))
.left_0()
.flex()
.flex_none()
.w(px(GUTTER_WIDTH))
.h(px(GUTTER_WIDTH + 12.0))
.items_center()
.justify_center()
.bg(cx.theme().colors().tab_bar_background)
.child(IconButton::new("control", IconName::Ellipsis)),
)
})
}
}
impl RenderableCell for CodeCell {
const CELL_TYPE: CellType = CellType::Code;
fn id(&self) -> &CellId {
&self.id
}
fn cell_type(&self) -> CellType {
CellType::Code
}
fn metadata(&self) -> &CellMetadata {
&self.metadata
}
fn source(&self) -> &String {
&self.source
}
fn control(&self, cx: &ViewContext<Self>) -> Option<CellControl> {
let cell_control = if self.has_outputs() {
CellControl::new("rerun-cell", CellControlType::RerunCell)
} else {
CellControl::new("run-cell", CellControlType::RunCell)
.on_click(cx.listener(move |this, _, cx| this.run(cx)))
};
Some(cell_control)
}
fn selected(&self) -> bool {
self.selected
}
fn set_selected(&mut self, selected: bool) -> &mut Self {
self.selected = selected;
self
}
fn cell_position(&self) -> Option<&CellPosition> {
self.cell_position.as_ref()
}
fn set_cell_position(&mut self, cell_position: CellPosition) -> &mut Self {
self.cell_position = Some(cell_position);
self
}
}
impl RunnableCell for CodeCell {
fn run(&mut self, cx: &mut ViewContext<Self>) {
println!("Running code cell: {}", self.id);
}
fn execution_count(&self) -> Option<i32> {
self.execution_count
.and_then(|count| if count > 0 { Some(count) } else { None })
}
fn set_execution_count(&mut self, count: i32) -> &mut Self {
self.execution_count = Some(count);
self
}
}
impl Render for CodeCell {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_flex()
.size_full()
// TODO: Move base cell render into trait impl so we don't have to repeat this
.children(self.cell_position_spacer(true, cx))
// Editor portion
.child(
h_flex()
.w_full()
.pr_6()
.rounded_sm()
.items_start()
.gap(Spacing::Large.rems(cx))
.bg(self.selected_bg_color(cx))
.child(self.gutter(cx))
.child(
div().py_1p5().w_full().child(
div()
.flex()
.size_full()
.flex_1()
.py_3()
.px_5()
.rounded_lg()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_background)
.child(div().w_full().child(self.editor.clone())),
),
),
)
// Output portion
.child(
h_flex()
.w_full()
.pr_6()
.rounded_sm()
.items_start()
.gap(Spacing::Large.rems(cx))
.bg(self.selected_bg_color(cx))
.child(self.gutter_output(cx))
.child(
div().py_1p5().w_full().child(
div()
.flex()
.size_full()
.flex_1()
.py_3()
.px_5()
.rounded_lg()
.border_1()
// .border_color(cx.theme().colors().border)
// .bg(cx.theme().colors().editor_background)
.child(div().w_full().children(self.outputs.iter().map(
|output| {
let content = match output {
Output::Plain { content, .. } => {
Some(content.clone().into_any_element())
}
Output::Markdown { content, .. } => {
Some(content.clone().into_any_element())
}
Output::Stream { content, .. } => {
Some(content.clone().into_any_element())
}
Output::Image { content, .. } => {
Some(content.clone().into_any_element())
}
Output::Message(message) => Some(
div().child(message.clone()).into_any_element(),
),
Output::Table { content, .. } => {
Some(content.clone().into_any_element())
}
Output::ErrorOutput(error_view) => {
error_view.render(cx)
}
Output::ClearOutputWaitMarker => None,
};
div()
// .w_full()
// .mt_3()
// .p_3()
// .rounded_md()
// .bg(cx.theme().colors().editor_background)
// .border(px(1.))
// .border_color(cx.theme().colors().border)
// .shadow_sm()
.children(content)
},
))),
),
),
)
// TODO: Move base cell render into trait impl so we don't have to repeat this
.children(self.cell_position_spacer(false, cx))
}
}
pub struct RawCell {
id: CellId,
metadata: CellMetadata,
source: String,
selected: bool,
cell_position: Option<CellPosition>,
}
impl RenderableCell for RawCell {
const CELL_TYPE: CellType = CellType::Raw;
fn id(&self) -> &CellId {
&self.id
}
fn cell_type(&self) -> CellType {
CellType::Raw
}
fn metadata(&self) -> &CellMetadata {
&self.metadata
}
fn source(&self) -> &String {
&self.source
}
fn selected(&self) -> bool {
self.selected
}
fn set_selected(&mut self, selected: bool) -> &mut Self {
self.selected = selected;
self
}
fn cell_position(&self) -> Option<&CellPosition> {
self.cell_position.as_ref()
}
fn set_cell_position(&mut self, cell_position: CellPosition) -> &mut Self {
self.cell_position = Some(cell_position);
self
}
}
impl Render for RawCell {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_flex()
.size_full()
// TODO: Move base cell render into trait impl so we don't have to repeat this
.children(self.cell_position_spacer(true, cx))
.child(
h_flex()
.w_full()
.pr_2()
.rounded_sm()
.items_start()
.gap(Spacing::Large.rems(cx))
.bg(self.selected_bg_color(cx))
.child(self.gutter(cx))
.child(
div()
.flex()
.size_full()
.flex_1()
.p_3()
.font_ui(cx)
.text_size(TextSize::Default.rems(cx))
.child(self.source.clone()),
),
)
// TODO: Move base cell render into trait impl so we don't have to repeat this
.children(self.cell_position_spacer(false, cx))
}
}

View File

@@ -0,0 +1,672 @@
#![allow(unused, dead_code)]
use std::{path::PathBuf, sync::Arc};
use client::proto::ViewId;
use collections::HashMap;
use feature_flags::{FeatureFlagAppExt as _, NotebookFeatureFlag};
use futures::FutureExt;
use gpui::{
actions, list, prelude::*, AppContext, EventEmitter, FocusHandle, FocusableView,
ListScrollEvent, ListState, Model, Task,
};
use language::LanguageRegistry;
use project::{Project, ProjectEntryId, ProjectPath};
use ui::{prelude::*, Tooltip};
use workspace::item::ItemEvent;
use workspace::{Item, ItemHandle, ProjectItem, ToolbarItemLocation};
use workspace::{ToolbarItemEvent, ToolbarItemView};
use super::{Cell, CellPosition, RenderableCell};
use nbformat::v4::CellId;
use nbformat::v4::Metadata as NotebookMetadata;
pub(crate) const DEFAULT_NOTEBOOK_FORMAT: i32 = 4;
pub(crate) const DEFAULT_NOTEBOOK_FORMAT_MINOR: i32 = 0;
actions!(
notebook,
[
OpenNotebook,
RunAll,
ClearOutputs,
MoveCellUp,
MoveCellDown,
AddMarkdownBlock,
AddCodeBlock,
]
);
pub(crate) const MAX_TEXT_BLOCK_WIDTH: f32 = 9999.0;
pub(crate) const SMALL_SPACING_SIZE: f32 = 8.0;
pub(crate) const MEDIUM_SPACING_SIZE: f32 = 12.0;
pub(crate) const LARGE_SPACING_SIZE: f32 = 16.0;
pub(crate) const GUTTER_WIDTH: f32 = 19.0;
pub(crate) const CODE_BLOCK_INSET: f32 = MEDIUM_SPACING_SIZE;
pub(crate) const CONTROL_SIZE: f32 = 20.0;
pub fn init(cx: &mut AppContext) {
if cx.has_flag::<NotebookFeatureFlag>() || std::env::var("LOCAL_NOTEBOOK_DEV").is_ok() {
workspace::register_project_item::<NotebookEditor>(cx);
}
cx.observe_flag::<NotebookFeatureFlag, _>({
move |is_enabled, cx| {
if is_enabled {
workspace::register_project_item::<NotebookEditor>(cx);
} else {
// todo: there is no way to unregister a project item, so if the feature flag
// gets turned off they need to restart Zed.
}
}
})
.detach();
}
pub struct NotebookEditor {
languages: Arc<LanguageRegistry>,
focus_handle: FocusHandle,
project: Model<Project>,
path: ProjectPath,
remote_id: Option<ViewId>,
cell_list: ListState,
metadata: NotebookMetadata,
nbformat: i32,
nbformat_minor: i32,
selected_cell_index: usize,
cell_order: Vec<CellId>,
cell_map: HashMap<CellId, Cell>,
}
impl NotebookEditor {
pub fn new(
project: Model<Project>,
notebook_item: Model<NotebookItem>,
cx: &mut ViewContext<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let notebook = notebook_item.read(cx).notebook.clone();
let languages = project.read(cx).languages().clone();
let metadata = notebook.metadata;
let nbformat = notebook.nbformat;
let nbformat_minor = notebook.nbformat_minor;
let language_name = metadata
.language_info
.as_ref()
.map(|l| l.name.clone())
.or(metadata
.kernelspec
.as_ref()
.and_then(|spec| spec.language.clone()));
let notebook_language = if let Some(language_name) = language_name {
cx.spawn(|_, _| {
let languages = languages.clone();
async move { languages.language_for_name(&language_name).await.ok() }
})
.shared()
} else {
Task::ready(None).shared()
};
let languages = project.read(cx).languages().clone();
let notebook_language = cx
.spawn(|_, _| {
// todo: pull from notebook metadata
const TODO: &'static str = "Python";
let languages = languages.clone();
async move { languages.language_for_name(TODO).await.ok() }
})
.shared();
let mut cell_order = vec![];
let mut cell_map = HashMap::default();
for (index, cell) in notebook.cells.iter().enumerate() {
let cell_id = cell.id();
cell_order.push(cell_id.clone());
cell_map.insert(
cell_id.clone(),
Cell::load(cell, &languages, notebook_language.clone(), cx),
);
}
let view = cx.view().downgrade();
let cell_count = cell_order.len();
let cell_order_for_list = cell_order.clone();
let cell_map_for_list = cell_map.clone();
let cell_list = ListState::new(
cell_count,
gpui::ListAlignment::Top,
// TODO: This is a totally random number,
// not sure what this should be
px(3000.),
move |ix, cx| {
let cell_order_for_list = cell_order_for_list.clone();
let cell_id = cell_order_for_list[ix].clone();
if let Some(view) = view.upgrade() {
let cell_id = cell_id.clone();
if let Some(cell) = cell_map_for_list.clone().get(&cell_id) {
view.update(cx, |view, cx| {
view.render_cell(ix, cell, cx).into_any_element()
})
} else {
div().into_any()
}
} else {
div().into_any()
}
},
);
Self {
languages: languages.clone(),
focus_handle,
project,
path: notebook_item.read(cx).project_path.clone(),
remote_id: None,
cell_list,
selected_cell_index: 0,
metadata,
nbformat,
nbformat_minor,
cell_order: cell_order.clone(),
cell_map: cell_map.clone(),
}
}
fn has_outputs(&self, cx: &ViewContext<Self>) -> bool {
self.cell_map.values().any(|cell| {
if let Cell::Code(code_cell) = cell {
code_cell.read(cx).has_outputs()
} else {
false
}
})
}
fn is_dirty(&self, cx: &AppContext) -> bool {
self.cell_map.values().any(|cell| {
if let Cell::Code(code_cell) = cell {
code_cell.read(cx).is_dirty(cx)
} else {
false
}
})
}
fn clear_outputs(&mut self, cx: &mut ViewContext<Self>) {
for cell in self.cell_map.values() {
if let Cell::Code(code_cell) = cell {
code_cell.update(cx, |cell, _cx| {
cell.clear_outputs();
});
}
}
}
fn run_cells(&mut self, cx: &mut ViewContext<Self>) {
println!("Cells would all run here, if that was implemented!");
}
fn open_notebook(&mut self, _: &OpenNotebook, _cx: &mut ViewContext<Self>) {
println!("Open notebook triggered");
}
fn move_cell_up(&mut self, cx: &mut ViewContext<Self>) {
println!("Move cell up triggered");
}
fn move_cell_down(&mut self, cx: &mut ViewContext<Self>) {
println!("Move cell down triggered");
}
fn add_markdown_block(&mut self, cx: &mut ViewContext<Self>) {
println!("Add markdown block triggered");
}
fn add_code_block(&mut self, cx: &mut ViewContext<Self>) {
println!("Add code block triggered");
}
fn cell_count(&self) -> usize {
self.cell_map.len()
}
fn selected_index(&self) -> usize {
self.selected_cell_index
}
pub fn set_selected_index(
&mut self,
index: usize,
jump_to_index: bool,
cx: &mut ViewContext<Self>,
) {
// let previous_index = self.selected_cell_index;
self.selected_cell_index = index;
let current_index = self.selected_cell_index;
// in the future we may have some `on_cell_change` event that we want to fire here
if jump_to_index {
self.jump_to_cell(current_index, cx);
}
}
pub fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
let count = self.cell_count();
if count > 0 {
let index = self.selected_index();
let ix = if index == count - 1 {
count - 1
} else {
index + 1
};
self.set_selected_index(ix, true, cx);
cx.notify();
}
}
pub fn select_previous(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
let count = self.cell_count();
if count > 0 {
let index = self.selected_index();
let ix = if index == 0 { 0 } else { index - 1 };
self.set_selected_index(ix, true, cx);
cx.notify();
}
}
pub fn select_first(&mut self, _: &menu::SelectFirst, cx: &mut ViewContext<Self>) {
let count = self.cell_count();
if count > 0 {
self.set_selected_index(0, true, cx);
cx.notify();
}
}
pub fn select_last(&mut self, _: &menu::SelectLast, cx: &mut ViewContext<Self>) {
let count = self.cell_count();
if count > 0 {
self.set_selected_index(count - 1, true, cx);
cx.notify();
}
}
fn jump_to_cell(&mut self, index: usize, _cx: &mut ViewContext<Self>) {
self.cell_list.scroll_to_reveal_item(index);
}
fn button_group(cx: &ViewContext<Self>) -> Div {
v_flex()
.gap(Spacing::Small.rems(cx))
.items_center()
.w(px(CONTROL_SIZE + 4.0))
.overflow_hidden()
.rounded(px(5.))
.bg(cx.theme().colors().title_bar_background)
.p_px()
.border_1()
.border_color(cx.theme().colors().border)
}
fn render_notebook_control(
id: impl Into<SharedString>,
icon: IconName,
_cx: &ViewContext<Self>,
) -> IconButton {
let id: ElementId = ElementId::Name(id.into());
IconButton::new(id, icon).width(px(CONTROL_SIZE).into())
}
fn render_notebook_controls(&self, cx: &ViewContext<Self>) -> impl IntoElement {
let has_outputs = self.has_outputs(cx);
v_flex()
.max_w(px(CONTROL_SIZE + 4.0))
.items_center()
.gap(Spacing::XXLarge.rems(cx))
.justify_between()
.flex_none()
.h_full()
.py(Spacing::XLarge.px(cx))
.child(
v_flex()
.gap(Spacing::Large.rems(cx))
.child(
Self::button_group(cx)
.child(
Self::render_notebook_control("run-all-cells", IconName::Play, cx)
.tooltip(move |cx| {
Tooltip::for_action("Execute all cells", &RunAll, cx)
})
.on_click(|_, cx| {
cx.dispatch_action(Box::new(RunAll));
}),
)
.child(
Self::render_notebook_control(
"clear-all-outputs",
IconName::ListX,
cx,
)
.disabled(!has_outputs)
.tooltip(move |cx| {
Tooltip::for_action("Clear all outputs", &ClearOutputs, cx)
})
.on_click(|_, cx| {
cx.dispatch_action(Box::new(ClearOutputs));
}),
),
)
.child(
Self::button_group(cx)
.child(
Self::render_notebook_control(
"move-cell-up",
IconName::ArrowUp,
cx,
)
.tooltip(move |cx| {
Tooltip::for_action("Move cell up", &MoveCellUp, cx)
})
.on_click(|_, cx| {
cx.dispatch_action(Box::new(MoveCellUp));
}),
)
.child(
Self::render_notebook_control(
"move-cell-down",
IconName::ArrowDown,
cx,
)
.tooltip(move |cx| {
Tooltip::for_action("Move cell down", &MoveCellDown, cx)
})
.on_click(|_, cx| {
cx.dispatch_action(Box::new(MoveCellDown));
}),
),
)
.child(
Self::button_group(cx)
.child(
Self::render_notebook_control(
"new-markdown-cell",
IconName::Plus,
cx,
)
.tooltip(move |cx| {
Tooltip::for_action("Add markdown block", &AddMarkdownBlock, cx)
})
.on_click(|_, cx| {
cx.dispatch_action(Box::new(AddMarkdownBlock));
}),
)
.child(
Self::render_notebook_control("new-code-cell", IconName::Code, cx)
.tooltip(move |cx| {
Tooltip::for_action("Add code block", &AddCodeBlock, cx)
})
.on_click(|_, cx| {
cx.dispatch_action(Box::new(AddCodeBlock));
}),
),
),
)
.child(
v_flex()
.gap(Spacing::Large.rems(cx))
.items_center()
.child(Self::render_notebook_control(
"more-menu",
IconName::Ellipsis,
cx,
))
.child(
Self::button_group(cx)
.child(IconButton::new("repl", IconName::ReplNeutral)),
),
)
}
fn cell_position(&self, index: usize) -> CellPosition {
match index {
0 => CellPosition::First,
index if index == self.cell_count() - 1 => CellPosition::Last,
_ => CellPosition::Middle,
}
}
fn render_cell(
&self,
index: usize,
cell: &Cell,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let cell_position = self.cell_position(index);
let is_selected = index == self.selected_cell_index;
match cell {
Cell::Code(cell) => {
cell.update(cx, |cell, _cx| {
cell.set_selected(is_selected)
.set_cell_position(cell_position);
});
cell.clone().into_any_element()
}
Cell::Markdown(cell) => {
cell.update(cx, |cell, _cx| {
cell.set_selected(is_selected)
.set_cell_position(cell_position);
});
cell.clone().into_any_element()
}
Cell::Raw(cell) => {
cell.update(cx, |cell, _cx| {
cell.set_selected(is_selected)
.set_cell_position(cell_position);
});
cell.clone().into_any_element()
}
}
}
}
impl Render for NotebookEditor {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
div()
.key_context("notebook")
.track_focus(&self.focus_handle)
.on_action(cx.listener(|this, &OpenNotebook, cx| this.open_notebook(&OpenNotebook, cx)))
.on_action(cx.listener(|this, &ClearOutputs, cx| this.clear_outputs(cx)))
.on_action(cx.listener(|this, &RunAll, cx| this.run_cells(cx)))
.on_action(cx.listener(|this, &MoveCellUp, cx| this.move_cell_up(cx)))
.on_action(cx.listener(|this, &MoveCellDown, cx| this.move_cell_down(cx)))
.on_action(cx.listener(|this, &AddMarkdownBlock, cx| this.add_markdown_block(cx)))
.on_action(cx.listener(|this, &AddCodeBlock, cx| this.add_code_block(cx)))
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_previous))
.on_action(cx.listener(Self::select_first))
.on_action(cx.listener(Self::select_last))
.flex()
.items_start()
.size_full()
.overflow_hidden()
.px(Spacing::XLarge.px(cx))
.gap(Spacing::XLarge.px(cx))
.bg(cx.theme().colors().tab_bar_background)
.child(
v_flex()
.id("notebook-cells")
.flex_1()
.size_full()
.overflow_y_scroll()
.child(list(self.cell_list.clone()).size_full()),
)
.child(self.render_notebook_controls(cx))
}
}
impl FocusableView for NotebookEditor {
fn focus_handle(&self, _: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
pub struct NotebookItem {
path: PathBuf,
project_path: ProjectPath,
notebook: nbformat::v4::Notebook,
}
impl project::Item for NotebookItem {
fn try_open(
project: &Model<Project>,
path: &ProjectPath,
cx: &mut AppContext,
) -> Option<Task<gpui::Result<Model<Self>>>> {
let path = path.clone();
let project = project.clone();
if path.path.extension().unwrap_or_default() == "ipynb" {
Some(cx.spawn(|mut cx| async move {
let abs_path = project
.read_with(&cx, |project, cx| project.absolute_path(&path, cx))?
.ok_or_else(|| anyhow::anyhow!("Failed to find the absolute path"))?;
let file_content = std::fs::read_to_string(abs_path.clone())?;
let notebook = nbformat::parse_notebook(&file_content);
let notebook = match notebook {
Ok(nbformat::Notebook::V4(notebook)) => notebook,
Ok(nbformat::Notebook::Legacy(legacy_notebook)) => {
// todo!(): Decide if we want to mutate the notebook by including Cell IDs
// and any other conversions
let notebook = nbformat::upgrade_legacy_notebook(legacy_notebook)?;
notebook
}
Err(e) => {
anyhow::bail!("Failed to parse notebook: {:?}", e);
}
};
cx.new_model(|_| NotebookItem {
path: abs_path,
project_path: path,
notebook,
})
}))
} else {
None
}
}
fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
None
}
fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
Some(self.project_path.clone())
}
}
impl EventEmitter<()> for NotebookEditor {}
// pub struct NotebookControls {
// pane_focused: bool,
// active_item: Option<Box<dyn ItemHandle>>,
// // subscription: Option<Subscription>,
// }
// impl NotebookControls {
// pub fn new() -> Self {
// Self {
// pane_focused: false,
// active_item: Default::default(),
// // subscription: Default::default(),
// }
// }
// }
// impl EventEmitter<ToolbarItemEvent> for NotebookControls {}
// impl Render for NotebookControls {
// fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
// div().child("notebook controls")
// }
// }
// impl ToolbarItemView for NotebookControls {
// fn set_active_pane_item(
// &mut self,
// active_pane_item: Option<&dyn workspace::ItemHandle>,
// cx: &mut ViewContext<Self>,
// ) -> workspace::ToolbarItemLocation {
// cx.notify();
// self.active_item = None;
// let Some(item) = active_pane_item else {
// return ToolbarItemLocation::Hidden;
// };
// ToolbarItemLocation::PrimaryLeft
// }
// fn pane_focus_update(&mut self, pane_focused: bool, _: &mut ViewContext<Self>) {
// self.pane_focused = pane_focused;
// }
// }
impl Item for NotebookEditor {
type Event = ();
fn tab_content_text(&self, _cx: &WindowContext) -> Option<SharedString> {
let path = self.path.path.clone();
path.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.map(SharedString::from)
}
fn tab_icon(&self, _cx: &ui::WindowContext) -> Option<Icon> {
Some(IconName::Book.into())
}
fn show_toolbar(&self) -> bool {
false
}
fn is_dirty(&self, cx: &AppContext) -> bool {
// self.is_dirty(cx)
false
}
}
// TODO: Implement this to allow us to persist to the database, etc:
// impl SerializableItem for NotebookEditor {}
impl ProjectItem for NotebookEditor {
type Item = NotebookItem;
fn for_project_item(
project: Model<Project>,
item: Model<Self::Item>,
cx: &mut ViewContext<Self>,
) -> Self
where
Self: Sized,
{
Self::new(project, item, cx)
}
}

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