Compare commits

..

104 Commits

Author SHA1 Message Date
Antonio Scandurra
8107e6d9db Occlude only modal and not the space around it used to center it 2024-03-11 18:12:24 +01:00
Antonio Scandurra
91a0923fc4 Fix a few regressions related to the flicker fix (#9190)
This pull request fixes
https://github.com/zed-industries/zed/issues/9187 and fixes also ix a
rendering problem that would show cursors on the same plane:


![image](https://github.com/zed-industries/zed/assets/482957/208304a4-286a-4fd9-a3d8-e2913e3a3dc7)


Release Notes:

- N/A
2024-03-11 18:07:26 +01:00
Ivan Žužak
b4ddc83e85 Allow overriding font style and weight via experimental.theme_overrides in settings (#9122)
Release Notes:

- Added support for overriding the current theme's syntax font styles
and weights in settings
([#9121](https://github.com/zed-industries/zed/issues/9121)).

| Before | After |
| ------ | ----- |
| ![Screenshot 2024-03-09 at 22 20
01@2x](https://github.com/zed-industries/zed/assets/38924/c693468d-1e04-45b4-b7c0-869e2a22a44c)
| ![Screenshot 2024-03-09 at 22 21
09@2x](https://github.com/zed-industries/zed/assets/38924/d8b09676-dd8b-46ac-8e9d-6cf2094a9c7e)
|
2024-03-11 12:21:37 -04:00
Vitor Ramos
3bd9d14420 linux: Fix panic missing screen mode for crtc specified mode ID (#9106)
Fix panic caused by missing screen mode for specified crtc mode id #9105
by searching over all crtcs instead of using the first one which may be
invalid.
2024-03-11 09:04:05 -07:00
charles-r-earp
95b311cb90 linux: gpui add Keysym::ISO_Left_Tab (#9126)
Fixes #9089.

On linux, pressing shift and tab together can potentially produce
`ISO_Left_Tab`. This PR maps this key to "tab" with the shift modifier,
similar to `SHIFT_TAB_KEY` in gpui::platform::mac::events.

Note: The [default linux
keymaps](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-linux.json)
have shift-tab mapped to editor::TabPrev and ctrl-[ mapped to
editor::Outdent. Both actions appear to have the same effect.

Release Notes:

- Support shift-tab on linux (#9089).
2024-03-11 09:03:15 -07:00
Antonio Scandurra
8eea281288 Show modals on top of zoomed pane (#9183)
Release Notes:

- N/A
2024-03-11 16:57:17 +01:00
Kirill Bulatov
373a4e7614 Properly display deleted diff hunks (#9182)
Follow-up of https://github.com/zed-industries/zed/pull/9068 

Release Notes:

- Fixed removal diff hunks not being displayed properly in the editor
2024-03-11 17:53:45 +02:00
Piotr Osiewicz
f9f9f0670f editor: Rearrange float operations in layout (#9176)
We were seeing weird layouts with large files, where - starting with
some verylargelineindex - lines were rendered at weird y offsets. It
turned out that in some cases we're doing operations on Pixel values of
different magnitude, which then led to wrong results in calculations.
This commit addresses some of these problems, visible at glance when
working with large plaintext files. I *did not* dig into things like
inlay hints or diagnostics to see if they are subject to the same
potential precision loss.

Fixes #5371 

Release Notes:

- Fixed editor layout for large files, where the lines might have been
laid out with incorrect Y offset from the top.
2024-03-11 16:46:20 +01:00
Joel Selvaraj
20d5f5e8da linux: wayland cursor fixes (#9047)
Release Notes:

- Fixed wayland cursor style handling


In upcoming Gnome 46, cursor icon names are considerably changing. For
example: this commit
74e9b79471
removed/modified a lot of cursor names. Then some of the names were
reintroduced in this commit
6f64dc55dc.
I also tried upcoming KDE Plasma 6. Some of the cursor names are not
used commonly between Gnome and KDE. From my analysis, these set of
cursor names should be more widely available in both previous and
upcoming release of Gnome and KDE.

Also, If a cursor style is not available, let's fallback to default
cursor style. This avoids scenarios where we get stuck with special
cursor styles like IBeam/Resize* because the current cursor style is not
available. This will lead to an unpleasant/broken experience. Falling
back to default cursor seems to be more acceptable.
2024-03-11 08:38:52 -07:00
Thorsten Ball
f066dd268f Fix race when language server registers for workspace/didChangeWatchedFiles (#9177)
This fixes #8896 by storing the `watched_paths` in a separate HashMap,
allowing us to handle the request even before we mark the language
server as running.

Downside is that we have yet another data structure for language
servers, but it also makes the `Running` enum case a bit smaller.

And it fixes the race condition.

Release Notes:

- Fixed language servers not being notified of file changes if language
server registers for file-notification right after starting up.
([#8896](https://github.com/zed-industries/zed/issues/8896)).

Co-authored-by: Bennet <bennetbo@gmx.de>
Co-authored-by: Remco <djsmits12@gmail.com>
2024-03-11 16:30:30 +01:00
Mart Zielman
0be20d0817 fix: vulkan dependencies in script/linux (#9116)
Just a quick pull request and a small fix, someone reported a dependency
was erroring for him, so I decided to open a small pull request. On top
of that, any `devel` header is not needed because Vulkan is only a
runtime dependency.

Release Notes:

- Fixed names of Vulkan dependencies that didn't exist
2024-03-11 08:29:06 -07:00
白山風露
a04932c4eb Windows: fix crash with unhandled window (#9164)
On Windows, some windows may be created that are not managed by the
application.
For example, the Japanese IME creates pop-ups like this one.

<img width="325" alt="image"
src="https://github.com/zed-industries/zed/assets/6465609/503aaa0a-7568-485a-a138-e689ae67001c">

The internal data associated with such a window is different from
`WindowsWindowInner` and will crash if referenced.
Therefore, before calling `try_get_window_inner`, it checks if it is an
owned window.


Release Notes:

- N/A
2024-03-11 08:28:18 -07:00
Antonio Scandurra
ceadb39c38 Prevent text from wrapping in code actions menu (#9178)
Right now we're basing the width of the menu on the longest code action
title. That is only an approximation and doesn't always coincide
perfectly with the true, longest code action.

Given that it's pretty close, however, this commit simply disables text
wrapping on the code action menu.

Release Notes:

- Fixed a rendering glitch that could cause code actions to not display
correctly ([#8341](https://github.com/zed-industries/zed/issues/8341))
2024-03-11 16:25:17 +01:00
白山風露
2244419dfd Add missed pad (#9175)
Sorry I missed explicitly `Quad::pad` insertion at #9172.

The struct layout is unchanged and does not affect the behavior.

Release Notes:

- N/A
2024-03-11 08:21:52 -07:00
Bennet Bo Fenner
a8fa1f7363 chat: fix emoji completions when word consists of emojis (#9107)
https://github.com/zed-industries/zed/assets/53836821/f4b31c47-d306-43f5-b971-0969f64a48f9

Fix for #9096 @JosephTLyons 

Release Notes:
- Fixed emoji completion not showing up when word contains only emojis
(#9096)
2024-03-11 09:08:18 -06:00
白山風露
eb5e18c66d Fix blade validation failure (#9172)
Fix: #9167

Release Notes:

- N/A
2024-03-11 08:06:20 -07:00
Antonio Scandurra
830e107921 Hide hover popover when mouse hovers over negative space (#9173)
Fixes https://github.com/zed-industries/zed/issues/8340

Release Notes:

- Fixed a bug that would cause hover information to not be dismissed
when hovering over negative space.
2024-03-11 15:29:44 +01:00
Piotr Osiewicz
45c4d35da8 rope: Preallocate chunks buffer
This commit also specializes 'fn push' for large text quantities. That specialized version uses a Vec instead of SmallVec.
This commit shaves off about ~100ms out of 800ms when loading a 600Mb text buffer.
2024-03-11 13:28:10 +01:00
Antonio Scandurra
298314d526 Fix regressions introduced by flicker fix (#9162)
This pull request fixes a couple of easy regressions we discovered right
after using #9012 on nightly:

- Popover buttons for a chat message were being occluded by the message
itself.
- Scrolling was not working on the `List` element.

Release Notes:

- N/A
2024-03-11 12:11:51 +01:00
Kirill Bulatov
2f6c78b0c0 Fix incorrect outline selections after submit (#9160)
Follow-up of https://github.com/zed-industries/zed/pull/9153

Release Notes:

- N/A
2024-03-11 12:07:42 +02:00
Antonio Scandurra
4700d33728 Fix flickering (#9012)
See https://zed.dev/channel/gpui-536

Fixes https://github.com/zed-industries/zed/issues/9010
Fixes https://github.com/zed-industries/zed/issues/8883
Fixes https://github.com/zed-industries/zed/issues/8640
Fixes https://github.com/zed-industries/zed/issues/8598
Fixes https://github.com/zed-industries/zed/issues/8579
Fixes https://github.com/zed-industries/zed/issues/8363
Fixes https://github.com/zed-industries/zed/issues/8207


### Problem

After transitioning Zed to GPUI 2, we started noticing that interacting
with the mouse on many UI elements would lead to a pretty annoying
flicker. The main issue with the old approach was that hover state was
calculated based on the previous frame. That is, when computing whether
a given element was hovered in the current frame, we would use
information about the same element in the previous frame.

However, inspecting the previous frame tells us very little about what
should be hovered in the current frame, as elements in the current frame
may have changed significantly.

### Solution

This pull request's main contribution is the introduction of a new
`after_layout` phase when redrawing the window. The key idea is that
we'll give every element a chance to register a hitbox (see
`ElementContext::insert_hitbox`) before painting anything. Then, during
the `paint` phase, elements can determine whether they're the topmost
and draw their hover state accordingly.

We are also removing the ability to give an arbitrary z-index to
elements. Instead, we will follow the much simpler painter's algorithm.
That is, an element that gets painted after will be drawn on top of an
element that got painted earlier. Elements can still escape their
current "stacking context" by using the new `ElementContext::defer_draw`
method (see `Overlay` for an example). Elements drawn using this method
will still be logically considered as being children of their original
parent (for keybinding, focus and cache invalidation purposes) but their
layout and paint passes will be deferred until the currently-drawn
element is done.

With these changes we also reworked geometry batching within the
`Scene`. The new approach uses an AABB tree to determine geometry
occlusion, which allows the GPU to render non-overlapping geometry in
parallel.

### Performance

Performance is slightly better than on `main` even though this new
approach is more correct and we're maintaining an extra data structure
(the AABB tree).


![before_after](https://github.com/zed-industries/zed/assets/482957/c8120b07-1dbd-4776-834a-d040e569a71e)

Release Notes:

- Fixed a bug that was causing popovers to flicker.

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: Thorsten <thorsten@zed.dev>
2024-03-11 10:45:57 +01:00
Thorsten Ball
9afd78b35e Add ESLint information to JavaScript docs (#9158)
Release Notes:

- N/A
2024-03-11 10:30:25 +01:00
Yanguk
9ff3cff6f8 Respect eslint.nodePath setting (#9073)
I'm using Yarn Plug'n'Play.
In this case, by default, eslint cannot find the path, so configuration
like `"eslint.nodePath": ".yarn/sdks"` is required.
So, I want to add this!

Release Notes:

- Added eslint config nodePath
2024-03-11 10:15:06 +01:00
Kainoa Kanter
d66f8f99bd docs: Move Linux tracking issue (#9130)
Release Notes:

- N/A
2024-03-11 10:08:14 +02:00
Hans
269848775c Fix Vim code formating (#9098)
- N/A
2024-03-11 10:03:51 +02:00
Andrew
39bd12a557 gpui: add set menus example (#9131)
Add an example showing how to add a menu item, register an action with
the `AppContext`, and successfully call the action.

Release Notes:

- N/A
2024-03-11 09:56:45 +02:00
Max
e1f8a1e8b2 Fix <!DOCTYPE html> syntax highlighting (#9108)
Release Notes:

- Added `<!DOCTYPE html>` syntax highlighting ([4318](https://github.com/zed-industries/zed/issues/4318))
2024-03-11 09:56:35 +02:00
Kirill Bulatov
41dc5fc412 Allow highlighting editor rows from multiple sources concurrently (#9153) 2024-03-11 02:17:32 +02:00
Thorsten Ball
f4a86e6fea Always single-quote directory when cd'ing to get shell env (#9145)
This avoids us potentially executing code (if someone were to name their
directory `$(echo you-are-pwned > /secure-files)`, for example).

Works with zsh, bash, fish, nushell. Tested locally with all of them.

Release Notes:

- N/A
2024-03-10 13:53:24 +01:00
Kirill Bulatov
597465b0f5 Slightly simplify editor highlights code (#9123)
Prepare for git diff hunk highlights by grouping all inlay highlight
properties into one struct, and removing the dead background highlight
code.


Release Notes:

- N/A
2024-03-10 00:38:45 +02:00
Max
ccc939124f Remove obsolete separator (#9117) 2024-03-09 15:33:19 -05:00
Joseph T. Lyons
a03fecafbb Remove feedback button from status bar (#9100)
This PR removes the feedback button from the status bar, as Nathan and I
discussed. We discussed the fact that we likely no longer need to take
up valuable screen real estate for this, with where Zed as at now.

This PR also moves the `Share Feedback...` collab menu item to the
`Help` menu, as that's where VS Code puts their action to send in-app
feedback (which might help with future discoverability) and renames it
to `Give Feedback...`, to make it consistent with the name of the
command palette action.

Release Notes:

- Removed the feedback button from the status bar.
2024-03-09 06:15:08 -05:00
Mikayla Maki
ca696fd5f6 Add rs-notify implementation of fs::watch (#9040)
This PR simplifies the Zed file system abstraction and implements
`Fs::watch` for linux and windows.

TODO:
- [x] Figure out why this fails to initialize the file watchers when we
have to initialize the config directory paths, but succeeds on
subsequent runs.
- [x] Fix macOS dependencies on old fsevents::Event crate

Release Notes:

- N/A
2024-03-08 22:18:44 -08:00
Jason Wen
456efb53ad windows: Add file dialog using IFileOpenDialog (#8919)
Release Notes:

- Added a file dialog for Windows
2024-03-08 20:07:48 -08:00
Adam
d4ec78f328 Add strikethrough to deprecated methods in CompletionsMenu (#9086)
Release Notes:

- Added ([#8390](https://github.com/zed-industries/zed/issues/8390)).
- Also Grays out deprecated methods

Before

<img width="730" alt="image"
src="https://github.com/zed-industries/zed/assets/71665039/8b5e8009-22c2-43f7-b85b-79e571a5d282">

After

<img width="773" alt="image"
src="https://github.com/zed-industries/zed/assets/71665039/0aff572b-6d3f-4ed9-b08b-d925ee650817">
2024-03-08 20:01:28 -08:00
bbb651
efe5203a09 GPUI: Wayland: Add fullscreen, minimize and avoid unnecessary resizes (#9060)
Release Notes:
- N/A
2024-03-08 19:52:36 -08:00
Kirill Bulatov
146971fb02 Splice remove suggesion hints when those are cleared in the editor. (#9088)
Closes https://github.com/zed-industries/zed/issues/6793

Release Notes:

- Fixed copilot suggestions not disappearing after disabling the tool
([6793](https://github.com/zed-industries/zed/issues/6793))
2024-03-09 02:00:01 +02:00
Kirill Bulatov
347178039c Add editor::RevertSelectedHunks to revert git diff hunks in the editor (#9068)
https://github.com/zed-industries/zed/assets/2690773/653b5658-e3f3-4aee-9a9d-0f2153b4141b

Release Notes:

- Added `editor::RevertSelectedHunks` (`cmd-alt-z` by default) for
reverting git hunks from the editor
2024-03-09 01:37:24 +02:00
Jadi
6a7a3b257a Add missing docstrings to settings.rs (#9054)
![image](https://github.com/zed-industries/zed/assets/1290639/46c13110-8506-4b03-91d4-b1cfcafe824a)

Add documentation for theme-related settings.

Release Notes:

- Add documentation for theme-related settings ([8383](https://github.com/zed-industries/zed/issues/8383))
2024-03-09 00:46:47 +02:00
Max Brunsfeld
8a6264d933 Provide wasm extensions with APIs needed for using pre-installed LSP binaries (#9085)
In this PR, we've added two new methods that LSP extensions can call:
* `shell_env()`, for retrieving the environment variables set in the
user's default shell in the worktree
* `which(command)`, for looking up paths to an executable (accounting
for the user's shell env in the worktree)

To test this out, we moved the `uiua` language support into an
extension. We went ahead and removed the built-in support, since this
language is extremely obscure. Sorry @mikayla-maki. To continue coding
in Uiua in Zed, for now you can `Add Dev Extension` from the extensions
pane, and select the `extensions/uiua` directory in the Zed repo. Very
soon, we'll support publishing these extensions so that you'll be able
to just install it normally.

Release Notes:

- N/A

---------

Co-authored-by: Marshall <marshall@zed.dev>
2024-03-08 17:18:06 -05:00
Jason Lee
5abcc1c3c5 Let LineColumn on StatusBar as clickable to open GoToLineColumn (#9002)
Release Notes:

- Added to let LineColumn on StatusBar as clickable to open
GoToLineColumn.
- Added placeholder to GoToLineColumn input, and show help message on
input changed.

## Screenshot


![go-to-line-column](https://github.com/zed-industries/zed/assets/5518/90a4f644-07d4-4208-8caa-5510e1537f37)
2024-03-08 14:11:17 -07:00
Conrad Irwin
977af37cfe open zed urls (#9081)
Release Notes:

- Added support for opening files on the zed protocol `open
zed:///Users/example/Desktop/a.txt`
([#8482](https://github.com/zed-industries/zed/issues/8482)).
2024-03-08 13:44:01 -07:00
Evren Sen
1756c1fc1e Improve UI of popover buttons when hovering over chat messages (#9041)
### Before


https://github.com/zed-industries/zed/assets/146845123/4a16c1ce-a671-4e39-abc9-3a0cb25bc0cd

### After


https://github.com/zed-industries/zed/assets/146845123/cfab3d00-246e-427d-9c40-8ee520a0a186




Release Notes:
- Improved the UI of popover buttons when hovering over chat messages.
2024-03-08 12:46:51 -07:00
Marshall Bowers
be953b78ef Add script for setting up WASI dependencies (#9078)
This PR adds a script for setting up the WASI dependencies needed for
extensions.

These already get downloaded when needed when using Zed, but in the
tests the HTTP client is faked out, so if you don't already have them
installed the `test_extension_store_with_gleam_extension` test will
fail.

Release Notes:

- N/A
2024-03-08 14:05:29 -05:00
Max Brunsfeld
51ebe0eb01 Allow wasm extensions to do arbitrary file I/O in their own directory to install language servers (#9043)
This PR provides WASM extensions with write access to their own specific
working directory under the Zed `extensions` dir. This directory is set
as the extensions `current_dir` when they run. Extensions can return
relative paths from the `Extension::language_server_command` method, and
those relative paths will be interpreted relative to this working dir.

With this functionality, most language servers that we currently build
into zed can be installed using extensions.

Release Notes:

- N/A
2024-03-08 08:49:27 -08:00
张小白
a550b9cecf Impl prompts and savefile dialog on Windows (#9009)
### Description
This is a part of #8809 , and this PR dose not include `open file
dialog`, as I already saw two PRs impl this.



https://github.com/zed-industries/zed/assets/14981363/3223490a-de77-4892-986f-97cf85aec3ae




Release Notes:

- N/A
2024-03-08 08:14:47 -08:00
Piotr Osiewicz
bf295eac90 Task::spawn now takes an optional task name as an argument.
If it is not set, we fall back to opening a modal. This allows user to spawn tasks via keybind.
2024-03-08 15:28:42 +01:00
Piotr Osiewicz
fa5dfe19f8 Fix default tasks.json definition 2024-03-08 15:28:42 +01:00
Piotr Osiewicz
7b73e2824b fs: allocate backing storage once in Fs::load (#9020)
`futures_lite::AsyncReadExt::read_to_string` (that we use in
`RealFs::load`) explicitly does not allocate memory for String contents
up front, which leads to excessive reallocations. That reallocation time
is a significant contributor to the time we spend loading files (esp
large ones). For example, out of ~1s that it takes to open up a 650Mb
ASCII buffer on my machine (after changes related to fingerprinting from
#9007), 350ms is spent in `RealFs::load`.
This change slashes that figure to ~110ms, which is still *a lot*. About
60ms out of 110ms remaining is spent zeroing memory. Sadly,
`AsyncReadExt` API forces us to zero a buffer we're reading into
(whether it's via read_to_string or read_exact), but at the very least
this commit alleviates unnecessary reallocations.

We could probably use something like
[simdutf8](https://docs.rs/simdutf8/latest/simdutf8/) to speed up UTF8
validation in this method as well, though that takes only about ~18ms
out of 110ms, so while it is significant, I've left that out for now.
Memory zeroing is a bigger problem at this point.

Before:

![image](https://github.com/zed-industries/zed/assets/24362066/5e53c004-8a02-47db-bc75-04cb4113a6bc)

After:

![image](https://github.com/zed-industries/zed/assets/24362066/00099032-d647-4683-b290-eaeb969cac4a)

/cc @as-cii 

Release Notes:

- Improved performance when loading large files.
2024-03-08 14:40:26 +01:00
Kirill Bulatov
1081ba7a62 Adjust to newer logic from zed-industries/cargo-bundle (#9058)
Zed uses a fork of cargo-bundle, that got upstream changes and
9e185bd44d
into the deploy branch.

Remove a TODO and adjust the script to the new packaging logic.


Release Notes:

- N/A
2024-03-08 13:37:10 +02:00
Conrad Irwin
ed8aa6d200 Fix panic in layout_line when Y coordinate is too high (#9052)
Release Notes:

- N/A
2024-03-07 22:33:44 -07:00
Valentine Briese
af564242e1 Make comment above util::fs::remove_matching a doc comment (#9051)
Just this one little thing, noticed it while working on an unrelated
pull request.

Release Notes:

- N/A
2024-03-07 21:15:52 -08:00
EricApostal
aa7be4b5d8 Add clipboard support for Windows (#8978)
Release Notes:

- Added Read / Write clipboard support to Windows via copypasta

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
2024-03-07 20:16:38 -08:00
Conrad Irwin
866d791760 Fix joining hosted projects (#9038)
Release Notes:

- N/A
2024-03-07 19:56:41 -07:00
Rom Grk
f67abd2943 vim: smartcase find option (#9033)
Release Notes:

- Added option `use_smartcase_find` to the vim-mode
2024-03-07 19:44:20 -07:00
Rom Grk
d247086b21 vim: subword motions (#8725)
Add subword motions to vim, inspired by
[nvim-spider](https://github.com/chrisgrieser/nvim-spider),
[CamelCaseMotion](https://github.com/bkad/CamelCaseMotion).


Release Notes:

- Added subword motions to vim
2024-03-07 19:36:12 -07:00
rauan
467a179837 Add Elixir symbols in outline view (#8761)
Release Notes:

- Improved: Add `@callback`, `@type` and `@typep` Elixir symbols in
outline view



https://github.com/zed-industries/zed/assets/14976415/208d3def-f49e-41e0-a306-fb8e00317e6b
2024-03-07 19:35:01 -07:00
Small White
b50f86735f Impl drag-drop action for Windows (#8959)
### Description

This is a part of #8809 



https://github.com/zed-industries/zed/assets/14981363/2b085b9d-8b83-4ac7-8b84-07c679760eba




Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
2024-03-07 15:59:48 -08:00
Small White
e85d484952 Fix terminal on Windows (#8999)
### Description

Since [this PR](https://github.com/alacritty/alacritty/pull/7796) has
been merged, so we can delete the `todo`s in `terminal` module.


Release Notes:

- N/A
2024-03-07 15:54:58 -08:00
Rom Grk
2d83580df4 linux: enable test TextSystem (#9037)
Make text tests work on linux.
2024-03-07 15:51:52 -08:00
白山風露
a90a667fd0 Windows: Add document (#8948)
Release Notes:

- N/A
2024-03-07 15:41:15 -08:00
Bing Wang
35c7b5d7dd Add vulkan linux dependency (#8932)
Release Notes:

- Fixed ([#8934](https://github.com/zed-industries/zed/issues/8934)).

Optionally, include screenshots / media showcasing your addition that
can be included in the release notes.

**or**

- N/A

Signed-off-by: pigletfly <wangbing.adam@gmail.com>
2024-03-07 15:40:27 -08:00
Kirill Bulatov
ffebe2e4a6 Initial Linux nightly bundles upload (#8913)
Changes Zed CI to build and upload Linux nightly bundles.

* `todo!(linux)` are replaced with `TODO linux` to make `todo!`-based
workflows more convenient
* renames `run-build-dmg` label into `run-bundling`, also renames a few
GH Actions entries to be more generic
* make another upload path for Linux, which keeps a separate file with SHA to version the nightly artifact.
* adds a `*.deb` package building with a couple of caveats, marked with
new `TODO linux` entries:

1. `cargo-bundle` is not very flexible, so it generates artifacts with
the structure and names that we're unable to alter before/during the
generation.
For that, a set of extra steps is made by repacking the *.deb package —
this is not very portable between different Linux distros, so later one
needs to find a way to combine multiple package types in this script.

2. `cargo-bundle` is not able to properly generate the *.msi bundle
despite declaring it in the features:
https://github.com/burtonageo/cargo-bundle/issues/116
Windows needs to invent its own way of bundling or fix the tool.

3. Both `cli` and `zed` binaries are added into the archive under
`/usr/local/bin/` path with their `-$channel` suffix
(-nightly/-preview/-dev/-stable) and a `/usr/local/bin/zed ->
/usr/local/bin/cli-nightly` symlink is made to make CLI work as Zed
launcher:
```
~/work/zed kb/linux-nightly:origin/kb/linux-nightly*​ ❯ dpkg -c target/zed_amd64.deb 
drwxr-xr-x allaptop/allaptop 0 2024-03-06 00:53 ./
drwxr-xr-x allaptop/allaptop 0 2024-03-06 00:53 ./usr/
drwxr-xr-x allaptop/allaptop 0 2024-03-06 00:53 ./usr/local/
drwxr-xr-x allaptop/allaptop 0 2024-03-06 00:53 ./usr/local/bin/
-rwxr-xr-x allaptop/allaptop 8746832 2024-03-06 00:53 ./usr/local/bin/cli-nightly
-rwxr-xr-x allaptop/allaptop 689078560 2024-03-06 00:53 ./usr/local/bin/zed-nightly
drwxr-xr-x allaptop/allaptop         0 2024-03-06 00:53 ./usr/share/
drwxr-xr-x allaptop/allaptop         0 2024-03-06 00:53 ./usr/share/applications/
-rw-r--r-- allaptop/allaptop       153 2024-03-06 00:53 ./usr/share/applications/zed.desktop
drwxr-xr-x allaptop/allaptop         0 2024-03-06 00:53 ./usr/share/icons/
drwxr-xr-x allaptop/allaptop         0 2024-03-06 00:53 ./usr/share/icons/hicolor/
drwxr-xr-x allaptop/allaptop         0 2024-03-06 00:53 ./usr/share/icons/hicolor/1024x1024@2x/
drwxr-xr-x allaptop/allaptop         0 2024-03-06 00:53 ./usr/share/icons/hicolor/1024x1024@2x/apps/
-rw-r--r-- allaptop/allaptop    716288 2024-03-06 00:53 ./usr/share/icons/hicolor/1024x1024@2x/apps/zed.png
drwxr-xr-x allaptop/allaptop         0 2024-03-06 00:53 ./usr/share/icons/hicolor/512x512/
drwxr-xr-x allaptop/allaptop         0 2024-03-06 00:53 ./usr/share/icons/hicolor/512x512/apps/
-rw-r--r-- allaptop/allaptop    239870 2024-03-06 00:53 ./usr/share/icons/hicolor/512x512/apps/zed.png
lrwxrwxrwx allaptop/allaptop         0 2024-03-06 00:53 ./usr/local/bin/zed -> /usr/local/bin/cli-nightly
```

But the CLI does not work under Linux yet and there's no way to install
that CLI from Zed now; Zed binary itself is not able to open
`file/location:12:34`-like things and set up the env properly, but is
able to start or open a directory.

So, this structure can be considered temporary and changed, if needed.

4. Zed Nightly on Linux does not know how to update itself, so all
nightly publishing is not picked up automatically.

5. Rust cache from `main` builds does not get shared between CI jobs,
due to being run in a different CI job that forms a different CI key, so
```
      - name: Cache dependencies
        uses: swatinem/rust-cache@v2
        with:
          save-if: ${{ false }}
```
would not work.
This makes Linux bundling jobs long.

Release Notes:

- N/A
2024-03-07 23:22:53 +02:00
Conrad Irwin
e85f190128 Fix 0 notes versions being always unread (#9030)
Co-Authored-By: Max <max@zed.dev>
Co-Authored-By: Nathan <nathan@zed.dev>

Release Notes:

- Fixed empty notes always showing as unread

Co-authored-by: Max <max@zed.dev>
Co-authored-by: Nathan <nathan@zed.dev>
2024-03-07 13:53:05 -07:00
Conrad Irwin
284a57d4d1 Fix panic in open urls (#9032)
Co-Authored-By: Nathan <nathan@zed.dev>

Release Notes:

- N/A

Co-authored-by: Nathan <nathan@zed.dev>
2024-03-07 13:52:50 -07:00
Rom Grk
9068911eb4 wayland: don't dispatch modifier key events (#9027)
Modifier keys are dispatched as events on wayland, unlike macos. This
prevents pending bindings from matching, because something like e.g. `g
shift-e` is received by the key matcher as `g shift shift-e`.
2024-03-07 12:42:48 -08:00
Max Brunsfeld
27518f4280 Fix extension store test failure on main due to wasi-sdk download 2024-03-07 10:53:28 -08:00
Conrad Irwin
86748a09e7 Denormalize buffer operations (#9026)
This should significantly reduce database load on redeploy.

Co-Authored-By: Max <max@zed.dev>
Co-Authored-By: Nathan <nathan@zed.dev>

Release Notes:

- Reduced likelihood of being disconnected during deploys

Co-authored-by: Max <max@zed.dev>
Co-authored-by: Nathan <nathan@zed.dev>
2024-03-07 11:35:47 -07:00
d1y
b5370cd15a Remove git_commit syntax highlighting from core Zed (#9025)
Fallback to extension
https://github.com/zed-industries/extensions/pull/307

Release Notes:

- Remove git_commit syntax highlighting from Zed core, `git-firefly` extension replaced that

Co-authored-by: William Desportes <williamdes@wdes.fr>
Co-authored-by: Kirill Bulatov <mail4score@gmail.com>
Co-authored-by: Marshall Bowers <1486634+maxdeviant@users.noreply.github.com>
2024-03-07 20:23:44 +02:00
Kirpal Grewal
85e6bc94e9 Enable clippy::suspicious_to_owned (#9004)
Another small change: calling to owned on the `Cow` was cloning the
`Cow`, not its contents and so calling `clone` makes this more explicit
2024-03-07 11:30:40 -05:00
Yesterday17
105e654dce Include font_features in cache key for fonts (#8928)
## Release Notes

- Fixed font ligatures not always respecting the setting
([#4313](https://github.com/zed-industries/zed/issues/4313)).

## Preview


![20240306133121_rec_-convert](https://github.com/zed-industries/zed/assets/8667822/dc2aaa00-41d0-4fe9-8d9c-80e1f047894d)
2024-03-07 11:06:19 -05:00
Max
6b8984279f Pluralize and order user menu items for consistency (#9013)
Release Notes:

- N/A
2024-03-07 17:59:33 +02:00
Jason Lee
bc7fb9f253 Show tooltip with item paths for recent project picker items (#8987)
Before

![empty-tooltip](https://github.com/zed-industries/zed/assets/5518/4a00158c-5ae1-4ef9-8686-d4c188a99050)

After

![output](https://github.com/zed-industries/zed/assets/5518/b30f8272-485d-40e1-989a-facd122e96c8)

Release Notes:

- Fixed empty tooltip for recent projects picker items
2024-03-07 17:35:48 +02:00
Piotr Osiewicz
d450fde1ed language: Track buffer dirty state based on edits, not on file contents 2024-03-07 14:11:35 +01:00
Anthony Eid
4c9c9df730 Add ZED_SELECTED_TEXT variable to tasks (#8865)
Tasks are able to access a users selected text using the environment
variable "ZED_SELECTED_TEXT".

Release notes:

- Added ZED_SELECTED_TEXT task variable which contains contents of
selection
2024-03-07 11:59:25 +01:00
Piotr Osiewicz
3a9ec906af task: make ZED_FILE return abs path, for real this time (#9000)
Release Notes:

- Fixed ZED_FILE environment variable containing a relative path, not an
absolute one.
2024-03-07 11:50:07 +01:00
Floyd Wang
01fe3eec4d Fix project panel icon bouncing when renaming (#8988)
I found the project panel icon has a little bounce when I tried to
rename some files.

Release Notes:

- Fix project panel icon bouncing when renaming.

## Before



https://github.com/zed-industries/zed/assets/28998859/76f04c33-da68-40e2-9c83-045e78187679

**Set `buffer_line_height` to `standard`**


https://github.com/zed-industries/zed/assets/28998859/9a9eca93-5fda-4060-ba1d-0cd4e0486eb8



## After


https://github.com/zed-industries/zed/assets/28998859/29b49f1c-a9ae-4281-8921-8f1d8dd74262

**Set `buffer_line_height` to `standard`**


https://github.com/zed-industries/zed/assets/28998859/8f1ccbb5-fe0e-4905-97c4-cb7431e5dc46
2024-03-07 09:18:59 +01:00
Joseph T. Lyons
0a07746381 Delete tasks.md
Moved documentation to zed.dev
2024-03-07 01:08:20 -05:00
Joseph T. Lyons
026cdc617c Update tasks.md 2024-03-07 01:02:07 -05:00
Thorsten Ball
4238793d16 Add [x/]x to select larger/smaller syntax node in Vim (#8985)
`[x` will select the larger syntax node, `]x` the smaller one. Inspired
by https://github.com/tpope/vim-unimpaired.

Release Notes:

- Added `[x` and `]x` as default keybindings in Vim mode to select
larger and smaller syntax nodes respectively.
2024-03-07 06:53:17 +01:00
Conrad Irwin
1a9387035d Only 5s of data! (#8983)
This is still 200Mb in production, and takes several minutes to process
and download.

Release Notes:

- N/A
2024-03-06 21:35:46 -07:00
Conrad Irwin
4f53e6e9a0 Update cargo.lock 2024-03-06 20:55:34 -07:00
Conrad Irwin
75a42c27db Migrate from scrypt to sha256. (#8969)
This reduces the server time to compute the hash from 40ms to 5µs,
which should remove this as a noticable chunk of CPU time in production.

(An attacker who has access to our database will now need only 10^54
years of CPU time instead of 10^58 to brute force a token).

Release Notes:

- Improved sign in latency by 40ms.
2024-03-06 20:51:43 -07:00
Evren Sen
4d2156e2ad Improved message hovering in chat panel (#8977)
Highlights messages on hover and fixed a more concise position for the
popover menu button.

Before:


https://github.com/zed-industries/zed/assets/146845123/39cab30f-659f-4164-a4ac-1dfee796e016

<img width="368" alt="Screenshot 2024-03-07 at 01 08 24"
src="https://github.com/zed-industries/zed/assets/146845123/74f41243-2dc2-4839-a733-9db3109e4665">

<img width="313" alt="Screenshot 2024-03-07 at 01 04 39"
src="https://github.com/zed-industries/zed/assets/146845123/f66c764d-488a-4303-b66e-f75835df6949">

After:


https://github.com/zed-industries/zed/assets/146845123/ac059c0d-7b16-4fd5-bbd7-ca96e1a6dfe1


<img width="368" alt="Screenshot 2024-03-07 at 01 09 42"
src="https://github.com/zed-industries/zed/assets/146845123/fa8940f6-52b4-489d-b0d3-d0e9443e2de2">

<img width="313" alt="Screenshot 2024-03-07 at 01 04 31"
src="https://github.com/zed-industries/zed/assets/146845123/850226f3-2c70-4a90-bb35-4a4cb0b7a219">


Thank you for the help @ConradIrwin and @RemcoSmitsDev !


Release Notes:
- Improved message hovering in chat panel
2024-03-06 20:47:19 -07:00
L
9481b346e2 Fix issue template formatting (#8866)
Release Notes:
- N/A

Co-authored-by: Joseph T. Lyons <JosephTLyons@gmail.com>
2024-03-06 22:11:48 -05:00
Mikayla Maki
8a92d28663 Remove todo! comments (#8981)
Switching fully to normal `todo` style

Release Notes:

- N/A
2024-03-06 18:25:20 -08:00
Bennet Bo Fenner
8be4b4d75d Support emoji shortcodes in chat (#8455)
Completes: https://github.com/zed-industries/zed/issues/7299

Suggestions


https://github.com/zed-industries/zed/assets/53836821/2a81ba89-4634-4d94-8370-6f76ff3e9403

Automatically replacing shortcodes without using the completions (only
enabled when `message_editor` > `auto_replace_emoji_shortcode` is
enabled in the settings):


https://github.com/zed-industries/zed/assets/53836821/10ef2b4b-c67b-4202-b958-332a37dc088e






Release Notes:

- Added autocompletion for emojis in chat when typing emoji shortcodes
([#7299](https://github.com/zed-industries/zed/issues/7299)).
- Added support for automatically replacing emoji shortcodes in chat
(e.g. typing "👋" will be converted to "👋")

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2024-03-06 19:18:29 -07:00
Mikayla Maki
c0edb5bd6c GPUI custom window prompts (#8980)
This adds a GPUI fallback for window prompts. Linux does not support
this feature by default, so we have to implement it ourselves.

This implementation also makes it possible for GPUI clients to override
the platform prompts with their own implementations.

This is just a first pass. These alerts are not keyboard accessible yet,
does not reflect the prompt level, they're implemented in-window, rather
than as popups, and the whole feature need a pass from a designer.
Regardless, this gets us one step closer to Linux support :)

<img width="650" alt="Screenshot 2024-03-06 at 5 58 08 PM"
src="https://github.com/zed-industries/zed/assets/2280405/972ebb55-fd1f-4066-969c-a87f63b22a6f">

Release Notes:

- N/A
2024-03-06 18:15:06 -08:00
bbb651
c8e03ce42a Wayland: Support integer scaling without wp_fractional_scale (#8886)
Release Notes:
- N/A

`DoubleBuffered` is not currently very necessary because we only care
about a single field `OutputState::scale` but I think it can be useful
for other objects as it's a fairly common pattern in wayland.
2024-03-06 18:13:23 -08:00
intigonzalez
74e7611ceb windows: get current display size (#8916)
For the moment the windows port has a single display with hard-coded
values.

This first PR is just to at least fetch the **actual size of the current
display**. The idea
is using this code as a first template to start getting familar with the
code base
and prepare the work for enumerating all displays.
2024-03-06 18:12:44 -08:00
Conrad Irwin
0b87be71e6 Make anchor_in_excerpt Optional (#8975)
We were seeing panics due to callers assuming they had valid
excerpt_ids, but that cannot easily be guaranteed across await points as
anyone may remove an excerpt.

Release Notes:

- Fixed a panic when hovering in a multibuffer
2024-03-06 18:55:36 -07:00
Kirill Bulatov
ae5ec9224c Small fixes to task modal & long commands (#8974)
* Show the entire task tooltip on terminal tab hover:
<img width="979" alt="Screenshot 2024-03-07 at 01 40 56"
src="https://github.com/zed-industries/zed/assets/2690773/bc274a5c-70f6-4f3d-87b4-04aff3594089">

* Scroll to the end of the query when a menu label is reused as a query:
<img width="658" alt="Screenshot 2024-03-07 at 01 41 03"
src="https://github.com/zed-industries/zed/assets/2690773/972857f4-36db-49dc-8fa1-dd15e0470660">

Release Notes:

- Improved task modal UX with long bash-like commands
2024-03-07 03:21:11 +02:00
Conrad Irwin
ca37d39109 add a script to get a flamegraph of collab in production (#8972)
Add `./script/collab-flamegraph` so you can profile in production (or
staging)

Release Notes:

- N/A
2024-03-06 16:39:48 -07:00
Max Brunsfeld
675ae24964 Add a command for building and installing a locally-developed Zed extension (#8781)
This PR adds an `zed: Install Local Extension` action, which lets you
select a path to a folder containing a Zed extension, and install that .
When you select a directory, the extension will be compiled (both the
Tree-sitter grammars and the Rust code for the extension itself) and
installed as a Zed extension, using a symlink.

### Details

A few dependencies are needed to build an extension:
* The Rust `wasm32-wasi` target. This is automatically installed if
needed via `rustup`.
* A wasi-preview1 adapter WASM module, for building WASM components with
Rust. This is automatically downloaded if needed from a `wasmtime`
GitHub release
* For building Tree-sitter parsers, a distribution of `wasi-sdk`. This
is automatically downloaded if needed from a `wasi-sdk` GitHub release.

The downloaded artifacts are cached in a support directory called
`Zed/extensions/build`.

### Tasks

UX

* [x] Show local extensions in the Extensions view
* [x] Provide a button for recompiling a linked extension
* [x] Make this action discoverable by adding a button for it on the
Extensions view
* [ ] Surface errors (don't just write them to the Zed log)

Packaging

* [ ] Create a separate executable that performs the extension
compilation. We'll switch the packaging system in our
[extensions](https://github.com/zed-industries/extensions) repo to use
this binary, so that there is one canonical definition of how to
build/package an extensions.

### Release Notes:

- N/A

---------

Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
2024-03-06 15:35:22 -08:00
Conrad Irwin
e273198ada Debounce language server updates (#8953)
We'll send at least one every 100ms, but may send more if other messages
are sent on the connection.

Release Notes:

- Fixed some slowness when collaborating with verbose language servers.
2024-03-06 15:58:22 -07:00
Small White
af87fb98d0 Implement more GPUI services on windows. (#8940)
### Description

This is a part of #8809 , impl the following functions:

- `os_version`
- `local_timezone`
- `double_click_interval`
- `set_cursor_style`
- `open_url`
- `reveal_path`

Release Notes:
- N/A

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
2024-03-06 12:48:43 -08:00
Andrew Lygin
effc317a06 Fix project panel scrolling position restoration (#8961)
Project panel loses the last scrolling position every time the user
hides/shows it. This PR fixes the problem.

The reason of the problem is that `UniformListScrollHandle`, which is
intended to store the scrolling position between redrawings, is only
used for ad-hoc autoscrollings to the list items, while the
`interactivity.scroll_handle` that is responsible for the scrolling
position, doesn't survive the project panel hiding.

How the problem looks:


https://github.com/zed-industries/zed/assets/2101250/7c7e3da6-9a9d-4f28-a181-ee9547349d4c

Release Notes:

- Fixed scrolling position restoration in the Project Panel.
2024-03-06 12:00:51 -08:00
Max Brunsfeld
6bbd09e28e Emit the WorktreeUpdatedEntries event for all projects, not just local (#8963)
Fixes a regression introduced in
https://github.com/zed-industries/zed/pull/8846 (which hasn't yet been
released), in which the project panel didn't update correctly for remote
projects when collaborating.

Release Notes:

- N/A
2024-03-06 11:58:18 -08:00
Ezekiel Warren
06035dadea windows: more frequent frame requests (#8921)
Note rust analyzer running in background now without keyboard/mouse
movement.

![](https://media.discordapp.net/attachments/1208481909676576818/1214769879098597416/high-framerate-windows.gif?ex=65fa519c&is=65e7dc9c&hm=4c9ba72fa3c3c548964e46d9c07f0c0bf9545ed9a9ae11495101dcae5db06d59&=)

Release Notes:

- Improved frame rate on Windows
2024-03-06 11:54:33 -08:00
Small White
8357039419 Set the default DPI awareness for Zed (#8936)
### Description

This is a part of #8809 


Release Notes:
- N/A
2024-03-06 11:45:18 -08:00
Small White
59faef5800 Update mio (#8935)
### Description
This is a part of #8809 

Update mio from 0.8.8 to 0.8.11.

When using named pipes on Windows, mio will under some circumstances
return invalid tokens that correspond to named pipes that have already
been deregistered from the mio registry. The impact of this
vulnerability depends on how mio is used. For some applications, invalid
tokens may be ignored or cause a warning or a crash. On the other hand,
for applications that store pointers in the tokens, this vulnerability
may result in a use-after-free.

### Connections

[named-pipes: fix receiving IOCP events after deregister
#1760](https://github.com/tokio-rs/mio/pull/1760)

[Windows Named pipes invalid memory access
#6369](https://github.com/tokio-rs/tokio/issues/6369)


Release Notes:

- N/A
2024-03-06 11:32:46 -08:00
Ezekiel Warren
a0fac3866a prevent empty cwd in terminal view (#8924)
closes #8825

Release Notes:

- N/A
2024-03-06 11:26:16 -08:00
Edvard Høiby
8352f39ff9 Improve bindings to better match VS-Code (#8584)
Release Notes:

- Changed default keybindings in the VS Code keymap so that
`alt-[up|down]` now move lines up/down and`alt-shift-[up|down]`
duplicate lines up/down. Previous bindings for selecting larger/smaller
syntax nodes are now bound to `ctrl-shift-[left|right]`.
([#4652](https://github.com/zed-industries/zed/issues/4652))([#7151](https://github.com/zed-industries/zed/issues/7151))

---------

Co-authored-by: Thorsten Ball <mrnugget@gmail.com>
2024-03-06 11:16:14 -08:00
Joseph T. Lyons
850ddddcac v0.127.x dev 2024-03-06 12:30:14 -05:00
227 changed files with 15229 additions and 9690 deletions

View File

@@ -32,9 +32,9 @@ body:
required: false
- type: textarea
attributes:
label: |
If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue.
label: If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue.
description: |
Drag Zed.log into the text input below.
If you only need the most recent lines, you can run the `zed: open log` command palette action to see the last 1000.
description: Drag Zed.log into the text input below
validations:
required: false

View File

@@ -31,9 +31,9 @@ body:
required: false
- type: textarea
attributes:
label: |
If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue.
label: If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue.
description: |
Drag Zed.log into the text input below.
If you only need the most recent lines, you can run the `zed: open log` command palette action to see the last 1000.
description: Drag Zed.log into the text input below
validations:
required: false

View File

@@ -86,12 +86,6 @@ jobs:
clean: false
submodules: "recursive"
- name: Install cargo-component
run: |
if ! which cargo-component > /dev/null; then
cargo install cargo-component
fi
- name: cargo clippy
run: cargo xtask clippy
@@ -152,12 +146,12 @@ jobs:
- name: Build Zed
run: cargo build -p zed
bundle:
name: Bundle macOS app
bundle-mac:
name: Create a macOS bundle
runs-on:
- self-hosted
- bundle
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
needs: [macos_tests]
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
@@ -212,12 +206,12 @@ jobs:
- name: Generate license file
run: script/generate-licenses
- name: Create app bundle
run: script/bundle
- name: Create macOS app bundle
run: script/bundle-mac
- name: Upload app bundle to workflow run if main branch or specific label
uses: actions/upload-artifact@v3
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
uses: actions/upload-artifact@v4
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
with:
name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg
path: target/release/Zed.dmg
@@ -232,3 +226,81 @@ jobs:
body: ""
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
bundle-deb:
name: Create a *.deb Linux bundle
runs-on: ubuntu-22.04 # keep the version fixed to avoid libc and dynamic linked library issues
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
needs: [linux_tests]
env:
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: Cache dependencies
uses: swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Configure linux
shell: bash -euxo pipefail {0}
run: script/linux
- name: Determine version and release channel
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
run: |
set -eu
version=$(script/get-crate-version zed)
channel=$(cat crates/zed/RELEASE_CHANNEL)
echo "Publishing version: ${version} on release channel ${channel}"
echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV
expected_tag_name=""
case ${channel} in
stable)
expected_tag_name="v${version}";;
preview)
expected_tag_name="v${version}-pre";;
nightly)
expected_tag_name="v${version}-nightly";;
*)
echo "can't publish a release on channel ${channel}"
exit 1;;
esac
if [[ $GITHUB_REF_NAME != $expected_tag_name ]]; then
echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}"
exit 1
fi
# TODO linux : Find a way to add licenses to the final bundle
# - name: Generate license file
# run: script/generate-licenses
- name: Create Linux *.deb bundle
run: script/bundle-linux
- name: Upload app bundle to workflow run if main branch or specific label
uses: actions/upload-artifact@v4
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
with:
name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.deb
path: target/release/*.deb
# TODO linux : make it stable enough to be uploaded as a release
# - uses: softprops/action-gh-release@v1
# name: Upload app bundle to release
# if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }}
# with:
# draft: true
# prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
# files: target/release/Zed.dmg
# body: ""
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -50,8 +50,8 @@ jobs:
- name: Run tests
uses: ./.github/actions/run_tests
bundle:
name: Bundle app
bundle-mac:
name: Create a macOS bundle
if: github.repository_owner == 'zed-industries'
runs-on:
- self-hosted
@@ -77,9 +77,6 @@ jobs:
clean: false
submodules: "recursive"
- name: Limit target directory size
run: script/clear-target-dir-if-larger-than 100
- name: Set release channel to nightly
run: |
set -eu
@@ -90,8 +87,50 @@ jobs:
- name: Generate license file
run: script/generate-licenses
- name: Create app bundle
run: script/bundle
- name: Create macOS app bundle
run: script/bundle-mac
- name: Upload Zed Nightly
run: script/upload-nightly
run: script/upload-nightly macos
bundle-deb:
name: Create a *.deb Linux bundle
if: github.repository_owner == 'zed-industries'
runs-on: ubuntu-22.04 # keep the version fixed to avoid libc and dynamic linked library issues
needs: tests
env:
DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: Cache dependencies
uses: swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Configure linux
shell: bash -euxo pipefail {0}
run: script/linux
- name: Set release channel to nightly
run: |
set -euo pipefail
version=$(git rev-parse --short HEAD)
echo "Publishing version: ${version} on release channel nightly"
echo "nightly" > crates/zed/RELEASE_CHANNEL
# TODO linux : find a way to add licenses to the final bundle
# - name: Generate license file
# run: script/generate-licenses
- name: Create Linux *.deb bundle
run: script/bundle-linux
- name: Upload Zed Nightly
run: script/upload-nightly linux-deb

2
.gitignore vendored
View File

@@ -23,4 +23,4 @@ DerivedData/
.pytest_cache
.venv
.blob_store
extensions/gleam/grammars
.vscode

299
Cargo.lock generated
View File

@@ -113,9 +113,8 @@ dependencies = [
[[package]]
name = "alacritty_terminal"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7ceabf6fc76511f616ca216b51398a2511f19ba9f71bcbd977999edff1b0d1"
version = "0.22.1-dev"
source = "git+https://github.com/alacritty/alacritty?rev=992011a4cd9a35f197acc0a0bd430d89a0d01013#992011a4cd9a35f197acc0a0bd430d89a0d01013"
dependencies = [
"base64 0.21.4",
"bitflags 2.4.2",
@@ -2223,6 +2222,7 @@ dependencies = [
"aws-sdk-s3",
"axum",
"axum-extra",
"base64 0.13.1",
"call",
"channel",
"chrono",
@@ -2272,6 +2272,7 @@ dependencies = [
"settings",
"sha2 0.10.7",
"sqlx",
"subtle",
"telemetry_events",
"text",
"theme",
@@ -2301,8 +2302,8 @@ dependencies = [
"collections",
"db",
"editor",
"emojis",
"extensions_ui",
"feedback",
"futures 0.3.28",
"fuzzy",
"gpui",
@@ -3009,15 +3010,6 @@ dependencies = [
"util",
]
[[package]]
name = "debugid"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
dependencies = [
"uuid",
]
[[package]]
name = "deflate"
version = "0.8.6"
@@ -3157,16 +3149,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "directories-next"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc"
dependencies = [
"cfg-if 1.0.0",
"dirs-sys-next",
]
[[package]]
name = "dirs"
version = "3.0.2"
@@ -3281,6 +3263,7 @@ dependencies = [
"copilot",
"ctor",
"db",
"emojis",
"env_logger",
"futures 0.3.28",
"fuzzy",
@@ -3349,6 +3332,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "emojis"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ee61eb945bff65ee7d19d157d39c67c33290ff0742907413fd5eefd29edc979"
dependencies = [
"phf",
]
[[package]]
name = "encode_unicode"
version = "0.3.6"
@@ -3524,7 +3516,10 @@ dependencies = [
"async-compression",
"async-tar",
"async-trait",
"cap-std",
"collections",
"ctor",
"env_logger",
"fs",
"futures 0.3.28",
"gpui",
@@ -3532,6 +3527,7 @@ dependencies = [
"log",
"lsp",
"node_runtime",
"parking_lot 0.11.2",
"project",
"schemars",
"serde",
@@ -3540,22 +3536,28 @@ dependencies = [
"theme",
"toml 0.8.10",
"util",
"wasm-encoder",
"wasmparser",
"wasmtime",
"wasmtime-wasi",
"wit-component 0.20.3",
]
[[package]]
name = "extensions_ui"
version = "0.1.0"
dependencies = [
"anyhow",
"client",
"editor",
"extension",
"fuzzy",
"gpui",
"settings",
"smallvec",
"theme",
"ui",
"util",
"workspace",
]
@@ -4144,28 +4146,6 @@ dependencies = [
"thread_local",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "fxprof-processed-profile"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd"
dependencies = [
"bitflags 2.4.2",
"debugid",
"fxhash",
"serde",
"serde_json",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -4307,9 +4287,16 @@ version = "0.1.0"
dependencies = [
"editor",
"gpui",
"indoc",
"language",
"menu",
"project",
"rope",
"serde_json",
"text",
"theme",
"tree-sitter-rust",
"tree-sitter-typescript",
"ui",
"util",
"workspace",
@@ -5039,26 +5026,6 @@ version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
[[package]]
name = "ittapi"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1"
dependencies = [
"anyhow",
"ittapi-sys",
"log",
]
[[package]]
name = "ittapi-sys"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc"
dependencies = [
"cc",
]
[[package]]
name = "jni"
version = "0.19.0"
@@ -5337,7 +5304,6 @@ dependencies = [
"tree-sitter-elm",
"tree-sitter-embedded-template",
"tree-sitter-erlang",
"tree-sitter-gitcommit",
"tree-sitter-gleam",
"tree-sitter-glsl",
"tree-sitter-go",
@@ -5365,7 +5331,6 @@ dependencies = [
"tree-sitter-svelte",
"tree-sitter-toml",
"tree-sitter-typescript",
"tree-sitter-uiua",
"tree-sitter-vue",
"tree-sitter-yaml",
"tree-sitter-zig",
@@ -5905,9 +5870,9 @@ dependencies = [
[[package]]
name = "mio"
version = "0.8.8"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
@@ -6170,7 +6135,7 @@ dependencies = [
"kqueue",
"libc",
"log",
"mio 0.8.8",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
@@ -6656,12 +6621,19 @@ dependencies = [
"editor",
"fuzzy",
"gpui",
"indoc",
"language",
"menu",
"ordered-float 2.10.0",
"picker",
"project",
"rope",
"serde_json",
"settings",
"smol",
"theme",
"tree-sitter-rust",
"tree-sitter-typescript",
"ui",
"util",
"workspace",
@@ -6893,6 +6865,24 @@ dependencies = [
"indexmap 2.0.0",
]
[[package]]
name = "phf"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
dependencies = [
"siphasher 0.3.11",
]
[[package]]
name = "picker"
version = "0.1.0"
@@ -8949,6 +8939,12 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac"
[[package]]
name = "siphasher"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "slab"
version = "0.4.9"
@@ -9591,7 +9587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c536faaff1a10837cfe373142583f6e27d81e96beba339147e77b67c9f260ff"
dependencies = [
"float-cmp",
"siphasher",
"siphasher 0.2.3",
]
[[package]]
@@ -10076,7 +10072,7 @@ dependencies = [
"backtrace",
"bytes 1.5.0",
"libc",
"mio 0.8.8",
"mio 0.8.11",
"num_cpus",
"parking_lot 0.12.1",
"pin-project-lite",
@@ -10513,15 +10509,6 @@ dependencies = [
"tree-sitter",
]
[[package]]
name = "tree-sitter-gitcommit"
version = "0.3.3"
source = "git+https://github.com/gbprod/tree-sitter-gitcommit#7c01af8d227b5344f62aade2ff00f19bd0c458ca"
dependencies = [
"cc",
"tree-sitter",
]
[[package]]
name = "tree-sitter-gleam"
version = "0.34.0"
@@ -10781,15 +10768,6 @@ dependencies = [
"tree-sitter",
]
[[package]]
name = "tree-sitter-uiua"
version = "0.10.0"
source = "git+https://github.com/shnarazk/tree-sitter-uiua?rev=21dc2db39494585bf29a3f86d5add6e9d11a22ba#21dc2db39494585bf29a3f86d5add6e9d11a22ba"
dependencies = [
"cc",
"tree-sitter",
]
[[package]]
name = "tree-sitter-vue"
version = "0.0.1"
@@ -11068,7 +11046,7 @@ dependencies = [
"roxmltree 0.14.1",
"rustybuzz 0.3.0",
"simplecss",
"siphasher",
"siphasher 0.2.3",
"svgtypes",
"ttf-parser 0.12.3",
"unicode-bidi",
@@ -11105,6 +11083,7 @@ dependencies = [
"log",
"parking_lot 0.11.2",
"rand 0.8.5",
"regex",
"rust-embed",
"serde",
"serde_json",
@@ -11405,15 +11384,6 @@ dependencies = [
"leb128",
]
[[package]]
name = "wasm-encoder"
version = "0.201.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9c7d2731df60006819b013f64ccc2019691deccf6e11a1804bc850cd6748f1a"
dependencies = [
"leb128",
]
[[package]]
name = "wasm-metadata"
version = "0.10.20"
@@ -11426,7 +11396,7 @@ dependencies = [
"serde_derive",
"serde_json",
"spdx",
"wasm-encoder 0.41.2",
"wasm-encoder",
"wasmparser",
]
@@ -11457,41 +11427,33 @@ version = "18.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c843b8bc4dd4f3a76173ba93405c71111d570af0d90ea5f6299c705d0c2add2"
dependencies = [
"addr2line",
"anyhow",
"async-trait",
"bincode",
"bumpalo",
"cfg-if 1.0.0",
"encoding_rs",
"fxprof-processed-profile",
"gimli",
"indexmap 2.0.0",
"ittapi",
"libc",
"log",
"object",
"once_cell",
"paste",
"rayon",
"rustix 0.38.30",
"serde",
"serde_derive",
"serde_json",
"target-lexicon",
"wasm-encoder 0.41.2",
"wasmparser",
"wasmtime-cache",
"wasmtime-component-macro",
"wasmtime-component-util",
"wasmtime-cranelift",
"wasmtime-environ",
"wasmtime-fiber",
"wasmtime-jit-debug",
"wasmtime-jit-icache-coherence",
"wasmtime-runtime",
"wasmtime-winch",
"wat",
"windows-sys 0.52.0",
]
@@ -11528,26 +11490,6 @@ dependencies = [
"quote",
]
[[package]]
name = "wasmtime-cache"
version = "18.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fb4fc2bbf9c790a57875eba65588fa97acf57a7d784dc86d057e648d9a1ed91"
dependencies = [
"anyhow",
"base64 0.21.4",
"bincode",
"directories-next",
"log",
"rustix 0.38.30",
"serde",
"serde_derive",
"sha2 0.10.7",
"toml 0.5.11",
"windows-sys 0.52.0",
"zstd",
]
[[package]]
name = "wasmtime-component-macro"
version = "18.0.2"
@@ -11629,7 +11571,7 @@ dependencies = [
"serde_derive",
"target-lexicon",
"thiserror",
"wasm-encoder 0.41.2",
"wasm-encoder",
"wasmparser",
"wasmprinter",
"wasmtime-component-util",
@@ -11651,18 +11593,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "wasmtime-jit-debug"
version = "18.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "833dae95bc7a4f9177bf93f9497419763535b74e37eb8c37be53937d3281e287"
dependencies = [
"object",
"once_cell",
"rustix 0.38.30",
"wasmtime-versioned-export-macros",
]
[[package]]
name = "wasmtime-jit-icache-coherence"
version = "18.0.2"
@@ -11694,11 +11624,10 @@ dependencies = [
"psm",
"rustix 0.38.30",
"sptr",
"wasm-encoder 0.41.2",
"wasm-encoder",
"wasmtime-asm-macros",
"wasmtime-environ",
"wasmtime-fiber",
"wasmtime-jit-debug",
"wasmtime-versioned-export-macros",
"wasmtime-wmemcheck",
"windows-sys 0.52.0",
@@ -11805,28 +11734,6 @@ dependencies = [
"leb128",
]
[[package]]
name = "wast"
version = "201.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ef6e1ef34d7da3e2b374fd2b1a9c0227aff6cad596e1b24df9b58d0f6222faa"
dependencies = [
"bumpalo",
"leb128",
"memchr",
"unicode-width",
"wasm-encoder 0.201.0",
]
[[package]]
name = "wat"
version = "1.201.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "453d5b37a45b98dee4f4cb68015fc73634d7883bbef1c65e6e9c78d454cf3f32"
dependencies = [
"wast 201.0.0",
]
[[package]]
name = "wayland-backend"
version = "0.3.3"
@@ -12129,6 +12036,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efc5cf48f83140dcaab716eeaea345f9e93d0018fb81162753a3f76c3397b538"
dependencies = [
"windows-core",
"windows-implement",
"windows-interface",
"windows-targets 0.52.4",
]
@@ -12142,6 +12051,28 @@ dependencies = [
"windows-targets 0.52.4",
]
[[package]]
name = "windows-implement"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "942ac266be9249c84ca862f0a164a39533dc2f6f33dc98ec89c8da99b82ea0bd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.48",
]
[[package]]
name = "windows-interface"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da33557140a288fae4e1d5f8873aaf9eb6613a9cf82c3e070223ff177f598b60"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.48",
]
[[package]]
name = "windows-result"
version = "0.1.0"
@@ -12426,7 +12357,7 @@ dependencies = [
"heck 0.4.1",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
"wit-component 0.21.0",
]
[[package]]
@@ -12443,6 +12374,25 @@ dependencies = [
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4436190e87b4e539807bcdcf5b817e79d2e29e16bc5ddb6445413fe3d1f5716"
dependencies = [
"anyhow",
"bitflags 2.4.2",
"indexmap 2.0.0",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser 0.13.2",
]
[[package]]
name = "wit-component"
version = "0.21.0"
@@ -12456,7 +12406,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"wasm-encoder 0.41.2",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser 0.14.0",
@@ -12506,7 +12456,7 @@ dependencies = [
"anyhow",
"log",
"thiserror",
"wast 35.0.2",
"wast",
]
[[package]]
@@ -12824,7 +12774,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.126.2"
version = "0.127.0"
dependencies = [
"activity_indicator",
"anyhow",
@@ -12854,13 +12804,11 @@ dependencies = [
"feedback",
"file_finder",
"fs",
"fsevent",
"futures 0.3.28",
"go_to_line",
"gpui",
"install_cli",
"isahc",
"itertools 0.11.0",
"journal",
"language",
"language_selector",
@@ -12926,6 +12874,13 @@ dependencies = [
"zed_extension_api",
]
[[package]]
name = "zed_uiua"
version = "0.0.1"
dependencies = [
"zed_extension_api",
]
[[package]]
name = "zeno"
version = "0.2.3"

View File

@@ -92,7 +92,10 @@ members = [
"crates/workspace",
"crates/zed",
"crates/zed_actions",
"extensions/gleam",
"extensions/uiua",
"tooling/xtask",
]
default-members = ["crates/zed"]
@@ -105,6 +108,7 @@ assets = { path = "crates/assets" }
assistant = { path = "crates/assistant" }
audio = { path = "crates/audio" }
auto_update = { path = "crates/auto_update" }
base64 = "0.13"
breadcrumbs = { path = "crates/breadcrumbs" }
call = { path = "crates/call" }
channel = { path = "crates/channel" }
@@ -200,6 +204,7 @@ bitflags = "2.4.2"
blade-graphics = { git = "https://github.com/kvark/blade", rev = "43721bf42d298b7cbee2195ee66f73a5f1c7b2fc" }
blade-macros = { git = "https://github.com/kvark/blade", rev = "43721bf42d298b7cbee2195ee66f73a5f1c7b2fc" }
blade-rwh = { package = "raw-window-handle", version = "0.5" }
cap-std = "2.0"
chrono = { version = "0.4", features = ["serde"] }
clap = "4.4"
clickhouse = { version = "0.11.6" }
@@ -207,6 +212,7 @@ ctor = "0.2.6"
core-foundation = { version = "0.9.3" }
core-foundation-sys = "0.8.6"
derive_more = "0.99.17"
emojis = "0.6.1"
env_logger = "0.9"
futures = "0.3"
git2 = { version = "0.15", default-features = false }
@@ -215,7 +221,10 @@ hex = "0.4.3"
ignore = "0.4.22"
indoc = "1"
# We explicitly disable http2 support in isahc.
isahc = { version = "1.7.2", default-features = false, features = ["static-curl", "text-decoding"] }
isahc = { version = "1.7.2", default-features = false, features = [
"static-curl",
"text-decoding",
] }
itertools = "0.11.0"
lazy_static = "1.4.0"
linkify = "0.10.0"
@@ -238,18 +247,26 @@ semver = "1.0"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_derive = { version = "1.0", features = ["deserialize_in_place"] }
serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] }
serde_json_lenient = { version = "0.1", features = ["preserve_order", "raw_value"] }
serde_json_lenient = { version = "0.1", features = [
"preserve_order",
"raw_value",
] }
serde_repr = "0.1"
sha2 = "0.10"
shellexpand = "2.1.0"
smallvec = { version = "1.6", features = ["union"] }
smol = "1.2"
strum = { version = "0.25.0", features = ["derive"] }
subtle = "2.5.0"
sysinfo = "0.29.10"
tempfile = "3.9.0"
thiserror = "1.0.29"
tiktoken-rs = "0.5.7"
time = { version = "0.3", features = ["serde", "serde-well-known", "formatting"] }
time = { version = "0.3", features = [
"serde",
"serde-well-known",
"formatting",
] }
toml = "0.8"
tower-http = "0.4.4"
tree-sitter = { version = "0.20", features = ["wasm"] }
@@ -266,7 +283,6 @@ tree-sitter-elixir = { git = "https://github.com/elixir-lang/tree-sitter-elixir"
tree-sitter-elm = { git = "https://github.com/elm-tooling/tree-sitter-elm", rev = "692c50c0b961364c40299e73c1306aecb5d20f40" }
tree-sitter-embedded-template = "0.20.0"
tree-sitter-erlang = "0.4.0"
tree-sitter-gitcommit = { git = "https://github.com/gbprod/tree-sitter-gitcommit" }
tree-sitter-gleam = { git = "https://github.com/gleam-lang/tree-sitter-gleam", rev = "58b7cac8fc14c92b0677c542610d8738c373fa81" }
tree-sitter-glsl = { git = "https://github.com/theHamsta/tree-sitter-glsl", rev = "2a56fb7bc8bb03a1892b4741279dd0a8758b7fb3" }
tree-sitter-go = { git = "https://github.com/tree-sitter/tree-sitter-go", rev = "aeb2f33b366fd78d5789ff104956ce23508b85db" }
@@ -295,7 +311,6 @@ tree-sitter-scheme = { git = "https://github.com/6cdh/tree-sitter-scheme", rev =
tree-sitter-svelte = { git = "https://github.com/Himujjal/tree-sitter-svelte", rev = "bd60db7d3d06f89b6ec3b287c9a6e9190b5564bd" }
tree-sitter-toml = { git = "https://github.com/tree-sitter/tree-sitter-toml", rev = "342d9be207c2dba869b9967124c679b5e6fd0ebe" }
tree-sitter-typescript = { git = "https://github.com/tree-sitter/tree-sitter-typescript", rev = "5d20856f34315b068c41edaee2ac8a100081d259" }
tree-sitter-uiua = { git = "https://github.com/shnarazk/tree-sitter-uiua", rev = "21dc2db39494585bf29a3f86d5add6e9d11a22ba" }
tree-sitter-vue = { git = "https://github.com/zed-industries/tree-sitter-vue", rev = "6608d9d60c386f19d80af7d8132322fa11199c42" }
tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "f545a41f57502e1b5ddf2a6668896c1b0620f930" }
tree-sitter-zig = { git = "https://github.com/maxxnino/tree-sitter-zig", rev = "0d08703e4c3f426ec61695d7617415fff97029bd" }
@@ -304,22 +319,35 @@ unicase = "2.6"
url = "2.2"
uuid = { version = "1.1.2", features = ["v4"] }
wasmparser = "0.121"
wasmtime = "18.0"
wasm-encoder = "0.41"
wasmtime = { version = "18.0", default-features = false, features = ["async", "demangle", "runtime", "cranelift", "component-model"] }
wasmtime-wasi = "18.0"
which = "6.0.0"
wit-component = "0.20"
sys-locale = "0.3.1"
[workspace.dependencies.windows]
version = "0.53.0"
features = [
"implement",
"Wdk_System_SystemServices",
"Win32_Graphics_Gdi",
"Win32_Graphics_DirectComposition",
"Win32_UI_Controls",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell",
"Win32_System_Com",
"Win32_System_SystemInformation",
"Win32_System_SystemServices",
"Win32_System_Time",
"Win32_Security",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Threading",
"Win32_System_DataExchange",
"Win32_System_Ole",
"Win32_System_Com",
]
[patch.crates-io]
@@ -381,7 +409,6 @@ non_canonical_clone_impl = "allow"
non_canonical_partial_ord_impl = "allow"
reversed_empty_ranges = "allow"
single_range_in_vec_init = "allow"
suspicious_to_owned = "allow"
type_complexity = "allow"
[workspace.metadata.cargo-machete]

View File

@@ -22,7 +22,8 @@ RUN --mount=type=cache,target=./target \
# Copy collab server binary to the runtime image
FROM debian:bookworm-slim as runtime
RUN apt-get update; \
apt-get install -y --no-install-recommends libcurl4-openssl-dev ca-certificates linux-perf
apt-get install -y --no-install-recommends libcurl4-openssl-dev ca-certificates \
linux-perf binutils
WORKDIR app
COPY --from=builder /app/collab /app/collab
COPY --from=builder /app/crates/collab/migrations /app/migrations

View File

@@ -10,7 +10,7 @@ You can [download](https://zed.dev/download) Zed today for macOS (v10.15+).
Support for additional platforms is on our [roadmap](https://zed.dev/roadmap):
- Linux ([tracking issue](https://github.com/zed-industries/zed/issues/5395))
- Linux ([tracking issue](https://github.com/zed-industries/zed/issues/7015))
- Windows ([tracking issue](https://github.com/zed-industries/zed/issues/5394))
- Web ([tracking issue](https://github.com/zed-industries/zed/issues/5396))
@@ -23,6 +23,7 @@ brew install zed
- [Building Zed for macOS](./docs/src/developing_zed__building_zed_macos.md)
- [Building Zed for Linux](./docs/src/developing_zed__building_zed_linux.md)
- [Building Zed for Windows](./docs/src/developing_zed__building_zed_windows.md)
- [Running Collaboration Locally](./docs/src/developing_zed__local_collaboration.md)
## Contributing

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 17V15.8C20 14.1198 20 13.2798 19.673 12.638C19.3854 12.0735 18.9265 11.6146 18.362 11.327C17.7202 11 16.8802 11 15.2 11H4M4 11L8 7M4 11L8 15" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 468 B

View File

@@ -118,7 +118,8 @@
"stop_at_soft_wraps": true
}
],
"ctrl-;": "editor::ToggleLineNumbers"
"ctrl-;": "editor::ToggleLineNumbers",
"ctrl-alt-z": "editor::RevertSelectedHunks"
}
},
{

View File

@@ -153,7 +153,8 @@
}
],
"ctrl-cmd-space": "editor::ShowCharacterPalette",
"cmd-;": "editor::ToggleLineNumbers"
"cmd-;": "editor::ToggleLineNumbers",
"cmd-alt-z": "editor::RevertSelectedHunks"
}
},
{
@@ -315,6 +316,18 @@
"cmd-ctrl-p": "editor::AddSelectionAbove",
"cmd-alt-down": "editor::AddSelectionBelow",
"cmd-ctrl-n": "editor::AddSelectionBelow",
"cmd-shift-k": "editor::DeleteLine",
"alt-up": "editor::MoveLineUp",
"alt-down": "editor::MoveLineDown",
"alt-shift-up": [
"editor::DuplicateLine",
{
"move_upwards": true
}
],
"alt-shift-down": "editor::DuplicateLine",
"ctrl-shift-right": "editor::SelectLargerSyntaxNode",
"ctrl-shift-left": "editor::SelectSmallerSyntaxNode",
"cmd-d": [
"editor::SelectNext",
{
@@ -347,8 +360,6 @@
"advance_downwards": false
}
],
"alt-up": "editor::SelectLargerSyntaxNode",
"alt-down": "editor::SelectSmallerSyntaxNode",
"cmd-u": "editor::UndoSelection",
"cmd-shift-u": "editor::RedoSelection",
"f8": "editor::GoToDiagnostic",
@@ -454,11 +465,7 @@
{
"context": "Editor",
"bindings": {
"ctrl-shift-k": "editor::DeleteLine",
"cmd-shift-d": "editor::DuplicateLine",
"ctrl-j": "editor::JoinLines",
"ctrl-cmd-up": "editor::MoveLineUp",
"ctrl-cmd-down": "editor::MoveLineDown",
"ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart",
"ctrl-alt-h": "editor::DeleteToPreviousSubwordStart",
"ctrl-alt-delete": "editor::DeleteToNextSubwordEnd",

View File

@@ -39,6 +39,8 @@
"advance_downwards": true
}
],
"alt-up": "editor::SelectLargerSyntaxNode",
"alt-down": "editor::SelectSmallerSyntaxNode",
"shift-alt-up": "editor::MoveLineUp",
"shift-alt-down": "editor::MoveLineDown",
"cmd-alt-l": "editor::Format",

View File

@@ -37,30 +37,42 @@
"_": "vim::StartOfLineDownward",
"g _": "vim::EndOfLineDownward",
"shift-g": "vim::EndOfDocument",
"w": "vim::NextWordStart",
"{": "vim::StartOfParagraph",
"}": "vim::EndOfParagraph",
"|": "vim::GoToColumn",
// Word motions
"w": "vim::NextWordStart",
"e": "vim::NextWordEnd",
"b": "vim::PreviousWordStart",
"g e": "vim::PreviousWordEnd",
// Subword motions
// "w": "vim::NextSubwordStart",
// "b": "vim::PreviousSubwordStart",
// "e": "vim::NextSubwordEnd",
// "g e": "vim::PreviousSubwordEnd",
"shift-w": [
"vim::NextWordStart",
{
"ignorePunctuation": true
}
],
"e": "vim::NextWordEnd",
"shift-e": [
"vim::NextWordEnd",
{
"ignorePunctuation": true
}
],
"b": "vim::PreviousWordStart",
"shift-b": [
"vim::PreviousWordStart",
{
"ignorePunctuation": true
}
],
"g shift-e": ["vim::PreviousWordEnd", { "ignorePunctuation": true }],
"n": "search::SelectNextMatch",
"shift-n": "search::SelectPrevMatch",
"%": "vim::Matching",
@@ -117,8 +129,6 @@
"ctrl-e": "vim::LineDown",
"ctrl-y": "vim::LineUp",
// "g" commands
"g e": "vim::PreviousWordEnd",
"g shift-e": ["vim::PreviousWordEnd", { "ignorePunctuation": true }],
"g g": "vim::StartOfDocument",
"g h": "editor::Hover",
"g t": "pane::ActivateNextItem",
@@ -353,7 +363,9 @@
"> >": "vim::Indent",
"< <": "vim::Outdent",
"ctrl-pagedown": "pane::ActivateNextItem",
"ctrl-pageup": "pane::ActivatePrevItem"
"ctrl-pageup": "pane::ActivatePrevItem",
"[ x": "editor::SelectLargerSyntaxNode",
"] x": "editor::SelectSmallerSyntaxNode"
}
},
{

View File

@@ -211,6 +211,11 @@
// Default width of the channels panel.
"default_width": 240
},
"message_editor": {
// Whether to automatically replace emoji shortcodes with emoji characters.
// For example: typing `:wave:` gets replaced with `👋`.
"auto_replace_emoji_shortcode": true
},
"notification_panel": {
// Whether to show the collaboration panel button in the status bar.
"button": true,
@@ -590,7 +595,8 @@
// Vim settings
"vim": {
"use_system_clipboard": "always",
"use_multiline_find": false
"use_multiline_find": false,
"use_smartcase_find": false
},
// The server to connect to. If the environment variable
// ZED_SERVER_URL is set, it will override this setting.

View File

@@ -4,9 +4,7 @@
[
{
"label": "Example task",
"command": "bash",
// rest of the parameters are optional
"args": ["-c", "for i in {1..5}; do echo \"Hello $i/5\"; sleep 1; done"],
"command": "for i in {1..5}; do echo \"Hello $i/5\"; sleep 1; done",
// Env overrides for the command, will be appended to the terminal's environment from the settings.
"env": { "foo": "bar" },
// Current working directory to spawn the command into, defaults to current project root.

View File

@@ -31,9 +31,9 @@ use fs::Fs;
use futures::StreamExt;
use gpui::{
canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AppContext,
AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, EventEmitter,
FocusHandle, FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement,
IntoElement, Model, ModelContext, ParentElement, Pixels, PromptLevel, Render, SharedString,
AsyncAppContext, AsyncWindowContext, ClipboardItem, Context, EventEmitter, FocusHandle,
FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement, IntoElement, Model,
ModelContext, ParentElement, Pixels, PromptLevel, Render, SharedString,
StatefulInteractiveElement, Styled, Subscription, Task, TextStyle, UniformListScrollHandle,
View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace, WindowContext,
};
@@ -1284,25 +1284,25 @@ impl Render for AssistantPanel {
let view = cx.view().clone();
let scroll_handle = self.saved_conversations_scroll_handle.clone();
let conversation_count = self.saved_conversations.len();
canvas(move |bounds, cx| {
uniform_list(
view,
"saved_conversations",
conversation_count,
|this, range, cx| {
range
.map(|ix| this.render_saved_conversation(ix, cx))
.collect()
},
)
.track_scroll(scroll_handle)
.into_any_element()
.draw(
bounds.origin,
bounds.size.map(AvailableSpace::Definite),
cx,
);
})
canvas(
move |bounds, cx| {
let mut list = uniform_list(
view,
"saved_conversations",
conversation_count,
|this, range, cx| {
range
.map(|ix| this.render_saved_conversation(ix, cx))
.collect()
},
)
.track_scroll(scroll_handle)
.into_any_element();
list.layout(bounds.origin, bounds.size.into(), cx);
list
},
|_bounds, mut list, cx| list.paint(cx),
)
.size_full()
.into_any_element()
}),

View File

@@ -4,11 +4,10 @@ use gpui::{
ViewContext,
};
use itertools::Itertools;
use std::cmp;
use theme::ActiveTheme;
use ui::{prelude::*, ButtonLike, ButtonStyle, Label, Tooltip};
use workspace::{
item::{BreadcrumbText, ItemEvent, ItemHandle},
item::{ItemEvent, ItemHandle},
ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
};
@@ -32,30 +31,14 @@ impl EventEmitter<ToolbarItemEvent> for Breadcrumbs {}
impl Render for Breadcrumbs {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
const MAX_SEGMENTS: usize = 12;
let element = h_flex().text_ui();
let Some(active_item) = self.active_item.as_ref() else {
return element;
};
let Some(mut segments) = active_item.breadcrumbs(cx.theme(), cx) else {
let Some(segments) = active_item.breadcrumbs(cx.theme(), cx) else {
return element;
};
let prefix_end_ix = cmp::min(segments.len(), MAX_SEGMENTS / 2);
let suffix_start_ix = cmp::max(
prefix_end_ix,
segments.len().saturating_sub(MAX_SEGMENTS / 2),
);
if suffix_start_ix > prefix_end_ix {
segments.splice(
prefix_end_ix..suffix_start_ix,
Some(BreadcrumbText {
text: "".into(),
highlights: None,
}),
);
}
let highlighted_segments = segments.into_iter().map(|segment| {
let mut text_style = cx.text_style();
text_style.color = Color::Muted.color(cx);

View File

@@ -3,9 +3,7 @@ mod channel_index;
use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat, ChannelMessage};
use anyhow::{anyhow, Result};
use channel_index::ChannelIndex;
use client::{
ChannelId, Client, ClientSettings, HostedProjectId, Subscription, User, UserId, UserStore,
};
use client::{ChannelId, Client, ClientSettings, ProjectId, Subscription, User, UserId, UserStore};
use collections::{hash_map, HashMap, HashSet};
use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt};
use gpui::{
@@ -29,7 +27,7 @@ pub fn init(client: &Arc<Client>, user_store: Model<UserStore>, cx: &mut AppCont
cx.set_global(GlobalChannelStore(channel_store));
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, Default, PartialEq)]
struct NotesVersion {
epoch: u64,
version: clock::Global,
@@ -37,7 +35,7 @@ struct NotesVersion {
#[derive(Debug, Clone)]
pub struct HostedProject {
id: HostedProjectId,
project_id: ProjectId,
channel_id: ChannelId,
name: SharedString,
_visibility: proto::ChannelVisibility,
@@ -46,7 +44,7 @@ pub struct HostedProject {
impl From<proto::HostedProject> for HostedProject {
fn from(project: proto::HostedProject) -> Self {
Self {
id: HostedProjectId(project.id),
project_id: ProjectId(project.project_id),
channel_id: ChannelId(project.channel_id),
_visibility: project.visibility(),
name: project.name.into(),
@@ -59,7 +57,7 @@ pub struct ChannelStore {
channel_invitations: Vec<Arc<Channel>>,
channel_participants: HashMap<ChannelId, Vec<Arc<User>>>,
channel_states: HashMap<ChannelId, ChannelState>,
hosted_projects: HashMap<HostedProjectId, HostedProject>,
hosted_projects: HashMap<ProjectId, HostedProject>,
outgoing_invites: HashSet<(ChannelId, UserId)>,
update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
@@ -81,14 +79,14 @@ pub struct Channel {
pub parent_path: Vec<ChannelId>,
}
#[derive(Default)]
#[derive(Default, Debug)]
pub struct ChannelState {
latest_chat_message: Option<u64>,
latest_notes_versions: Option<NotesVersion>,
latest_notes_version: NotesVersion,
observed_notes_version: NotesVersion,
observed_chat_message: Option<u64>,
observed_notes_versions: Option<NotesVersion>,
role: Option<ChannelRole>,
projects: HashSet<HostedProjectId>,
projects: HashSet<ProjectId>,
}
impl Channel {
@@ -305,8 +303,8 @@ impl ChannelStore {
self.channel_index.by_id().get(&channel_id)
}
pub fn projects_for_id(&self, channel_id: ChannelId) -> Vec<(SharedString, HostedProjectId)> {
let mut projects: Vec<(SharedString, HostedProjectId)> = self
pub fn projects_for_id(&self, channel_id: ChannelId) -> Vec<(SharedString, ProjectId)> {
let mut projects: Vec<(SharedString, ProjectId)> = self
.channel_states
.get(&channel_id)
.map(|state| state.projects.clone())
@@ -1159,27 +1157,27 @@ impl ChannelStore {
let hosted_project: HostedProject = hosted_project.into();
if let Some(old_project) = self
.hosted_projects
.insert(hosted_project.id, hosted_project.clone())
.insert(hosted_project.project_id, hosted_project.clone())
{
self.channel_states
.entry(old_project.channel_id)
.or_default()
.remove_hosted_project(old_project.id);
.remove_hosted_project(old_project.project_id);
}
self.channel_states
.entry(hosted_project.channel_id)
.or_default()
.add_hosted_project(hosted_project.id);
.add_hosted_project(hosted_project.project_id);
}
for hosted_project_id in payload.deleted_hosted_projects {
let hosted_project_id = HostedProjectId(hosted_project_id);
let hosted_project_id = ProjectId(hosted_project_id);
if let Some(old_project) = self.hosted_projects.remove(&hosted_project_id) {
self.channel_states
.entry(old_project.channel_id)
.or_default()
.remove_hosted_project(old_project.id);
.remove_hosted_project(old_project.project_id);
}
}
}
@@ -1236,19 +1234,12 @@ impl ChannelState {
}
fn has_channel_buffer_changed(&self) -> bool {
if let Some(latest_version) = &self.latest_notes_versions {
if let Some(observed_version) = &self.observed_notes_versions {
latest_version.epoch > observed_version.epoch
|| (latest_version.epoch == observed_version.epoch
&& latest_version
.version
.changed_since(&observed_version.version))
} else {
true
}
} else {
false
}
self.latest_notes_version.epoch > self.observed_notes_version.epoch
|| (self.latest_notes_version.epoch == self.observed_notes_version.epoch
&& self
.latest_notes_version
.version
.changed_since(&self.observed_notes_version.version))
}
fn has_new_messages(&self) -> bool {
@@ -1275,36 +1266,32 @@ impl ChannelState {
}
fn acknowledge_notes_version(&mut self, epoch: u64, version: &clock::Global) {
if let Some(existing) = &mut self.observed_notes_versions {
if existing.epoch == epoch {
existing.version.join(version);
return;
}
if self.observed_notes_version.epoch == epoch {
self.observed_notes_version.version.join(version);
} else {
self.observed_notes_version = NotesVersion {
epoch,
version: version.clone(),
};
}
self.observed_notes_versions = Some(NotesVersion {
epoch,
version: version.clone(),
});
}
fn update_latest_notes_version(&mut self, epoch: u64, version: &clock::Global) {
if let Some(existing) = &mut self.latest_notes_versions {
if existing.epoch == epoch {
existing.version.join(version);
return;
}
if self.latest_notes_version.epoch == epoch {
self.latest_notes_version.version.join(version);
} else {
self.latest_notes_version = NotesVersion {
epoch,
version: version.clone(),
};
}
self.latest_notes_versions = Some(NotesVersion {
epoch,
version: version.clone(),
});
}
fn add_hosted_project(&mut self, project_id: HostedProjectId) {
fn add_hosted_project(&mut self, project_id: ProjectId) {
self.projects.insert(project_id);
}
fn remove_hosted_project(&mut self, project_id: HostedProjectId) {
fn remove_hosted_project(&mut self, project_id: ProjectId) {
self.projects.remove(&project_id);
}
}

View File

@@ -25,7 +25,7 @@ impl std::fmt::Display for ChannelId {
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub struct HostedProjectId(pub u64);
pub struct ProjectId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParticipantIndex(pub u32);

View File

@@ -23,6 +23,7 @@ aws-config = { version = "1.1.5" }
aws-sdk-s3 = { version = "1.15.0" }
axum = { version = "0.6", features = ["json", "headers", "ws"] }
axum-extra = { version = "0.4", features = ["erased-json"] }
base64.workspace = true
chrono.workspace = true
clock.workspace = true
clickhouse.workspace = true
@@ -48,6 +49,7 @@ serde_derive.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "json", "time", "uuid", "any"] }
subtle.workspace = true
rustc-demangle.workspace = true
telemetry_events.workspace = true
text.workspace = true

View File

@@ -248,7 +248,10 @@ CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channe
CREATE TABLE "buffers" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"epoch" INTEGER NOT NULL DEFAULT 0
"epoch" INTEGER NOT NULL DEFAULT 0,
"latest_operation_epoch" INTEGER,
"latest_operation_replica_id" INTEGER,
"latest_operation_lamport_timestamp" INTEGER
);
CREATE INDEX "index_buffers_on_channel_id" ON "buffers" ("channel_id");

View File

@@ -0,0 +1,17 @@
-- Add migration script here
ALTER TABLE buffers ADD COLUMN latest_operation_epoch INTEGER;
ALTER TABLE buffers ADD COLUMN latest_operation_lamport_timestamp INTEGER;
ALTER TABLE buffers ADD COLUMN latest_operation_replica_id INTEGER;
WITH ops AS (
SELECT DISTINCT ON (buffer_id) buffer_id, epoch, lamport_timestamp, replica_id
FROM buffer_operations
ORDER BY buffer_id, epoch DESC, lamport_timestamp DESC, replica_id DESC
)
UPDATE buffers
SET latest_operation_epoch = ops.epoch,
latest_operation_lamport_timestamp = ops.lamport_timestamp,
latest_operation_replica_id = ops.replica_id
FROM ops
WHERE buffers.id = ops.buffer_id;

View File

@@ -9,14 +9,15 @@ use axum::{
response::IntoResponse,
};
use prometheus::{exponential_buckets, register_histogram, Histogram};
use rand::thread_rng;
use scrypt::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
password_hash::{PasswordHash, PasswordVerifier},
Scrypt,
};
use serde::{Deserialize, Serialize};
use sha2::Digest;
use std::sync::OnceLock;
use std::{sync::Arc, time::Instant};
use subtle::ConstantTimeEq;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Impersonator(pub Option<db::User>);
@@ -115,8 +116,7 @@ pub async fn create_access_token(
) -> Result<String> {
const VERSION: usize = 1;
let access_token = rpc::auth::random_token();
let access_token_hash =
hash_access_token(&access_token).context("failed to hash access token")?;
let access_token_hash = hash_access_token(&access_token);
let id = db
.create_access_token(
user_id,
@@ -132,23 +132,15 @@ pub async fn create_access_token(
})?)
}
fn hash_access_token(token: &str) -> Result<String> {
// Avoid slow hashing in debug mode.
let params = if cfg!(debug_assertions) {
scrypt::Params::new(1, 1, 1).unwrap()
} else {
scrypt::Params::new(14, 8, 1).unwrap()
};
Ok(Scrypt
.hash_password(
token.as_bytes(),
None,
params,
&SaltString::generate(thread_rng()),
)
.map_err(anyhow::Error::new)?
.to_string())
/// Hashing prevents anyone with access to the database being able to login.
/// As the token is randomly generated, we don't need to worry about scrypt-style
/// protection.
fn hash_access_token(token: &str) -> String {
let digest = sha2::Sha256::digest(token);
format!(
"$sha256${}",
base64::encode_config(digest, base64::URL_SAFE)
)
}
/// Encrypts the given access token with the given public key to avoid leaking it on the way
@@ -190,15 +182,27 @@ pub async fn verify_access_token(
if token_user_id != user_id {
return Err(anyhow!("no such access token"))?;
}
let db_hash = PasswordHash::new(&db_token.hash).map_err(anyhow::Error::new)?;
let t0 = Instant::now();
let is_valid = Scrypt
.verify_password(token.token.as_bytes(), &db_hash)
.is_ok();
let is_valid = if db_token.hash.starts_with("$scrypt$") {
let db_hash = PasswordHash::new(&db_token.hash).map_err(anyhow::Error::new)?;
Scrypt
.verify_password(token.token.as_bytes(), &db_hash)
.is_ok()
} else {
let token_hash = hash_access_token(&token.token);
db_token.hash.as_bytes().ct_eq(token_hash.as_ref()).into()
};
let duration = t0.elapsed();
log::info!("hashed access token in {:?}", duration);
metric_access_token_hashing_time.observe(duration.as_millis() as f64);
if is_valid && db_token.hash.starts_with("$scrypt$") {
let new_hash = hash_access_token(&token.token);
db.update_access_token_hash(db_token.id, &new_hash).await?;
}
Ok(VerifyAccessTokenResult {
is_valid,
impersonator_id: if db_token.impersonated_user_id.is_some() {
@@ -208,3 +212,145 @@ pub async fn verify_access_token(
},
})
}
#[cfg(test)]
mod test {
use rand::thread_rng;
use scrypt::password_hash::{PasswordHasher, SaltString};
use sea_orm::EntityTrait;
use super::*;
use crate::db::{access_token, NewUserParams};
#[gpui::test]
async fn test_verify_access_token(cx: &mut gpui::TestAppContext) {
let test_db = crate::db::TestDb::postgres(cx.executor().clone());
let db = test_db.db();
let user = db
.create_user(
"example@example.com",
false,
NewUserParams {
github_login: "example".into(),
github_user_id: 1,
},
)
.await
.unwrap();
let token = create_access_token(&db, user.user_id, None).await.unwrap();
assert!(matches!(
verify_access_token(&token, user.user_id, &db)
.await
.unwrap(),
VerifyAccessTokenResult {
is_valid: true,
impersonator_id: None,
}
));
let old_token = create_previous_access_token(user.user_id, None, &db)
.await
.unwrap();
let old_token_id = serde_json::from_str::<AccessTokenJson>(&old_token)
.unwrap()
.id;
let hash = db
.transaction(|tx| async move {
Ok(access_token::Entity::find_by_id(old_token_id)
.one(&*tx)
.await?)
})
.await
.unwrap()
.unwrap()
.hash;
assert!(hash.starts_with("$scrypt$"));
assert!(matches!(
verify_access_token(&old_token, user.user_id, &db)
.await
.unwrap(),
VerifyAccessTokenResult {
is_valid: true,
impersonator_id: None,
}
));
let hash = db
.transaction(|tx| async move {
Ok(access_token::Entity::find_by_id(old_token_id)
.one(&*tx)
.await?)
})
.await
.unwrap()
.unwrap()
.hash;
assert!(hash.starts_with("$sha256$"));
assert!(matches!(
verify_access_token(&old_token, user.user_id, &db)
.await
.unwrap(),
VerifyAccessTokenResult {
is_valid: true,
impersonator_id: None,
}
));
assert!(matches!(
verify_access_token(&token, user.user_id, &db)
.await
.unwrap(),
VerifyAccessTokenResult {
is_valid: true,
impersonator_id: None,
}
));
}
async fn create_previous_access_token(
user_id: UserId,
impersonated_user_id: Option<UserId>,
db: &Database,
) -> Result<String> {
let access_token = rpc::auth::random_token();
let access_token_hash = previous_hash_access_token(&access_token)?;
let id = db
.create_access_token(
user_id,
impersonated_user_id,
&access_token_hash,
MAX_ACCESS_TOKENS_TO_STORE,
)
.await?;
Ok(serde_json::to_string(&AccessTokenJson {
version: 1,
id,
token: access_token,
})?)
}
fn previous_hash_access_token(token: &str) -> Result<String> {
// Avoid slow hashing in debug mode.
let params = if cfg!(debug_assertions) {
scrypt::Params::new(1, 1, 1).unwrap()
} else {
scrypt::Params::new(14, 8, 1).unwrap()
};
Ok(Scrypt
.hash_password(
token.as_bytes(),
None,
params,
&SaltString::generate(thread_rng()),
)
.map_err(anyhow::Error::new)?
.to_string())
}
}

View File

@@ -55,4 +55,22 @@ impl Database {
})
.await
}
/// Retrieves the access token with the given ID.
pub async fn update_access_token_hash(
&self,
id: AccessTokenId,
new_hash: &str,
) -> Result<access_token::Model> {
self.transaction(|tx| async move {
Ok(access_token::Entity::update(access_token::ActiveModel {
id: ActiveValue::unchanged(id),
hash: ActiveValue::set(new_hash.into()),
..Default::default()
})
.exec(&*tx)
.await?)
})
.await
}
}

View File

@@ -558,6 +558,17 @@ impl Database {
lamport_timestamp: i32,
tx: &DatabaseTransaction,
) -> Result<()> {
buffer::Entity::update(buffer::ActiveModel {
id: ActiveValue::Unchanged(buffer_id),
epoch: ActiveValue::Unchanged(epoch),
latest_operation_epoch: ActiveValue::Set(Some(epoch)),
latest_operation_replica_id: ActiveValue::Set(Some(replica_id)),
latest_operation_lamport_timestamp: ActiveValue::Set(Some(lamport_timestamp)),
channel_id: ActiveValue::NotSet,
})
.exec(tx)
.await?;
use observed_buffer_edits::Column;
observed_buffer_edits::Entity::insert(observed_buffer_edits::ActiveModel {
user_id: ActiveValue::Set(user_id),
@@ -711,7 +722,10 @@ impl Database {
buffer::ActiveModel {
id: ActiveValue::Unchanged(buffer.id),
epoch: ActiveValue::Set(epoch),
..Default::default()
latest_operation_epoch: ActiveValue::NotSet,
latest_operation_replica_id: ActiveValue::NotSet,
latest_operation_lamport_timestamp: ActiveValue::NotSet,
channel_id: ActiveValue::NotSet,
}
.save(tx)
.await?;
@@ -745,30 +759,6 @@ impl Database {
.await
}
pub async fn latest_channel_buffer_changes(
&self,
channel_ids_by_buffer_id: &HashMap<BufferId, ChannelId>,
tx: &DatabaseTransaction,
) -> Result<Vec<proto::ChannelBufferVersion>> {
let latest_operations = self
.get_latest_operations_for_buffers(channel_ids_by_buffer_id.keys().copied(), tx)
.await?;
Ok(latest_operations
.iter()
.flat_map(|op| {
Some(proto::ChannelBufferVersion {
channel_id: channel_ids_by_buffer_id.get(&op.buffer_id)?.to_proto(),
epoch: op.epoch as u64,
version: vec![proto::VectorClockEntry {
replica_id: op.replica_id as u32,
timestamp: op.lamport_timestamp as u32,
}],
})
})
.collect())
}
pub async fn observed_channel_buffer_changes(
&self,
channel_ids_by_buffer_id: &HashMap<BufferId, ChannelId>,
@@ -798,55 +788,6 @@ impl Database {
})
.collect())
}
/// Returns the latest operations for the buffers with the specified IDs.
pub async fn get_latest_operations_for_buffers(
&self,
buffer_ids: impl IntoIterator<Item = BufferId>,
tx: &DatabaseTransaction,
) -> Result<Vec<buffer_operation::Model>> {
let mut values = String::new();
for id in buffer_ids {
if !values.is_empty() {
values.push_str(", ");
}
write!(&mut values, "({})", id).unwrap();
}
if values.is_empty() {
return Ok(Vec::default());
}
let sql = format!(
r#"
SELECT
*
FROM
(
SELECT
*,
row_number() OVER (
PARTITION BY buffer_id
ORDER BY
epoch DESC,
lamport_timestamp DESC,
replica_id DESC
) as row_number
FROM buffer_operations
WHERE
buffer_id in ({values})
) AS last_operations
WHERE
row_number = 1
"#,
);
let stmt = Statement::from_string(self.pool.get_database_backend(), sql);
Ok(buffer_operation::Entity::find()
.from_raw_sql(stmt)
.all(tx)
.await?)
}
}
fn operation_to_storage(

View File

@@ -1,5 +1,8 @@
use super::*;
use rpc::{proto::channel_member::Kind, ErrorCode, ErrorCodeExt};
use rpc::{
proto::{channel_member::Kind, ChannelBufferVersion, VectorClockEntry},
ErrorCode, ErrorCodeExt,
};
use sea_orm::TryGetableMany;
impl Database {
@@ -625,6 +628,7 @@ impl Database {
let channel_ids = channels.iter().map(|c| c.id).collect::<Vec<_>>();
let mut channel_ids_by_buffer_id = HashMap::default();
let mut latest_buffer_versions: Vec<ChannelBufferVersion> = vec![];
let mut rows = buffer::Entity::find()
.filter(buffer::Column::ChannelId.is_in(channel_ids.iter().copied()))
.stream(tx)
@@ -632,13 +636,24 @@ impl Database {
while let Some(row) = rows.next().await {
let row = row?;
channel_ids_by_buffer_id.insert(row.id, row.channel_id);
latest_buffer_versions.push(ChannelBufferVersion {
channel_id: row.channel_id.0 as u64,
epoch: row.latest_operation_epoch.unwrap_or_default() as u64,
version: if let Some((latest_lamport_timestamp, latest_replica_id)) = row
.latest_operation_lamport_timestamp
.zip(row.latest_operation_replica_id)
{
vec![VectorClockEntry {
timestamp: latest_lamport_timestamp as u32,
replica_id: latest_replica_id as u32,
}]
} else {
vec![]
},
});
}
drop(rows);
let latest_buffer_versions = self
.latest_channel_buffer_changes(&channel_ids_by_buffer_id, tx)
.await?;
let latest_channel_messages = self.latest_channel_messages(&channel_ids, tx).await?;
let observed_buffer_versions = self

View File

@@ -9,20 +9,21 @@ impl Database {
roles: &HashMap<ChannelId, ChannelRole>,
tx: &DatabaseTransaction,
) -> Result<Vec<proto::HostedProject>> {
Ok(hosted_project::Entity::find()
let projects = hosted_project::Entity::find()
.find_also_related(project::Entity)
.filter(hosted_project::Column::ChannelId.is_in(channel_ids.iter().map(|id| id.0)))
.all(tx)
.await?
.into_iter()
.flat_map(|project| {
if project.deleted_at.is_some() {
.flat_map(|(hosted_project, project)| {
if hosted_project.deleted_at.is_some() {
return None;
}
match project.visibility {
match hosted_project.visibility {
ChannelVisibility::Public => {}
ChannelVisibility::Members => {
let is_visible = roles
.get(&project.channel_id)
.get(&hosted_project.channel_id)
.map(|role| role.can_see_all_descendants())
.unwrap_or(false);
if !is_visible {
@@ -31,13 +32,15 @@ impl Database {
}
};
Some(proto::HostedProject {
id: project.id.to_proto(),
channel_id: project.channel_id.to_proto(),
name: project.name.clone(),
visibility: project.visibility.into(),
project_id: project?.id.to_proto(),
channel_id: hosted_project.channel_id.to_proto(),
name: hosted_project.name.clone(),
visibility: hosted_project.visibility.into(),
})
})
.collect())
.collect();
Ok(projects)
}
pub async fn get_hosted_project(

View File

@@ -512,18 +512,30 @@ impl Database {
/// Adds the given connection to the specified hosted project
pub async fn join_hosted_project(
&self,
id: HostedProjectId,
id: ProjectId,
user_id: UserId,
connection: ConnectionId,
) -> Result<(Project, ReplicaId)> {
self.transaction(|tx| async move {
let (hosted_project, role) = self.get_hosted_project(id, user_id, &tx).await?;
let project = project::Entity::find()
.filter(project::Column::HostedProjectId.eq(hosted_project.id))
let (project, hosted_project) = project::Entity::find_by_id(id)
.find_also_related(hosted_project::Entity)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("hosted project is no longer shared"))?;
let Some(hosted_project) = hosted_project else {
return Err(anyhow!("project is not hosted"))?;
};
let channel = channel::Entity::find_by_id(hosted_project.channel_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such channel"))?;
let role = self
.check_user_is_channel_participant(&channel, user_id, &tx)
.await?;
self.join_project_internal(project, user_id, connection, role, &tx)
.await
})

View File

@@ -8,6 +8,9 @@ pub struct Model {
pub id: BufferId,
pub epoch: i32,
pub channel_id: ChannelId,
pub latest_operation_epoch: Option<i32>,
pub latest_operation_lamport_timestamp: Option<i32>,
pub latest_operation_replica_id: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -15,4 +15,13 @@ pub struct Model {
impl ActiveModelBehavior for ActiveModel {}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
pub enum Relation {
#[sea_orm(has_one = "super::project::Entity")]
Project,
}
impl Related<super::project::Entity> for Entity {
fn to() -> RelationDef {
Relation::Project.def()
}
}

View File

@@ -50,6 +50,12 @@ pub enum Relation {
Collaborators,
#[sea_orm(has_many = "super::language_server::Entity")]
LanguageServers,
#[sea_orm(
belongs_to = "super::hosted_project::Entity",
from = "Column::HostedProjectId",
to = "super::hosted_project::Column::Id"
)]
HostedProject,
}
impl Related<super::user::Entity> for Entity {
@@ -82,4 +88,10 @@ impl Related<super::language_server::Entity> for Entity {
}
}
impl Related<super::hosted_project::Entity> for Entity {
fn to() -> RelationDef {
Relation::HostedProject.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -235,19 +235,6 @@ async fn test_channel_buffers_last_operations(db: &Database) {
));
}
let operations = db
.transaction(|tx| {
let buffers = &buffers;
async move {
db.get_latest_operations_for_buffers([buffers[0].id, buffers[2].id], &tx)
.await
}
})
.await
.unwrap();
assert!(operations.is_empty());
update_buffer(
buffers[0].channel_id,
user_id,
@@ -299,57 +286,10 @@ async fn test_channel_buffers_last_operations(db: &Database) {
)
.await;
let operations = db
.transaction(|tx| {
let buffers = &buffers;
async move {
db.get_latest_operations_for_buffers([buffers[1].id, buffers[2].id], &tx)
.await
}
})
.await
.unwrap();
assert_operations(
&operations,
&[
(buffers[1].id, 1, &text_buffers[1]),
(buffers[2].id, 0, &text_buffers[2]),
],
);
let operations = db
.transaction(|tx| {
let buffers = &buffers;
async move {
db.get_latest_operations_for_buffers([buffers[0].id, buffers[1].id], &tx)
.await
}
})
.await
.unwrap();
assert_operations(
&operations,
&[
(buffers[0].id, 0, &text_buffers[0]),
(buffers[1].id, 1, &text_buffers[1]),
],
);
let buffer_changes = db
.transaction(|tx| {
let buffers = &buffers;
let mut hash = HashMap::default();
hash.insert(buffers[0].id, buffers[0].channel_id);
hash.insert(buffers[1].id, buffers[1].channel_id);
hash.insert(buffers[2].id, buffers[2].channel_id);
async move { db.latest_channel_buffer_changes(&hash, &tx).await }
})
.await
.unwrap();
let channels_for_user = db.get_channels_for_user(user_id).await.unwrap();
pretty_assertions::assert_eq!(
buffer_changes,
channels_for_user.latest_buffer_versions,
[
rpc::proto::ChannelBufferVersion {
channel_id: buffers[0].channel_id.to_proto(),
@@ -361,8 +301,7 @@ async fn test_channel_buffers_last_operations(db: &Database) {
epoch: 1,
version: serialize_version(&text_buffers[1].version())
.into_iter()
.filter(|vector| vector.replica_id
== buffer_changes[1].version.first().unwrap().replica_id)
.filter(|vector| vector.replica_id == text_buffers[1].replica_id() as u32)
.collect::<Vec<_>>(),
},
rpc::proto::ChannelBufferVersion {
@@ -388,30 +327,3 @@ async fn update_buffer(
.await
.unwrap();
}
fn assert_operations(
operations: &[buffer_operation::Model],
expected: &[(BufferId, i32, &text::Buffer)],
) {
let actual = operations
.iter()
.map(|op| buffer_operation::Model {
buffer_id: op.buffer_id,
epoch: op.epoch,
lamport_timestamp: op.lamport_timestamp,
replica_id: op.replica_id,
value: vec![],
})
.collect::<Vec<_>>();
let expected = expected
.iter()
.map(|(buffer_id, epoch, buffer)| buffer_operation::Model {
buffer_id: *buffer_id,
epoch: *epoch,
lamport_timestamp: buffer.lamport_clock.value as i32 - 1,
replica_id: buffer.replica_id() as i32,
value: vec![],
})
.collect::<Vec<_>>();
assert_eq!(actual, expected, "unexpected operations")
}

View File

@@ -4,9 +4,9 @@ use crate::{
auth::{self, Impersonator},
db::{
self, BufferId, ChannelId, ChannelRole, ChannelsForUser, CreatedChannelMessage, Database,
HostedProjectId, InviteMemberResult, MembershipUpdated, MessageId, NotificationId, Project,
ProjectId, RemoveChannelMemberResult, ReplicaId, RespondToChannelInvite, RoomId, ServerId,
User, UserId,
InviteMemberResult, MembershipUpdated, MessageId, NotificationId, Project, ProjectId,
RemoveChannelMemberResult, ReplicaId, RespondToChannelInvite, RoomId, ServerId, User,
UserId,
},
executor::Executor,
AppState, Error, Result,
@@ -1770,7 +1770,7 @@ async fn join_hosted_project(
.db()
.await
.join_hosted_project(
HostedProjectId(request.id as i32),
ProjectId(request.project_id as i32),
session.user_id,
session.connection_id,
)

View File

@@ -5,7 +5,8 @@ use crate::{
use call::ActiveCall;
use editor::{
actions::{
ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Redo, Rename, ToggleCodeActions, Undo,
ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Redo, Rename, RevertSelectedHunks,
ToggleCodeActions, Undo,
},
test::editor_test_context::{AssertionContextManager, EditorTestContext},
Editor,
@@ -17,6 +18,7 @@ use language::{
language_settings::{AllLanguageSettings, InlayHintSettings},
FakeLspAdapter,
};
use project::SERVER_PROGRESS_DEBOUNCE_TIMEOUT;
use rpc::RECEIVE_TIMEOUT;
use serde_json::json;
use settings::SettingsStore;
@@ -865,6 +867,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes
},
)),
});
executor.advance_clock(SERVER_PROGRESS_DEBOUNCE_TIMEOUT);
executor.run_until_parked();
project_a.read_with(cx_a, |project, _| {
@@ -898,6 +901,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes
},
)),
});
executor.advance_clock(SERVER_PROGRESS_DEBOUNCE_TIMEOUT);
executor.run_until_parked();
project_a.read_with(cx_a, |project, _| {
@@ -1811,6 +1815,171 @@ async fn test_inlay_hint_refresh_is_forwarded(
});
}
#[gpui::test]
async fn test_multiple_types_reverts(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).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 active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
client_a.language_registry().add(rust_lang());
client_b.language_registry().add(rust_lang());
let base_text = indoc! {r#"struct Row;
struct Row1;
struct Row2;
struct Row4;
struct Row5;
struct Row6;
struct Row8;
struct Row9;
struct Row10;"#};
client_a
.fs()
.insert_tree(
"/a",
json!({
"main.rs": base_text,
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.build_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_a = workspace_a
.update(cx_a, |workspace, cx| {
workspace.open_path((worktree_id, "main.rs"), None, true, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let editor_b = workspace_b
.update(cx_b, |workspace, cx| {
workspace.open_path((worktree_id, "main.rs"), None, true, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let mut editor_cx_a = EditorTestContext {
cx: cx_a.clone(),
window: cx_a.handle(),
editor: editor_a,
assertion_cx: AssertionContextManager::new(),
};
let mut editor_cx_b = EditorTestContext {
cx: cx_b.clone(),
window: cx_b.handle(),
editor: editor_b,
assertion_cx: AssertionContextManager::new(),
};
// host edits the file, that differs from the base text, producing diff hunks
editor_cx_a.set_state(indoc! {r#"struct Row;
struct Row0.1;
struct Row0.2;
struct Row1;
struct Row4;
struct Row5444;
struct Row6;
struct Row9;
struct Row1220;ˇ"#});
editor_cx_a.update_editor(|editor, cx| {
editor
.buffer()
.read(cx)
.as_singleton()
.unwrap()
.update(cx, |buffer, cx| {
buffer.set_diff_base(Some(base_text.to_string()), cx);
});
});
editor_cx_b.update_editor(|editor, cx| {
editor
.buffer()
.read(cx)
.as_singleton()
.unwrap()
.update(cx, |buffer, cx| {
buffer.set_diff_base(Some(base_text.to_string()), cx);
});
});
cx_a.executor().run_until_parked();
cx_b.executor().run_until_parked();
// client, selects a range in the updated buffer, and reverts it
// both host and the client observe the reverted state (with one hunk left, not covered by client's selection)
editor_cx_b.set_selections_state(indoc! {r#"«ˇstruct Row;
struct Row0.1;
struct Row0.2;
struct Row1;
struct Row4;
struct Row5444;
struct Row6;
struct R»ow9;
struct Row1220;"#});
editor_cx_b.update_editor(|editor, cx| {
editor.revert_selected_hunks(&RevertSelectedHunks, cx);
});
cx_a.executor().run_until_parked();
cx_b.executor().run_until_parked();
editor_cx_a.assert_editor_state(indoc! {r#"struct Row;
struct Row1;
struct Row2;
struct Row4;
struct Row5;
struct Row6;
struct Row8;
struct Row9;
struct Row1220;ˇ"#});
editor_cx_b.assert_editor_state(indoc! {r#"«ˇstruct Row;
struct Row1;
struct Row2;
struct Row4;
struct Row5;
struct Row6;
struct Row8;
struct R»ow9;
struct Row1220;"#});
}
fn extract_hint_labels(editor: &Editor) -> Vec<String> {
let mut labels = Vec::new();
for hint in editor.inlay_hint_cache().hints() {

View File

@@ -37,8 +37,8 @@ clock.workspace = true
collections.workspace = true
db.workspace = true
editor.workspace = true
emojis.workspace = true
extensions_ui.workspace = true
feedback.workspace = true
futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true

View File

@@ -430,7 +430,6 @@ impl ChatPanel {
ChannelMessageId::Saved(id) => ("saved-message", id).into(),
ChannelMessageId::Pending(id) => ("pending-message", id).into(),
};
let this = cx.view().clone();
let mentioning_you = message
.mentions
@@ -465,15 +464,21 @@ impl ChatPanel {
v_flex()
.w_full()
.relative()
.group("")
.when(!is_continuation_from_previous, |this| this.pt_2())
.child(
div()
.group("")
.bg(background)
.rounded_md()
.overflow_hidden()
.px_1()
.px_1p5()
.py_0p5()
.when(!self.has_open_menu(message_id), |this| {
this.hover(|style| style.bg(cx.theme().colors().element_hover))
})
.when(!is_continuation_from_previous, |this| {
this.mt_2().child(
this.child(
h_flex()
.text_ui_sm()
.child(div().absolute().child(
@@ -545,37 +550,11 @@ impl ChatPanel {
.w_full()
.text_ui_sm()
.id(element_id)
.group("")
.child(text.element("body".into(), cx))
.child(
div()
.absolute()
.z_index(1)
.right_0()
.w_6()
.bg(background)
.when(!self.has_open_menu(message_id), |el| {
el.visible_on_hover("")
})
.when_some(message_id, |el, message_id| {
el.child(
popover_menu(("menu", message_id))
.trigger(IconButton::new(
("trigger", message_id),
IconName::Ellipsis,
))
.menu(move |cx| {
Some(Self::render_message_menu(
&this,
message_id,
can_delete_message,
cx,
))
}),
)
}),
),
.child(text.element("body".into(), cx)),
)
.when(self.has_open_menu(message_id), |el| {
el.bg(cx.theme().colors().element_selected)
})
}),
)
.when(
@@ -600,6 +579,10 @@ impl ChatPanel {
)
},
)
.child(
self.render_popover_buttons(&cx, message_id, can_delete_message)
.neg_mt_2p5(),
)
}
fn has_open_menu(&self, message_id: Option<u64>) -> bool {
@@ -609,6 +592,90 @@ impl ChatPanel {
}
}
fn render_popover_buttons(
&self,
cx: &ViewContext<Self>,
message_id: Option<u64>,
can_delete_message: bool,
) -> Div {
div()
.absolute()
.child(
div()
.absolute()
.right_8()
.w_6()
.rounded_tl_md()
.rounded_bl_md()
.border_l_1()
.border_t_1()
.border_b_1()
.border_color(cx.theme().colors().element_selected)
.bg(cx.theme().colors().element_background)
.hover(|style| style.bg(cx.theme().colors().element_hover))
.when(!self.has_open_menu(message_id), |el| {
el.visible_on_hover("")
})
.when_some(message_id, |el, message_id| {
el.child(
div()
.id("reply")
.child(
IconButton::new(("reply", message_id), IconName::ReplyArrow)
.on_click(cx.listener(move |this, _, cx| {
this.message_editor.update(cx, |editor, cx| {
editor.set_reply_to_message_id(message_id);
editor.focus_handle(cx).focus(cx);
})
})),
)
.tooltip(|cx| Tooltip::text("Reply", cx)),
)
}),
)
.child(
div()
.absolute()
.right_2()
.w_6()
.rounded_tr_md()
.rounded_br_md()
.border_r_1()
.border_t_1()
.border_b_1()
.border_color(cx.theme().colors().element_selected)
.bg(cx.theme().colors().element_background)
.hover(|style| style.bg(cx.theme().colors().element_hover))
.when(!self.has_open_menu(message_id), |el| {
el.visible_on_hover("")
})
.when_some(message_id, |el, message_id| {
let this = cx.view().clone();
el.child(
div()
.id("more")
.child(
popover_menu(("menu", message_id))
.trigger(IconButton::new(
("trigger", message_id),
IconName::Ellipsis,
))
.menu(move |cx| {
Some(Self::render_message_menu(
&this,
message_id,
can_delete_message,
cx,
))
}),
)
.tooltip(|cx| Tooltip::text("More", cx)),
)
}),
)
}
fn render_message_menu(
this: &View<Self>,
message_id: u64,
@@ -785,7 +852,7 @@ impl Render for ChatPanel {
.size_full()
.on_action(cx.listener(Self::send))
.child(
h_flex().z_index(1).child(
h_flex().child(
TabBar::new("chat_header").child(
h_flex()
.w_full()

View File

@@ -3,7 +3,7 @@ use channel::{ChannelMembership, ChannelStore, MessageParams};
use client::{ChannelId, UserId};
use collections::{HashMap, HashSet};
use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle};
use fuzzy::StringMatchCandidate;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
AsyncWindowContext, FocusableView, FontStyle, FontWeight, HighlightStyle, IntoElement, Model,
Render, SharedString, Task, TextStyle, View, ViewContext, WeakView, WhiteSpace,
@@ -16,10 +16,12 @@ use lazy_static::lazy_static;
use parking_lot::RwLock;
use project::search::SearchQuery;
use settings::Settings;
use std::{sync::Arc, time::Duration};
use std::{ops::Range, sync::Arc, time::Duration};
use theme::ThemeSettings;
use ui::{prelude::*, UiTextSize};
use crate::panel_settings::MessageEditorSettings;
const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
lazy_static! {
@@ -86,6 +88,11 @@ impl MessageEditor {
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
editor.set_use_autoclose(false);
editor.set_completion_provider(Box::new(MessageEditorCompletionProvider(this)));
editor.set_auto_replace_emoji_shortcode(
MessageEditorSettings::get_global(cx)
.auto_replace_emoji_shortcode
.unwrap_or_default(),
);
});
let buffer = editor
@@ -96,6 +103,16 @@ impl MessageEditor {
.expect("message editor must be singleton");
cx.subscribe(&buffer, Self::on_buffer_event).detach();
cx.observe_global::<settings::SettingsStore>(|view, cx| {
view.editor.update(cx, |editor, cx| {
editor.set_auto_replace_emoji_shortcode(
MessageEditorSettings::get_global(cx)
.auto_replace_emoji_shortcode
.unwrap_or_default(),
)
})
})
.detach();
let markdown = language_registry.language_for_name("Markdown");
cx.spawn(|_, mut cx| async move {
@@ -219,6 +236,101 @@ impl MessageEditor {
end_anchor: Anchor,
cx: &mut ViewContext<Self>,
) -> Task<Result<Vec<Completion>>> {
if let Some((start_anchor, query, candidates)) =
self.collect_mention_candidates(buffer, end_anchor, cx)
{
if !candidates.is_empty() {
return cx.spawn(|_, cx| async move {
Ok(Self::resolve_completions_for_candidates(
&cx,
query.as_str(),
&candidates,
start_anchor..end_anchor,
Self::completion_for_mention,
)
.await)
});
}
}
if let Some((start_anchor, query, candidates)) =
self.collect_emoji_candidates(buffer, end_anchor, cx)
{
if !candidates.is_empty() {
return cx.spawn(|_, cx| async move {
Ok(Self::resolve_completions_for_candidates(
&cx,
query.as_str(),
candidates,
start_anchor..end_anchor,
Self::completion_for_emoji,
)
.await)
});
}
}
Task::ready(Ok(vec![]))
}
async fn resolve_completions_for_candidates(
cx: &AsyncWindowContext,
query: &str,
candidates: &[StringMatchCandidate],
range: Range<Anchor>,
completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel),
) -> Vec<Completion> {
let matches = fuzzy::match_strings(
&candidates,
&query,
true,
10,
&Default::default(),
cx.background_executor().clone(),
)
.await;
matches
.into_iter()
.map(|mat| {
let (new_text, label) = completion_fn(&mat);
Completion {
old_range: range.clone(),
new_text,
label,
documentation: None,
server_id: LanguageServerId(0), // TODO: Make this optional or something?
lsp_completion: Default::default(), // TODO: Make this optional or something?
}
})
.collect()
}
fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) {
let label = CodeLabel {
filter_range: 1..mat.string.len() + 1,
text: format!("@{}", mat.string),
runs: Vec::new(),
};
(mat.string.clone(), label)
}
fn completion_for_emoji(mat: &StringMatch) -> (String, CodeLabel) {
let emoji = emojis::get_by_shortcode(&mat.string).unwrap();
let label = CodeLabel {
filter_range: 1..mat.string.len() + 1,
text: format!(":{}: {}", mat.string, emoji),
runs: Vec::new(),
};
(emoji.to_string(), label)
}
fn collect_mention_candidates(
&mut self,
buffer: &Model<Buffer>,
end_anchor: Anchor,
cx: &mut ViewContext<Self>,
) -> Option<(Anchor, String, Vec<StringMatchCandidate>)> {
let end_offset = end_anchor.to_offset(buffer.read(cx));
let Some(query) = buffer.update(cx, |buffer, _| {
@@ -232,9 +344,9 @@ impl MessageEditor {
}
query.push(ch);
}
return None;
None
}) else {
return Task::ready(Ok(vec![]));
return None;
};
let start_offset = end_offset - query.len();
@@ -258,33 +370,76 @@ impl MessageEditor {
char_bag: user.chars().collect(),
})
.collect::<Vec<_>>();
cx.spawn(|_, cx| async move {
let matches = fuzzy::match_strings(
&candidates,
&query,
true,
10,
&Default::default(),
cx.background_executor().clone(),
)
.await;
Ok(matches
.into_iter()
.map(|mat| Completion {
old_range: start_anchor..end_anchor,
new_text: mat.string.clone(),
label: CodeLabel {
filter_range: 1..mat.string.len() + 1,
text: format!("@{}", mat.string),
runs: Vec::new(),
},
documentation: None,
server_id: LanguageServerId(0), // TODO: Make this optional or something?
lsp_completion: Default::default(), // TODO: Make this optional or something?
})
.collect())
})
Some((start_anchor, query, candidates))
}
fn collect_emoji_candidates(
&mut self,
buffer: &Model<Buffer>,
end_anchor: Anchor,
cx: &mut ViewContext<Self>,
) -> Option<(Anchor, String, &'static [StringMatchCandidate])> {
lazy_static! {
static ref EMOJI_FUZZY_MATCH_CANDIDATES: Vec<StringMatchCandidate> = {
let emojis = emojis::iter()
.flat_map(|s| s.shortcodes())
.map(|emoji| StringMatchCandidate {
id: 0,
string: emoji.to_string(),
char_bag: emoji.chars().collect(),
})
.collect::<Vec<_>>();
emojis
};
}
let end_offset = end_anchor.to_offset(buffer.read(cx));
let Some(query) = buffer.update(cx, |buffer, _| {
let mut query = String::new();
for ch in buffer.reversed_chars_at(end_offset).take(100) {
if ch == ':' {
let next_char = buffer
.reversed_chars_at(end_offset - query.len() - 1)
.next();
// Ensure we are at the start of the message or that the previous character is a whitespace
if next_char.is_none() || next_char.unwrap().is_whitespace() {
return Some(query.chars().rev().collect::<String>());
}
// If the previous character is not a whitespace, we are in the middle of a word
// and we only want to complete the shortcode if the word is made up of other emojis
let mut containing_word = String::new();
for ch in buffer
.reversed_chars_at(end_offset - query.len() - 1)
.take(100)
{
if ch.is_whitespace() {
break;
}
containing_word.push(ch);
}
let containing_word = containing_word.chars().rev().collect::<String>();
if util::word_consists_of_emojis(containing_word.as_str()) {
return Some(query.chars().rev().collect::<String>());
}
break;
}
if ch.is_whitespace() || !ch.is_ascii() {
break;
}
query.push(ch);
}
None
}) else {
return None;
};
let start_offset = end_offset - query.len() - 1;
let start_anchor = buffer.read(cx).anchor_before(start_offset);
Some((start_anchor, query, &EMOJI_FUZZY_MATCH_CANDIDATES))
}
async fn find_mentions(
@@ -465,6 +620,8 @@ mod tests {
editor::init(cx);
client::init(&client, cx);
channel::init(&client, user_store, cx);
MessageEditorSettings::register(cx);
});
let language_registry = Arc::new(LanguageRegistry::test());

View File

@@ -8,7 +8,7 @@ use crate::{
};
use call::ActiveCall;
use channel::{Channel, ChannelEvent, ChannelStore};
use client::{ChannelId, Client, Contact, HostedProjectId, User, UserStore};
use client::{ChannelId, Client, Contact, ProjectId, User, UserStore};
use contact_finder::ContactFinder;
use db::kvp::KEY_VALUE_STORE;
use editor::{Editor, EditorElement, EditorStyle};
@@ -185,7 +185,7 @@ enum ListEntry {
depth: usize,
},
HostedProject {
id: HostedProjectId,
id: ProjectId,
name: SharedString,
},
Contact {
@@ -989,7 +989,6 @@ impl CollabPanel {
.children(has_channel_buffer_changed.then(|| {
div()
.w_1p5()
.z_index(1)
.absolute()
.right(px(2.))
.top(px(2.))
@@ -1022,7 +1021,6 @@ impl CollabPanel {
.children(has_messages_notification.then(|| {
div()
.w_1p5()
.z_index(1)
.absolute()
.right(px(2.))
.top(px(4.))
@@ -1035,7 +1033,7 @@ impl CollabPanel {
fn render_channel_project(
&self,
id: HostedProjectId,
id: ProjectId,
name: &SharedString,
is_selected: bool,
cx: &mut ViewContext<Self>,
@@ -2617,7 +2615,6 @@ impl CollabPanel {
.children(has_notes_notification.then(|| {
div()
.w_1p5()
.z_index(1)
.absolute()
.right(px(-1.))
.top(px(-1.))
@@ -2632,49 +2629,44 @@ impl CollabPanel {
),
)
.child(
h_flex()
.absolute()
.right(rems(0.))
.z_index(1)
.h_full()
.child(
h_flex()
.h_full()
.gap_1()
.px_1()
.child(
IconButton::new("channel_chat", IconName::MessageBubbles)
.style(ButtonStyle::Filled)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::Small)
.icon_color(if has_messages_notification {
Color::Default
} else {
Color::Muted
})
.on_click(cx.listener(move |this, _, cx| {
this.join_channel_chat(channel_id, cx)
}))
.tooltip(|cx| Tooltip::text("Open channel chat", cx))
.visible_on_hover(""),
)
.child(
IconButton::new("channel_notes", IconName::File)
.style(ButtonStyle::Filled)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::Small)
.icon_color(if has_notes_notification {
Color::Default
} else {
Color::Muted
})
.on_click(cx.listener(move |this, _, cx| {
this.open_channel_notes(channel_id, cx)
}))
.tooltip(|cx| Tooltip::text("Open channel notes", cx))
.visible_on_hover(""),
),
),
h_flex().absolute().right(rems(0.)).h_full().child(
h_flex()
.h_full()
.gap_1()
.px_1()
.child(
IconButton::new("channel_chat", IconName::MessageBubbles)
.style(ButtonStyle::Filled)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::Small)
.icon_color(if has_messages_notification {
Color::Default
} else {
Color::Muted
})
.on_click(cx.listener(move |this, _, cx| {
this.join_channel_chat(channel_id, cx)
}))
.tooltip(|cx| Tooltip::text("Open channel chat", cx))
.visible_on_hover(""),
)
.child(
IconButton::new("channel_notes", IconName::File)
.style(ButtonStyle::Filled)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::Small)
.icon_color(if has_notes_notification {
Color::Default
} else {
Color::Muted
})
.on_click(cx.listener(move |this, _, cx| {
this.open_channel_notes(channel_id, cx)
}))
.tooltip(|cx| Tooltip::text("Open channel notes", cx))
.visible_on_hover(""),
),
),
)
.tooltip({
let channel_store = self.channel_store.clone();
@@ -2720,31 +2712,34 @@ fn render_tree_branch(is_last: bool, overdraw: bool, cx: &mut WindowContext) ->
let thickness = px(1.);
let color = cx.theme().colors().text;
canvas(move |bounds, cx| {
let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
let right = bounds.right();
let top = bounds.top();
canvas(
|_, _| {},
move |bounds, _, cx| {
let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
let right = bounds.right();
let top = bounds.top();
cx.paint_quad(fill(
Bounds::from_corners(
point(start_x, top),
point(
start_x + thickness,
if is_last {
start_y
} else {
bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
},
cx.paint_quad(fill(
Bounds::from_corners(
point(start_x, top),
point(
start_x + thickness,
if is_last {
start_y
} else {
bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
},
),
),
),
color,
));
cx.paint_quad(fill(
Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
color,
));
})
color,
));
cx.paint_quad(fill(
Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
color,
));
},
)
.w(width)
.h(line_height)
}

View File

@@ -329,24 +329,27 @@ impl Render for CollabTitlebarItem {
}
}
fn render_color_ribbon(color: Hsla) -> gpui::Canvas {
canvas(move |bounds, cx| {
let height = bounds.size.height;
let horizontal_offset = height;
let vertical_offset = px(height.0 / 2.0);
let mut path = Path::new(bounds.lower_left());
path.curve_to(
bounds.origin + point(horizontal_offset, vertical_offset),
bounds.origin + point(px(0.0), vertical_offset),
);
path.line_to(bounds.upper_right() + point(-horizontal_offset, vertical_offset));
path.curve_to(
bounds.lower_right(),
bounds.upper_right() + point(px(0.0), vertical_offset),
);
path.line_to(bounds.lower_left());
cx.paint_path(path, color);
})
fn render_color_ribbon(color: Hsla) -> impl Element {
canvas(
move |_, _| {},
move |bounds, _, cx| {
let height = bounds.size.height;
let horizontal_offset = height;
let vertical_offset = px(height.0 / 2.0);
let mut path = Path::new(bounds.lower_left());
path.curve_to(
bounds.origin + point(horizontal_offset, vertical_offset),
bounds.origin + point(px(0.0), vertical_offset),
);
path.line_to(bounds.upper_right() + point(-horizontal_offset, vertical_offset));
path.curve_to(
bounds.lower_right(),
bounds.upper_right() + point(px(0.0), vertical_offset),
);
path.line_to(bounds.lower_left());
cx.paint_path(path, color);
},
)
.h_1()
.w_full()
}
@@ -698,9 +701,8 @@ impl CollabTitlebarItem {
ContextMenu::build(cx, |menu, _| {
menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
.action("Extensions", extensions_ui::Extensions.boxed_clone())
.action("Theme...", theme_selector::Toggle.boxed_clone())
.action("Themes...", theme_selector::Toggle.boxed_clone())
.separator()
.action("Share Feedback...", feedback::GiveFeedback.boxed_clone())
.action("Sign Out", client::SignOut.boxed_clone())
})
.into()
@@ -722,10 +724,8 @@ impl CollabTitlebarItem {
.menu(|cx| {
ContextMenu::build(cx, |menu, _| {
menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
.action("Theme...", theme_selector::Toggle.boxed_clone())
.action("Extensions", extensions_ui::Extensions.boxed_clone())
.separator()
.action("Share Feedback...", feedback::GiveFeedback.boxed_clone())
.action("Themes...", theme_selector::Toggle.boxed_clone())
})
.into()
})

View File

@@ -16,6 +16,7 @@ use gpui::{
actions, point, AppContext, GlobalPixels, Pixels, PlatformDisplay, Size, Task, WindowBounds,
WindowContext, WindowKind, WindowOptions,
};
use panel_settings::MessageEditorSettings;
pub use panel_settings::{
ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
};
@@ -31,6 +32,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
CollaborationPanelSettings::register(cx);
ChatPanelSettings::register(cx);
NotificationPanelSettings::register(cx);
MessageEditorSettings::register(cx);
vcs_menu::init(cx);
collab_titlebar_item::init(cx);

View File

@@ -14,25 +14,25 @@ impl FacePile {
}
pub fn new(faces: SmallVec<[AnyElement; 2]>) -> Self {
Self {
base: h_flex(),
faces,
}
Self { base: div(), faces }
}
}
impl RenderOnce for FacePile {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
let player_count = self.faces.len();
let player_list = self.faces.into_iter().enumerate().map(|(ix, player)| {
let isnt_last = ix < player_count - 1;
div()
.z_index((player_count - ix) as u16)
.when(isnt_last, |div| div.neg_mr_1())
.child(player)
});
self.base.children(player_list)
// Lay the faces out in reverse so they overlap in the desired order (left to right, front to back)
self.base
.flex()
.flex_row_reverse()
.items_center()
.justify_start()
.children(
self.faces
.into_iter()
.enumerate()
.rev()
.map(|(ix, player)| div().when(ix > 0, |div| div.neg_ml_1()).child(player)),
)
}
}

View File

@@ -42,6 +42,15 @@ pub struct PanelSettingsContent {
pub default_width: Option<f32>,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
pub struct MessageEditorSettings {
/// Whether to automatically replace emoji shortcodes with emoji characters.
/// For example: typing `:wave:` gets replaced with `👋`.
///
/// Default: false
pub auto_replace_emoji_shortcode: Option<bool>,
}
impl Settings for CollaborationPanelSettings {
const KEY: Option<&'static str> = Some("collaboration_panel");
type FileContent = PanelSettingsContent;
@@ -77,3 +86,15 @@ impl Settings for NotificationPanelSettings {
Self::load_via_json_merge(default_value, user_values)
}
}
impl Settings for MessageEditorSettings {
const KEY: Option<&'static str> = Some("message_editor");
type FileContent = MessageEditorSettings;
fn load(
default_value: &Self::FileContent,
user_values: &[&Self::FileContent],
_: &mut gpui::AppContext,
) -> anyhow::Result<Self> {
Self::load_via_json_merge(default_value, user_values)
}
}

View File

@@ -149,7 +149,7 @@ impl CopilotButton {
pub fn build_copilot_menu(&mut self, cx: &mut ViewContext<Self>) -> View<ContextMenu> {
let fs = self.fs.clone();
return ContextMenu::build(cx, move |mut menu, cx| {
ContextMenu::build(cx, move |mut menu, cx| {
if let Some(language) = self.language.clone() {
let fs = fs.clone();
let language_enabled =
@@ -216,7 +216,7 @@ impl CopilotButton {
.boxed_clone(),
)
.action("Sign Out", SignOut.boxed_clone())
});
})
}
pub fn update_enabled(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {

View File

@@ -894,7 +894,7 @@ mod tests {
display_map::{BlockContext, TransformBlock},
DisplayPoint, GutterDimensions,
};
use gpui::{px, TestAppContext, VisualTestContext, WindowContext};
use gpui::{px, Stateful, TestAppContext, VisualTestContext, WindowContext};
use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, PointUtf16, Unclipped};
use project::FakeFs;
use serde_json::json;
@@ -1600,20 +1600,18 @@ mod tests {
let name: SharedString = match block {
TransformBlock::Custom(block) => cx.with_element_context({
|cx| -> Option<SharedString> {
block
.render(&mut BlockContext {
context: cx,
anchor_x: px(0.),
gutter_dimensions: &GutterDimensions::default(),
line_height: px(0.),
em_width: px(0.),
max_width: px(0.),
block_id: ix,
editor_style: &editor::EditorStyle::default(),
})
.inner_id()?
.try_into()
.ok()
let mut element = block.render(&mut BlockContext {
context: cx,
anchor_x: px(0.),
gutter_dimensions: &GutterDimensions::default(),
line_height: px(0.),
em_width: px(0.),
max_width: px(0.),
block_id: ix,
editor_style: &editor::EditorStyle::default(),
});
let element = element.downcast_mut::<Stateful<Div>>().unwrap();
element.interactivity().element_id.clone()?.try_into().ok()
}
})?,

View File

@@ -36,11 +36,12 @@ collections.workspace = true
convert_case = "0.6.0"
copilot.workspace = true
db.workspace = true
emojis.workspace = true
futures.workspace = true
fuzzy.workspace = true
git.workspace = true
gpui.workspace = true
indoc = "1.0.4"
indoc.workspace = true
itertools.workspace = true
language.workspace = true
lazy_static.workspace = true

View File

@@ -94,6 +94,12 @@ pub struct SelectDownByLines {
pub(super) lines: u32,
}
#[derive(PartialEq, Clone, Deserialize, Default)]
pub struct DuplicateLine {
#[serde(default)]
pub move_upwards: bool,
}
impl_actions!(
editor,
[
@@ -112,7 +118,8 @@ impl_actions!(
MoveUpByLines,
MoveDownByLines,
SelectUpByLines,
SelectDownByLines
SelectDownByLines,
DuplicateLine
]
);
@@ -152,7 +159,6 @@ gpui::actions!(
DeleteToPreviousSubwordStart,
DeleteToPreviousWordStart,
DisplayCursorNames,
DuplicateLine,
ExpandMacroRecursively,
FindAllReferences,
Fold,
@@ -204,6 +210,7 @@ gpui::actions!(
PageDown,
PageUp,
Paste,
RevertSelectedHunks,
Redo,
RedoSelection,
Rename,

View File

@@ -46,7 +46,7 @@ pub use block_map::{
BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock, TransformBlock,
};
pub use self::fold_map::{Fold, FoldPoint};
pub use self::fold_map::{Fold, FoldId, FoldPoint};
pub use self::inlay_map::{InlayOffset, InlayPoint};
pub(crate) use inlay_map::Inlay;
@@ -339,8 +339,13 @@ impl DisplayMap {
pub(crate) struct Highlights<'a> {
pub text_highlights: Option<&'a TextHighlights>,
pub inlay_highlights: Option<&'a InlayHighlights>,
pub inlay_highlight_style: Option<HighlightStyle>,
pub suggestion_highlight_style: Option<HighlightStyle>,
pub styles: HighlightStyles,
}
#[derive(Default, Debug, Clone, Copy)]
pub struct HighlightStyles {
pub inlay_hint: Option<HighlightStyle>,
pub suggestion: Option<HighlightStyle>,
}
pub struct HighlightedChunk<'a> {
@@ -516,8 +521,7 @@ impl DisplaySnapshot {
&self,
display_rows: Range<u32>,
language_aware: bool,
inlay_highlight_style: Option<HighlightStyle>,
suggestion_highlight_style: Option<HighlightStyle>,
highlight_styles: HighlightStyles,
) -> DisplayChunks<'_> {
self.block_snapshot.chunks(
display_rows,
@@ -525,8 +529,7 @@ impl DisplaySnapshot {
Highlights {
text_highlights: Some(&self.text_highlights),
inlay_highlights: Some(&self.inlay_highlights),
inlay_highlight_style,
suggestion_highlight_style,
styles: highlight_styles,
},
)
}
@@ -540,8 +543,10 @@ impl DisplaySnapshot {
self.chunks(
display_rows,
language_aware,
Some(editor_style.inlays_style),
Some(editor_style.suggestions_style),
HighlightStyles {
inlay_hint: Some(editor_style.inlay_hints_style),
suggestion: Some(editor_style.suggestions_style),
},
)
.map(|chunk| {
let mut highlight_style = chunk
@@ -1846,7 +1851,7 @@ pub mod tests {
) -> Vec<(String, Option<Hsla>, Option<Hsla>)> {
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
let mut chunks: Vec<(String, Option<Hsla>, Option<Hsla>)> = Vec::new();
for chunk in snapshot.chunks(rows, true, None, None) {
for chunk in snapshot.chunks(rows, true, HighlightStyles::default()) {
let syntax_color = chunk
.syntax_highlight_id
.and_then(|id| id.style(theme)?.color);

View File

@@ -1,4 +1,4 @@
use crate::InlayId;
use crate::{HighlightStyles, InlayId};
use collections::{BTreeMap, BTreeSet};
use gpui::HighlightStyle;
use language::{Chunk, Edit, Point, TextSummary};
@@ -215,8 +215,7 @@ pub struct InlayChunks<'a> {
inlay_chunk: Option<&'a str>,
output_offset: InlayOffset,
max_output_offset: InlayOffset,
inlay_highlight_style: Option<HighlightStyle>,
suggestion_highlight_style: Option<HighlightStyle>,
highlight_styles: HighlightStyles,
highlight_endpoints: Peekable<vec::IntoIter<HighlightEndpoint>>,
active_highlights: BTreeMap<Option<TypeId>, HighlightStyle>,
highlights: Highlights<'a>,
@@ -307,8 +306,8 @@ impl<'a> Iterator for InlayChunks<'a> {
}
let mut highlight_style = match inlay.id {
InlayId::Suggestion(_) => self.suggestion_highlight_style,
InlayId::Hint(_) => self.inlay_highlight_style,
InlayId::Suggestion(_) => self.highlight_styles.suggestion,
InlayId::Hint(_) => self.highlight_styles.inlay_hint,
};
let next_inlay_highlight_endpoint;
let offset_in_inlay = self.output_offset - self.transforms.start().0;
@@ -1052,8 +1051,7 @@ impl InlaySnapshot {
buffer_chunk: None,
output_offset: range.start,
max_output_offset: range.end,
inlay_highlight_style: highlights.inlay_highlight_style,
suggestion_highlight_style: highlights.suggestion_highlight_style,
highlight_styles: highlights.styles,
highlight_endpoints: highlight_endpoints.into_iter().peekable(),
active_highlights: Default::default(),
highlights,

View File

@@ -36,14 +36,14 @@ mod selections_collection;
mod editor_tests;
#[cfg(any(test, feature = "test-support"))]
pub mod test;
use ::git::diff::DiffHunk;
use ::git::diff::{DiffHunk, DiffHunkStatus};
pub(crate) use actions::*;
use aho_corasick::AhoCorasick;
use anyhow::{anyhow, Context as _, Result};
use blink_manager::BlinkManager;
use client::{Collaborator, ParticipantIndex};
use clock::ReplicaId;
use collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque};
use collections::{hash_map, BTreeMap, Bound, HashMap, HashSet, VecDeque};
use convert_case::{Case, Casing};
use copilot::Copilot;
use debounced_delay::DebouncedDelay;
@@ -51,7 +51,9 @@ pub use display_map::DisplayPoint;
use display_map::*;
pub use editor_settings::EditorSettings;
use element::LineWithInvisibles;
pub use element::{Cursor, EditorElement, HighlightedRange, HighlightedRangeLine};
pub use element::{
CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition,
};
use futures::FutureExt;
use fuzzy::{StringMatch, StringMatchCandidate};
use git::diff_hunk_to_display;
@@ -60,9 +62,9 @@ use gpui::{
AnyElement, AppContext, AsyncWindowContext, BackgroundExecutor, Bounds, ClipboardItem, Context,
DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusableView, FontId, FontStyle,
FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext, Model, MouseButton,
ParentElement, Pixels, Render, SharedString, Styled, StyledText, Subscription, Task, TextStyle,
UnderlineStyle, UniformListScrollHandle, View, ViewContext, ViewInputHandler, VisualContext,
WeakView, WhiteSpace, WindowContext,
ParentElement, Pixels, Render, SharedString, StrikethroughStyle, Styled, StyledText,
Subscription, Task, TextStyle, UnderlineStyle, UniformListScrollHandle, View, ViewContext,
ViewInputHandler, VisualContext, WeakView, WhiteSpace, WindowContext,
};
use highlight_matching_bracket::refresh_matching_bracket_highlights;
use hover_popover::{hide_hover, HoverState};
@@ -111,7 +113,6 @@ use std::{
time::{Duration, Instant},
};
pub use sum_tree::Bias;
use sum_tree::TreeMap;
use text::{BufferId, OffsetUtf16, Rope};
use theme::{
observe_buffer_font_size_adjustment, ActiveTheme, PlayerColor, StatusColors, SyntaxTheme,
@@ -323,7 +324,7 @@ pub struct EditorStyle {
pub scrollbar_width: Pixels,
pub syntax: Arc<SyntaxTheme>,
pub status: StatusColors,
pub inlays_style: HighlightStyle,
pub inlay_hints_style: HighlightStyle,
pub suggestions_style: HighlightStyle,
}
@@ -339,7 +340,7 @@ impl Default for EditorStyle {
// We should look into removing the status colors from the editor
// style and retrieve them directly from the theme.
status: StatusColors::dark(),
inlays_style: HighlightStyle::default(),
inlay_hints_style: HighlightStyle::default(),
suggestions_style: HighlightStyle::default(),
}
}
@@ -351,7 +352,6 @@ type CompletionId = usize;
// type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option<HighlightStyle>;
type BackgroundHighlight = (fn(&ThemeColors) -> Hsla, Vec<Range<Anchor>>);
type InlayBackgroundHighlight = (fn(&ThemeColors) -> Hsla, Vec<InlayHighlight>);
/// Zed's primary text input `View`, allowing users to edit a [`MultiBuffer`]
///
@@ -388,9 +388,9 @@ pub struct Editor {
show_gutter: bool,
show_wrap_guides: Option<bool>,
placeholder_text: Option<Arc<str>>,
highlighted_rows: Option<Range<u32>>,
highlight_order: usize,
highlighted_rows: HashMap<TypeId, Vec<(usize, Range<Anchor>, Hsla)>>,
background_highlights: BTreeMap<TypeId, BackgroundHighlight>,
inlay_background_highlights: TreeMap<Option<TypeId>, InlayBackgroundHighlight>,
nav_history: Option<ItemNavHistory>,
context_menu: RwLock<Option<ContextMenu>>,
mouse_context_menu: Option<MouseContextMenu>,
@@ -425,6 +425,7 @@ pub struct Editor {
editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
show_copilot_suggestions: bool,
use_autoclose: bool,
auto_replace_emoji_shortcode: bool,
custom_context_menu: Option<
Box<
dyn 'static
@@ -929,6 +930,15 @@ impl CompletionsMenu {
// Ignore font weight for syntax highlighting, as we'll use it
// for fuzzy matches.
highlight.font_weight = None;
if completion.lsp_completion.deprecated.unwrap_or(false) {
highlight.strikethrough = Some(StrikethroughStyle {
thickness: 1.0.into(),
..Default::default()
});
highlight.color = Some(cx.theme().colors().text_muted);
}
(range, highlight)
},
),
@@ -1186,6 +1196,7 @@ impl CodeActionsMenu {
}
}),
)
.whitespace_nowrap()
// TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
.child(SharedString::from(action.lsp_action.title.clone()))
})
@@ -1214,6 +1225,7 @@ impl CodeActionsMenu {
}
}
#[derive(Debug)]
pub(crate) struct CopilotState {
excerpt_id: Option<ExcerptId>,
pending_refresh: Task<Option<()>>,
@@ -1515,9 +1527,9 @@ impl Editor {
show_gutter: mode == EditorMode::Full,
show_wrap_guides: None,
placeholder_text: None,
highlighted_rows: None,
highlight_order: 0,
highlighted_rows: HashMap::default(),
background_highlights: Default::default(),
inlay_background_highlights: Default::default(),
nav_history: None,
context_menu: RwLock::new(None),
mouse_context_menu: None,
@@ -1539,6 +1551,7 @@ impl Editor {
use_modal_editing: mode == EditorMode::Full,
read_only: false,
use_autoclose: true,
auto_replace_emoji_shortcode: false,
leader_peer_id: None,
remote_id: None,
hover_state: Default::default(),
@@ -1829,6 +1842,10 @@ impl Editor {
self.use_autoclose = autoclose;
}
pub fn set_auto_replace_emoji_shortcode(&mut self, auto_replace: bool) {
self.auto_replace_emoji_shortcode = auto_replace;
}
pub fn set_show_copilot_suggestions(&mut self, show_copilot_suggestions: bool) {
self.show_copilot_suggestions = show_copilot_suggestions;
}
@@ -2505,6 +2522,47 @@ impl Editor {
}
}
if self.auto_replace_emoji_shortcode
&& selection.is_empty()
&& text.as_ref().ends_with(':')
{
if let Some(possible_emoji_short_code) =
Self::find_possible_emoji_shortcode_at_position(&snapshot, selection.start)
{
if !possible_emoji_short_code.is_empty() {
if let Some(emoji) = emojis::get_by_shortcode(&possible_emoji_short_code) {
let emoji_shortcode_start = Point::new(
selection.start.row,
selection.start.column - possible_emoji_short_code.len() as u32 - 1,
);
// Remove shortcode from buffer
edits.push((
emoji_shortcode_start..selection.start,
"".to_string().into(),
));
new_selections.push((
Selection {
id: selection.id,
start: snapshot.anchor_after(emoji_shortcode_start),
end: snapshot.anchor_before(selection.start),
reversed: selection.reversed,
goal: selection.goal,
},
0,
));
// Insert emoji
let selection_start_anchor = snapshot.anchor_after(selection.start);
new_selections.push((selection.map(|_| selection_start_anchor), 0));
edits.push((selection.start..selection.end, emoji.to_string().into()));
continue;
}
}
}
}
// If not handling any auto-close operation, then just replace the selected
// text with the given input and move the selection to the end of the
// newly inserted text.
@@ -2588,6 +2646,53 @@ impl Editor {
});
}
fn find_possible_emoji_shortcode_at_position(
snapshot: &MultiBufferSnapshot,
position: Point,
) -> Option<String> {
let mut chars = Vec::new();
let mut found_colon = false;
for char in snapshot.reversed_chars_at(position).take(100) {
// Found a possible emoji shortcode in the middle of the buffer
if found_colon {
if char.is_whitespace() {
chars.reverse();
return Some(chars.iter().collect());
}
// If the previous character is not a whitespace, we are in the middle of a word
// and we only want to complete the shortcode if the word is made up of other emojis
let mut containing_word = String::new();
for ch in snapshot
.reversed_chars_at(position)
.skip(chars.len() + 1)
.take(100)
{
if ch.is_whitespace() {
break;
}
containing_word.push(ch);
}
let containing_word = containing_word.chars().rev().collect::<String>();
if util::word_consists_of_emojis(containing_word.as_str()) {
chars.reverse();
return Some(chars.iter().collect());
}
}
if char.is_whitespace() || !char.is_ascii() {
return None;
}
if char == ':' {
found_colon = true;
} else {
chars.push(char);
}
}
// Found a possible emoji shortcode at the beginning of the buffer
chars.reverse();
Some(chars.iter().collect())
}
pub fn newline(&mut self, _: &Newline, cx: &mut ViewContext<Self>) {
self.transact(cx, |this, cx| {
let (edits, selection_fixup_info): (Vec<_>, Vec<_>) = {
@@ -3041,7 +3146,7 @@ impl Editor {
(InvalidationStrategy::RefreshRequested, None)
} else {
self.inlay_hint_cache.clear();
self.splice_inlay_hints(
self.splice_inlays(
self.visible_inlay_hints(cx)
.iter()
.map(|inlay| inlay.id)
@@ -3063,7 +3168,7 @@ impl Editor {
to_remove,
to_insert,
})) => {
self.splice_inlay_hints(to_remove, to_insert, cx);
self.splice_inlays(to_remove, to_insert, cx);
return;
}
ControlFlow::Break(None) => return,
@@ -3076,7 +3181,7 @@ impl Editor {
to_insert,
}) = self.inlay_hint_cache.remove_excerpts(excerpts_removed)
{
self.splice_inlay_hints(to_remove, to_insert, cx);
self.splice_inlays(to_remove, to_insert, cx);
}
return;
}
@@ -3099,7 +3204,7 @@ impl Editor {
ignore_debounce,
cx,
) {
self.splice_inlay_hints(to_remove, to_insert, cx);
self.splice_inlays(to_remove, to_insert, cx);
}
}
@@ -3107,9 +3212,7 @@ impl Editor {
self.display_map
.read(cx)
.current_inlays()
.filter(move |inlay| {
Some(inlay.id) != self.copilot_state.suggestion.as_ref().map(|h| h.id)
})
.filter(move |inlay| matches!(inlay.id, InlayId::Hint(_)))
.cloned()
.collect()
}
@@ -3180,7 +3283,7 @@ impl Editor {
}
}
fn splice_inlay_hints(
fn splice_inlays(
&self,
to_remove: Vec<InlayId>,
to_insert: Vec<Inlay>,
@@ -4088,7 +4191,10 @@ impl Editor {
}
fn clear_copilot_suggestions(&mut self, cx: &mut ViewContext<Self>) {
self.copilot_state = Default::default();
if let Some(old_suggestion) = self.copilot_state.suggestion.take() {
self.splice_inlays(vec![old_suggestion.id], Vec::new(), cx);
}
self.copilot_state = CopilotState::default();
self.discard_copilot_suggestion(cx);
}
@@ -4120,14 +4226,14 @@ impl Editor {
}
pub fn render_fold_indicators(
&self,
&mut self,
fold_data: Vec<Option<(FoldStatus, u32, bool)>>,
_style: &EditorStyle,
gutter_hovered: bool,
_line_height: Pixels,
_gutter_margin: Pixels,
editor_view: View<Editor>,
) -> Vec<Option<IconButton>> {
cx: &mut ViewContext<Self>,
) -> Vec<Option<AnyElement>> {
fold_data
.iter()
.enumerate()
@@ -4136,24 +4242,20 @@ impl Editor {
.map(|(fold_status, buffer_row, active)| {
(active || gutter_hovered || fold_status == FoldStatus::Folded).then(|| {
IconButton::new(ix, ui::IconName::ChevronDown)
.on_click({
let view = editor_view.clone();
move |_e, cx| {
view.update(cx, |editor, cx| match fold_status {
FoldStatus::Folded => {
editor.unfold_at(&UnfoldAt { buffer_row }, cx);
}
FoldStatus::Foldable => {
editor.fold_at(&FoldAt { buffer_row }, cx);
}
})
.on_click(cx.listener(move |this, _e, cx| match fold_status {
FoldStatus::Folded => {
this.unfold_at(&UnfoldAt { buffer_row }, cx);
}
})
FoldStatus::Foldable => {
this.fold_at(&FoldAt { buffer_row }, cx);
}
}))
.icon_color(ui::Color::Muted)
.icon_size(ui::IconSize::Small)
.selected(fold_status == FoldStatus::Folded)
.selected_icon(ui::IconName::ChevronRight)
.size(ui::ButtonSize::None)
.into_any_element()
})
})
.flatten()
@@ -4835,6 +4937,105 @@ impl Editor {
})
}
pub fn revert_selected_hunks(&mut self, _: &RevertSelectedHunks, cx: &mut ViewContext<Self>) {
let revert_changes = self.gather_revert_changes(&self.selections.disjoint_anchors(), cx);
if !revert_changes.is_empty() {
self.transact(cx, |editor, cx| {
editor.buffer().update(cx, |multi_buffer, cx| {
for (buffer_id, buffer_revert_ranges) in revert_changes {
if let Some(buffer) = multi_buffer.buffer(buffer_id) {
buffer.update(cx, |buffer, cx| {
buffer.edit(buffer_revert_ranges, None, cx);
});
}
}
});
editor.change_selections(None, cx, |selections| selections.refresh());
});
}
}
fn gather_revert_changes(
&mut self,
selections: &[Selection<Anchor>],
cx: &mut ViewContext<'_, Editor>,
) -> HashMap<BufferId, Vec<(Range<text::Anchor>, Arc<str>)>> {
let mut revert_changes = HashMap::default();
self.buffer.update(cx, |multi_buffer, cx| {
let multi_buffer_snapshot = multi_buffer.snapshot(cx);
let selected_multi_buffer_rows = selections.iter().map(|selection| {
let head = selection.head();
let tail = selection.tail();
let start = tail.to_point(&multi_buffer_snapshot).row;
let end = head.to_point(&multi_buffer_snapshot).row;
if start > end {
end..start
} else {
start..end
}
});
let mut processed_buffer_rows =
HashMap::<BufferId, HashSet<Range<text::Anchor>>>::default();
for selected_multi_buffer_rows in selected_multi_buffer_rows {
let query_rows =
selected_multi_buffer_rows.start..selected_multi_buffer_rows.end + 1;
for hunk in multi_buffer_snapshot.git_diff_hunks_in_range(query_rows.clone()) {
// Deleted hunk is an empty row range, no caret can be placed there and Zed allows to revert it
// when the caret is just above or just below the deleted hunk.
let allow_adjacent = hunk.status() == DiffHunkStatus::Removed;
let related_to_selection = if allow_adjacent {
hunk.associated_range.overlaps(&query_rows)
|| hunk.associated_range.start == query_rows.end
|| hunk.associated_range.end == query_rows.start
} else {
// `selected_multi_buffer_rows` are inclusive (e.g. [2..2] means 2nd row is selected)
// `hunk.associated_range` is exclusive (e.g. [2..3] means 2nd row is selected)
hunk.associated_range.overlaps(&selected_multi_buffer_rows)
|| selected_multi_buffer_rows.end == hunk.associated_range.start
};
if related_to_selection {
if !processed_buffer_rows
.entry(hunk.buffer_id)
.or_default()
.insert(hunk.buffer_range.start..hunk.buffer_range.end)
{
continue;
}
Self::prepare_revert_change(&mut revert_changes, &multi_buffer, &hunk, cx);
}
}
}
});
revert_changes
}
fn prepare_revert_change(
revert_changes: &mut HashMap<BufferId, Vec<(Range<text::Anchor>, Arc<str>)>>,
multi_buffer: &MultiBuffer,
hunk: &DiffHunk<u32>,
cx: &mut AppContext,
) -> Option<()> {
let buffer = multi_buffer.buffer(hunk.buffer_id)?;
let buffer = buffer.read(cx);
let original_text = buffer.diff_base()?.get(hunk.diff_base_byte_range.clone())?;
let buffer_snapshot = buffer.snapshot();
let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default();
if let Err(i) = buffer_revert_changes.binary_search_by(|probe| {
probe
.0
.start
.cmp(&hunk.buffer_range.start, &buffer_snapshot)
.then(probe.0.end.cmp(&hunk.buffer_range.end, &buffer_snapshot))
.then(probe.1.as_ref().cmp(original_text))
}) {
buffer_revert_changes.insert(i, (hunk.buffer_range.clone(), Arc::from(original_text)));
Some(())
} else {
None
}
}
pub fn reverse_lines(&mut self, _: &ReverseLines, cx: &mut ViewContext<Self>) {
self.manipulate_lines(cx, |lines| lines.reverse())
}
@@ -5032,7 +5233,7 @@ impl Editor {
});
}
pub fn duplicate_line(&mut self, _: &DuplicateLine, cx: &mut ViewContext<Self>) {
pub fn duplicate_line(&mut self, action: &DuplicateLine, cx: &mut ViewContext<Self>) {
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let buffer = &display_map.buffer_snapshot;
let selections = self.selections.all::<Point>(cx);
@@ -5053,14 +5254,20 @@ impl Editor {
}
}
// Copy the text from the selected row region and splice it at the start of the region.
// Copy the text from the selected row region and splice it either at the start
// or end of the region.
let start = Point::new(rows.start, 0);
let end = Point::new(rows.end - 1, buffer.line_len(rows.end - 1));
let text = buffer
.text_for_range(start..end)
.chain(Some("\n"))
.collect::<String>();
edits.push((start..start, text));
let insert_location = if action.move_upwards {
Point::new(rows.end, 0)
} else {
start
};
edits.push((insert_location..insert_location, text));
}
self.transact(cx, |this, cx| {
@@ -7959,7 +8166,7 @@ impl Editor {
scrollbar_width: cx.editor_style.scrollbar_width,
syntax: cx.editor_style.syntax.clone(),
status: cx.editor_style.status.clone(),
inlays_style: HighlightStyle {
inlay_hints_style: HighlightStyle {
color: Some(cx.theme().status().hint),
font_weight: Some(FontWeight::BOLD),
..HighlightStyle::default()
@@ -8736,12 +8943,93 @@ impl Editor {
}
}
pub fn highlight_rows(&mut self, rows: Option<Range<u32>>) {
self.highlighted_rows = rows;
/// Adds or removes (on `None` color) a highlight for the rows corresponding to the anchor range given.
/// On matching anchor range, replaces the old highlight; does not clear the other existing highlights.
/// If multiple anchor ranges will produce highlights for the same row, the last range added will be used.
pub fn highlight_rows<T: 'static>(
&mut self,
rows: Range<Anchor>,
color: Option<Hsla>,
cx: &mut ViewContext<Self>,
) {
let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx);
match self.highlighted_rows.entry(TypeId::of::<T>()) {
hash_map::Entry::Occupied(o) => {
let row_highlights = o.into_mut();
let existing_highlight_index =
row_highlights.binary_search_by(|(_, highlight_range, _)| {
highlight_range
.start
.cmp(&rows.start, &multi_buffer_snapshot)
.then(highlight_range.end.cmp(&rows.end, &multi_buffer_snapshot))
});
match color {
Some(color) => {
let insert_index = match existing_highlight_index {
Ok(i) => i,
Err(i) => i,
};
row_highlights.insert(
insert_index,
(post_inc(&mut self.highlight_order), rows, color),
);
}
None => {
if let Ok(i) = existing_highlight_index {
row_highlights.remove(i);
}
}
}
}
hash_map::Entry::Vacant(v) => {
if let Some(color) = color {
v.insert(vec![(post_inc(&mut self.highlight_order), rows, color)]);
}
}
}
}
pub fn highlighted_rows(&self) -> Option<Range<u32>> {
self.highlighted_rows.clone()
/// Clear all anchor ranges for a certain highlight context type, so no corresponding rows will be highlighted.
pub fn clear_row_highlights<T: 'static>(&mut self) {
self.highlighted_rows.remove(&TypeId::of::<T>());
}
/// For a highlight given context type, gets all anchor ranges that will be used for row highlighting.
pub fn highlighted_rows<T: 'static>(
&self,
) -> Option<impl Iterator<Item = (&Range<Anchor>, &Hsla)>> {
Some(
self.highlighted_rows
.get(&TypeId::of::<T>())?
.iter()
.map(|(_, range, color)| (range, color)),
)
}
// Merges all anchor ranges for all context types ever set, picking the last highlight added in case of a row conflict.
// Rerturns a map of display rows that are highlighted and their corresponding highlight color.
pub fn highlighted_display_rows(&mut self, cx: &mut WindowContext) -> BTreeMap<u32, Hsla> {
let snapshot = self.snapshot(cx);
let mut used_highlight_orders = HashMap::default();
self.highlighted_rows
.iter()
.flat_map(|(_, highlighted_rows)| highlighted_rows.iter())
.fold(
BTreeMap::<u32, Hsla>::new(),
|mut unique_rows, (highlight_order, anchor_range, hsla)| {
let start_row = anchor_range.start.to_display_point(&snapshot).row();
let end_row = anchor_range.end.to_display_point(&snapshot).row();
for row in start_row..=end_row {
let used_index =
used_highlight_orders.entry(row).or_insert(*highlight_order);
if highlight_order >= used_index {
*used_index = *highlight_order;
unique_rows.insert(row, *hsla);
}
}
unique_rows
},
)
}
pub fn highlight_background<T: 'static>(
@@ -8766,29 +9054,11 @@ impl Editor {
cx.notify();
}
pub(crate) fn highlight_inlay_background<T: 'static>(
&mut self,
ranges: Vec<InlayHighlight>,
color_fetcher: fn(&ThemeColors) -> Hsla,
cx: &mut ViewContext<Self>,
) {
// TODO: no actual highlights happen for inlays currently, find a way to do that
self.inlay_background_highlights
.insert(Some(TypeId::of::<T>()), (color_fetcher, ranges));
cx.notify();
}
pub fn clear_background_highlights<T: 'static>(
&mut self,
cx: &mut ViewContext<Self>,
_cx: &mut ViewContext<Self>,
) -> Option<BackgroundHighlight> {
let text_highlights = self.background_highlights.remove(&TypeId::of::<T>());
let inlay_highlights = self
.inlay_background_highlights
.remove(&Some(TypeId::of::<T>()));
if text_highlights.is_some() || inlay_highlights.is_some() {
cx.notify();
}
text_highlights
}
@@ -8965,7 +9235,7 @@ impl Editor {
&self,
search_range: Range<Anchor>,
display_snapshot: &DisplaySnapshot,
cx: &mut ViewContext<Self>,
cx: &WindowContext,
) -> Vec<Range<DisplayPoint>> {
display_snapshot
.buffer_snapshot
@@ -9736,7 +10006,7 @@ impl EditorSnapshot {
self.is_focused
}
pub fn placeholder_text(&self, _cx: &mut WindowContext) -> Option<&Arc<str>> {
pub fn placeholder_text(&self) -> Option<&Arc<str>> {
self.placeholder_text.as_ref()
}
@@ -9900,7 +10170,7 @@ impl Render for Editor {
scrollbar_width: px(12.),
syntax: cx.theme().syntax().clone(),
status: cx.theme().status().clone(),
inlays_style: HighlightStyle {
inlay_hints_style: HighlightStyle {
color: Some(cx.theme().status().hint),
..HighlightStyle::default()
},

View File

@@ -3118,7 +3118,7 @@ fn test_duplicate_line(cx: &mut TestAppContext) {
DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0),
])
});
view.duplicate_line(&DuplicateLine, cx);
view.duplicate_line(&DuplicateLine::default(), cx);
assert_eq!(view.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n");
assert_eq!(
view.selections.display_ranges(cx),
@@ -3142,7 +3142,7 @@ fn test_duplicate_line(cx: &mut TestAppContext) {
DisplayPoint::new(1, 2)..DisplayPoint::new(2, 1),
])
});
view.duplicate_line(&DuplicateLine, cx);
view.duplicate_line(&DuplicateLine::default(), cx);
assert_eq!(view.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n");
assert_eq!(
view.selections.display_ranges(cx),
@@ -3152,6 +3152,56 @@ fn test_duplicate_line(cx: &mut TestAppContext) {
]
);
});
// With `move_upwards` the selections stay in place, except for
// the lines inserted above them
let view = cx.add_window(|cx| {
let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx);
build_editor(buffer, cx)
});
_ = view.update(cx, |view, cx| {
view.change_selections(None, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1),
DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0),
DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0),
])
});
view.duplicate_line(&DuplicateLine { move_upwards: true }, cx);
assert_eq!(view.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n");
assert_eq!(
view.selections.display_ranges(cx),
vec![
DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1),
DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
DisplayPoint::new(2, 0)..DisplayPoint::new(2, 0),
DisplayPoint::new(6, 0)..DisplayPoint::new(6, 0),
]
);
});
let view = cx.add_window(|cx| {
let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx);
build_editor(buffer, cx)
});
_ = view.update(cx, |view, cx| {
view.change_selections(None, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(0, 1)..DisplayPoint::new(1, 1),
DisplayPoint::new(1, 2)..DisplayPoint::new(2, 1),
])
});
view.duplicate_line(&DuplicateLine { move_upwards: true }, cx);
assert_eq!(view.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n");
assert_eq!(
view.selections.display_ranges(cx),
vec![
DisplayPoint::new(0, 1)..DisplayPoint::new(1, 1),
DisplayPoint::new(1, 2)..DisplayPoint::new(2, 1),
]
);
});
}
#[gpui::test]
@@ -5121,6 +5171,78 @@ async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) {
});
}
#[gpui::test]
async fn test_auto_replace_emoji_shortcode(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let language = Arc::new(Language::new(
LanguageConfig::default(),
Some(tree_sitter_rust::language()),
));
let buffer = cx.new_model(|cx| {
Buffer::new(0, BufferId::new(cx.entity_id().as_u64()).unwrap(), "")
.with_language(language, cx)
});
let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
let (editor, cx) = cx.add_window_view(|cx| build_editor(buffer, cx));
editor
.condition::<crate::EditorEvent>(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx))
.await;
_ = editor.update(cx, |editor, cx| {
editor.set_auto_replace_emoji_shortcode(true);
editor.handle_input("Hello ", cx);
editor.handle_input(":wave", cx);
assert_eq!(editor.text(cx), "Hello :wave".unindent());
editor.handle_input(":", cx);
assert_eq!(editor.text(cx), "Hello 👋".unindent());
editor.handle_input(" :smile", cx);
assert_eq!(editor.text(cx), "Hello 👋 :smile".unindent());
editor.handle_input(":", cx);
assert_eq!(editor.text(cx), "Hello 👋 😄".unindent());
// Ensure shortcode gets replaced when it is part of a word that only consists of emojis
editor.handle_input(":wave", cx);
assert_eq!(editor.text(cx), "Hello 👋 😄:wave".unindent());
editor.handle_input(":", cx);
assert_eq!(editor.text(cx), "Hello 👋 😄👋".unindent());
editor.handle_input(":1", cx);
assert_eq!(editor.text(cx), "Hello 👋 😄👋:1".unindent());
editor.handle_input(":", cx);
assert_eq!(editor.text(cx), "Hello 👋 😄👋:1:".unindent());
// Ensure shortcode does not get replaced when it is part of a word
editor.handle_input(" Test:wave", cx);
assert_eq!(editor.text(cx), "Hello 👋 😄👋:1: Test:wave".unindent());
editor.handle_input(":", cx);
assert_eq!(editor.text(cx), "Hello 👋 😄👋:1: Test:wave:".unindent());
editor.set_auto_replace_emoji_shortcode(false);
// Ensure shortcode does not get replaced when auto replace is off
editor.handle_input(" :wave", cx);
assert_eq!(
editor.text(cx),
"Hello 👋 😄👋:1: Test:wave: :wave".unindent()
);
editor.handle_input(":", cx);
assert_eq!(
editor.text(cx),
"Hello 👋 😄👋:1: Test:wave: :wave:".unindent()
);
});
}
#[gpui::test]
async fn test_snippets(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
@@ -8640,6 +8762,560 @@ async fn test_find_all_references(cx: &mut gpui::TestAppContext) {
"});
}
#[gpui::test]
async fn test_addition_reverts(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
let base_text = indoc! {r#"struct Row;
struct Row1;
struct Row2;
struct Row4;
struct Row5;
struct Row6;
struct Row8;
struct Row9;
struct Row10;"#};
// When addition hunks are not adjacent to carets, no hunk revert is performed
assert_hunk_revert(
indoc! {r#"struct Row;
struct Row1;
struct Row1.1;
struct Row1.2;
struct Row2;ˇ
struct Row4;
struct Row5;
struct Row6;
struct Row8;
ˇstruct Row9;
struct Row9.1;
struct Row9.2;
struct Row9.3;
struct Row10;"#},
vec![DiffHunkStatus::Added, DiffHunkStatus::Added],
indoc! {r#"struct Row;
struct Row1;
struct Row1.1;
struct Row1.2;
struct Row2;ˇ
struct Row4;
struct Row5;
struct Row6;
struct Row8;
ˇstruct Row9;
struct Row9.1;
struct Row9.2;
struct Row9.3;
struct Row10;"#},
base_text,
&mut cx,
);
// Same for selections
assert_hunk_revert(
indoc! {r#"struct Row;
struct Row1;
struct Row2;
struct Row2.1;
struct Row2.2;
«ˇ
struct Row4;
struct» Row5;
«struct Row6;
ˇ»
struct Row9.1;
struct Row9.2;
struct Row9.3;
struct Row8;
struct Row9;
struct Row10;"#},
vec![DiffHunkStatus::Added, DiffHunkStatus::Added],
indoc! {r#"struct Row;
struct Row1;
struct Row2;
struct Row2.1;
struct Row2.2;
«ˇ
struct Row4;
struct» Row5;
«struct Row6;
ˇ»
struct Row9.1;
struct Row9.2;
struct Row9.3;
struct Row8;
struct Row9;
struct Row10;"#},
base_text,
&mut cx,
);
// When carets and selections intersect the addition hunks, those are reverted.
// Adjacent carets got merged.
assert_hunk_revert(
indoc! {r#"struct Row;
ˇ// something on the top
struct Row1;
struct Row2;
struct Roˇw3.1;
struct Row2.2;
struct Row2.3;ˇ
struct Row4;
struct ˇRow5.1;
struct Row5.2;
struct «Rowˇ»5.3;
struct Row5;
struct Row6;
ˇ
struct Row9.1;
struct «Rowˇ»9.2;
struct «ˇRow»9.3;
struct Row8;
struct Row9;
«ˇ// something on bottom»
struct Row10;"#},
vec![
DiffHunkStatus::Added,
DiffHunkStatus::Added,
DiffHunkStatus::Added,
DiffHunkStatus::Added,
DiffHunkStatus::Added,
],
indoc! {r#"struct Row;
ˇstruct Row1;
struct Row2;
ˇ
struct Row4;
ˇstruct Row5;
struct Row6;
ˇ
ˇstruct Row8;
struct Row9;
ˇstruct Row10;"#},
base_text,
&mut cx,
);
}
#[gpui::test]
async fn test_modification_reverts(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
let base_text = indoc! {r#"struct Row;
struct Row1;
struct Row2;
struct Row4;
struct Row5;
struct Row6;
struct Row8;
struct Row9;
struct Row10;"#};
// Modification hunks behave the same as the addition ones.
assert_hunk_revert(
indoc! {r#"struct Row;
struct Row1;
struct Row33;
ˇ
struct Row4;
struct Row5;
struct Row6;
ˇ
struct Row99;
struct Row9;
struct Row10;"#},
vec![DiffHunkStatus::Modified, DiffHunkStatus::Modified],
indoc! {r#"struct Row;
struct Row1;
struct Row33;
ˇ
struct Row4;
struct Row5;
struct Row6;
ˇ
struct Row99;
struct Row9;
struct Row10;"#},
base_text,
&mut cx,
);
assert_hunk_revert(
indoc! {r#"struct Row;
struct Row1;
struct Row33;
«ˇ
struct Row4;
struct» Row5;
«struct Row6;
ˇ»
struct Row99;
struct Row9;
struct Row10;"#},
vec![DiffHunkStatus::Modified, DiffHunkStatus::Modified],
indoc! {r#"struct Row;
struct Row1;
struct Row33;
«ˇ
struct Row4;
struct» Row5;
«struct Row6;
ˇ»
struct Row99;
struct Row9;
struct Row10;"#},
base_text,
&mut cx,
);
assert_hunk_revert(
indoc! {r#"ˇstruct Row1.1;
struct Row1;
«ˇstr»uct Row22;
struct ˇRow44;
struct Row5;
struct «Rˇ»ow66;ˇ
«struˇ»ct Row88;
struct Row9;
struct Row1011;ˇ"#},
vec![
DiffHunkStatus::Modified,
DiffHunkStatus::Modified,
DiffHunkStatus::Modified,
DiffHunkStatus::Modified,
DiffHunkStatus::Modified,
DiffHunkStatus::Modified,
],
indoc! {r#"struct Row;
ˇstruct Row1;
struct Row2;
ˇ
struct Row4;
ˇstruct Row5;
struct Row6;
ˇ
struct Row8;
ˇstruct Row9;
struct Row10;ˇ"#},
base_text,
&mut cx,
);
}
#[gpui::test]
async fn test_deletion_reverts(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
let base_text = indoc! {r#"struct Row;
struct Row1;
struct Row2;
struct Row4;
struct Row5;
struct Row6;
struct Row8;
struct Row9;
struct Row10;"#};
// Deletion hunks trigger with carets on ajacent rows, so carets and selections have to stay farther to avoid the revert
assert_hunk_revert(
indoc! {r#"struct Row;
struct Row2;
ˇstruct Row4;
struct Row5;
struct Row6;
ˇ
struct Row8;
struct Row10;"#},
vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed],
indoc! {r#"struct Row;
struct Row2;
ˇstruct Row4;
struct Row5;
struct Row6;
ˇ
struct Row8;
struct Row10;"#},
base_text,
&mut cx,
);
assert_hunk_revert(
indoc! {r#"struct Row;
struct Row2;
«ˇstruct Row4;
struct» Row5;
«struct Row6;
ˇ»
struct Row8;
struct Row10;"#},
vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed],
indoc! {r#"struct Row;
struct Row2;
«ˇstruct Row4;
struct» Row5;
«struct Row6;
ˇ»
struct Row8;
struct Row10;"#},
base_text,
&mut cx,
);
// Deletion hunks are ephemeral, so it's impossible to place the caret into them — Zed triggers reverts for lines, adjacent to carets and selections.
assert_hunk_revert(
indoc! {r#"struct Row;
ˇstruct Row2;
struct Row4;
struct Row5;
struct Row6;
struct Row8;ˇ
struct Row10;"#},
vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed],
indoc! {r#"struct Row;
struct Row1;
ˇstruct Row2;
struct Row4;
struct Row5;
struct Row6;
struct Row8;ˇ
struct Row9;
struct Row10;"#},
base_text,
&mut cx,
);
assert_hunk_revert(
indoc! {r#"struct Row;
struct Row2«ˇ;
struct Row4;
struct» Row5;
«struct Row6;
struct Row8;ˇ»
struct Row10;"#},
vec![
DiffHunkStatus::Removed,
DiffHunkStatus::Removed,
DiffHunkStatus::Removed,
],
indoc! {r#"struct Row;
struct Row1;
struct Row2«ˇ;
struct Row4;
struct» Row5;
«struct Row6;
struct Row8;ˇ»
struct Row9;
struct Row10;"#},
base_text,
&mut cx,
);
}
#[gpui::test]
async fn test_multibuffer_reverts(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let cols = 4;
let rows = 10;
let sample_text_1 = sample_text(rows, cols, 'a');
assert_eq!(
sample_text_1,
"aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj"
);
let sample_text_2 = sample_text(rows, cols, 'l');
assert_eq!(
sample_text_2,
"llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu"
);
let sample_text_3 = sample_text(rows, cols, 'v');
assert_eq!(
sample_text_3,
"vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}"
);
fn diff_every_buffer_row(
buffer: &Model<Buffer>,
sample_text: String,
cols: usize,
cx: &mut gpui::TestAppContext,
) {
// revert first character in each row, creating one large diff hunk per buffer
let is_first_char = |offset: usize| offset % cols == 0;
buffer.update(cx, |buffer, cx| {
buffer.set_text(
sample_text
.chars()
.enumerate()
.map(|(offset, c)| if is_first_char(offset) { 'X' } else { c })
.collect::<String>(),
cx,
);
buffer.set_diff_base(Some(sample_text), cx);
});
cx.executor().run_until_parked();
}
let buffer_1 = cx.new_model(|cx| {
Buffer::new(
0,
BufferId::new(cx.entity_id().as_u64()).unwrap(),
sample_text_1.clone(),
)
});
diff_every_buffer_row(&buffer_1, sample_text_1.clone(), cols, cx);
let buffer_2 = cx.new_model(|cx| {
Buffer::new(
1,
BufferId::new(cx.entity_id().as_u64() + 1).unwrap(),
sample_text_2.clone(),
)
});
diff_every_buffer_row(&buffer_2, sample_text_2.clone(), cols, cx);
let buffer_3 = cx.new_model(|cx| {
Buffer::new(
2,
BufferId::new(cx.entity_id().as_u64() + 2).unwrap(),
sample_text_3.clone(),
)
});
diff_every_buffer_row(&buffer_3, sample_text_3.clone(), cols, cx);
let multibuffer = cx.new_model(|cx| {
let mut multibuffer = MultiBuffer::new(0, ReadWrite);
multibuffer.push_excerpts(
buffer_1.clone(),
[
ExcerptRange {
context: Point::new(0, 0)..Point::new(3, 0),
primary: None,
},
ExcerptRange {
context: Point::new(5, 0)..Point::new(7, 0),
primary: None,
},
ExcerptRange {
context: Point::new(9, 0)..Point::new(10, 4),
primary: None,
},
],
cx,
);
multibuffer.push_excerpts(
buffer_2.clone(),
[
ExcerptRange {
context: Point::new(0, 0)..Point::new(3, 0),
primary: None,
},
ExcerptRange {
context: Point::new(5, 0)..Point::new(7, 0),
primary: None,
},
ExcerptRange {
context: Point::new(9, 0)..Point::new(10, 4),
primary: None,
},
],
cx,
);
multibuffer.push_excerpts(
buffer_3.clone(),
[
ExcerptRange {
context: Point::new(0, 0)..Point::new(3, 0),
primary: None,
},
ExcerptRange {
context: Point::new(5, 0)..Point::new(7, 0),
primary: None,
},
ExcerptRange {
context: Point::new(9, 0)..Point::new(10, 4),
primary: None,
},
],
cx,
);
multibuffer
});
let (editor, cx) = cx.add_window_view(|cx| build_editor(multibuffer, cx));
editor.update(cx, |editor, cx| {
assert_eq!(editor.text(cx), "XaaaXbbbX\nccXc\ndXdd\n\nhXhh\nXiiiXjjjX\n\nXlllXmmmX\nnnXn\noXoo\n\nsXss\nXtttXuuuX\n\nXvvvXwwwX\nxxXx\nyXyy\n\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X\n");
editor.select_all(&SelectAll, cx);
editor.revert_selected_hunks(&RevertSelectedHunks, cx);
});
cx.executor().run_until_parked();
// When all ranges are selected, all buffer hunks are reverted.
editor.update(cx, |editor, cx| {
assert_eq!(editor.text(cx), "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nllll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu\n\n\nvvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}\n\n");
});
buffer_1.update(cx, |buffer, _| {
assert_eq!(buffer.text(), sample_text_1);
});
buffer_2.update(cx, |buffer, _| {
assert_eq!(buffer.text(), sample_text_2);
});
buffer_3.update(cx, |buffer, _| {
assert_eq!(buffer.text(), sample_text_3);
});
diff_every_buffer_row(&buffer_1, sample_text_1.clone(), cols, cx);
diff_every_buffer_row(&buffer_2, sample_text_2.clone(), cols, cx);
diff_every_buffer_row(&buffer_3, sample_text_3.clone(), cols, cx);
editor.update(cx, |editor, cx| {
editor.change_selections(None, cx, |s| {
s.select_ranges(Some(Point::new(0, 0)..Point::new(6, 0)));
});
editor.revert_selected_hunks(&RevertSelectedHunks, cx);
});
// Now, when all ranges selected belong to buffer_1, the revert should succeed,
// but not affect buffer_2 and its related excerpts.
editor.update(cx, |editor, cx| {
assert_eq!(
editor.text(cx),
"aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nXlllXmmmX\nnnXn\noXoo\nXpppXqqqX\nrrXr\nsXss\nXtttXuuuX\n\n\nXvvvXwwwX\nxxXx\nyXyy\nXzzzX{{{X\n||X|\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X\n\n"
);
});
buffer_1.update(cx, |buffer, _| {
assert_eq!(buffer.text(), sample_text_1);
});
buffer_2.update(cx, |buffer, _| {
assert_eq!(
buffer.text(),
"XlllXmmmX\nnnXn\noXoo\nXpppXqqqX\nrrXr\nsXss\nXtttXuuuX"
);
});
buffer_3.update(cx, |buffer, _| {
assert_eq!(
buffer.text(),
"XvvvXwwwX\nxxXx\nyXyy\nXzzzX{{{X\n||X|\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X"
);
});
}
fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
let point = DisplayPoint::new(row as u32, column as u32);
point..point
@@ -8810,3 +9486,45 @@ pub(crate) fn rust_lang() -> Arc<Language> {
Some(tree_sitter_rust::language()),
))
}
#[track_caller]
fn assert_hunk_revert(
not_reverted_text_with_selections: &str,
expected_not_reverted_hunk_statuses: Vec<DiffHunkStatus>,
expected_reverted_text_with_selections: &str,
base_text: &str,
cx: &mut EditorLspTestContext,
) {
cx.set_state(not_reverted_text_with_selections);
cx.update_editor(|editor, cx| {
editor
.buffer()
.read(cx)
.as_singleton()
.unwrap()
.update(cx, |buffer, cx| {
buffer.set_diff_base(Some(base_text.to_string()), cx);
});
});
cx.executor().run_until_parked();
let reverted_hunk_statuses = cx.update_editor(|editor, cx| {
let snapshot = editor
.buffer()
.read(cx)
.as_singleton()
.unwrap()
.read(cx)
.snapshot();
let reverted_hunk_statuses = snapshot
.git_diff_hunks_in_row_range(0..u32::MAX)
.map(|hunk| hunk.status())
.collect::<Vec<_>>();
editor.revert_selected_hunks(&RevertSelectedHunks, cx);
reverted_hunk_statuses
});
cx.executor().run_until_parked();
cx.assert_editor_state(expected_reverted_text_with_selections);
assert_eq!(reverted_hunk_statuses, expected_not_reverted_hunk_statuses);
}

File diff suppressed because it is too large Load Diff

View File

@@ -46,20 +46,20 @@ impl DisplayDiffHunk {
}
pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) -> DisplayDiffHunk {
let hunk_start_point = Point::new(hunk.buffer_range.start, 0);
let hunk_start_point_sub = Point::new(hunk.buffer_range.start.saturating_sub(1), 0);
let hunk_start_point = Point::new(hunk.associated_range.start, 0);
let hunk_start_point_sub = Point::new(hunk.associated_range.start.saturating_sub(1), 0);
let hunk_end_point_sub = Point::new(
hunk.buffer_range
hunk.associated_range
.end
.saturating_sub(1)
.max(hunk.buffer_range.start),
.max(hunk.associated_range.start),
0,
);
let is_removal = hunk.status() == DiffHunkStatus::Removed;
let folds_start = Point::new(hunk.buffer_range.start.saturating_sub(2), 0);
let folds_end = Point::new(hunk.buffer_range.end + 2, 0);
let folds_start = Point::new(hunk.associated_range.start.saturating_sub(2), 0);
let folds_end = Point::new(hunk.associated_range.end + 2, 0);
let folds_range = folds_start..folds_end;
let containing_fold = snapshot.folds_in_range(folds_range).find(|fold| {
@@ -79,7 +79,7 @@ pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) ->
} else {
let start = hunk_start_point.to_display_point(snapshot).row();
let hunk_end_row = hunk.buffer_range.end.max(hunk.buffer_range.start);
let hunk_end_row = hunk.associated_range.end.max(hunk.associated_range.start);
let hunk_end_point = Point::new(hunk_end_row, 0);
let end = hunk_end_point.to_display_point(snapshot).row();
@@ -264,7 +264,7 @@ mod tests {
assert_eq!(
snapshot
.git_diff_hunks_in_range(0..12)
.map(|hunk| (hunk.status(), hunk.buffer_range))
.map(|hunk| (hunk.status(), hunk.associated_range))
.collect::<Vec<_>>(),
&expected,
);
@@ -272,7 +272,7 @@ mod tests {
assert_eq!(
snapshot
.git_diff_hunks_in_range_rev(0..12)
.map(|hunk| (hunk.status(), hunk.buffer_range))
.map(|hunk| (hunk.status(), hunk.associated_range))
.collect::<Vec<_>>(),
expected
.iter()

View File

@@ -1,7 +1,7 @@
use crate::{
element::PointForPosition,
hover_popover::{self, InlayHover},
Anchor, Editor, EditorSnapshot, GoToDefinition, GoToTypeDefinition, InlayId, SelectPhase,
Anchor, Editor, EditorSnapshot, GoToDefinition, GoToTypeDefinition, InlayId, PointForPosition,
SelectPhase,
};
use gpui::{px, AsyncWindowContext, Model, Modifiers, Task, ViewContext};
use language::{Bias, ToOffset};

View File

@@ -114,12 +114,7 @@ pub fn hover_at_inlay(editor: &mut Editor, inlay_hover: InlayHover, cx: &mut Vie
};
this.update(&mut cx, |this, cx| {
// Highlight the selected symbol using a background highlight
this.highlight_inlay_background::<HoverState>(
vec![inlay_hover.range],
|theme| theme.element_hover, // todo("use a proper background here")
cx,
);
// TODO: no background highlights happen for inlays currently
this.hover_state.info_popover = Some(hover_popover);
cx.notify();
})?;
@@ -504,9 +499,10 @@ impl InfoPopover {
.overflow_y_scroll()
.max_w(max_size.width)
.max_h(max_size.height)
// Prevent a mouse move on the popover from being propagated to the editor,
// Prevent a mouse down/move on the popover from being propagated to the editor,
// because that would dismiss the popover.
.on_mouse_move(|_, cx| cx.stop_propagation())
.on_mouse_down(MouseButton::Left, |_, cx| cx.stop_propagation())
.child(crate::render_parsed_markdown(
"content",
&self.parsed_content,
@@ -568,6 +564,7 @@ impl DiagnosticPopover {
div()
.id("diagnostic")
.block()
.elevation_2(cx)
.overflow_y_scroll()
.px_2()
@@ -607,11 +604,10 @@ mod tests {
use super::*;
use crate::{
editor_tests::init_test,
element::PointForPosition,
hover_links::update_inlay_link_and_hover_points,
inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels},
test::editor_lsp_test_context::EditorLspTestContext,
InlayId,
InlayId, PointForPosition,
};
use collections::BTreeSet;
use gpui::{FontWeight, HighlightStyle, UnderlineStyle};

View File

@@ -1255,7 +1255,7 @@ fn apply_hint_update(
editor.inlay_hint_cache.version += 1;
}
if displayed_inlays_changed {
editor.splice_inlay_hints(to_remove, to_insert, cx)
editor.splice_inlays(to_remove, to_insert, cx)
}
}

View File

@@ -7,9 +7,9 @@ use anyhow::{anyhow, Context as _, Result};
use collections::HashSet;
use futures::future::try_join_all;
use gpui::{
div, point, AnyElement, AppContext, AsyncWindowContext, Context, Entity, EntityId,
EventEmitter, IntoElement, Model, ParentElement, Pixels, Render, SharedString, Styled,
Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext,
point, AnyElement, AppContext, AsyncWindowContext, Context, Entity, EntityId, EventEmitter,
IntoElement, Model, ParentElement, Pixels, SharedString, Styled, Task, View, ViewContext,
VisualContext, WeakView, WindowContext,
};
use language::{
proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, CharKind, OffsetRangeExt,
@@ -21,7 +21,6 @@ use rpc::proto::{self, update_view, PeerId};
use settings::Settings;
use workspace::item::ItemSettings;
use std::fmt::Write;
use std::{
borrow::Cow,
cmp::{self, Ordering},
@@ -33,11 +32,8 @@ use std::{
use text::{BufferId, Selection};
use theme::Theme;
use ui::{h_flex, prelude::*, Label};
use util::{paths::PathExt, paths::FILE_ROW_COLUMN_DELIMITER, ResultExt, TryFutureExt};
use workspace::{
item::{BreadcrumbText, FollowEvent, FollowableItemHandle},
StatusItemView,
};
use util::{paths::PathExt, ResultExt, TryFutureExt};
use workspace::item::{BreadcrumbText, FollowEvent, FollowableItemHandle};
use workspace::{
item::{FollowableItem, Item, ItemEvent, ItemHandle, ProjectItem},
searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
@@ -1199,83 +1195,6 @@ pub fn active_match_index(
}
}
pub struct CursorPosition {
position: Option<Point>,
selected_count: usize,
_observe_active_editor: Option<Subscription>,
}
impl Default for CursorPosition {
fn default() -> Self {
Self::new()
}
}
impl CursorPosition {
pub fn new() -> Self {
Self {
position: None,
selected_count: 0,
_observe_active_editor: None,
}
}
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);
self.selected_count = 0;
let mut last_selection: Option<Selection<usize>> = None;
for selection in editor.selections.all::<usize>(cx) {
self.selected_count += selection.end - selection.start;
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));
cx.notify();
}
}
impl Render for CursorPosition {
fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
div().when_some(self.position, |el, position| {
let mut text = format!(
"{}{FILE_ROW_COLUMN_DELIMITER}{}",
position.row + 1,
position.column + 1
);
if self.selected_count > 0 {
write!(text, " ({} selected)", self.selected_count).unwrap();
}
el.child(Label::new(text).size(LabelSize::Small))
})
}
}
impl StatusItemView for CursorPosition {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
cx: &mut ViewContext<Self>,
) {
if let Some(editor) = active_pane_item.and_then(|item| item.act_as::<Editor>(cx)) {
self._observe_active_editor = Some(cx.observe(&editor, Self::update_position));
self.update_position(editor, cx);
} else {
self.position = None;
self._observe_active_editor = None;
}
cx.notify();
}
}
fn path_for_buffer<'a>(
buffer: &Model<MultiBuffer>,
height: usize,

View File

@@ -81,8 +81,8 @@ impl Editor {
let mut target_top;
let mut target_bottom;
if let Some(highlighted_rows) = &self.highlighted_rows {
target_top = highlighted_rows.start as f32;
if let Some(first_highlighted_row) = &self.highlighted_display_rows(cx).first_entry() {
target_top = *first_highlighted_row.key() as f32;
target_bottom = target_top + 1.;
} else {
let selections = self.selections.all::<Point>(cx);
@@ -205,10 +205,7 @@ impl Editor {
let mut target_left;
let mut target_right;
if self.highlighted_rows.is_some() {
target_left = px(0.);
target_right = px(0.);
} else {
if self.highlighted_rows.is_empty() {
target_left = px(f32::INFINITY);
target_right = px(0.);
for selection in selections {
@@ -229,6 +226,9 @@ impl Editor {
);
}
}
} else {
target_left = px(0.);
target_right = px(0.);
}
target_right = target_right.min(scroll_width);

View File

@@ -6,7 +6,7 @@ use crate::{
DisplayPoint, Editor, EditorMode, MultiBuffer,
};
use gpui::{Context, Model, Pixels, ViewContext};
use gpui::{Context, Font, FontFeatures, FontStyle, FontWeight, Model, Pixels, ViewContext};
use project::Project;
use util::test::{marked_text_offsets, marked_text_ranges};
@@ -26,7 +26,12 @@ pub fn marked_display_snapshot(
) -> (DisplaySnapshot, Vec<DisplayPoint>) {
let (unmarked_text, markers) = marked_text_offsets(text);
let font = cx.text_style().font();
let font = Font {
family: "Courier".into(),
features: FontFeatures::default(),
weight: FontWeight::default(),
style: FontStyle::default(),
};
let font_size: Pixels = 14usize.into();
let buffer = MultiBuffer::build_simple(&unmarked_text, cx);

View File

@@ -274,7 +274,7 @@ impl EditorTestContext {
let buffer_text = self.buffer_text();
if buffer_text != unmarked_text {
panic!("Unmarked text doesn't match buffer text\nBuffer text: {buffer_text:?}\nUnmarked text: {unmarked_text:?}\nRaw buffer text\n{buffer_text}Raw unmarked text\n{unmarked_text}");
panic!("Unmarked text doesn't match buffer text\nBuffer text: {buffer_text:?}\nUnmarked text: {unmarked_text:?}\nRaw buffer text\n{buffer_text}\nRaw unmarked text\n{unmarked_text}");
}
self.assert_selections(expected_selections, marked_text.to_string())

View File

@@ -20,6 +20,7 @@ anyhow.workspace = true
async-compression.workspace = true
async-tar.workspace = true
async-trait.workspace = true
cap-std.workspace = true
collections.workspace = true
fs.workspace = true
futures.workspace = true
@@ -36,11 +37,17 @@ settings.workspace = true
theme.workspace = true
toml.workspace = true
util.workspace = true
wasmtime = { workspace = true, features = ["async"] }
wasm-encoder.workspace = true
wasmtime.workspace = true
wasmtime-wasi.workspace = true
wasmparser.workspace = true
wit-component.workspace = true
[dev-dependencies]
ctor.workspace = true
env_logger.workspace = true
parking_lot.workspace = true
fs = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] }

View File

@@ -0,0 +1,456 @@
use crate::ExtensionManifest;
use crate::{extension_manifest::ExtensionLibraryKind, GrammarManifestEntry};
use anyhow::{anyhow, bail, Context as _, Result};
use async_compression::futures::bufread::GzipDecoder;
use async_tar::Archive;
use futures::io::BufReader;
use futures::AsyncReadExt;
use serde::Deserialize;
use std::mem;
use std::{
env, fs,
path::{Path, PathBuf},
process::{Command, Stdio},
sync::Arc,
};
use util::http::{self, AsyncBody, HttpClient};
use wasm_encoder::{ComponentSectionId, Encode as _, RawSection, Section as _};
use wasmparser::Parser;
use wit_component::ComponentEncoder;
/// Currently, we compile with Rust's `wasm32-wasi` target, which works with WASI `preview1`.
/// But the WASM component model is based on WASI `preview2`. So we need an 'adapter' WASM
/// module, which implements the `preview1` interface in terms of `preview2`.
///
/// Once Rust 1.78 is released, there will be a `wasm32-wasip2` target available, so we will
/// not need the adapter anymore.
const RUST_TARGET: &str = "wasm32-wasi";
const WASI_ADAPTER_URL: &str =
"https://github.com/bytecodealliance/wasmtime/releases/download/v18.0.2/wasi_snapshot_preview1.reactor.wasm";
/// Compiling Tree-sitter parsers from C to WASM requires Clang 17, and a WASM build of libc
/// and clang's runtime library. The `wasi-sdk` provides these binaries.
///
/// Once Clang 17 and its wasm target are available via system package managers, we won't need
/// to download this.
const WASI_SDK_URL: &str = "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-21/";
const WASI_SDK_ASSET_NAME: Option<&str> = if cfg!(target_os = "macos") {
Some("wasi-sdk-21.0-macos.tar.gz")
} else if cfg!(target_os = "linux") {
Some("wasi-sdk-21.0-linux.tar.gz")
} else {
None
};
pub struct ExtensionBuilder {
cache_dir: PathBuf,
pub http: Arc<dyn HttpClient>,
}
pub struct CompileExtensionOptions {
pub release: bool,
}
#[derive(Deserialize)]
struct CargoToml {
package: CargoTomlPackage,
}
#[derive(Deserialize)]
struct CargoTomlPackage {
name: String,
}
impl ExtensionBuilder {
pub fn new(cache_dir: PathBuf) -> Self {
Self {
cache_dir,
http: http::client(),
}
}
pub async fn compile_extension(
&self,
extension_dir: &Path,
options: CompileExtensionOptions,
) -> Result<()> {
fs::create_dir_all(&self.cache_dir)?;
let extension_toml_path = extension_dir.join("extension.toml");
let extension_toml_content = fs::read_to_string(&extension_toml_path)?;
let extension_toml: ExtensionManifest = toml::from_str(&extension_toml_content)?;
let cargo_toml_path = extension_dir.join("Cargo.toml");
if extension_toml.lib.kind == Some(ExtensionLibraryKind::Rust)
|| fs::metadata(&cargo_toml_path)?.is_file()
{
self.compile_rust_extension(extension_dir, options).await?;
}
for (grammar_name, grammar_metadata) in extension_toml.grammars {
self.compile_grammar(extension_dir, grammar_name, grammar_metadata)
.await?;
}
log::info!("finished compiling extension {}", extension_dir.display());
Ok(())
}
async fn compile_rust_extension(
&self,
extension_dir: &Path,
options: CompileExtensionOptions,
) -> Result<(), anyhow::Error> {
self.install_rust_wasm_target_if_needed()?;
let adapter_bytes = self.install_wasi_preview1_adapter_if_needed().await?;
let cargo_toml_content = fs::read_to_string(&extension_dir.join("Cargo.toml"))?;
let cargo_toml: CargoToml = toml::from_str(&cargo_toml_content)?;
log::info!("compiling rust extension {}", extension_dir.display());
let output = Command::new("cargo")
.args(["build", "--target", RUST_TARGET])
.args(options.release.then_some("--release"))
.arg("--target-dir")
.arg(extension_dir.join("target"))
.current_dir(&extension_dir)
.output()
.context("failed to run `cargo`")?;
if !output.status.success() {
bail!(
"failed to build extension {}",
String::from_utf8_lossy(&output.stderr)
);
}
let mut wasm_path = PathBuf::from(extension_dir);
wasm_path.extend([
"target",
RUST_TARGET,
if options.release { "release" } else { "debug" },
cargo_toml.package.name.as_str(),
]);
wasm_path.set_extension("wasm");
let wasm_bytes = fs::read(&wasm_path)
.with_context(|| format!("failed to read output module `{}`", wasm_path.display()))?;
let encoder = ComponentEncoder::default()
.module(&wasm_bytes)?
.adapter("wasi_snapshot_preview1", &adapter_bytes)
.context("failed to load adapter module")?
.validate(true);
let component_bytes = encoder
.encode()
.context("failed to encode wasm component")?;
let component_bytes = self
.strip_custom_sections(&component_bytes)
.context("failed to strip debug sections from wasm component")?;
fs::write(extension_dir.join("extension.wasm"), &component_bytes)
.context("failed to write extension.wasm")?;
Ok(())
}
async fn compile_grammar(
&self,
extension_dir: &Path,
grammar_name: Arc<str>,
grammar_metadata: GrammarManifestEntry,
) -> Result<()> {
let clang_path = self.install_wasi_sdk_if_needed().await?;
let mut grammar_repo_dir = extension_dir.to_path_buf();
grammar_repo_dir.extend(["grammars", grammar_name.as_ref()]);
let mut grammar_wasm_path = grammar_repo_dir.clone();
grammar_wasm_path.set_extension("wasm");
log::info!("checking out {grammar_name} parser");
self.checkout_repo(
&grammar_repo_dir,
&grammar_metadata.repository,
&grammar_metadata.rev,
)?;
let src_path = grammar_repo_dir.join("src");
let parser_path = src_path.join("parser.c");
let scanner_path = src_path.join("scanner.c");
log::info!("compiling {grammar_name} parser");
let clang_output = Command::new(&clang_path)
.args(["-fPIC", "-shared", "-Os"])
.arg(format!("-Wl,--export=tree_sitter_{grammar_name}"))
.arg("-o")
.arg(&grammar_wasm_path)
.arg("-I")
.arg(&src_path)
.arg(&parser_path)
.args(scanner_path.exists().then_some(scanner_path))
.output()
.context("failed to run clang")?;
if !clang_output.status.success() {
bail!(
"failed to compile {} parser with clang: {}",
grammar_name,
String::from_utf8_lossy(&clang_output.stderr),
);
}
Ok(())
}
fn checkout_repo(&self, directory: &Path, url: &str, rev: &str) -> Result<()> {
let git_dir = directory.join(".git");
if directory.exists() {
let remotes_output = Command::new("git")
.arg("--git-dir")
.arg(&git_dir)
.args(["remote", "-v"])
.output()?;
let has_remote = remotes_output.status.success()
&& String::from_utf8_lossy(&remotes_output.stdout)
.lines()
.any(|line| {
let mut parts = line.split(|c: char| c.is_whitespace());
parts.next() == Some("origin") && parts.any(|part| part == url)
});
if !has_remote {
bail!(
"grammar directory '{}' already exists, but is not a git clone of '{}'",
directory.display(),
url
);
}
} else {
fs::create_dir_all(&directory).with_context(|| {
format!("failed to create grammar directory {}", directory.display(),)
})?;
let init_output = Command::new("git")
.arg("init")
.current_dir(&directory)
.output()?;
if !init_output.status.success() {
bail!(
"failed to run `git init` in directory '{}'",
directory.display()
);
}
let remote_add_output = Command::new("git")
.arg("--git-dir")
.arg(&git_dir)
.args(["remote", "add", "origin", url])
.output()
.context("failed to execute `git remote add`")?;
if !remote_add_output.status.success() {
bail!(
"failed to add remote {url} for git repository {}",
git_dir.display()
);
}
}
let fetch_output = Command::new("git")
.arg("--git-dir")
.arg(&git_dir)
.args(["fetch", "--depth", "1", "origin", &rev])
.output()
.context("failed to execute `git fetch`")?;
if !fetch_output.status.success() {
bail!(
"failed to fetch revision {} in directory '{}'",
rev,
directory.display()
);
}
let checkout_output = Command::new("git")
.arg("--git-dir")
.arg(&git_dir)
.args(["checkout", &rev])
.current_dir(&directory)
.output()
.context("failed to execute `git checkout`")?;
if !checkout_output.status.success() {
bail!(
"failed to checkout revision {} in directory '{}'",
rev,
directory.display()
);
}
Ok(())
}
fn install_rust_wasm_target_if_needed(&self) -> Result<()> {
let rustc_output = Command::new("rustc")
.arg("--print")
.arg("sysroot")
.output()
.context("failed to run rustc")?;
if !rustc_output.status.success() {
bail!(
"failed to retrieve rust sysroot: {}",
String::from_utf8_lossy(&rustc_output.stderr)
);
}
let sysroot = PathBuf::from(String::from_utf8(rustc_output.stdout)?.trim());
if sysroot.join("lib/rustlib").join(RUST_TARGET).exists() {
return Ok(());
}
let output = Command::new("rustup")
.args(["target", "add", RUST_TARGET])
.stderr(Stdio::inherit())
.stdout(Stdio::inherit())
.output()
.context("failed to run `rustup target add`")?;
if !output.status.success() {
bail!("failed to install the `{RUST_TARGET}` target");
}
Ok(())
}
async fn install_wasi_preview1_adapter_if_needed(&self) -> Result<Vec<u8>> {
let cache_path = self.cache_dir.join("wasi_snapshot_preview1.reactor.wasm");
if let Ok(content) = fs::read(&cache_path) {
if Parser::is_core_wasm(&content) {
return Ok(content);
}
}
fs::remove_file(&cache_path).ok();
log::info!(
"downloading wasi adapter module to {}",
cache_path.display()
);
let mut response = self
.http
.get(WASI_ADAPTER_URL, AsyncBody::default(), true)
.await?;
let mut content = Vec::new();
let mut body = BufReader::new(response.body_mut());
body.read_to_end(&mut content).await?;
fs::write(&cache_path, &content)
.with_context(|| format!("failed to save file {}", cache_path.display()))?;
if !Parser::is_core_wasm(&content) {
bail!("downloaded wasi adapter is invalid");
}
Ok(content)
}
async fn install_wasi_sdk_if_needed(&self) -> Result<PathBuf> {
let url = if let Some(asset_name) = WASI_SDK_ASSET_NAME {
format!("{WASI_SDK_URL}/{asset_name}")
} else {
bail!("wasi-sdk is not available for platform {}", env::consts::OS);
};
let wasi_sdk_dir = self.cache_dir.join("wasi-sdk");
let mut clang_path = wasi_sdk_dir.clone();
clang_path.extend(["bin", "clang-17"]);
if fs::metadata(&clang_path).map_or(false, |metadata| metadata.is_file()) {
return Ok(clang_path);
}
let mut tar_out_dir = wasi_sdk_dir.clone();
tar_out_dir.set_extension("archive");
fs::remove_dir_all(&wasi_sdk_dir).ok();
fs::remove_dir_all(&tar_out_dir).ok();
log::info!("downloading wasi-sdk to {}", wasi_sdk_dir.display());
let mut response = self.http.get(&url, AsyncBody::default(), true).await?;
let body = BufReader::new(response.body_mut());
let body = GzipDecoder::new(body);
let tar = Archive::new(body);
tar.unpack(&tar_out_dir)
.await
.context("failed to unpack wasi-sdk archive")?;
let inner_dir = fs::read_dir(&tar_out_dir)?
.next()
.ok_or_else(|| anyhow!("no content"))?
.context("failed to read contents of extracted wasi archive directory")?
.path();
fs::rename(&inner_dir, &wasi_sdk_dir).context("failed to move extracted wasi dir")?;
fs::remove_dir_all(&tar_out_dir).ok();
Ok(clang_path)
}
// This was adapted from:
// https://github.com/bytecodealliance/wasm-tools/1791a8f139722e9f8679a2bd3d8e423e55132b22/src/bin/wasm-tools/strip.rs
fn strip_custom_sections(&self, input: &Vec<u8>) -> Result<Vec<u8>> {
use wasmparser::Payload::*;
let strip_custom_section = |name: &str| name.starts_with(".debug");
let mut output = Vec::new();
let mut stack = Vec::new();
for payload in Parser::new(0).parse_all(input) {
let payload = payload?;
// Track nesting depth, so that we don't mess with inner producer sections:
match payload {
Version { encoding, .. } => {
output.extend_from_slice(match encoding {
wasmparser::Encoding::Component => &wasm_encoder::Component::HEADER,
wasmparser::Encoding::Module => &wasm_encoder::Module::HEADER,
});
}
ModuleSection { .. } | ComponentSection { .. } => {
stack.push(mem::take(&mut output));
continue;
}
End { .. } => {
let mut parent = match stack.pop() {
Some(c) => c,
None => break,
};
if output.starts_with(&wasm_encoder::Component::HEADER) {
parent.push(ComponentSectionId::Component as u8);
output.encode(&mut parent);
} else {
parent.push(ComponentSectionId::CoreModule as u8);
output.encode(&mut parent);
}
output = parent;
}
_ => {}
}
match &payload {
CustomSection(c) => {
if strip_custom_section(c.name()) {
continue;
}
}
_ => {}
}
if let Some((id, range)) = payload.as_section() {
RawSection {
id,
data: &input[range],
}
.append_to(&mut output);
}
}
Ok(output)
}
}

View File

@@ -1,4 +1,4 @@
use crate::wasm_host::{wit::LanguageServerConfig, WasmExtension};
use crate::wasm_host::{wit::LanguageServerConfig, WasmExtension, WasmHost};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use futures::{Future, FutureExt};
@@ -16,7 +16,7 @@ use wasmtime_wasi::preview2::WasiView as _;
pub struct ExtensionLspAdapter {
pub(crate) extension: WasmExtension,
pub(crate) config: LanguageServerConfig,
pub(crate) work_dir: PathBuf,
pub(crate) host: Arc<WasmHost>,
}
#[async_trait]
@@ -41,18 +41,23 @@ impl LspAdapter for ExtensionLspAdapter {
|extension, store| {
async move {
let resource = store.data_mut().table().push(delegate)?;
extension
let command = extension
.call_language_server_command(store, &this.config, resource)
.await
.await?
.map_err(|e| anyhow!("{}", e))?;
anyhow::Ok(command)
}
.boxed()
}
})
.await?
.map_err(|e| anyhow!("{}", e))?;
.await?;
let path = self
.host
.path_from_extension(&self.extension.manifest.id, command.command.as_ref());
Ok(LanguageServerBinary {
path: self.work_dir.join(&command.command),
path,
arguments: command.args.into_iter().map(|arg| arg.into()).collect(),
env: Some(command.env.into_iter().collect()),
})

View File

@@ -0,0 +1,72 @@
use collections::BTreeMap;
use language::LanguageServerName;
use serde::{Deserialize, Serialize};
use std::{path::PathBuf, sync::Arc};
/// This is the old version of the extension manifest, from when it was `extension.json`.
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct OldExtensionManifest {
pub name: String,
pub version: Arc<str>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub authors: Vec<String>,
#[serde(default)]
pub themes: BTreeMap<Arc<str>, PathBuf>,
#[serde(default)]
pub languages: BTreeMap<Arc<str>, PathBuf>,
#[serde(default)]
pub grammars: BTreeMap<Arc<str>, PathBuf>,
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct ExtensionManifest {
pub id: Arc<str>,
pub name: String,
pub version: Arc<str>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub authors: Vec<String>,
#[serde(default)]
pub lib: LibManifestEntry,
#[serde(default)]
pub themes: Vec<PathBuf>,
#[serde(default)]
pub languages: Vec<PathBuf>,
#[serde(default)]
pub grammars: BTreeMap<Arc<str>, GrammarManifestEntry>,
#[serde(default)]
pub language_servers: BTreeMap<LanguageServerName, LanguageServerManifestEntry>,
}
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct LibManifestEntry {
pub kind: Option<ExtensionLibraryKind>,
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub enum ExtensionLibraryKind {
Rust,
}
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct GrammarManifestEntry {
pub repository: String,
#[serde(alias = "commit")]
pub rev: String,
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct LanguageServerManifestEntry {
pub language: Arc<str>,
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,15 @@
use crate::{
ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry, ExtensionManifest,
ExtensionStore, GrammarManifestEntry,
ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry, ExtensionIndexThemeEntry,
ExtensionManifest, ExtensionStore, GrammarManifestEntry, RELOAD_DEBOUNCE_DURATION,
};
use async_compression::futures::bufread::GzipEncoder;
use collections::BTreeMap;
use fs::{FakeFs, Fs};
use fs::{FakeFs, Fs, RealFs};
use futures::{io::BufReader, AsyncReadExt, StreamExt};
use gpui::{Context, TestAppContext};
use language::{
Language, LanguageConfig, LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus,
LanguageServerName,
};
use language::{LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus, LanguageServerName};
use node_runtime::FakeNodeRuntime;
use parking_lot::Mutex;
use project::Project;
use serde_json::json;
use settings::SettingsStore;
@@ -21,7 +19,18 @@ use std::{
sync::Arc,
};
use theme::ThemeRegistry;
use util::http::{FakeHttpClient, Response};
use util::{
http::{FakeHttpClient, Response},
test::temp_tree,
};
#[cfg(test)]
#[ctor::ctor]
fn init_logger() {
if std::env::var("RUST_LOG").is_ok() {
env_logger::init();
}
}
#[gpui::test]
async fn test_extension_store(cx: &mut TestAppContext) {
@@ -131,45 +140,49 @@ async fn test_extension_store(cx: &mut TestAppContext) {
extensions: [
(
"zed-ruby".into(),
ExtensionManifest {
id: "zed-ruby".into(),
name: "Zed Ruby".into(),
version: "1.0.0".into(),
description: None,
authors: Vec::new(),
repository: None,
themes: Default::default(),
lib: Default::default(),
languages: vec!["languages/erb".into(), "languages/ruby".into()],
grammars: [
("embedded_template".into(), GrammarManifestEntry::default()),
("ruby".into(), GrammarManifestEntry::default()),
]
.into_iter()
.collect(),
language_servers: BTreeMap::default(),
}
.into(),
ExtensionIndexEntry {
manifest: Arc::new(ExtensionManifest {
id: "zed-ruby".into(),
name: "Zed Ruby".into(),
version: "1.0.0".into(),
description: None,
authors: Vec::new(),
repository: None,
themes: Default::default(),
lib: Default::default(),
languages: vec!["languages/erb".into(), "languages/ruby".into()],
grammars: [
("embedded_template".into(), GrammarManifestEntry::default()),
("ruby".into(), GrammarManifestEntry::default()),
]
.into_iter()
.collect(),
language_servers: BTreeMap::default(),
}),
dev: false,
},
),
(
"zed-monokai".into(),
ExtensionManifest {
id: "zed-monokai".into(),
name: "Zed Monokai".into(),
version: "2.0.0".into(),
description: None,
authors: vec![],
repository: None,
themes: vec![
"themes/monokai-pro.json".into(),
"themes/monokai.json".into(),
],
lib: Default::default(),
languages: Default::default(),
grammars: BTreeMap::default(),
language_servers: BTreeMap::default(),
}
.into(),
ExtensionIndexEntry {
manifest: Arc::new(ExtensionManifest {
id: "zed-monokai".into(),
name: "Zed Monokai".into(),
version: "2.0.0".into(),
description: None,
authors: vec![],
repository: None,
themes: vec![
"themes/monokai-pro.json".into(),
"themes/monokai.json".into(),
],
lib: Default::default(),
languages: Default::default(),
grammars: BTreeMap::default(),
language_servers: BTreeMap::default(),
}),
dev: false,
},
),
]
.into_iter()
@@ -205,28 +218,28 @@ async fn test_extension_store(cx: &mut TestAppContext) {
themes: [
(
"Monokai Dark".into(),
ExtensionIndexEntry {
ExtensionIndexThemeEntry {
extension: "zed-monokai".into(),
path: "themes/monokai.json".into(),
},
),
(
"Monokai Light".into(),
ExtensionIndexEntry {
ExtensionIndexThemeEntry {
extension: "zed-monokai".into(),
path: "themes/monokai.json".into(),
},
),
(
"Monokai Pro Dark".into(),
ExtensionIndexEntry {
ExtensionIndexThemeEntry {
extension: "zed-monokai".into(),
path: "themes/monokai-pro.json".into(),
},
),
(
"Monokai Pro Light".into(),
ExtensionIndexEntry {
ExtensionIndexThemeEntry {
extension: "zed-monokai".into(),
path: "themes/monokai-pro.json".into(),
},
@@ -243,6 +256,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
let store = cx.new_model(|cx| {
ExtensionStore::new(
PathBuf::from("/the-extension-dir"),
None,
fs.clone(),
http_client.clone(),
node_runtime.clone(),
@@ -252,7 +266,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
)
});
cx.executor().run_until_parked();
cx.executor().advance_clock(super::RELOAD_DEBOUNCE_DURATION);
store.read_with(cx, |store, _| {
let index = &store.extension_index;
assert_eq!(index.extensions, expected_index.extensions);
@@ -305,32 +319,34 @@ async fn test_extension_store(cx: &mut TestAppContext) {
expected_index.extensions.insert(
"zed-gruvbox".into(),
ExtensionManifest {
id: "zed-gruvbox".into(),
name: "Zed Gruvbox".into(),
version: "1.0.0".into(),
description: None,
authors: vec![],
repository: None,
themes: vec!["themes/gruvbox.json".into()],
lib: Default::default(),
languages: Default::default(),
grammars: BTreeMap::default(),
language_servers: BTreeMap::default(),
}
.into(),
ExtensionIndexEntry {
manifest: Arc::new(ExtensionManifest {
id: "zed-gruvbox".into(),
name: "Zed Gruvbox".into(),
version: "1.0.0".into(),
description: None,
authors: vec![],
repository: None,
themes: vec!["themes/gruvbox.json".into()],
lib: Default::default(),
languages: Default::default(),
grammars: BTreeMap::default(),
language_servers: BTreeMap::default(),
}),
dev: false,
},
);
expected_index.themes.insert(
"Gruvbox".into(),
ExtensionIndexEntry {
ExtensionIndexThemeEntry {
extension: "zed-gruvbox".into(),
path: "themes/gruvbox.json".into(),
},
);
store.update(cx, |store, cx| store.reload(cx));
let _ = store.update(cx, |store, cx| store.reload(None, cx));
cx.executor().run_until_parked();
cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
store.read_with(cx, |store, _| {
let index = &store.extension_index;
assert_eq!(index.extensions, expected_index.extensions);
@@ -358,6 +374,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
let store = cx.new_model(|cx| {
ExtensionStore::new(
PathBuf::from("/the-extension-dir"),
None,
fs.clone(),
http_client.clone(),
node_runtime.clone(),
@@ -400,7 +417,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
store.uninstall_extension("zed-ruby".into(), cx)
});
cx.executor().run_until_parked();
cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
expected_index.extensions.remove("zed-ruby");
expected_index.languages.remove("Ruby");
expected_index.languages.remove("ERB");
@@ -415,35 +432,29 @@ async fn test_extension_store(cx: &mut TestAppContext) {
#[gpui::test]
async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
let gleam_extension_dir = PathBuf::from_iter([
env!("CARGO_MANIFEST_DIR"),
"..",
"..",
"extensions",
"gleam",
])
.canonicalize()
.unwrap();
let root_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap();
let cache_dir = root_dir.join("target");
let gleam_extension_dir = root_dir.join("extensions").join("gleam");
compile_extension("zed_gleam", &gleam_extension_dir);
let fs = Arc::new(RealFs);
let extensions_dir = temp_tree(json!({
"installed": {},
"work": {}
}));
let project_dir = temp_tree(json!({
"test.gleam": ""
}));
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/the-extension-dir", json!({ "installed": {} }))
.await;
fs.insert_tree_from_real_fs("/the-extension-dir/installed/gleam", gleam_extension_dir)
.await;
let extensions_dir = extensions_dir.path().canonicalize().unwrap();
let project_dir = project_dir.path().canonicalize().unwrap();
fs.insert_tree(
"/the-project-dir",
json!({
".tool-versions": "rust 1.73.0",
"test.gleam": ""
}),
)
.await;
let project = Project::test(fs.clone(), ["/the-project-dir".as_ref()], cx).await;
let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await;
let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
@@ -451,55 +462,76 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
let mut status_updates = language_registry.language_server_binary_statuses();
let http_client = FakeHttpClient::create({
move |request| async move {
match request.uri().to_string().as_str() {
"https://api.github.com/repos/gleam-lang/gleam/releases" => Ok(Response::new(
json!([
{
"tag_name": "v1.2.3",
"prerelease": false,
"tarball_url": "",
"zipball_url": "",
"assets": [
{
"name": "gleam-v1.2.3-aarch64-apple-darwin.tar.gz",
"browser_download_url": "http://example.com/the-download"
}
]
}
])
.to_string()
.into(),
)),
struct FakeLanguageServerVersion {
version: String,
binary_contents: String,
http_request_count: usize,
}
"http://example.com/the-download" => {
let language_server_version = Arc::new(Mutex::new(FakeLanguageServerVersion {
version: "v1.2.3".into(),
binary_contents: "the-binary-contents".into(),
http_request_count: 0,
}));
let http_client = FakeHttpClient::create({
let language_server_version = language_server_version.clone();
move |request| {
let language_server_version = language_server_version.clone();
async move {
language_server_version.lock().http_request_count += 1;
let version = language_server_version.lock().version.clone();
let binary_contents = language_server_version.lock().binary_contents.clone();
let github_releases_uri = "https://api.github.com/repos/gleam-lang/gleam/releases";
let asset_download_uri =
format!("https://fake-download.example.com/gleam-{version}");
let uri = request.uri().to_string();
if uri == github_releases_uri {
Ok(Response::new(
json!([
{
"tag_name": version,
"prerelease": false,
"tarball_url": "",
"zipball_url": "",
"assets": [
{
"name": format!("gleam-{version}-aarch64-apple-darwin.tar.gz"),
"browser_download_url": asset_download_uri
}
]
}
])
.to_string()
.into(),
))
} else if uri == asset_download_uri {
let mut bytes = Vec::<u8>::new();
let mut archive = async_tar::Builder::new(&mut bytes);
let mut header = async_tar::Header::new_gnu();
let content = "the-gleam-binary-contents".as_bytes();
header.set_size(content.len() as u64);
header.set_size(binary_contents.len() as u64);
archive
.append_data(&mut header, "gleam", content)
.append_data(&mut header, "gleam", binary_contents.as_bytes())
.await
.unwrap();
archive.into_inner().await.unwrap();
let mut gzipped_bytes = Vec::new();
let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice()));
encoder.read_to_end(&mut gzipped_bytes).await.unwrap();
Ok(Response::new(gzipped_bytes.into()))
} else {
Ok(Response::builder().status(404).body("not found".into())?)
}
_ => Ok(Response::builder().status(404).body("not found".into())?),
}
}
});
let _store = cx.new_model(|cx| {
let extension_store = cx.new_model(|cx| {
ExtensionStore::new(
PathBuf::from("/the-extension-dir"),
extensions_dir.clone(),
Some(cache_dir),
fs.clone(),
http_client.clone(),
node_runtime,
@@ -509,46 +541,47 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
)
});
cx.executor().run_until_parked();
// Ensure that debounces fire.
let mut events = cx.events(&extension_store);
let executor = cx.executor();
let _task = cx.executor().spawn(async move {
while let Some(event) = events.next().await {
match event {
crate::Event::StartedReloading => {
executor.advance_clock(RELOAD_DEBOUNCE_DURATION);
}
_ => (),
}
}
});
extension_store
.update(cx, |store, cx| {
store.install_dev_extension(gleam_extension_dir.clone(), cx)
})
.await
.unwrap();
let mut fake_servers = language_registry.fake_language_servers("Gleam");
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer("/the-project-dir/test.gleam", cx)
project.open_local_buffer(project_dir.join("test.gleam"), cx)
})
.await
.unwrap();
project.update(cx, |project, cx| {
project.set_language_for_buffer(
&buffer,
Arc::new(Language::new(
LanguageConfig {
name: "Gleam".into(),
..Default::default()
},
None,
)),
cx,
)
});
let fake_server = fake_servers.next().await.unwrap();
let expected_server_path = extensions_dir.join("work/gleam/gleam-v1.2.3/gleam");
let expected_binary_contents = language_server_version.lock().binary_contents.clone();
assert_eq!(
fs.load("/the-extension-dir/work/gleam/gleam-v1.2.3/gleam".as_ref())
.await
.unwrap(),
"the-gleam-binary-contents"
);
assert_eq!(
fake_server.binary.path,
PathBuf::from("/the-extension-dir/work/gleam/gleam-v1.2.3/gleam")
);
assert_eq!(fake_server.binary.path, expected_server_path);
assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
assert_eq!(
fs.load(&expected_server_path).await.unwrap(),
expected_binary_contents
);
assert_eq!(language_server_version.lock().http_request_count, 2);
assert_eq!(
[
status_updates.next().await.unwrap(),
@@ -570,27 +603,51 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
)
]
);
}
fn compile_extension(name: &str, extension_dir_path: &Path) {
let output = std::process::Command::new("cargo")
.args(["component", "build", "--target-dir"])
.arg(extension_dir_path.join("target"))
.current_dir(&extension_dir_path)
.output()
.unwrap();
// Simulate a new version of the language server being released
language_server_version.lock().version = "v2.0.0".into();
language_server_version.lock().binary_contents = "the-new-binary-contents".into();
language_server_version.lock().http_request_count = 0;
assert!(
output.status.success(),
"failed to build component {}",
String::from_utf8_lossy(&output.stderr)
// Start a new instance of the language server.
project.update(cx, |project, cx| {
project.restart_language_servers_for_buffers([buffer.clone()], cx)
});
// The extension has cached the binary path, and does not attempt
// to reinstall it.
let fake_server = fake_servers.next().await.unwrap();
assert_eq!(fake_server.binary.path, expected_server_path);
assert_eq!(
fs.load(&expected_server_path).await.unwrap(),
expected_binary_contents
);
assert_eq!(language_server_version.lock().http_request_count, 0);
// Reload the extension, clearing its cache.
// Start a new instance of the language server.
extension_store
.update(cx, |store, cx| store.reload(Some("gleam".into()), cx))
.await;
cx.executor().run_until_parked();
project.update(cx, |project, cx| {
project.restart_language_servers_for_buffers([buffer.clone()], cx)
});
// The extension re-fetches the latest version of the language server.
let fake_server = fake_servers.next().await.unwrap();
let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam");
let expected_binary_contents = language_server_version.lock().binary_contents.clone();
assert_eq!(fake_server.binary.path, new_expected_server_path);
assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
assert_eq!(
fs.load(&new_expected_server_path).await.unwrap(),
expected_binary_contents
);
let mut wasm_path = PathBuf::from(extension_dir_path);
wasm_path.extend(["target", "wasm32-wasi", "debug", name]);
wasm_path.set_extension("wasm");
std::fs::rename(wasm_path, extension_dir_path.join("extension.wasm")).unwrap();
// The old language server directory has been cleaned up.
assert!(fs.metadata(&expected_server_path).await.unwrap().is_none());
}
fn init_test(cx: &mut TestAppContext) {

View File

@@ -3,9 +3,12 @@ use anyhow::{anyhow, bail, Context as _, Result};
use async_compression::futures::bufread::GzipDecoder;
use async_tar::Archive;
use async_trait::async_trait;
use fs::Fs;
use fs::{normalize_path, Fs};
use futures::{
channel::{mpsc::UnboundedSender, oneshot},
channel::{
mpsc::{self, UnboundedSender},
oneshot,
},
future::BoxFuture,
io::BufReader,
Future, FutureExt, StreamExt as _,
@@ -14,7 +17,8 @@ use gpui::BackgroundExecutor;
use language::{LanguageRegistry, LanguageServerBinaryStatus, LspAdapterDelegate};
use node_runtime::NodeRuntime;
use std::{
path::PathBuf,
env,
path::{Path, PathBuf},
sync::{Arc, OnceLock},
};
use util::{http::HttpClient, SemanticVersion};
@@ -22,7 +26,7 @@ use wasmtime::{
component::{Component, Linker, Resource, ResourceTable},
Engine, Store,
};
use wasmtime_wasi::preview2::{command as wasi_command, WasiCtx, WasiCtxBuilder, WasiView};
use wasmtime_wasi::preview2::{self as wasi, WasiCtx};
pub mod wit {
wasmtime::component::bindgen!({
@@ -49,6 +53,7 @@ pub(crate) struct WasmHost {
#[derive(Clone)]
pub struct WasmExtension {
tx: UnboundedSender<ExtensionCall>,
pub(crate) manifest: Arc<ExtensionManifest>,
#[allow(unused)]
zed_api_version: SemanticVersion,
}
@@ -56,7 +61,7 @@ pub struct WasmExtension {
pub(crate) struct WasmState {
manifest: Arc<ExtensionManifest>,
table: ResourceTable,
ctx: WasiCtx,
ctx: wasi::WasiCtx,
host: Arc<WasmHost>,
}
@@ -84,8 +89,8 @@ impl WasmHost {
})
.clone();
let mut linker = Linker::new(&engine);
wasi_command::add_to_linker(&mut linker).unwrap();
wit::Extension::add_to_linker(&mut linker, |state: &mut WasmState| state).unwrap();
wasi::command::add_to_linker(&mut linker).unwrap();
wit::Extension::add_to_linker(&mut linker, wasi_view).unwrap();
Arc::new(Self {
engine,
linker: Arc::new(linker),
@@ -112,22 +117,14 @@ impl WasmHost {
for part in wasmparser::Parser::new(0).parse_all(&wasm_bytes) {
if let wasmparser::Payload::CustomSection(s) = part? {
if s.name() == "zed:api-version" {
if s.data().len() != 6 {
zed_api_version = parse_extension_version(s.data());
if zed_api_version.is_none() {
bail!(
"extension {} has invalid zed:api-version section: {:?}",
manifest.id,
s.data()
);
}
let major = u16::from_be_bytes(s.data()[0..2].try_into().unwrap()) as _;
let minor = u16::from_be_bytes(s.data()[2..4].try_into().unwrap()) as _;
let patch = u16::from_be_bytes(s.data()[4..6].try_into().unwrap()) as _;
zed_api_version = Some(SemanticVersion {
major,
minor,
patch,
})
}
}
}
@@ -139,36 +136,94 @@ impl WasmHost {
let mut store = wasmtime::Store::new(
&this.engine,
WasmState {
manifest,
ctx: this.build_wasi_ctx(&manifest).await?,
manifest: manifest.clone(),
table: ResourceTable::new(),
ctx: WasiCtxBuilder::new()
.inherit_stdio()
.env("RUST_BACKTRACE", "1")
.build(),
host: this.clone(),
},
);
let (mut extension, instance) =
wit::Extension::instantiate_async(&mut store, &component, &this.linker)
.await
.context("failed to instantiate wasm component")?;
let (tx, mut rx) = futures::channel::mpsc::unbounded::<ExtensionCall>();
.context("failed to instantiate wasm extension")?;
extension
.call_init_extension(&mut store)
.await
.context("failed to initialize wasm extension")?;
let (tx, mut rx) = mpsc::unbounded::<ExtensionCall>();
executor
.spawn(async move {
extension.call_init_extension(&mut store).await.unwrap();
let _instance = instance;
while let Some(call) = rx.next().await {
(call)(&mut extension, &mut store).await;
}
})
.detach();
Ok(WasmExtension {
manifest,
tx,
zed_api_version,
})
}
}
async fn build_wasi_ctx(&self, manifest: &Arc<ExtensionManifest>) -> Result<WasiCtx> {
use cap_std::{ambient_authority, fs::Dir};
let extension_work_dir = self.work_dir.join(manifest.id.as_ref());
self.fs
.create_dir(&extension_work_dir)
.await
.context("failed to create extension work dir")?;
let work_dir_preopen = Dir::open_ambient_dir(&extension_work_dir, ambient_authority())
.context("failed to preopen extension work directory")?;
let current_dir_preopen = work_dir_preopen
.try_clone()
.context("failed to preopen extension current directory")?;
let extension_work_dir = extension_work_dir.to_string_lossy();
let perms = wasi::FilePerms::all();
let dir_perms = wasi::DirPerms::all();
Ok(wasi::WasiCtxBuilder::new()
.inherit_stdio()
.preopened_dir(current_dir_preopen, dir_perms, perms, ".")
.preopened_dir(work_dir_preopen, dir_perms, perms, &extension_work_dir)
.env("PWD", &extension_work_dir)
.env("RUST_BACKTRACE", "full")
.build())
}
pub fn path_from_extension(&self, id: &Arc<str>, path: &Path) -> PathBuf {
let extension_work_dir = self.work_dir.join(id.as_ref());
normalize_path(&extension_work_dir.join(path))
}
pub fn writeable_path_from_extension(&self, id: &Arc<str>, path: &Path) -> Result<PathBuf> {
let extension_work_dir = self.work_dir.join(id.as_ref());
let path = normalize_path(&extension_work_dir.join(path));
if path.starts_with(&extension_work_dir) {
Ok(path)
} else {
Err(anyhow!("cannot write to path {}", path.display()))
}
}
}
fn parse_extension_version(data: &[u8]) -> Option<SemanticVersion> {
if data.len() == 6 {
Some(SemanticVersion {
major: u16::from_be_bytes([data[0], data[1]]) as _,
minor: u16::from_be_bytes([data[2], data[3]]) as _,
patch: u16::from_be_bytes([data[4], data[5]]) as _,
})
} else {
None
}
}
impl WasmExtension {
@@ -201,13 +256,33 @@ impl wit::HostWorktree for WasmState {
delegate: Resource<Arc<dyn LspAdapterDelegate>>,
path: String,
) -> wasmtime::Result<Result<String, String>> {
let delegate = self.table().get(&delegate)?;
let delegate = self.table.get(&delegate)?;
Ok(delegate
.read_text_file(path.into())
.await
.map_err(|error| error.to_string()))
}
async fn shell_env(
&mut self,
delegate: Resource<Arc<dyn LspAdapterDelegate>>,
) -> wasmtime::Result<wit::EnvVars> {
let delegate = self.table.get(&delegate)?;
Ok(delegate.shell_env().await.into_iter().collect())
}
async fn which(
&mut self,
delegate: Resource<Arc<dyn LspAdapterDelegate>>,
binary_name: String,
) -> wasmtime::Result<Option<String>> {
let delegate = self.table.get(&delegate)?;
Ok(delegate
.which(binary_name.as_ref())
.await
.map(|path| path.to_string_lossy().to_string()))
}
fn drop(&mut self, _worktree: Resource<wit::Worktree>) -> Result<()> {
// we only ever hand out borrows of worktrees
Ok(())
@@ -269,13 +344,13 @@ impl wit::ExtensionImports for WasmState {
async fn current_platform(&mut self) -> Result<(wit::Os, wit::Architecture)> {
Ok((
match std::env::consts::OS {
match env::consts::OS {
"macos" => wit::Os::Mac,
"linux" => wit::Os::Linux,
"windows" => wit::Os::Windows,
_ => panic!("unsupported os"),
},
match std::env::consts::ARCH {
match env::consts::ARCH {
"aarch64" => wit::Architecture::Aarch64,
"x86" => wit::Architecture::X86,
"x86_64" => wit::Architecture::X8664,
@@ -314,18 +389,24 @@ impl wit::ExtensionImports for WasmState {
async fn download_file(
&mut self,
url: String,
filename: String,
path: String,
file_type: wit::DownloadedFileType,
) -> wasmtime::Result<Result<(), String>> {
let path = PathBuf::from(path);
async fn inner(
this: &mut WasmState,
url: String,
filename: String,
path: PathBuf,
file_type: wit::DownloadedFileType,
) -> anyhow::Result<()> {
this.host.fs.create_dir(&this.host.work_dir).await?;
let container_dir = this.host.work_dir.join(this.manifest.id.as_ref());
let destination_path = container_dir.join(&filename);
let extension_work_dir = this.host.work_dir.join(this.manifest.id.as_ref());
this.host.fs.create_dir(&extension_work_dir).await?;
let destination_path = this
.host
.writeable_path_from_extension(&this.manifest.id, &path)?;
let mut response = this
.host
@@ -367,19 +448,24 @@ impl wit::ExtensionImports for WasmState {
.await?;
}
wit::DownloadedFileType::Zip => {
let zip_filename = format!("{filename}.zip");
let file_name = destination_path
.file_name()
.ok_or_else(|| anyhow!("invalid download path"))?
.to_string_lossy();
let zip_filename = format!("{file_name}.zip");
let mut zip_path = destination_path.clone();
zip_path.set_file_name(zip_filename);
futures::pin_mut!(body);
this.host.fs.create_file_with(&zip_path, body).await?;
let unzip_status = std::process::Command::new("unzip")
.current_dir(&container_dir)
.current_dir(&extension_work_dir)
.arg(&zip_path)
.output()?
.status;
if !unzip_status.success() {
Err(anyhow!("failed to unzip {filename} archive"))?;
Err(anyhow!("failed to unzip {} archive", path.display()))?;
}
}
}
@@ -387,19 +473,23 @@ impl wit::ExtensionImports for WasmState {
Ok(())
}
Ok(inner(self, url, filename, file_type)
Ok(inner(self, url, path, file_type)
.await
.map(|_| ())
.map_err(|err| err.to_string()))
}
}
impl WasiView for WasmState {
fn wasi_view(state: &mut WasmState) -> &mut WasmState {
state
}
impl wasi::WasiView for WasmState {
fn table(&mut self) -> &mut ResourceTable {
&mut self.table
}
fn ctx(&mut self) -> &mut WasiCtx {
fn ctx(&mut self) -> &mut wasi::WasiCtx {
&mut self.ctx
}
}

View File

@@ -20,6 +20,7 @@ macro_rules! register_extension {
($extension_type:ty) => {
#[export_name = "init-extension"]
pub extern "C" fn __init_extension() {
std::env::set_current_dir(std::env::var("PWD").unwrap()).unwrap();
zed_extension_api::register_extension(|| {
Box::new(<$extension_type as zed_extension_api::Extension>::new())
});

View File

@@ -61,14 +61,18 @@ world extension {
/// Updates the installation status for the given language server.
import set-language-server-installation-status: func(language-server-name: string, status: language-server-installation-status);
type env-vars = list<tuple<string, string>>;
record command {
command: string,
args: list<string>,
env: list<tuple<string, string>>,
env: env-vars,
}
resource worktree {
read-text-file: func(path: string) -> result<string, string>;
which: func(binary-name: string) -> option<string>;
shell-env: func() -> env-vars;
}
record language-server-config {

View File

@@ -15,13 +15,17 @@ path = "src/extensions_ui.rs"
test-support = []
[dependencies]
anyhow.workspace = true
client.workspace = true
editor.workspace = true
extension.workspace = true
fuzzy.workspace = true
gpui.workspace = true
settings.workspace = true
smallvec.workspace = true
theme.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
[dev-dependencies]

View File

@@ -0,0 +1,3 @@
mod extension_card;
pub use extension_card::*;

View File

@@ -0,0 +1,40 @@
use gpui::{prelude::*, AnyElement};
use smallvec::SmallVec;
use ui::prelude::*;
#[derive(IntoElement)]
pub struct ExtensionCard {
children: SmallVec<[AnyElement; 2]>,
}
impl ExtensionCard {
pub fn new() -> Self {
Self {
children: SmallVec::new(),
}
}
}
impl ParentElement for ExtensionCard {
fn extend(&mut self, elements: impl Iterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for ExtensionCard {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
div().w_full().child(
v_flex()
.w_full()
.h(rems(7.))
.p_3()
.mt_4()
.gap_2()
.bg(cx.theme().colors().elevated_surface_background)
.border_1()
.border_color(cx.theme().colors().border)
.rounded_md()
.children(self.children),
)
}
}

View File

@@ -1,31 +1,58 @@
mod components;
use crate::components::ExtensionCard;
use client::telemetry::Telemetry;
use editor::{Editor, EditorElement, EditorStyle};
use extension::{ExtensionApiResponse, ExtensionStatus, ExtensionStore};
use extension::{ExtensionApiResponse, ExtensionManifest, ExtensionStatus, ExtensionStore};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{
actions, canvas, uniform_list, AnyElement, AppContext, AvailableSpace, EventEmitter,
FocusableView, FontStyle, FontWeight, InteractiveElement, KeyContext, ParentElement, Render,
Styled, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WhiteSpace,
WindowContext,
actions, canvas, uniform_list, AnyElement, AppContext, EventEmitter, FocusableView, FontStyle,
FontWeight, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
UniformListScrollHandle, View, ViewContext, VisualContext, WhiteSpace, WindowContext,
};
use settings::Settings;
use std::ops::DerefMut;
use std::time::Duration;
use std::{ops::Range, sync::Arc};
use theme::ThemeSettings;
use ui::{prelude::*, ToggleButton, Tooltip};
use util::ResultExt as _;
use workspace::{
item::{Item, ItemEvent},
Workspace, WorkspaceId,
};
actions!(zed, [Extensions]);
actions!(zed, [Extensions, InstallDevExtension]);
pub fn init(cx: &mut AppContext) {
cx.observe_new_views(move |workspace: &mut Workspace, _cx| {
workspace.register_action(move |workspace, _: &Extensions, cx| {
let extensions_page = ExtensionsPage::new(workspace, cx);
workspace.add_item_to_active_pane(Box::new(extensions_page), cx)
});
workspace
.register_action(move |workspace, _: &Extensions, cx| {
let extensions_page = ExtensionsPage::new(workspace, cx);
workspace.add_item_to_active_pane(Box::new(extensions_page), cx)
})
.register_action(move |_, _: &InstallDevExtension, cx| {
let store = ExtensionStore::global(cx);
let prompt = cx.prompt_for_paths(gpui::PathPromptOptions {
files: false,
directories: true,
multiple: false,
});
cx.deref_mut()
.spawn(|mut cx| async move {
let extension_path = prompt.await.log_err()??.pop()?;
store
.update(&mut cx, |store, cx| {
store
.install_dev_extension(extension_path, cx)
.detach_and_log_err(cx)
})
.ok()?;
Some(())
})
.detach();
});
})
.detach();
}
@@ -37,15 +64,26 @@ enum ExtensionFilter {
NotInstalled,
}
impl ExtensionFilter {
pub fn include_dev_extensions(&self) -> bool {
match self {
Self::All | Self::Installed => true,
Self::NotInstalled => false,
}
}
}
pub struct ExtensionsPage {
list: UniformListScrollHandle,
telemetry: Arc<Telemetry>,
is_fetching_extensions: bool,
filter: ExtensionFilter,
extension_entries: Vec<ExtensionApiResponse>,
remote_extension_entries: Vec<ExtensionApiResponse>,
dev_extension_entries: Vec<Arc<ExtensionManifest>>,
filtered_remote_extension_indices: Vec<usize>,
query_editor: View<Editor>,
query_contains_error: bool,
_subscription: gpui::Subscription,
_subscriptions: [gpui::Subscription; 2],
extension_fetch_task: Option<Task<()>>,
}
@@ -53,7 +91,13 @@ impl ExtensionsPage {
pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
cx.new_view(|cx: &mut ViewContext<Self>| {
let store = ExtensionStore::global(cx);
let subscription = cx.observe(&store, |_, _, cx| cx.notify());
let subscriptions = [
cx.observe(&store, |_, _, cx| cx.notify()),
cx.subscribe(&store, |this, _, event, cx| match event {
extension::Event::ExtensionsUpdated => this.fetch_extensions_debounced(cx),
_ => {}
}),
];
let query_editor = cx.new_view(|cx| {
let mut input = Editor::single_line(cx);
@@ -67,10 +111,12 @@ impl ExtensionsPage {
telemetry: workspace.client().telemetry().clone(),
is_fetching_extensions: false,
filter: ExtensionFilter::All,
extension_entries: Vec::new(),
dev_extension_entries: Vec::new(),
filtered_remote_extension_indices: Vec::new(),
remote_extension_entries: Vec::new(),
query_contains_error: false,
extension_fetch_task: None,
_subscription: subscription,
_subscriptions: subscriptions,
query_editor,
};
this.fetch_extensions(None, cx);
@@ -78,250 +124,374 @@ impl ExtensionsPage {
})
}
fn filtered_extension_entries(&self, cx: &mut ViewContext<Self>) -> Vec<ExtensionApiResponse> {
fn filter_extension_entries(&mut self, cx: &mut ViewContext<Self>) {
let extension_store = ExtensionStore::global(cx).read(cx);
self.extension_entries
.iter()
.filter(|extension| match self.filter {
ExtensionFilter::All => true,
ExtensionFilter::Installed => {
let status = extension_store.extension_status(&extension.id);
self.filtered_remote_extension_indices.clear();
self.filtered_remote_extension_indices.extend(
self.remote_extension_entries
.iter()
.enumerate()
.filter(|(_, extension)| match self.filter {
ExtensionFilter::All => true,
ExtensionFilter::Installed => {
let status = extension_store.extension_status(&extension.id);
matches!(status, ExtensionStatus::Installed(_))
}
ExtensionFilter::NotInstalled => {
let status = extension_store.extension_status(&extension.id);
matches!(status, ExtensionStatus::Installed(_))
}
ExtensionFilter::NotInstalled => {
let status = extension_store.extension_status(&extension.id);
matches!(status, ExtensionStatus::NotInstalled)
}
})
.cloned()
.collect::<Vec<_>>()
}
fn install_extension(
&self,
extension_id: Arc<str>,
version: Arc<str>,
cx: &mut ViewContext<Self>,
) {
ExtensionStore::global(cx).update(cx, |store, cx| {
store.install_extension(extension_id, version, cx)
});
matches!(status, ExtensionStatus::NotInstalled)
}
})
.map(|(ix, _)| ix),
);
cx.notify();
}
fn uninstall_extension(&self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
ExtensionStore::global(cx)
.update(cx, |store, cx| store.uninstall_extension(extension_id, cx));
cx.notify();
}
fn fetch_extensions(&mut self, search: Option<&str>, cx: &mut ViewContext<Self>) {
fn fetch_extensions(&mut self, search: Option<String>, cx: &mut ViewContext<Self>) {
self.is_fetching_extensions = true;
cx.notify();
let extensions =
ExtensionStore::global(cx).update(cx, |store, cx| store.fetch_extensions(search, cx));
let extension_store = ExtensionStore::global(cx);
let dev_extensions = extension_store.update(cx, |store, _| {
store.dev_extensions().cloned().collect::<Vec<_>>()
});
let remote_extensions = extension_store.update(cx, |store, cx| {
store.fetch_extensions(search.as_deref(), cx)
});
cx.spawn(move |this, mut cx| async move {
let fetch_result = extensions.await;
match fetch_result {
Ok(extensions) => this.update(&mut cx, |this, cx| {
this.extension_entries = extensions;
this.is_fetching_extensions = false;
cx.notify();
}),
Err(err) => {
this.update(&mut cx, |this, cx| {
this.is_fetching_extensions = false;
cx.notify();
let dev_extensions = if let Some(search) = search {
let match_candidates = dev_extensions
.iter()
.enumerate()
.map(|(ix, manifest)| StringMatchCandidate {
id: ix,
string: manifest.name.clone(),
char_bag: manifest.name.as_str().into(),
})
.ok();
.collect::<Vec<_>>();
Err(err)
}
}
let matches = match_strings(
&match_candidates,
&search,
false,
match_candidates.len(),
&Default::default(),
cx.background_executor().clone(),
)
.await;
matches
.into_iter()
.map(|mat| dev_extensions[mat.candidate_id].clone())
.collect()
} else {
dev_extensions
};
let fetch_result = remote_extensions.await;
this.update(&mut cx, |this, cx| {
cx.notify();
this.dev_extension_entries = dev_extensions;
this.is_fetching_extensions = false;
this.remote_extension_entries = fetch_result?;
this.filter_extension_entries(cx);
anyhow::Ok(())
})?
})
.detach_and_log_err(cx);
}
fn render_extensions(&mut self, range: Range<usize>, cx: &mut ViewContext<Self>) -> Vec<Div> {
self.filtered_extension_entries(cx)[range]
.iter()
.map(|extension| self.render_entry(extension, cx))
fn render_extensions(
&mut self,
range: Range<usize>,
cx: &mut ViewContext<Self>,
) -> Vec<ExtensionCard> {
let dev_extension_entries_len = if self.filter.include_dev_extensions() {
self.dev_extension_entries.len()
} else {
0
};
range
.map(|ix| {
if ix < dev_extension_entries_len {
let extension = &self.dev_extension_entries[ix];
self.render_dev_extension(extension, cx)
} else {
let extension_ix =
self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
let extension = &self.remote_extension_entries[extension_ix];
self.render_remote_extension(extension, cx)
}
})
.collect()
}
fn render_entry(&self, extension: &ExtensionApiResponse, cx: &mut ViewContext<Self>) -> Div {
fn render_dev_extension(
&self,
extension: &ExtensionManifest,
cx: &mut ViewContext<Self>,
) -> ExtensionCard {
let status = ExtensionStore::global(cx)
.read(cx)
.extension_status(&extension.id);
let upgrade_button = match status.clone() {
ExtensionStatus::NotInstalled
| ExtensionStatus::Installing
| ExtensionStatus::Removing => None,
ExtensionStatus::Installed(installed_version) => {
if installed_version != extension.version {
Some(
Button::new(
SharedString::from(format!("upgrade-{}", extension.id)),
"Upgrade",
let repository_url = extension.repository.clone();
ExtensionCard::new()
.child(
h_flex()
.justify_between()
.child(
h_flex()
.gap_2()
.items_end()
.child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
.child(
Headline::new(format!("v{}", extension.version))
.size(HeadlineSize::XSmall),
),
)
.child(
h_flex()
.gap_2()
.justify_between()
.child(
Button::new(
SharedString::from(format!("rebuild-{}", extension.id)),
"Rebuild",
)
.on_click({
let extension_id = extension.id.clone();
move |_, cx| {
ExtensionStore::global(cx).update(cx, |store, cx| {
store.rebuild_dev_extension(extension_id.clone(), cx)
});
}
})
.color(Color::Accent)
.disabled(matches!(status, ExtensionStatus::Upgrading)),
)
.child(
Button::new(SharedString::from(extension.id.clone()), "Uninstall")
.on_click({
let extension_id = extension.id.clone();
move |_, cx| {
ExtensionStore::global(cx).update(cx, |store, cx| {
store.uninstall_extension(extension_id.clone(), cx)
});
}
})
.color(Color::Accent)
.disabled(matches!(status, ExtensionStatus::Removing)),
),
),
)
.child(
h_flex()
.justify_between()
.child(
Label::new(format!(
"{}: {}",
if extension.authors.len() > 1 {
"Authors"
} else {
"Author"
},
extension.authors.join(", ")
))
.size(LabelSize::Small),
)
.child(Label::new("<>").size(LabelSize::Small)),
)
.child(
h_flex()
.justify_between()
.children(extension.description.as_ref().map(|description| {
Label::new(description.clone())
.size(LabelSize::Small)
.color(Color::Default)
}))
.children(repository_url.map(|repository_url| {
IconButton::new(
SharedString::from(format!("repository-{}", extension.id)),
IconName::Github,
)
.icon_color(Color::Accent)
.icon_size(IconSize::Small)
.style(ButtonStyle::Filled)
.on_click(cx.listener({
let extension_id = extension.id.clone();
let version = extension.version.clone();
move |this, _, cx| {
this.telemetry
.report_app_event("extensions: install extension".to_string());
this.install_extension(extension_id.clone(), version.clone(), cx);
let repository_url = repository_url.clone();
move |_, _, cx| {
cx.open_url(&repository_url);
}
}))
.color(Color::Accent),
)
} else {
None
}
}
ExtensionStatus::Upgrading => Some(
Button::new(
SharedString::from(format!("upgrade-{}", extension.id)),
"Upgrade",
)
.color(Color::Accent)
.disabled(true),
),
};
let install_or_uninstall_button = match status {
ExtensionStatus::NotInstalled | ExtensionStatus::Installing => Button::new(
SharedString::from(extension.id.clone()),
if status.is_installing() {
"Installing..."
} else {
"Install"
},
.tooltip(move |cx| Tooltip::text(repository_url.clone(), cx))
})),
)
.on_click(cx.listener({
let extension_id = extension.id.clone();
let version = extension.version.clone();
move |this, _, cx| {
this.telemetry
.report_app_event("extensions: install extension".to_string());
this.install_extension(extension_id.clone(), version.clone(), cx);
}
}))
.disabled(status.is_installing()),
ExtensionStatus::Installed(_)
| ExtensionStatus::Upgrading
| ExtensionStatus::Removing => Button::new(
SharedString::from(extension.id.clone()),
if status.is_upgrading() {
"Upgrading..."
} else if status.is_removing() {
"Removing..."
} else {
"Uninstall"
},
)
.on_click(cx.listener({
let extension_id = extension.id.clone();
move |this, _, cx| {
this.telemetry
.report_app_event("extensions: uninstall extension".to_string());
this.uninstall_extension(extension_id.clone(), cx);
}
}))
.disabled(matches!(
status,
ExtensionStatus::Upgrading | ExtensionStatus::Removing
)),
}
.color(Color::Accent);
}
fn render_remote_extension(
&self,
extension: &ExtensionApiResponse,
cx: &mut ViewContext<Self>,
) -> ExtensionCard {
let status = ExtensionStore::global(cx)
.read(cx)
.extension_status(&extension.id);
let (install_or_uninstall_button, upgrade_button) =
self.buttons_for_entry(extension, &status, cx);
let repository_url = extension.repository.clone();
let tooltip_text = Tooltip::text(repository_url.clone(), cx);
div().w_full().child(
v_flex()
.w_full()
.h(rems(7.))
.p_3()
.mt_4()
.gap_2()
.bg(cx.theme().colors().elevated_surface_background)
.border_1()
.border_color(cx.theme().colors().border)
.rounded_md()
.child(
h_flex()
.justify_between()
.child(
h_flex()
.gap_2()
.items_end()
.child(
Headline::new(extension.name.clone())
.size(HeadlineSize::Medium),
)
.child(
Headline::new(format!("v{}", extension.version))
.size(HeadlineSize::XSmall),
),
)
.child(
h_flex()
.gap_2()
.justify_between()
.children(upgrade_button)
.child(install_or_uninstall_button),
),
)
.child(
h_flex()
.justify_between()
.child(
Label::new(format!(
"{}: {}",
if extension.authors.len() > 1 {
"Authors"
} else {
"Author"
},
extension.authors.join(", ")
))
ExtensionCard::new()
.child(
h_flex()
.justify_between()
.child(
h_flex()
.gap_2()
.items_end()
.child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
.child(
Headline::new(format!("v{}", extension.version))
.size(HeadlineSize::XSmall),
),
)
.child(
h_flex()
.gap_2()
.justify_between()
.children(upgrade_button)
.child(install_or_uninstall_button),
),
)
.child(
h_flex()
.justify_between()
.child(
Label::new(format!(
"{}: {}",
if extension.authors.len() > 1 {
"Authors"
} else {
"Author"
},
extension.authors.join(", ")
))
.size(LabelSize::Small),
)
.child(
Label::new(format!("Downloads: {}", extension.download_count))
.size(LabelSize::Small),
),
)
.child(
h_flex()
.justify_between()
.children(extension.description.as_ref().map(|description| {
Label::new(description.clone())
.size(LabelSize::Small)
.color(Color::Default)
}))
.child(
IconButton::new(
SharedString::from(format!("repository-{}", extension.id)),
IconName::Github,
)
.child(
Label::new(format!("Downloads: {}", extension.download_count))
.size(LabelSize::Small),
),
)
.child(
h_flex()
.justify_between()
.children(extension.description.as_ref().map(|description| {
Label::new(description.clone())
.size(LabelSize::Small)
.color(Color::Default)
}))
.child(
IconButton::new(
SharedString::from(format!("repository-{}", extension.id)),
IconName::Github,
)
.icon_color(Color::Accent)
.icon_size(IconSize::Small)
.style(ButtonStyle::Filled)
.on_click(cx.listener(move |_, _, cx| {
.icon_color(Color::Accent)
.icon_size(IconSize::Small)
.style(ButtonStyle::Filled)
.on_click(cx.listener({
let repository_url = repository_url.clone();
move |_, _, cx| {
cx.open_url(&repository_url);
}))
.tooltip(move |_| tooltip_text.clone()),
),
}
}))
.tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)),
),
)
}
fn buttons_for_entry(
&self,
extension: &ExtensionApiResponse,
status: &ExtensionStatus,
cx: &mut ViewContext<Self>,
) -> (Button, Option<Button>) {
match status.clone() {
ExtensionStatus::NotInstalled => (
Button::new(SharedString::from(extension.id.clone()), "Install").on_click(
cx.listener({
let extension_id = extension.id.clone();
let version = extension.version.clone();
move |this, _, cx| {
this.telemetry
.report_app_event("extensions: install extension".to_string());
ExtensionStore::global(cx).update(cx, |store, cx| {
store.install_extension(extension_id.clone(), version.clone(), cx)
});
}
}),
),
)
None,
),
ExtensionStatus::Installing => (
Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
None,
),
ExtensionStatus::Upgrading => (
Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
Some(
Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
),
),
ExtensionStatus::Installed(installed_version) => (
Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click(
cx.listener({
let extension_id = extension.id.clone();
move |this, _, cx| {
this.telemetry
.report_app_event("extensions: uninstall extension".to_string());
ExtensionStore::global(cx).update(cx, |store, cx| {
store.uninstall_extension(extension_id.clone(), cx)
});
}
}),
),
if installed_version == extension.version {
None
} else {
Some(
Button::new(SharedString::from(extension.id.clone()), "Upgrade").on_click(
cx.listener({
let extension_id = extension.id.clone();
let version = extension.version.clone();
move |this, _, cx| {
this.telemetry.report_app_event(
"extensions: install extension".to_string(),
);
ExtensionStore::global(cx).update(cx, |store, cx| {
store.upgrade_extension(
extension_id.clone(),
version.clone(),
cx,
)
});
}
}),
),
)
},
),
ExtensionStatus::Removing => (
Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
None,
),
}
}
fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
@@ -394,32 +564,36 @@ impl ExtensionsPage {
) {
if let editor::EditorEvent::Edited = event {
self.query_contains_error = false;
self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
let search = this
.update(&mut cx, |this, cx| this.search_query(cx))
.ok()
.flatten();
// Only debounce the fetching of extensions if we have a search
// query.
//
// If the search was just cleared then we can just reload the list
// of extensions without a debounce, which allows us to avoid seeing
// an intermittent flash of a "no extensions" state.
if let Some(_) = search {
cx.background_executor()
.timer(Duration::from_millis(250))
.await;
};
this.update(&mut cx, |this, cx| {
this.fetch_extensions(search.as_deref(), cx);
})
.ok();
}));
self.fetch_extensions_debounced(cx);
}
}
fn fetch_extensions_debounced(&mut self, cx: &mut ViewContext<'_, ExtensionsPage>) {
self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
let search = this
.update(&mut cx, |this, cx| this.search_query(cx))
.ok()
.flatten();
// Only debounce the fetching of extensions if we have a search
// query.
//
// If the search was just cleared then we can just reload the list
// of extensions without a debounce, which allows us to avoid seeing
// an intermittent flash of a "no extensions" state.
if let Some(_) = search {
cx.background_executor()
.timer(Duration::from_millis(250))
.await;
};
this.update(&mut cx, |this, cx| {
this.fetch_extensions(search, cx);
})
.ok();
}));
}
pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
let search = self.query_editor.read(cx).text(cx);
if search.trim().is_empty() {
@@ -479,7 +653,17 @@ impl Render for ExtensionsPage {
.child(
h_flex()
.w_full()
.child(Headline::new("Extensions").size(HeadlineSize::XLarge)),
.gap_2()
.justify_between()
.child(Headline::new("Extensions").size(HeadlineSize::XLarge))
.child(
Button::new("add-dev-extension", "Add Dev Extension")
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.on_click(|_event, cx| {
cx.dispatch_action(Box::new(InstallDevExtension))
}),
),
)
.child(
h_flex()
@@ -494,8 +678,9 @@ impl Render for ExtensionsPage {
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.selected(self.filter == ExtensionFilter::All)
.on_click(cx.listener(|this, _event, _cx| {
.on_click(cx.listener(|this, _event, cx| {
this.filter = ExtensionFilter::All;
this.filter_extension_entries(cx);
}))
.tooltip(move |cx| {
Tooltip::text("Show all extensions", cx)
@@ -507,8 +692,9 @@ impl Render for ExtensionsPage {
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.selected(self.filter == ExtensionFilter::Installed)
.on_click(cx.listener(|this, _event, _cx| {
.on_click(cx.listener(|this, _event, cx| {
this.filter = ExtensionFilter::Installed;
this.filter_extension_entries(cx);
}))
.tooltip(move |cx| {
Tooltip::text("Show installed extensions", cx)
@@ -520,8 +706,9 @@ impl Render for ExtensionsPage {
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.selected(self.filter == ExtensionFilter::NotInstalled)
.on_click(cx.listener(|this, _event, _cx| {
.on_click(cx.listener(|this, _event, cx| {
this.filter = ExtensionFilter::NotInstalled;
this.filter_extension_entries(cx);
}))
.tooltip(move |cx| {
Tooltip::text("Show not installed extensions", cx)
@@ -532,34 +719,35 @@ impl Render for ExtensionsPage {
),
)
.child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
let entries = self.filtered_extension_entries(cx);
if entries.is_empty() {
let mut count = self.filtered_remote_extension_indices.len();
if self.filter.include_dev_extensions() {
count += self.dev_extension_entries.len();
}
if count == 0 {
return this.py_4().child(self.render_empty_state(cx));
}
let view = cx.view().clone();
let scroll_handle = self.list.clone();
this.child(
canvas({
let view = cx.view().clone();
let scroll_handle = self.list.clone();
let item_count = entries.len();
canvas(
move |bounds, cx| {
uniform_list::<_, Div, _>(
let mut list = uniform_list::<_, ExtensionCard, _>(
view,
"entries",
item_count,
count,
Self::render_extensions,
)
.size_full()
.pb_4()
.track_scroll(scroll_handle)
.into_any_element()
.draw(
bounds.origin,
bounds.size.map(AvailableSpace::Definite),
cx,
)
}
})
.into_any_element();
list.layout(bounds.origin, bounds.size.into(), cx);
list
},
|_bounds, mut list, cx| list.paint(cx),
)
.size_full(),
)
}))

View File

@@ -1,49 +0,0 @@
use gpui::{Render, ViewContext, WeakView};
use ui::{prelude::*, ButtonCommon, IconButton, IconName, Tooltip};
use workspace::{item::ItemHandle, StatusItemView, Workspace};
use crate::{feedback_modal::FeedbackModal, GiveFeedback};
pub struct DeployFeedbackButton {
workspace: WeakView<Workspace>,
}
impl DeployFeedbackButton {
pub fn new(workspace: &Workspace) -> Self {
DeployFeedbackButton {
workspace: workspace.weak_handle(),
}
}
}
impl Render for DeployFeedbackButton {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let is_open = self
.workspace
.upgrade()
.and_then(|workspace| {
workspace.update(cx, |workspace, cx| {
workspace.active_modal::<FeedbackModal>(cx)
})
})
.is_some();
IconButton::new("give-feedback", IconName::Envelope)
.style(ui::ButtonStyle::Subtle)
.icon_size(IconSize::Small)
.selected(is_open)
.tooltip(|cx| Tooltip::text("Share Feedback", cx))
.on_click(|_, cx| {
cx.dispatch_action(Box::new(GiveFeedback));
})
.into_any_element()
}
}
impl StatusItemView for DeployFeedbackButton {
fn set_active_pane_item(
&mut self,
_item: Option<&dyn ItemHandle>,
_cx: &mut ViewContext<Self>,
) {
}
}

View File

@@ -2,7 +2,6 @@ use gpui::{actions, AppContext, ClipboardItem, PromptLevel};
use system_specs::SystemSpecs;
use workspace::Workspace;
pub mod deploy_feedback_button;
pub mod feedback_modal;
actions!(feedback, [GiveFeedback, SubmitFeedback]);

View File

@@ -431,7 +431,7 @@ impl Render for FeedbackModal {
.h(rems(32.))
.p_4()
.gap_2()
.child(Headline::new("Share Feedback"))
.child(Headline::new("Give Feedback"))
.child(
Label::new(if self.character_count < *FEEDBACK_CHAR_LIMIT.start() {
format!(

View File

@@ -13,7 +13,6 @@ path = "src/fs.rs"
[dependencies]
collections.workspace = true
fsevent.workspace = true
rope.workspace = true
text.workspace = true
util.workspace = true
@@ -37,6 +36,9 @@ time.workspace = true
gpui = { workspace = true, optional = true }
[target.'cfg(target_os = "macos")'.dependencies]
fsevent.workspace = true
[target.'cfg(not(target_os = "macos"))'.dependencies]
notify = "6.1.1"

View File

@@ -1,15 +1,6 @@
pub mod repository;
use anyhow::{anyhow, Result};
pub use fsevent::Event;
#[cfg(target_os = "macos")]
use fsevent::EventStream;
#[cfg(not(target_os = "macos"))]
use fsevent::StreamFlags;
#[cfg(not(target_os = "macos"))]
use notify::{Config, EventKind, Watcher};
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
@@ -43,6 +34,7 @@ use std::ffi::OsStr;
#[async_trait::async_trait]
pub trait Fs: Send + Sync {
async fn create_dir(&self, path: &Path) -> Result<()>;
async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()>;
async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()>;
async fn create_file_with(
&self,
@@ -75,7 +67,7 @@ pub trait Fs: Send + Sync {
&self,
path: &Path,
latency: Duration,
) -> Pin<Box<dyn Send + Stream<Item = Vec<Event>>>>;
) -> Pin<Box<dyn Send + Stream<Item = Vec<PathBuf>>>>;
fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<Mutex<dyn GitRepository>>>;
fn is_fake(&self) -> bool;
@@ -124,6 +116,16 @@ impl Fs for RealFs {
Ok(smol::fs::create_dir_all(path).await?)
}
async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()> {
#[cfg(target_family = "unix")]
smol::fs::unix::symlink(target, path).await?;
#[cfg(target_family = "windows")]
Err(anyhow!("not supported yet on windows"))?;
Ok(())
}
async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()> {
let mut open_options = smol::fs::OpenOptions::new();
open_options.write(true).create(true);
@@ -212,9 +214,11 @@ impl Fs for RealFs {
async fn load(&self, path: &Path) -> Result<String> {
let mut file = smol::fs::File::open(path).await?;
let mut text = String::new();
file.read_to_string(&mut text).await?;
Ok(text)
// We use `read_exact` here instead of `read_to_string` as the latter is *very*
// happy to reallocate often, which comes into play when we're loading large files.
let mut storage = vec![0; file.metadata().await?.len() as usize];
file.read_exact(&mut storage).await?;
Ok(String::from_utf8(storage)?)
}
async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> {
@@ -314,12 +318,18 @@ impl Fs for RealFs {
&self,
path: &Path,
latency: Duration,
) -> Pin<Box<dyn Send + Stream<Item = Vec<Event>>>> {
) -> Pin<Box<dyn Send + Stream<Item = Vec<PathBuf>>>> {
use fsevent::EventStream;
let (tx, rx) = smol::channel::unbounded();
let (stream, handle) = EventStream::new(&[path], latency);
std::thread::spawn(move || {
stream.run(move |events| smol::block_on(tx.send(events)).is_ok());
stream.run(move |events| {
smol::block_on(tx.send(events.into_iter().map(|event| event.path).collect()))
.is_ok()
});
});
Box::pin(rx.chain(futures::stream::once(async move {
drop(handle);
vec![]
@@ -330,49 +340,66 @@ impl Fs for RealFs {
async fn watch(
&self,
path: &Path,
latency: Duration,
) -> Pin<Box<dyn Send + Stream<Item = Vec<Event>>>> {
_latency: Duration,
) -> Pin<Box<dyn Send + Stream<Item = Vec<PathBuf>>>> {
use notify::{event::EventKind, Watcher};
// todo(linux): This spawns two threads, while the macOS impl
// only spawns one. Can we use a OnceLock or some such to make
// this better
let (tx, rx) = smol::channel::unbounded();
if !path.exists() {
log::error!("watch path does not exist: {}", path.display());
return Box::pin(rx);
}
let mut watcher =
notify::recommended_watcher(move |res: Result<notify::Event, _>| match res {
Ok(event) => {
let flags = match event.kind {
// ITEM_REMOVED is currently the only flag we care about
EventKind::Remove(_) => StreamFlags::ITEM_REMOVED,
_ => StreamFlags::NONE,
};
let events = event
.paths
.into_iter()
.map(|path| Event {
event_id: 0,
flags,
path,
})
.collect::<Vec<_>>();
let _ = tx.try_send(events);
let mut file_watcher = notify::recommended_watcher({
let tx = tx.clone();
move |event: Result<notify::Event, _>| {
if let Some(event) = event.log_err() {
tx.try_send(event.paths).ok();
}
Err(err) => {
log::error!("watch error: {}", err);
}
})
.unwrap();
}
})
.expect("Could not start file watcher");
watcher
.configure(Config::default().with_poll_interval(latency))
.unwrap();
watcher
file_watcher
.watch(path, notify::RecursiveMode::Recursive)
.unwrap();
.ok(); // It's ok if this fails, the parent watcher will add it.
Box::pin(rx)
let mut parent_watcher = notify::recommended_watcher({
let watched_path = path.to_path_buf();
let tx = tx.clone();
move |event: Result<notify::Event, _>| {
if let Some(event) = event.ok() {
if event.paths.into_iter().any(|path| *path == watched_path) {
match event.kind {
EventKind::Create(_) => {
file_watcher
.watch(watched_path.as_path(), notify::RecursiveMode::Recursive)
.log_err();
let _ = tx.try_send(vec![watched_path.clone()]).ok();
}
EventKind::Remove(_) => {
file_watcher.unwatch(&watched_path).log_err();
let _ = tx.try_send(vec![watched_path.clone()]).ok();
}
_ => {}
}
}
}
}
})
.expect("Could not start file watcher");
parent_watcher
.watch(
path.parent()
.expect("Watching root is probably not what you want"),
notify::RecursiveMode::NonRecursive,
)
.expect("Could not start watcher on parent directory");
Box::pin(rx.chain(futures::stream::once(async move {
drop(parent_watcher);
vec![]
})))
}
fn open_repo(&self, dotgit_path: &Path) -> Option<Arc<Mutex<dyn GitRepository>>> {
@@ -430,10 +457,6 @@ impl Fs for RealFs {
}
}
pub fn fs_events_paths(events: Vec<Event>) -> Vec<PathBuf> {
events.into_iter().map(|event| event.path).collect()
}
#[cfg(any(test, feature = "test-support"))]
pub struct FakeFs {
// Use an unfair lock to ensure tests are deterministic.
@@ -446,9 +469,9 @@ struct FakeFsState {
root: Arc<Mutex<FakeFsEntry>>,
next_inode: u64,
next_mtime: SystemTime,
event_txs: Vec<smol::channel::Sender<Vec<fsevent::Event>>>,
event_txs: Vec<smol::channel::Sender<Vec<PathBuf>>>,
events_paused: bool,
buffered_events: Vec<fsevent::Event>,
buffered_events: Vec<PathBuf>,
metadata_call_count: usize,
read_dir_call_count: usize,
}
@@ -556,11 +579,7 @@ impl FakeFsState {
T: Into<PathBuf>,
{
self.buffered_events
.extend(paths.into_iter().map(|path| fsevent::Event {
event_id: 0,
flags: fsevent::StreamFlags::empty(),
path: path.into(),
}));
.extend(paths.into_iter().map(Into::into));
if !self.events_paused {
self.flush_events(self.buffered_events.len());
@@ -994,6 +1013,25 @@ impl Fs for FakeFs {
Ok(())
}
async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()> {
let mut state = self.state.lock();
let file = Arc::new(Mutex::new(FakeFsEntry::Symlink { target }));
state
.write_path(path.as_ref(), move |e| match e {
btree_map::Entry::Vacant(e) => {
e.insert(file);
Ok(())
}
btree_map::Entry::Occupied(mut e) => {
*e.get_mut() = file;
Ok(())
}
})
.unwrap();
state.emit_event(&[path]);
Ok(())
}
async fn create_file_with(
&self,
path: &Path,
@@ -1296,14 +1334,14 @@ impl Fs for FakeFs {
&self,
path: &Path,
_: Duration,
) -> Pin<Box<dyn Send + Stream<Item = Vec<fsevent::Event>>>> {
) -> Pin<Box<dyn Send + Stream<Item = Vec<PathBuf>>>> {
self.simulate_random_delay().await;
let (tx, rx) = smol::channel::unbounded();
self.state.lock().event_txs.push(tx);
let path = path.to_path_buf();
let executor = self.executor.clone();
Box::pin(futures::StreamExt::filter(rx, move |events| {
let result = events.iter().any(|event| event.path.starts_with(&path));
let result = events.iter().any(|evt_path| evt_path.starts_with(&path));
let executor = executor.clone();
async move {
executor.simulate_random_delay().await;
@@ -1503,8 +1541,9 @@ mod tests {
]
);
fs.insert_symlink("/root/dir2/link-to-dir3", "./dir3".into())
.await;
fs.create_symlink("/root/dir2/link-to-dir3".as_ref(), "./dir3".into())
.await
.unwrap();
assert_eq!(
fs.canonicalize("/root/dir2/link-to-dir3".as_ref())

View File

@@ -1,11 +1,17 @@
#[cfg(target_os = "macos")]
pub use mac_impl::*;
#![cfg(target_os = "macos")]
use bitflags::bitflags;
use std::path::PathBuf;
#[cfg(target_os = "macos")]
mod mac_impl;
use fsevent_sys::{self as fs, core_foundation as cf};
use parking_lot::Mutex;
use std::{
convert::AsRef,
ffi::{c_void, CStr, OsStr},
os::unix::ffi::OsStrExt,
path::{Path, PathBuf},
ptr, slice,
sync::Arc,
time::Duration,
};
#[derive(Clone, Debug)]
pub struct Event {
@@ -14,10 +20,244 @@ pub struct Event {
pub path: PathBuf,
}
pub struct EventStream {
lifecycle: Arc<Mutex<Lifecycle>>,
state: Box<State>,
}
struct State {
latency: Duration,
paths: cf::CFMutableArrayRef,
callback: Option<Box<dyn FnMut(Vec<Event>) -> bool>>,
last_valid_event_id: Option<fs::FSEventStreamEventId>,
stream: fs::FSEventStreamRef,
}
impl Drop for State {
fn drop(&mut self) {
unsafe {
cf::CFRelease(self.paths);
fs::FSEventStreamStop(self.stream);
fs::FSEventStreamInvalidate(self.stream);
fs::FSEventStreamRelease(self.stream);
}
}
}
enum Lifecycle {
New,
Running(cf::CFRunLoopRef),
Stopped,
}
pub struct Handle(Arc<Mutex<Lifecycle>>);
unsafe impl Send for EventStream {}
unsafe impl Send for Lifecycle {}
impl EventStream {
pub fn new(paths: &[&Path], latency: Duration) -> (Self, Handle) {
unsafe {
let cf_paths =
cf::CFArrayCreateMutable(cf::kCFAllocatorDefault, 0, &cf::kCFTypeArrayCallBacks);
assert!(!cf_paths.is_null());
for path in paths {
let path_bytes = path.as_os_str().as_bytes();
let cf_url = cf::CFURLCreateFromFileSystemRepresentation(
cf::kCFAllocatorDefault,
path_bytes.as_ptr() as *const i8,
path_bytes.len() as cf::CFIndex,
false,
);
let cf_path = cf::CFURLCopyFileSystemPath(cf_url, cf::kCFURLPOSIXPathStyle);
cf::CFArrayAppendValue(cf_paths, cf_path);
cf::CFRelease(cf_path);
cf::CFRelease(cf_url);
}
let mut state = Box::new(State {
latency,
paths: cf_paths,
callback: None,
last_valid_event_id: None,
stream: ptr::null_mut(),
});
let stream_context = fs::FSEventStreamContext {
version: 0,
info: state.as_ref() as *const _ as *mut c_void,
retain: None,
release: None,
copy_description: None,
};
let stream = fs::FSEventStreamCreate(
cf::kCFAllocatorDefault,
Self::trampoline,
&stream_context,
cf_paths,
FSEventsGetCurrentEventId(),
latency.as_secs_f64(),
fs::kFSEventStreamCreateFlagFileEvents
| fs::kFSEventStreamCreateFlagNoDefer
| fs::kFSEventStreamCreateFlagWatchRoot,
);
state.stream = stream;
let lifecycle = Arc::new(Mutex::new(Lifecycle::New));
(
EventStream {
lifecycle: lifecycle.clone(),
state,
},
Handle(lifecycle),
)
}
}
pub fn run<F>(mut self, f: F)
where
F: FnMut(Vec<Event>) -> bool + 'static,
{
self.state.callback = Some(Box::new(f));
unsafe {
let run_loop = cf::CFRunLoopGetCurrent();
{
let mut state = self.lifecycle.lock();
match *state {
Lifecycle::New => *state = Lifecycle::Running(run_loop),
Lifecycle::Running(_) => unreachable!(),
Lifecycle::Stopped => return,
}
}
fs::FSEventStreamScheduleWithRunLoop(
self.state.stream,
run_loop,
cf::kCFRunLoopDefaultMode,
);
fs::FSEventStreamStart(self.state.stream);
cf::CFRunLoopRun();
}
}
extern "C" fn trampoline(
stream_ref: fs::FSEventStreamRef,
info: *mut ::std::os::raw::c_void,
num: usize, // size_t numEvents
event_paths: *mut ::std::os::raw::c_void, // void *eventPaths
event_flags: *const ::std::os::raw::c_void, // const FSEventStreamEventFlags eventFlags[]
event_ids: *const ::std::os::raw::c_void, // const FSEventStreamEventId eventIds[]
) {
unsafe {
let event_paths = event_paths as *const *const ::std::os::raw::c_char;
let e_ptr = event_flags as *mut u32;
let i_ptr = event_ids as *mut u64;
let state = (info as *mut State).as_mut().unwrap();
let callback = if let Some(callback) = state.callback.as_mut() {
callback
} else {
return;
};
let paths = slice::from_raw_parts(event_paths, num);
let flags = slice::from_raw_parts_mut(e_ptr, num);
let ids = slice::from_raw_parts_mut(i_ptr, num);
let mut stream_restarted = false;
// Sometimes FSEvents reports a "dropped" event, an indication that either the kernel
// or our code couldn't keep up with the sheer volume of file-system events that were
// generated. If we observed a valid event before this happens, we'll try to read the
// file-system journal by stopping the current stream and creating a new one starting at
// such event. Otherwise, we'll let invoke the callback with the dropped event, which
// will likely perform a re-scan of one of the root directories.
if flags
.iter()
.copied()
.filter_map(StreamFlags::from_bits)
.any(|flags| {
flags.contains(StreamFlags::USER_DROPPED)
|| flags.contains(StreamFlags::KERNEL_DROPPED)
})
{
if let Some(last_valid_event_id) = state.last_valid_event_id.take() {
fs::FSEventStreamStop(state.stream);
fs::FSEventStreamInvalidate(state.stream);
fs::FSEventStreamRelease(state.stream);
let stream_context = fs::FSEventStreamContext {
version: 0,
info,
retain: None,
release: None,
copy_description: None,
};
let stream = fs::FSEventStreamCreate(
cf::kCFAllocatorDefault,
Self::trampoline,
&stream_context,
state.paths,
last_valid_event_id,
state.latency.as_secs_f64(),
fs::kFSEventStreamCreateFlagFileEvents
| fs::kFSEventStreamCreateFlagNoDefer
| fs::kFSEventStreamCreateFlagWatchRoot,
);
state.stream = stream;
fs::FSEventStreamScheduleWithRunLoop(
state.stream,
cf::CFRunLoopGetCurrent(),
cf::kCFRunLoopDefaultMode,
);
fs::FSEventStreamStart(state.stream);
stream_restarted = true;
}
}
if !stream_restarted {
let mut events = Vec::with_capacity(num);
for p in 0..num {
if let Some(flag) = StreamFlags::from_bits(flags[p]) {
if !flag.contains(StreamFlags::HISTORY_DONE) {
let path_c_str = CStr::from_ptr(paths[p]);
let path = PathBuf::from(OsStr::from_bytes(path_c_str.to_bytes()));
let event = Event {
event_id: ids[p],
flags: flag,
path,
};
state.last_valid_event_id = Some(event.event_id);
events.push(event);
}
} else {
debug_assert!(false, "unknown flag set for fs event: {}", flags[p]);
}
}
if !events.is_empty() && !callback(events) {
fs::FSEventStreamStop(stream_ref);
cf::CFRunLoopStop(cf::CFRunLoopGetCurrent());
}
}
}
}
}
impl Drop for Handle {
fn drop(&mut self) {
let mut state = self.0.lock();
if let Lifecycle::Running(run_loop) = *state {
unsafe {
cf::CFRunLoopStop(run_loop);
}
}
*state = Lifecycle::Stopped;
}
}
// Synchronize with
// /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/FSEvents.framework/Versions/A/Headers/FSEvents.h
bitflags! {
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
#[repr(C)]
pub struct StreamFlags: u32 {
const NONE = 0x00000000;
@@ -121,3 +361,138 @@ impl std::fmt::Display for StreamFlags {
write!(f, "")
}
}
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
pub fn FSEventsGetCurrentEventId() -> u64;
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, sync::mpsc, thread, time::Duration};
#[test]
fn test_event_stream_simple() {
for _ in 0..3 {
let dir = tempfile::Builder::new()
.prefix("test-event-stream")
.tempdir()
.unwrap();
let path = dir.path().canonicalize().unwrap();
for i in 0..10 {
fs::write(path.join(format!("existing-file-{}", i)), "").unwrap();
}
flush_historical_events();
let (tx, rx) = mpsc::channel();
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
thread::spawn(move || stream.run(move |events| tx.send(events.to_vec()).is_ok()));
fs::write(path.join("new-file"), "").unwrap();
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
let event = events.last().unwrap();
assert_eq!(event.path, path.join("new-file"));
assert!(event.flags.contains(StreamFlags::ITEM_CREATED));
fs::remove_file(path.join("existing-file-5")).unwrap();
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
let event = events.last().unwrap();
assert_eq!(event.path, path.join("existing-file-5"));
assert!(event.flags.contains(StreamFlags::ITEM_REMOVED));
drop(handle);
}
}
#[test]
fn test_event_stream_delayed_start() {
for _ in 0..3 {
let dir = tempfile::Builder::new()
.prefix("test-event-stream")
.tempdir()
.unwrap();
let path = dir.path().canonicalize().unwrap();
for i in 0..10 {
fs::write(path.join(format!("existing-file-{}", i)), "").unwrap();
}
flush_historical_events();
let (tx, rx) = mpsc::channel();
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
// Delay the call to `run` in order to make sure we don't miss any events that occur
// between creating the `EventStream` and calling `run`.
thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
stream.run(move |events| tx.send(events.to_vec()).is_ok())
});
fs::write(path.join("new-file"), "").unwrap();
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
let event = events.last().unwrap();
assert_eq!(event.path, path.join("new-file"));
assert!(event.flags.contains(StreamFlags::ITEM_CREATED));
fs::remove_file(path.join("existing-file-5")).unwrap();
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
let event = events.last().unwrap();
assert_eq!(event.path, path.join("existing-file-5"));
assert!(event.flags.contains(StreamFlags::ITEM_REMOVED));
drop(handle);
}
}
#[test]
fn test_event_stream_shutdown_by_dropping_handle() {
let dir = tempfile::Builder::new()
.prefix("test-event-stream")
.tempdir()
.unwrap();
let path = dir.path().canonicalize().unwrap();
flush_historical_events();
let (tx, rx) = mpsc::channel();
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
thread::spawn(move || {
stream.run({
let tx = tx.clone();
move |_| {
tx.send("running").unwrap();
true
}
});
tx.send("stopped").unwrap();
});
fs::write(path.join("new-file"), "").unwrap();
assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "running");
// Dropping the handle causes `EventStream::run` to return.
drop(handle);
assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "stopped");
}
#[test]
fn test_event_stream_shutdown_before_run() {
let dir = tempfile::Builder::new()
.prefix("test-event-stream")
.tempdir()
.unwrap();
let path = dir.path().canonicalize().unwrap();
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
drop(handle);
// This returns immediately because the handle was already dropped.
stream.run(|_| true);
}
fn flush_historical_events() {
let duration = if std::env::var("CI").is_ok() {
Duration::from_secs(2)
} else {
Duration::from_millis(500)
};
thread::sleep(duration);
}
}

View File

@@ -1,382 +0,0 @@
use fsevent_sys::{self as fs, core_foundation as cf};
use parking_lot::Mutex;
use std::{
convert::AsRef,
ffi::{c_void, CStr, OsStr},
os::unix::ffi::OsStrExt,
path::{Path, PathBuf},
ptr, slice,
sync::Arc,
time::Duration,
};
use crate::{Event, StreamFlags};
pub struct EventStream {
lifecycle: Arc<Mutex<Lifecycle>>,
state: Box<State>,
}
struct State {
latency: Duration,
paths: cf::CFMutableArrayRef,
callback: Option<Box<dyn FnMut(Vec<Event>) -> bool>>,
last_valid_event_id: Option<fs::FSEventStreamEventId>,
stream: fs::FSEventStreamRef,
}
impl Drop for State {
fn drop(&mut self) {
unsafe {
cf::CFRelease(self.paths);
fs::FSEventStreamStop(self.stream);
fs::FSEventStreamInvalidate(self.stream);
fs::FSEventStreamRelease(self.stream);
}
}
}
enum Lifecycle {
New,
Running(cf::CFRunLoopRef),
Stopped,
}
pub struct Handle(Arc<Mutex<Lifecycle>>);
unsafe impl Send for EventStream {}
unsafe impl Send for Lifecycle {}
impl EventStream {
pub fn new(paths: &[&Path], latency: Duration) -> (Self, Handle) {
unsafe {
let cf_paths =
cf::CFArrayCreateMutable(cf::kCFAllocatorDefault, 0, &cf::kCFTypeArrayCallBacks);
assert!(!cf_paths.is_null());
for path in paths {
let path_bytes = path.as_os_str().as_bytes();
let cf_url = cf::CFURLCreateFromFileSystemRepresentation(
cf::kCFAllocatorDefault,
path_bytes.as_ptr() as *const i8,
path_bytes.len() as cf::CFIndex,
false,
);
let cf_path = cf::CFURLCopyFileSystemPath(cf_url, cf::kCFURLPOSIXPathStyle);
cf::CFArrayAppendValue(cf_paths, cf_path);
cf::CFRelease(cf_path);
cf::CFRelease(cf_url);
}
let mut state = Box::new(State {
latency,
paths: cf_paths,
callback: None,
last_valid_event_id: None,
stream: ptr::null_mut(),
});
let stream_context = fs::FSEventStreamContext {
version: 0,
info: state.as_ref() as *const _ as *mut c_void,
retain: None,
release: None,
copy_description: None,
};
let stream = fs::FSEventStreamCreate(
cf::kCFAllocatorDefault,
Self::trampoline,
&stream_context,
cf_paths,
FSEventsGetCurrentEventId(),
latency.as_secs_f64(),
fs::kFSEventStreamCreateFlagFileEvents
| fs::kFSEventStreamCreateFlagNoDefer
| fs::kFSEventStreamCreateFlagWatchRoot,
);
state.stream = stream;
let lifecycle = Arc::new(Mutex::new(Lifecycle::New));
(
EventStream {
lifecycle: lifecycle.clone(),
state,
},
Handle(lifecycle),
)
}
}
pub fn run<F>(mut self, f: F)
where
F: FnMut(Vec<Event>) -> bool + 'static,
{
self.state.callback = Some(Box::new(f));
unsafe {
let run_loop = cf::CFRunLoopGetCurrent();
{
let mut state = self.lifecycle.lock();
match *state {
Lifecycle::New => *state = Lifecycle::Running(run_loop),
Lifecycle::Running(_) => unreachable!(),
Lifecycle::Stopped => return,
}
}
fs::FSEventStreamScheduleWithRunLoop(
self.state.stream,
run_loop,
cf::kCFRunLoopDefaultMode,
);
fs::FSEventStreamStart(self.state.stream);
cf::CFRunLoopRun();
}
}
extern "C" fn trampoline(
stream_ref: fs::FSEventStreamRef,
info: *mut ::std::os::raw::c_void,
num: usize, // size_t numEvents
event_paths: *mut ::std::os::raw::c_void, // void *eventPaths
event_flags: *const ::std::os::raw::c_void, // const FSEventStreamEventFlags eventFlags[]
event_ids: *const ::std::os::raw::c_void, // const FSEventStreamEventId eventIds[]
) {
unsafe {
let event_paths = event_paths as *const *const ::std::os::raw::c_char;
let e_ptr = event_flags as *mut u32;
let i_ptr = event_ids as *mut u64;
let state = (info as *mut State).as_mut().unwrap();
let callback = if let Some(callback) = state.callback.as_mut() {
callback
} else {
return;
};
let paths = slice::from_raw_parts(event_paths, num);
let flags = slice::from_raw_parts_mut(e_ptr, num);
let ids = slice::from_raw_parts_mut(i_ptr, num);
let mut stream_restarted = false;
// Sometimes FSEvents reports a "dropped" event, an indication that either the kernel
// or our code couldn't keep up with the sheer volume of file-system events that were
// generated. If we observed a valid event before this happens, we'll try to read the
// file-system journal by stopping the current stream and creating a new one starting at
// such event. Otherwise, we'll let invoke the callback with the dropped event, which
// will likely perform a re-scan of one of the root directories.
if flags
.iter()
.copied()
.filter_map(StreamFlags::from_bits)
.any(|flags| {
flags.contains(StreamFlags::USER_DROPPED)
|| flags.contains(StreamFlags::KERNEL_DROPPED)
})
{
if let Some(last_valid_event_id) = state.last_valid_event_id.take() {
fs::FSEventStreamStop(state.stream);
fs::FSEventStreamInvalidate(state.stream);
fs::FSEventStreamRelease(state.stream);
let stream_context = fs::FSEventStreamContext {
version: 0,
info,
retain: None,
release: None,
copy_description: None,
};
let stream = fs::FSEventStreamCreate(
cf::kCFAllocatorDefault,
Self::trampoline,
&stream_context,
state.paths,
last_valid_event_id,
state.latency.as_secs_f64(),
fs::kFSEventStreamCreateFlagFileEvents
| fs::kFSEventStreamCreateFlagNoDefer
| fs::kFSEventStreamCreateFlagWatchRoot,
);
state.stream = stream;
fs::FSEventStreamScheduleWithRunLoop(
state.stream,
cf::CFRunLoopGetCurrent(),
cf::kCFRunLoopDefaultMode,
);
fs::FSEventStreamStart(state.stream);
stream_restarted = true;
}
}
if !stream_restarted {
let mut events = Vec::with_capacity(num);
for p in 0..num {
if let Some(flag) = StreamFlags::from_bits(flags[p]) {
if !flag.contains(StreamFlags::HISTORY_DONE) {
let path_c_str = CStr::from_ptr(paths[p]);
let path = PathBuf::from(OsStr::from_bytes(path_c_str.to_bytes()));
let event = Event {
event_id: ids[p],
flags: flag,
path,
};
state.last_valid_event_id = Some(event.event_id);
events.push(event);
}
} else {
debug_assert!(false, "unknown flag set for fs event: {}", flags[p]);
}
}
if !events.is_empty() && !callback(events) {
fs::FSEventStreamStop(stream_ref);
cf::CFRunLoopStop(cf::CFRunLoopGetCurrent());
}
}
}
}
}
impl Drop for Handle {
fn drop(&mut self) {
let mut state = self.0.lock();
if let Lifecycle::Running(run_loop) = *state {
unsafe {
cf::CFRunLoopStop(run_loop);
}
}
*state = Lifecycle::Stopped;
}
}
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
pub fn FSEventsGetCurrentEventId() -> u64;
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, sync::mpsc, thread, time::Duration};
#[test]
fn test_event_stream_simple() {
for _ in 0..3 {
let dir = tempfile::Builder::new()
.prefix("test-event-stream")
.tempdir()
.unwrap();
let path = dir.path().canonicalize().unwrap();
for i in 0..10 {
fs::write(path.join(format!("existing-file-{}", i)), "").unwrap();
}
flush_historical_events();
let (tx, rx) = mpsc::channel();
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
thread::spawn(move || stream.run(move |events| tx.send(events.to_vec()).is_ok()));
fs::write(path.join("new-file"), "").unwrap();
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
let event = events.last().unwrap();
assert_eq!(event.path, path.join("new-file"));
assert!(event.flags.contains(StreamFlags::ITEM_CREATED));
fs::remove_file(path.join("existing-file-5")).unwrap();
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
let event = events.last().unwrap();
assert_eq!(event.path, path.join("existing-file-5"));
assert!(event.flags.contains(StreamFlags::ITEM_REMOVED));
drop(handle);
}
}
#[test]
fn test_event_stream_delayed_start() {
for _ in 0..3 {
let dir = tempfile::Builder::new()
.prefix("test-event-stream")
.tempdir()
.unwrap();
let path = dir.path().canonicalize().unwrap();
for i in 0..10 {
fs::write(path.join(format!("existing-file-{}", i)), "").unwrap();
}
flush_historical_events();
let (tx, rx) = mpsc::channel();
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
// Delay the call to `run` in order to make sure we don't miss any events that occur
// between creating the `EventStream` and calling `run`.
thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
stream.run(move |events| tx.send(events.to_vec()).is_ok())
});
fs::write(path.join("new-file"), "").unwrap();
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
let event = events.last().unwrap();
assert_eq!(event.path, path.join("new-file"));
assert!(event.flags.contains(StreamFlags::ITEM_CREATED));
fs::remove_file(path.join("existing-file-5")).unwrap();
let events = rx.recv_timeout(Duration::from_secs(2)).unwrap();
let event = events.last().unwrap();
assert_eq!(event.path, path.join("existing-file-5"));
assert!(event.flags.contains(StreamFlags::ITEM_REMOVED));
drop(handle);
}
}
#[test]
fn test_event_stream_shutdown_by_dropping_handle() {
let dir = tempfile::Builder::new()
.prefix("test-event-stream")
.tempdir()
.unwrap();
let path = dir.path().canonicalize().unwrap();
flush_historical_events();
let (tx, rx) = mpsc::channel();
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
thread::spawn(move || {
stream.run({
let tx = tx.clone();
move |_| {
tx.send("running").unwrap();
true
}
});
tx.send("stopped").unwrap();
});
fs::write(path.join("new-file"), "").unwrap();
assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "running");
// Dropping the handle causes `EventStream::run` to return.
drop(handle);
assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), "stopped");
}
#[test]
fn test_event_stream_shutdown_before_run() {
let dir = tempfile::Builder::new()
.prefix("test-event-stream")
.tempdir()
.unwrap();
let path = dir.path().canonicalize().unwrap();
let (stream, handle) = EventStream::new(&[&path], Duration::from_millis(50));
drop(handle);
// This returns immediately because the handle was already dropped.
stream.run(|_| true);
}
fn flush_historical_events() {
let duration = if std::env::var("CI").is_ok() {
Duration::from_secs(2)
} else {
Duration::from_millis(500)
};
thread::sleep(duration);
}
}

View File

@@ -1,6 +1,6 @@
use std::{iter, ops::Range};
use sum_tree::SumTree;
use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point};
use text::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, Point};
pub use git2 as libgit;
use libgit::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch};
@@ -12,9 +12,20 @@ pub enum DiffHunkStatus {
Removed,
}
/// A diff hunk, representing a range of consequent lines in a singleton buffer, associated with a generic range.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffHunk<T> {
pub buffer_range: Range<T>,
/// E.g. a range in multibuffer, that has an excerpt added, singleton buffer for which has this diff hunk.
/// Consider a singleton buffer with 10 lines, all of them are modified — so a corresponding diff hunk would have a range 0..10.
/// And a multibuffer with the excerpt of lines 2-6 from the singleton buffer.
/// If the multibuffer is searched for diff hunks, the associated range would be multibuffer rows, corresponding to rows 2..6 from the singleton buffer.
/// But the hunk range would be 0..10, same for any other excerpts from the same singleton buffer.
pub associated_range: Range<T>,
/// Singleton buffer ID this hunk belongs to.
pub buffer_id: BufferId,
/// A consequent range of lines in the singleton buffer, that were changed and produced this diff hunk.
pub buffer_range: Range<Anchor>,
/// Original singleton buffer text before the change, that was instead of the `buffer_range`.
pub diff_base_byte_range: Range<usize>,
}
@@ -22,7 +33,7 @@ impl DiffHunk<u32> {
pub fn status(&self) -> DiffHunkStatus {
if self.diff_base_byte_range.is_empty() {
DiffHunkStatus::Added
} else if self.buffer_range.is_empty() {
} else if self.associated_range.is_empty() {
DiffHunkStatus::Removed
} else {
DiffHunkStatus::Modified
@@ -35,7 +46,7 @@ impl sum_tree::Item for DiffHunk<Anchor> {
fn summary(&self) -> Self::Summary {
DiffHunkSummary {
buffer_range: self.buffer_range.clone(),
buffer_range: self.associated_range.clone(),
}
}
}
@@ -57,7 +68,7 @@ impl sum_tree::Summary for DiffHunkSummary {
}
}
#[derive(Clone)]
#[derive(Debug, Clone)]
pub struct BufferDiff {
last_buffer_version: Option<clock::Global>,
tree: SumTree<DiffHunk<Anchor>>,
@@ -103,8 +114,11 @@ impl BufferDiff {
})
.flat_map(move |hunk| {
[
(&hunk.buffer_range.start, hunk.diff_base_byte_range.start),
(&hunk.buffer_range.end, hunk.diff_base_byte_range.end),
(
&hunk.associated_range.start,
hunk.diff_base_byte_range.start,
),
(&hunk.associated_range.end, hunk.diff_base_byte_range.end),
]
.into_iter()
});
@@ -112,17 +126,17 @@ impl BufferDiff {
let mut summaries = buffer.summaries_for_anchors_with_payload::<Point, _, _>(anchor_iter);
iter::from_fn(move || {
let (start_point, start_base) = summaries.next()?;
let (end_point, end_base) = summaries.next()?;
let (mut end_point, end_base) = summaries.next()?;
let end_row = if end_point.column > 0 {
end_point.row + 1
} else {
end_point.row
};
if end_point.column > 0 {
end_point.row += 1;
}
Some(DiffHunk {
buffer_range: start_point.row..end_row,
associated_range: start_point.row..end_point.row,
diff_base_byte_range: start_base..end_base,
buffer_range: buffer.anchor_before(start_point)..buffer.anchor_after(end_point),
buffer_id: buffer.remote_id(),
})
})
}
@@ -142,7 +156,7 @@ impl BufferDiff {
cursor.prev(buffer);
let hunk = cursor.item()?;
let range = hunk.buffer_range.to_point(buffer);
let range = hunk.associated_range.to_point(buffer);
let end_row = if range.end.column > 0 {
range.end.row + 1
} else {
@@ -150,8 +164,10 @@ impl BufferDiff {
};
Some(DiffHunk {
buffer_range: range.start.row..end_row,
associated_range: range.start.row..end_row,
diff_base_byte_range: hunk.diff_base_byte_range.clone(),
buffer_range: hunk.buffer_range.clone(),
buffer_id: hunk.buffer_id,
})
})
}
@@ -269,8 +285,10 @@ impl BufferDiff {
let end = Point::new(buffer_row_range.end, 0);
let buffer_range = buffer.anchor_before(start)..buffer.anchor_before(end);
DiffHunk {
associated_range: buffer_range.clone(),
buffer_range,
diff_base_byte_range,
buffer_id: buffer.remote_id(),
}
}
}
@@ -289,12 +307,12 @@ pub fn assert_hunks<Iter>(
let actual_hunks = diff_hunks
.map(|hunk| {
(
hunk.buffer_range.clone(),
hunk.associated_range.clone(),
&diff_base[hunk.diff_base_byte_range],
buffer
.text_for_range(
Point::new(hunk.buffer_range.start, 0)
..Point::new(hunk.buffer_range.end, 0),
Point::new(hunk.associated_range.start, 0)
..Point::new(hunk.associated_range.end, 0),
)
.collect::<String>(),
)

View File

@@ -24,3 +24,13 @@ workspace.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
indoc.workspace = true
language = { workspace = true, features = ["test-support"] }
menu.workspace = true
project = { workspace = true, features = ["test-support"] }
rope.workspace = true
serde_json.workspace = true
tree-sitter-rust.workspace = true
tree-sitter-typescript.workspace = true
workspace = { workspace = true, features = ["test-support"] }

View File

@@ -0,0 +1,100 @@
use editor::{Editor, ToPoint};
use gpui::{Subscription, View, WeakView};
use std::fmt::Write;
use text::{Point, Selection};
use ui::{
div, Button, ButtonCommon, Clickable, FluentBuilder, IntoElement, LabelSize, ParentElement,
Render, Tooltip, ViewContext,
};
use util::paths::FILE_ROW_COLUMN_DELIMITER;
use workspace::{item::ItemHandle, StatusItemView, Workspace};
pub struct CursorPosition {
position: Option<Point>,
selected_count: usize,
workspace: WeakView<Workspace>,
_observe_active_editor: Option<Subscription>,
}
impl CursorPosition {
pub fn new(workspace: &Workspace) -> Self {
Self {
position: None,
selected_count: 0,
workspace: workspace.weak_handle(),
_observe_active_editor: None,
}
}
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);
self.selected_count = 0;
let mut last_selection: Option<Selection<usize>> = None;
for selection in editor.selections.all::<usize>(cx) {
self.selected_count += selection.end - selection.start;
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));
cx.notify();
}
}
impl Render for CursorPosition {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
div().when_some(self.position, |el, position| {
let mut text = format!(
"{}{FILE_ROW_COLUMN_DELIMITER}{}",
position.row + 1,
position.column + 1
);
if self.selected_count > 0 {
write!(text, " ({} selected)", self.selected_count).unwrap();
}
el.child(
Button::new("go-to-line-column", text)
.label_size(LabelSize::Small)
.on_click(cx.listener(|this, _, cx| {
if let Some(workspace) = this.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
if let Some(editor) = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))
{
workspace
.toggle_modal(cx, |cx| crate::GoToLine::new(editor, cx))
}
});
}
}))
.tooltip(|cx| Tooltip::for_action("Go to Line/Column", &crate::Toggle, cx)),
)
})
}
}
impl StatusItemView for CursorPosition {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
cx: &mut ViewContext<Self>,
) {
if let Some(editor) = active_pane_item.and_then(|item| item.act_as::<Editor>(cx)) {
self._observe_active_editor = Some(cx.observe(&editor, Self::update_position));
self.update_position(editor, cx);
} else {
self.position = None;
self._observe_active_editor = None;
}
cx.notify();
}
}

View File

@@ -1,4 +1,6 @@
use editor::{display_map::ToDisplayPoint, scroll::Autoscroll, Editor};
pub mod cursor_position;
use editor::{scroll::Autoscroll, Editor};
use gpui::{
actions, div, prelude::*, AnyWindowHandle, AppContext, DismissEvent, EventEmitter, FocusHandle,
FocusableView, Render, SharedString, Styled, Subscription, View, ViewContext, VisualContext,
@@ -32,6 +34,8 @@ impl FocusableView for GoToLine {
}
impl EventEmitter<DismissEvent> for GoToLine {}
enum GoToLineRowHighlights {}
impl GoToLine {
fn register(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
let handle = cx.view().downgrade();
@@ -49,20 +53,24 @@ impl GoToLine {
}
pub fn new(active_editor: View<Editor>, cx: &mut ViewContext<Self>) -> Self {
let line_editor = cx.new_view(|cx| Editor::single_line(cx));
let editor = active_editor.read(cx);
let cursor = editor.selections.last::<Point>(cx).head();
let line = cursor.row + 1;
let column = cursor.column + 1;
let line_editor = cx.new_view(|cx| {
let mut editor = Editor::single_line(cx);
editor.set_placeholder_text(format!("{line}{FILE_ROW_COLUMN_DELIMITER}{column}"), cx);
editor
});
let line_editor_change = cx.subscribe(&line_editor, Self::on_line_editor_event);
let editor = active_editor.read(cx);
let cursor = editor.selections.last::<Point>(cx).head();
let last_line = editor.buffer().read(cx).snapshot(cx).max_point().row;
let scroll_position = active_editor.update(cx, |editor, cx| editor.scroll_position(cx));
let current_text = format!(
"line {} of {} (column {})",
cursor.row + 1,
last_line + 1,
cursor.column + 1,
);
let current_text = format!("line {} of {} (column {})", line, last_line + 1, column);
Self {
line_editor,
@@ -78,7 +86,7 @@ impl GoToLine {
.update(cx, |_, cx| {
let scroll_position = self.prev_scroll_position.take();
self.active_editor.update(cx, |editor, cx| {
editor.highlight_rows(None);
editor.clear_row_highlights::<GoToLineRowHighlights>();
if let Some(scroll_position) = scroll_position {
editor.set_scroll_position(scroll_position, cx);
}
@@ -106,9 +114,13 @@ impl GoToLine {
self.active_editor.update(cx, |active_editor, cx| {
let snapshot = active_editor.snapshot(cx).display_snapshot;
let point = snapshot.buffer_snapshot.clip_point(point, Bias::Left);
let display_point = point.to_display_point(&snapshot);
let row = display_point.row();
active_editor.highlight_rows(Some(row..row + 1));
let anchor = snapshot.buffer_snapshot.anchor_before(point);
active_editor.clear_row_highlights::<GoToLineRowHighlights>();
active_editor.highlight_rows::<GoToLineRowHighlights>(
anchor..anchor,
Some(cx.theme().colors().editor_highlighted_line_background),
cx,
);
active_editor.request_autoscroll(Autoscroll::center(), cx);
});
cx.notify();
@@ -116,17 +128,22 @@ impl GoToLine {
}
fn point_from_query(&self, cx: &ViewContext<Self>) -> Option<Point> {
let line_editor = self.line_editor.read(cx).text(cx);
let mut components = line_editor
let (row, column) = self.line_column_from_query(cx);
Some(Point::new(
row?.saturating_sub(1),
column.unwrap_or(0).saturating_sub(1),
))
}
fn line_column_from_query(&self, cx: &ViewContext<Self>) -> (Option<u32>, Option<u32>) {
let input = self.line_editor.read(cx).text(cx);
let mut components = input
.splitn(2, FILE_ROW_COLUMN_DELIMITER)
.map(str::trim)
.fuse();
let row = components.next().and_then(|row| row.parse::<u32>().ok())?;
let row = components.next().and_then(|row| row.parse::<u32>().ok());
let column = components.next().and_then(|col| col.parse::<u32>().ok());
Some(Point::new(
row.saturating_sub(1),
column.unwrap_or(0).saturating_sub(1),
))
(row, column)
}
fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
@@ -153,6 +170,16 @@ impl GoToLine {
impl Render for GoToLine {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let mut help_text = self.current_text.clone();
let query = self.line_column_from_query(cx);
if let Some(line) = query.0 {
if let Some(column) = query.1 {
help_text = format!("Go to line {line}, column {column}").into();
} else {
help_text = format!("Go to line {line}").into();
}
}
div()
.elevation_2(cx)
.key_context("GoToLine")
@@ -181,8 +208,182 @@ impl Render for GoToLine {
.justify_between()
.px_2()
.py_1()
.child(Label::new(self.current_text.clone()).color(Color::Muted)),
.child(Label::new(help_text).color(Color::Muted)),
),
)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use gpui::{TestAppContext, VisualTestContext};
use indoc::indoc;
use project::{FakeFs, Project};
use serde_json::json;
use workspace::{AppState, Workspace};
use super::*;
#[gpui::test]
async fn test_go_to_line_view_row_highlights(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/dir",
json!({
"a.rs": indoc!{"
struct SingleLine; // display line 0
// display line 1
struct MultiLine { // display line 2
field_1: i32, // display line 3
field_2: i32, // display line 4
} // display line 5
// display line 7
struct Another { // display line 8
field_1: i32, // display line 9
field_2: i32, // display line 10
field_3: i32, // display line 11
field_4: i32, // display line 12
} // display line 13
"}
}),
)
.await;
let project = Project::test(fs, ["/dir".as_ref()], cx).await;
let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
let worktree_id = workspace.update(cx, |workspace, cx| {
workspace.project().update(cx, |project, cx| {
project.worktrees().next().unwrap().read(cx).id()
})
});
let _buffer = project
.update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
.await
.unwrap();
let editor = workspace
.update(cx, |workspace, cx| {
workspace.open_path((worktree_id, "a.rs"), None, true, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let go_to_line_view = open_go_to_line_view(&workspace, cx);
assert_eq!(
highlighted_display_rows(&editor, cx),
Vec::<u32>::new(),
"Initially opened go to line modal should not highlight any rows"
);
assert_single_caret_at_row(&editor, 0, cx);
cx.simulate_input("1");
assert_eq!(
highlighted_display_rows(&editor, cx),
vec![0],
"Go to line modal should highlight a row, corresponding to the query"
);
assert_single_caret_at_row(&editor, 0, cx);
cx.simulate_input("8");
assert_eq!(
highlighted_display_rows(&editor, cx),
vec![13],
"If the query is too large, the last row should be highlighted"
);
assert_single_caret_at_row(&editor, 0, cx);
cx.dispatch_action(menu::Cancel);
drop(go_to_line_view);
editor.update(cx, |_, _| {});
assert_eq!(
highlighted_display_rows(&editor, cx),
Vec::<u32>::new(),
"After cancelling and closing the modal, no rows should be highlighted"
);
assert_single_caret_at_row(&editor, 0, cx);
let go_to_line_view = open_go_to_line_view(&workspace, cx);
assert_eq!(
highlighted_display_rows(&editor, cx),
Vec::<u32>::new(),
"Reopened modal should not highlight any rows"
);
assert_single_caret_at_row(&editor, 0, cx);
let expected_highlighted_row = 4;
cx.simulate_input("5");
assert_eq!(
highlighted_display_rows(&editor, cx),
vec![expected_highlighted_row]
);
assert_single_caret_at_row(&editor, 0, cx);
cx.dispatch_action(menu::Confirm);
drop(go_to_line_view);
editor.update(cx, |_, _| {});
assert_eq!(
highlighted_display_rows(&editor, cx),
Vec::<u32>::new(),
"After confirming and closing the modal, no rows should be highlighted"
);
// On confirm, should place the caret on the highlighted row.
assert_single_caret_at_row(&editor, expected_highlighted_row, cx);
}
fn open_go_to_line_view(
workspace: &View<Workspace>,
cx: &mut VisualTestContext,
) -> View<GoToLine> {
cx.dispatch_action(Toggle::default());
workspace.update(cx, |workspace, cx| {
workspace.active_modal::<GoToLine>(cx).unwrap().clone()
})
}
fn highlighted_display_rows(editor: &View<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
editor.update(cx, |editor, cx| {
editor.highlighted_display_rows(cx).into_keys().collect()
})
}
#[track_caller]
fn assert_single_caret_at_row(
editor: &View<Editor>,
buffer_row: u32,
cx: &mut VisualTestContext,
) {
let selections = editor.update(cx, |editor, cx| {
editor
.selections
.all::<rope::Point>(cx)
.into_iter()
.map(|s| s.start..s.end)
.collect::<Vec<_>>()
});
assert!(
selections.len() == 1,
"Expected one caret selection but got: {selections:?}"
);
let selection = &selections[0];
assert!(
selection.start == selection.end,
"Expected a single caret selection, but got: {selection:?}"
);
assert_eq!(selection.start.row, buffer_row);
}
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
cx.update(|cx| {
let state = AppState::test(cx);
language::init(cx);
crate::init(cx);
editor::init(cx);
workspace::init_settings(cx);
Project::init_settings(cx);
state
})
}
}

View File

@@ -103,20 +103,24 @@ blade-macros.workspace = true
blade-rwh.workspace = true
bytemuck = "1"
cosmic-text = "0.10.0"
copypasta = "0.10.1"
[target.'cfg(target_os = "linux")'.dependencies]
open = "5.0.1"
ashpd = "0.7.0"
xcb = { version = "1.3", features = ["as-raw-xcb-connection", "randr", "xkb"] }
wayland-client= { version = "0.31.2" }
wayland-client = { version = "0.31.2" }
wayland-cursor = "0.31.1"
wayland-protocols = { version = "0.31.2", features = ["client", "staging", "unstable"] }
wayland-protocols = { version = "0.31.2", features = [
"client",
"staging",
"unstable",
] }
wayland-backend = { version = "0.3.3", features = ["client_system"] }
xkbcommon = { version = "0.7", features = ["wayland", "x11"] }
as-raw-xcb-connection = "1"
calloop = "0.12.4"
calloop-wayland-source = "0.2.0"
copypasta = "0.10.1"
oo7 = "0.3.0"
[target.'cfg(windows)'.dependencies]
@@ -129,3 +133,7 @@ path = "examples/hello_world.rs"
[[example]]
name = "image"
path = "examples/image/image.rs"
[[example]]
name = "set_menus"
path = "examples/set_menus.rs"

View File

@@ -0,0 +1,43 @@
use gpui::*;
struct SetMenus;
impl Render for SetMenus {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
div()
.flex()
.bg(rgb(0x2e7d32))
.size_full()
.justify_center()
.items_center()
.text_xl()
.text_color(rgb(0xffffff))
.child("Set Menus Example")
}
}
fn main() {
App::new().run(|cx: &mut AppContext| {
// Bring the menu bar to the foreground (so you can see the menu bar)
cx.activate(true);
// Register the `quit` function so it can be referenced by the `MenuItem::action` in the menu bar
cx.on_action(quit);
// Add menu items
cx.set_menus(vec![Menu {
name: "set_menus",
items: vec![MenuItem::action("Quit", Quit)],
}]);
cx.open_window(WindowOptions::default(), |cx| {
cx.new_view(|_cx| SetMenus {})
});
});
}
// Associate actions using the `actions!` macro (or `impl_actions!` macro)
actions!(set_menus, [Quit]);
// Define the quit function that is registered with the AppContext
fn quit(_: &Quit, cx: &mut AppContext) {
println!("Gracefully quitting the application . . .");
cx.quit();
}

View File

@@ -20,7 +20,6 @@ pub use async_context::*;
use collections::{FxHashMap, FxHashSet, VecDeque};
pub use entity_map::*;
pub use model_context::*;
use refineable::Refineable;
#[cfg(any(test, feature = "test-support"))]
pub use test_context::*;
use util::{
@@ -28,14 +27,14 @@ use util::{
ResultExt,
};
use crate::WindowAppearance;
use crate::{
current_platform, image_cache::ImageCache, init_app_menus, Action, ActionRegistry, Any,
AnyView, AnyWindowHandle, AppMetadata, AssetSource, BackgroundExecutor, ClipboardItem, Context,
DispatchPhase, Entity, EventEmitter, ForegroundExecutor, Global, KeyBinding, Keymap, Keystroke,
LayoutId, Menu, PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, Render,
SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextStyle, TextStyleRefinement,
TextSystem, View, ViewContext, Window, WindowContext, WindowHandle, WindowId,
LayoutId, Menu, PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, PromptBuilder,
PromptHandle, PromptLevel, Render, RenderablePromptHandle, SharedString, SubscriberSet,
Subscription, SvgRenderer, Task, TextSystem, View, ViewContext, Window, WindowAppearance,
WindowContext, WindowHandle, WindowId,
};
mod async_context;
@@ -216,7 +215,6 @@ pub struct AppContext {
pub(crate) svg_renderer: SvgRenderer,
asset_source: Arc<dyn AssetSource>,
pub(crate) image_cache: ImageCache,
pub(crate) text_style_stack: Vec<TextStyleRefinement>,
pub(crate) globals_by_type: FxHashMap<TypeId, Box<dyn Any>>,
pub(crate) entities: EntityMap,
pub(crate) new_view_observers: SubscriberSet<TypeId, NewViewListener>,
@@ -237,6 +235,7 @@ pub struct AppContext {
pub(crate) quit_observers: SubscriberSet<(), QuitHandler>,
pub(crate) layout_id_buffer: Vec<LayoutId>, // We recycle this memory across layout requests.
pub(crate) propagate_event: bool,
pub(crate) prompt_builder: Option<PromptBuilder>,
}
impl AppContext {
@@ -277,7 +276,6 @@ impl AppContext {
svg_renderer: SvgRenderer::new(asset_source.clone()),
asset_source,
image_cache: ImageCache::new(http_client),
text_style_stack: Vec::new(),
globals_by_type: FxHashMap::default(),
entities,
new_view_observers: SubscriberSet::new(),
@@ -296,6 +294,7 @@ impl AppContext {
quit_observers: SubscriberSet::new(),
layout_id_buffer: Default::default(),
propagate_event: true,
prompt_builder: Some(PromptBuilder::Default),
}),
});
@@ -827,15 +826,6 @@ impl AppContext {
&self.text_system
}
/// The current text style. Which is composed of all the style refinements provided to `with_text_style`.
pub fn text_style(&self) -> TextStyle {
let mut style = TextStyle::default();
for refinement in &self.text_style_stack {
style.refine(refinement);
}
style
}
/// Check whether a global of the given type has been assigned.
pub fn has_global<G: Global>(&self) -> bool {
self.globals_by_type.contains_key(&TypeId::of::<G>())
@@ -1019,14 +1009,6 @@ impl AppContext {
inner(&mut self.keystroke_observers, Box::new(f))
}
pub(crate) fn push_text_style(&mut self, text_style: TextStyleRefinement) {
self.text_style_stack.push(text_style);
}
pub(crate) fn pop_text_style(&mut self) {
self.text_style_stack.pop();
}
/// Register key bindings.
pub fn bind_keys(&mut self, bindings: impl IntoIterator<Item = KeyBinding>) {
self.keymap.borrow_mut().add_bindings(bindings);
@@ -1125,16 +1107,19 @@ impl AppContext {
/// Checks if the given action is bound in the current context, as defined by the app's current focus,
/// the bindings in the element tree, and any global action listeners.
pub fn is_action_available(&mut self, action: &dyn Action) -> bool {
let mut action_available = false;
if let Some(window) = self.active_window() {
if let Ok(window_action_available) =
window.update(self, |_, cx| cx.is_action_available(action))
{
return window_action_available;
action_available = window_action_available;
}
}
self.global_action_listeners
.contains_key(&action.as_any().type_id())
action_available
|| self
.global_action_listeners
.contains_key(&action.as_any().type_id())
}
/// Sets the menu bar for this application. This will replace any existing menu bar.
@@ -1150,14 +1135,41 @@ impl AppContext {
.update(self, |_, cx| cx.dispatch_action(action.boxed_clone()))
.log_err();
} else {
self.propagate_event = true;
self.dispatch_global_action(action);
}
}
pub(crate) fn dispatch_global_action(&mut self, action: &dyn Action) {
self.propagate_event = true;
if let Some(mut global_listeners) = self
.global_action_listeners
.remove(&action.as_any().type_id())
{
for listener in &global_listeners {
listener(action.as_any(), DispatchPhase::Capture, self);
if !self.propagate_event {
break;
}
}
global_listeners.extend(
self.global_action_listeners
.remove(&action.as_any().type_id())
.unwrap_or_default(),
);
self.global_action_listeners
.insert(action.as_any().type_id(), global_listeners);
}
if self.propagate_event {
if let Some(mut global_listeners) = self
.global_action_listeners
.remove(&action.as_any().type_id())
{
for listener in &global_listeners {
listener(action.as_any(), DispatchPhase::Capture, self);
for listener in global_listeners.iter().rev() {
listener(action.as_any(), DispatchPhase::Bubble, self);
if !self.propagate_event {
break;
}
@@ -1172,29 +1184,6 @@ impl AppContext {
self.global_action_listeners
.insert(action.as_any().type_id(), global_listeners);
}
if self.propagate_event {
if let Some(mut global_listeners) = self
.global_action_listeners
.remove(&action.as_any().type_id())
{
for listener in global_listeners.iter().rev() {
listener(action.as_any(), DispatchPhase::Bubble, self);
if !self.propagate_event {
break;
}
}
global_listeners.extend(
self.global_action_listeners
.remove(&action.as_any().type_id())
.unwrap_or_default(),
);
self.global_action_listeners
.insert(action.as_any().type_id(), global_listeners);
}
}
}
}
@@ -1202,6 +1191,23 @@ impl AppContext {
pub fn has_active_drag(&self) -> bool {
self.active_drag.is_some()
}
/// Set the prompt renderer for GPUI. This will replace the default or platform specific
/// prompts with this custom implementation.
pub fn set_prompt_builder(
&mut self,
renderer: impl Fn(
PromptLevel,
&str,
Option<&str>,
&[&str],
PromptHandle,
&mut WindowContext,
) -> RenderablePromptHandle
+ 'static,
) {
self.prompt_builder = Some(PromptBuilder::Custom(Box::new(renderer)))
}
}
impl Context for AppContext {

View File

@@ -674,17 +674,10 @@ impl VisualTestContext {
f: impl FnOnce(&mut WindowContext) -> AnyElement,
) {
self.update(|cx| {
let entity_id = cx
.window
.root_view
.as_ref()
.expect("Can't draw to this window without a root view")
.entity_id();
cx.with_element_context(|cx| {
cx.with_view_id(entity_id, |cx| {
f(cx).draw(origin, space, cx);
})
let mut element = f(cx);
element.layout(origin, space, cx);
element.paint(cx);
});
cx.refresh();

View File

@@ -0,0 +1,292 @@
use crate::{Bounds, Half};
use std::{
cmp,
fmt::Debug,
ops::{Add, Sub},
};
#[derive(Debug)]
pub(crate) struct BoundsTree<U>
where
U: Default + Clone + Debug,
{
root: Option<usize>,
nodes: Vec<Node<U>>,
stack: Vec<usize>,
}
impl<U> BoundsTree<U>
where
U: Clone + Debug + PartialOrd + Add<U, Output = U> + Sub<Output = U> + Half + Default,
{
pub fn clear(&mut self) {
self.root = None;
self.nodes.clear();
self.stack.clear();
}
pub fn insert(&mut self, new_bounds: Bounds<U>) -> u32 {
// If the tree is empty, make the root the new leaf.
if self.root.is_none() {
let new_node = self.push_leaf(new_bounds, 1);
self.root = Some(new_node);
return 1;
}
// Search for the best place to add the new leaf based on heuristics.
let mut max_intersecting_ordering = 0;
let mut index = self.root.unwrap();
while let Node::Internal {
left,
right,
bounds: node_bounds,
..
} = &mut self.nodes[index]
{
let left = *left;
let right = *right;
*node_bounds = node_bounds.union(&new_bounds);
self.stack.push(index);
// Descend to the best-fit child, based on which one would increase
// the surface area the least. This attempts to keep the tree balanced
// in terms of surface area. If there is an intersection with the other child,
// add its keys to the intersections vector.
let left_cost = new_bounds
.union(&self.nodes[left].bounds())
.half_perimeter();
let right_cost = new_bounds
.union(&self.nodes[right].bounds())
.half_perimeter();
if left_cost < right_cost {
max_intersecting_ordering =
self.find_max_ordering(right, &new_bounds, max_intersecting_ordering);
index = left;
} else {
max_intersecting_ordering =
self.find_max_ordering(left, &new_bounds, max_intersecting_ordering);
index = right;
}
}
// We've found a leaf ('index' now refers to a leaf node).
// We'll insert a new parent node above the leaf and attach our new leaf to it.
let sibling = index;
// Check for collision with the located leaf node
let Node::Leaf {
bounds: sibling_bounds,
order: sibling_ordering,
..
} = &self.nodes[index]
else {
unreachable!();
};
if sibling_bounds.intersects(&new_bounds) {
max_intersecting_ordering = cmp::max(max_intersecting_ordering, *sibling_ordering);
}
let ordering = max_intersecting_ordering + 1;
let new_node = self.push_leaf(new_bounds, ordering);
let new_parent = self.push_internal(sibling, new_node);
// If there was an old parent, we need to update its children indices.
if let Some(old_parent) = self.stack.last().copied() {
let Node::Internal { left, right, .. } = &mut self.nodes[old_parent] else {
unreachable!();
};
if *left == sibling {
*left = new_parent;
} else {
*right = new_parent;
}
} else {
// If the old parent was the root, the new parent is the new root.
self.root = Some(new_parent);
}
for node_index in self.stack.drain(..) {
let Node::Internal {
max_order: max_ordering,
..
} = &mut self.nodes[node_index]
else {
unreachable!()
};
*max_ordering = cmp::max(*max_ordering, ordering);
}
ordering
}
fn find_max_ordering(&self, index: usize, bounds: &Bounds<U>, mut max_ordering: u32) -> u32 {
match &self.nodes[index] {
Node::Leaf {
bounds: node_bounds,
order: ordering,
..
} => {
if bounds.intersects(node_bounds) {
max_ordering = cmp::max(*ordering, max_ordering);
}
}
Node::Internal {
left,
right,
bounds: node_bounds,
max_order: node_max_ordering,
..
} => {
if bounds.intersects(node_bounds) && max_ordering < *node_max_ordering {
let left_max_ordering = self.nodes[*left].max_ordering();
let right_max_ordering = self.nodes[*right].max_ordering();
if left_max_ordering > right_max_ordering {
max_ordering = self.find_max_ordering(*left, bounds, max_ordering);
max_ordering = self.find_max_ordering(*right, bounds, max_ordering);
} else {
max_ordering = self.find_max_ordering(*right, bounds, max_ordering);
max_ordering = self.find_max_ordering(*left, bounds, max_ordering);
}
}
}
}
max_ordering
}
fn push_leaf(&mut self, bounds: Bounds<U>, order: u32) -> usize {
self.nodes.push(Node::Leaf { bounds, order });
self.nodes.len() - 1
}
fn push_internal(&mut self, left: usize, right: usize) -> usize {
let left_node = &self.nodes[left];
let right_node = &self.nodes[right];
let new_bounds = left_node.bounds().union(right_node.bounds());
let max_ordering = cmp::max(left_node.max_ordering(), right_node.max_ordering());
self.nodes.push(Node::Internal {
bounds: new_bounds,
left,
right,
max_order: max_ordering,
});
self.nodes.len() - 1
}
}
impl<U> Default for BoundsTree<U>
where
U: Default + Clone + Debug,
{
fn default() -> Self {
BoundsTree {
root: None,
nodes: Vec::new(),
stack: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
enum Node<U>
where
U: Clone + Default + Debug,
{
Leaf {
bounds: Bounds<U>,
order: u32,
},
Internal {
left: usize,
right: usize,
bounds: Bounds<U>,
max_order: u32,
},
}
impl<U> Node<U>
where
U: Clone + Default + Debug,
{
fn bounds(&self) -> &Bounds<U> {
match self {
Node::Leaf { bounds, .. } => bounds,
Node::Internal { bounds, .. } => bounds,
}
}
fn max_ordering(&self) -> u32 {
match self {
Node::Leaf {
order: ordering, ..
} => *ordering,
Node::Internal {
max_order: max_ordering,
..
} => *max_ordering,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Bounds, Point, Size};
#[test]
fn test_insert() {
let mut tree = BoundsTree::<f32>::default();
let bounds1 = Bounds {
origin: Point { x: 0.0, y: 0.0 },
size: Size {
width: 10.0,
height: 10.0,
},
};
let bounds2 = Bounds {
origin: Point { x: 5.0, y: 5.0 },
size: Size {
width: 10.0,
height: 10.0,
},
};
let bounds3 = Bounds {
origin: Point { x: 10.0, y: 10.0 },
size: Size {
width: 10.0,
height: 10.0,
},
};
// Insert the bounds into the tree and verify the order is correct
assert_eq!(tree.insert(bounds1), 1);
assert_eq!(tree.insert(bounds2), 2);
assert_eq!(tree.insert(bounds3), 3);
// Insert non-overlapping bounds and verify they can reuse orders
let bounds4 = Bounds {
origin: Point { x: 20.0, y: 20.0 },
size: Size {
width: 10.0,
height: 10.0,
},
};
let bounds5 = Bounds {
origin: Point { x: 40.0, y: 40.0 },
size: Size {
width: 10.0,
height: 10.0,
},
};
let bounds6 = Bounds {
origin: Point { x: 25.0, y: 25.0 },
size: Size {
width: 10.0,
height: 10.0,
},
};
assert_eq!(tree.insert(bounds4), 1); // bounds4 does not overlap with bounds1, bounds2, or bounds3
assert_eq!(tree.insert(bounds5), 1); // bounds5 does not overlap with any other bounds
assert_eq!(tree.insert(bounds6), 2); // bounds6 overlaps with bounds4, so it should have a different order
}
}

View File

@@ -247,6 +247,16 @@ pub fn transparent_black() -> Hsla {
}
}
/// Opaque grey in [`Hsla`], values will be clamped to the range [0, 1]
pub fn opaque_grey(lightness: f32, opacity: f32) -> Hsla {
Hsla {
h: 0.,
s: 0.,
l: lightness.clamp(0., 1.),
a: opacity.clamp(0., 1.),
}
}
/// Pure white in [`Hsla`]
pub fn white() -> Hsla {
Hsla {

View File

@@ -15,9 +15,6 @@
//!
//! But some state is too simple and voluminous to store in every view that needs it, e.g.
//! whether a hover has been started or not. For this, GPUI provides the [`Element::State`], associated type.
//! If an element returns an [`ElementId`] from [`IntoElement::element_id()`], and that element id
//! appears in the same place relative to other views and ElementIds in the frame, then the previous
//! frame's state will be passed to the element's layout and paint methods.
//!
//! # Implementing your own elements
//!
@@ -35,33 +32,48 @@
//! your own custom layout algorithm or rendering a code editor.
use crate::{
util::FluentBuilder, ArenaBox, AvailableSpace, Bounds, ElementContext, ElementId, LayoutId,
Pixels, Point, Size, ViewContext, WindowContext, ELEMENT_ARENA,
util::FluentBuilder, ArenaBox, AvailableSpace, Bounds, DispatchNodeId, ElementContext,
ElementId, LayoutId, Pixels, Point, Size, ViewContext, WindowContext, ELEMENT_ARENA,
};
use derive_more::{Deref, DerefMut};
pub(crate) use smallvec::SmallVec;
use std::{any::Any, fmt::Debug, ops::DerefMut};
use std::{any::Any, fmt::Debug, mem, ops::DerefMut};
/// Implemented by types that participate in laying out and painting the contents of a window.
/// Elements form a tree and are laid out according to web-based layout rules, as implemented by Taffy.
/// You can create custom elements by implementing this trait, see the module-level documentation
/// for more details.
pub trait Element: 'static + IntoElement {
/// The type of state to store for this element between frames. See the module-level documentation
/// for details.
type State: 'static;
/// The type of state returned from [`Element::before_layout`]. A mutable reference to this state is subsequently
/// provided to [`Element::after_layout`] and [`Element::paint`].
type BeforeLayout: 'static;
/// The type of state returned from [`Element::after_layout`]. A mutable reference to this state is subsequently
/// provided to [`Element::paint`].
type AfterLayout: 'static;
/// Before an element can be painted, we need to know where it's going to be and how big it is.
/// Use this method to request a layout from Taffy and initialize the element's state.
fn request_layout(
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout);
/// After laying out an element, we need to commit its bounds to the current frame for hitbox
/// purposes. The state argument is the same state that was returned from [`Element::before_layout()`].
fn after_layout(
&mut self,
state: Option<Self::State>,
bounds: Bounds<Pixels>,
before_layout: &mut Self::BeforeLayout,
cx: &mut ElementContext,
) -> (LayoutId, Self::State);
) -> Self::AfterLayout;
/// Once layout has been completed, this method will be called to paint the element to the screen.
/// The state argument is the same state that was returned from [`Element::request_layout()`].
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut Self::State, cx: &mut ElementContext);
/// The state argument is the same state that was returned from [`Element::before_layout()`].
fn paint(
&mut self,
bounds: Bounds<Pixels>,
before_layout: &mut Self::BeforeLayout,
after_layout: &mut Self::AfterLayout,
cx: &mut ElementContext,
);
/// Convert this element into a dynamically-typed [`AnyElement`].
fn into_any(self) -> AnyElement {
@@ -75,10 +87,6 @@ pub trait IntoElement: Sized {
/// Useful for converting other types into elements automatically, like Strings
type Element: Element;
/// The [`ElementId`] of self once converted into an [`Element`].
/// If present, the resulting element's state will be carried across frames.
fn element_id(&self) -> Option<ElementId>;
/// Convert self into a type that implements [`Element`].
fn into_element(self) -> Self::Element;
@@ -86,41 +94,6 @@ pub trait IntoElement: Sized {
fn into_any_element(self) -> AnyElement {
self.into_element().into_any()
}
/// Convert into an element, then draw in the current window at the given origin.
/// The available space argument is provided to the layout engine to determine the size of the
// root element. Once the element is drawn, its associated element state is yielded to the
// given callback.
fn draw_and_update_state<T, R>(
self,
origin: Point<Pixels>,
available_space: Size<T>,
cx: &mut ElementContext,
f: impl FnOnce(&mut <Self::Element as Element>::State, &mut ElementContext) -> R,
) -> R
where
T: Clone + Default + Debug + Into<AvailableSpace>,
{
let element = self.into_element();
let element_id = element.element_id();
let element = DrawableElement {
element: Some(element),
phase: ElementDrawPhase::Start,
};
let frame_state =
DrawableElement::draw(element, origin, available_space.map(Into::into), cx);
if let Some(mut frame_state) = frame_state {
f(&mut frame_state, cx)
} else {
cx.with_element_state(element_id.unwrap(), |element_state, cx| {
let mut element_state = element_state.unwrap();
let result = f(&mut element_state, cx);
(result, element_state)
})
}
}
}
impl<T: IntoElement> FluentBuilder for T {}
@@ -188,24 +161,36 @@ impl<C: RenderOnce> Component<C> {
}
impl<C: RenderOnce> Element for Component<C> {
type State = AnyElement;
type BeforeLayout = AnyElement;
type AfterLayout = ();
fn request_layout(
&mut self,
_: Option<Self::State>,
cx: &mut ElementContext,
) -> (LayoutId, Self::State) {
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
let mut element = self
.0
.take()
.unwrap()
.render(cx.deref_mut())
.into_any_element();
let layout_id = element.request_layout(cx);
let layout_id = element.before_layout(cx);
(layout_id, element)
}
fn paint(&mut self, _: Bounds<Pixels>, element: &mut Self::State, cx: &mut ElementContext) {
fn after_layout(
&mut self,
_: Bounds<Pixels>,
element: &mut AnyElement,
cx: &mut ElementContext,
) {
element.after_layout(cx);
}
fn paint(
&mut self,
_: Bounds<Pixels>,
element: &mut Self::BeforeLayout,
_: &mut Self::AfterLayout,
cx: &mut ElementContext,
) {
element.paint(cx)
}
}
@@ -213,10 +198,6 @@ impl<C: RenderOnce> Element for Component<C> {
impl<C: RenderOnce> IntoElement for Component<C> {
type Element = Self;
fn element_id(&self) -> Option<ElementId> {
None
}
fn into_element(self) -> Self::Element {
self
}
@@ -227,9 +208,11 @@ impl<C: RenderOnce> IntoElement for Component<C> {
pub(crate) struct GlobalElementId(SmallVec<[ElementId; 32]>);
trait ElementObject {
fn element_id(&self) -> Option<ElementId>;
fn inner_element(&mut self) -> &mut dyn Any;
fn request_layout(&mut self, cx: &mut ElementContext) -> LayoutId;
fn before_layout(&mut self, cx: &mut ElementContext) -> LayoutId;
fn after_layout(&mut self, cx: &mut ElementContext);
fn paint(&mut self, cx: &mut ElementContext);
@@ -238,110 +221,102 @@ trait ElementObject {
available_space: Size<AvailableSpace>,
cx: &mut ElementContext,
) -> Size<Pixels>;
fn draw(
&mut self,
origin: Point<Pixels>,
available_space: Size<AvailableSpace>,
cx: &mut ElementContext,
);
}
/// A wrapper around an implementer of [`Element`] that allows it to be drawn in a window.
pub(crate) struct DrawableElement<E: Element> {
element: Option<E>,
phase: ElementDrawPhase<E::State>,
pub struct Drawable<E: Element> {
/// The drawn element.
pub element: E,
phase: ElementDrawPhase<E::BeforeLayout, E::AfterLayout>,
}
#[derive(Default)]
enum ElementDrawPhase<S> {
enum ElementDrawPhase<BeforeLayout, AfterLayout> {
#[default]
Start,
LayoutRequested {
BeforeLayout {
layout_id: LayoutId,
frame_state: Option<S>,
before_layout: BeforeLayout,
},
LayoutComputed {
layout_id: LayoutId,
available_space: Size<AvailableSpace>,
frame_state: Option<S>,
before_layout: BeforeLayout,
},
AfterLayout {
node_id: DispatchNodeId,
bounds: Bounds<Pixels>,
before_layout: BeforeLayout,
after_layout: AfterLayout,
},
Painted,
}
/// A wrapper around an implementer of [`Element`] that allows it to be drawn in a window.
impl<E: Element> DrawableElement<E> {
impl<E: Element> Drawable<E> {
fn new(element: E) -> Self {
DrawableElement {
element: Some(element),
Drawable {
element,
phase: ElementDrawPhase::Start,
}
}
fn element_id(&self) -> Option<ElementId> {
self.element.as_ref()?.element_id()
fn before_layout(&mut self, cx: &mut ElementContext) -> LayoutId {
match mem::take(&mut self.phase) {
ElementDrawPhase::Start => {
let (layout_id, before_layout) = self.element.before_layout(cx);
self.phase = ElementDrawPhase::BeforeLayout {
layout_id,
before_layout,
};
layout_id
}
_ => panic!("must call before_layout only once"),
}
}
fn request_layout(&mut self, cx: &mut ElementContext) -> LayoutId {
let (layout_id, frame_state) = if let Some(id) = self.element.as_ref().unwrap().element_id()
{
let layout_id = cx.with_element_state(id, |element_state, cx| {
self.element
.as_mut()
.unwrap()
.request_layout(element_state, cx)
});
(layout_id, None)
} else {
let (layout_id, frame_state) = self.element.as_mut().unwrap().request_layout(None, cx);
(layout_id, Some(frame_state))
};
self.phase = ElementDrawPhase::LayoutRequested {
layout_id,
frame_state,
};
layout_id
}
fn paint(mut self, cx: &mut ElementContext) -> Option<E::State> {
match self.phase {
ElementDrawPhase::LayoutRequested {
fn after_layout(&mut self, cx: &mut ElementContext) {
match mem::take(&mut self.phase) {
ElementDrawPhase::BeforeLayout {
layout_id,
frame_state,
mut before_layout,
}
| ElementDrawPhase::LayoutComputed {
layout_id,
frame_state,
mut before_layout,
..
} => {
let bounds = cx.layout_bounds(layout_id);
if let Some(mut frame_state) = frame_state {
self.element
.take()
.unwrap()
.paint(bounds, &mut frame_state, cx);
Some(frame_state)
} else {
let element_id = self
.element
.as_ref()
.unwrap()
.element_id()
.expect("if we don't have frame state, we should have element state");
cx.with_element_state(element_id, |element_state, cx| {
let mut element_state = element_state.unwrap();
self.element
.take()
.unwrap()
.paint(bounds, &mut element_state, cx);
((), element_state)
});
None
}
let node_id = cx.window.next_frame.dispatch_tree.push_node();
let after_layout = self.element.after_layout(bounds, &mut before_layout, cx);
self.phase = ElementDrawPhase::AfterLayout {
node_id,
bounds,
before_layout,
after_layout,
};
cx.window.next_frame.dispatch_tree.pop_node();
}
_ => panic!("must call before_layout before after_layout"),
}
}
_ => panic!("must call layout before paint"),
fn paint(&mut self, cx: &mut ElementContext) -> E::BeforeLayout {
match mem::take(&mut self.phase) {
ElementDrawPhase::AfterLayout {
node_id,
bounds,
mut before_layout,
mut after_layout,
..
} => {
cx.window.next_frame.dispatch_tree.set_active_node(node_id);
self.element
.paint(bounds, &mut before_layout, &mut after_layout, cx);
self.phase = ElementDrawPhase::Painted;
before_layout
}
_ => panic!("must call after_layout before paint"),
}
}
@@ -351,66 +326,63 @@ impl<E: Element> DrawableElement<E> {
cx: &mut ElementContext,
) -> Size<Pixels> {
if matches!(&self.phase, ElementDrawPhase::Start) {
self.request_layout(cx);
self.before_layout(cx);
}
let layout_id = match &mut self.phase {
ElementDrawPhase::LayoutRequested {
let layout_id = match mem::take(&mut self.phase) {
ElementDrawPhase::BeforeLayout {
layout_id,
frame_state,
before_layout,
} => {
cx.compute_layout(*layout_id, available_space);
let layout_id = *layout_id;
cx.compute_layout(layout_id, available_space);
self.phase = ElementDrawPhase::LayoutComputed {
layout_id,
available_space,
frame_state: frame_state.take(),
before_layout,
};
layout_id
}
ElementDrawPhase::LayoutComputed {
layout_id,
available_space: prev_available_space,
..
before_layout,
} => {
if available_space != *prev_available_space {
cx.compute_layout(*layout_id, available_space);
*prev_available_space = available_space;
if available_space != prev_available_space {
cx.compute_layout(layout_id, available_space);
}
*layout_id
self.phase = ElementDrawPhase::LayoutComputed {
layout_id,
available_space,
before_layout,
};
layout_id
}
_ => panic!("cannot measure after painting"),
};
cx.layout_bounds(layout_id).size
}
fn draw(
mut self,
origin: Point<Pixels>,
available_space: Size<AvailableSpace>,
cx: &mut ElementContext,
) -> Option<E::State> {
self.measure(available_space, cx);
cx.with_absolute_element_offset(origin, |cx| self.paint(cx))
}
}
impl<E> ElementObject for Option<DrawableElement<E>>
impl<E> ElementObject for Drawable<E>
where
E: Element,
E::State: 'static,
E::BeforeLayout: 'static,
{
fn element_id(&self) -> Option<ElementId> {
self.as_ref().unwrap().element_id()
fn inner_element(&mut self) -> &mut dyn Any {
&mut self.element
}
fn request_layout(&mut self, cx: &mut ElementContext) -> LayoutId {
DrawableElement::request_layout(self.as_mut().unwrap(), cx)
fn before_layout(&mut self, cx: &mut ElementContext) -> LayoutId {
Drawable::before_layout(self, cx)
}
fn after_layout(&mut self, cx: &mut ElementContext) {
Drawable::after_layout(self, cx);
}
fn paint(&mut self, cx: &mut ElementContext) {
DrawableElement::paint(self.take().unwrap(), cx);
Drawable::paint(self, cx);
}
fn measure(
@@ -418,16 +390,7 @@ where
available_space: Size<AvailableSpace>,
cx: &mut ElementContext,
) -> Size<Pixels> {
DrawableElement::measure(self.as_mut().unwrap(), available_space, cx)
}
fn draw(
&mut self,
origin: Point<Pixels>,
available_space: Size<AvailableSpace>,
cx: &mut ElementContext,
) {
DrawableElement::draw(self.take().unwrap(), origin, available_space, cx);
Drawable::measure(self, available_space, cx)
}
}
@@ -438,18 +401,28 @@ impl AnyElement {
pub(crate) fn new<E>(element: E) -> Self
where
E: 'static + Element,
E::State: Any,
E::BeforeLayout: Any,
{
let element = ELEMENT_ARENA
.with_borrow_mut(|arena| arena.alloc(|| Some(DrawableElement::new(element))))
.with_borrow_mut(|arena| arena.alloc(|| Drawable::new(element)))
.map(|element| element as &mut dyn ElementObject);
AnyElement(element)
}
/// Attempt to downcast a reference to the boxed element to a specific type.
pub fn downcast_mut<T: 'static>(&mut self) -> Option<&mut T> {
self.0.inner_element().downcast_mut::<T>()
}
/// Request the layout ID of the element stored in this `AnyElement`.
/// Used for laying out child elements in a parent element.
pub fn request_layout(&mut self, cx: &mut ElementContext) -> LayoutId {
self.0.request_layout(cx)
pub fn before_layout(&mut self, cx: &mut ElementContext) -> LayoutId {
self.0.before_layout(cx)
}
/// Commits the element bounds of this [AnyElement] for hitbox purposes.
pub fn after_layout(&mut self, cx: &mut ElementContext) {
self.0.after_layout(cx)
}
/// Paints the element stored in this `AnyElement`.
@@ -466,35 +439,44 @@ impl AnyElement {
self.0.measure(available_space, cx)
}
/// Initializes this element and performs layout in the available space, then paints it at the given origin.
pub fn draw(
/// Initializes this element, performs layout if needed and commits its bounds for hitbox purposes.
pub fn layout(
&mut self,
origin: Point<Pixels>,
absolute_offset: Point<Pixels>,
available_space: Size<AvailableSpace>,
cx: &mut ElementContext,
) {
self.0.draw(origin, available_space, cx)
}
/// Returns the element ID of the element stored in this `AnyElement`, if any.
pub fn inner_id(&self) -> Option<ElementId> {
self.0.element_id()
) -> Size<Pixels> {
let size = self.measure(available_space, cx);
cx.with_absolute_element_offset(absolute_offset, |cx| self.after_layout(cx));
size
}
}
impl Element for AnyElement {
type State = ();
type BeforeLayout = ();
type AfterLayout = ();
fn request_layout(
&mut self,
_: Option<Self::State>,
cx: &mut ElementContext,
) -> (LayoutId, Self::State) {
let layout_id = self.request_layout(cx);
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
let layout_id = self.before_layout(cx);
(layout_id, ())
}
fn paint(&mut self, _: Bounds<Pixels>, _: &mut Self::State, cx: &mut ElementContext) {
fn after_layout(
&mut self,
_: Bounds<Pixels>,
_: &mut Self::BeforeLayout,
cx: &mut ElementContext,
) {
self.after_layout(cx)
}
fn paint(
&mut self,
_: Bounds<Pixels>,
_: &mut Self::BeforeLayout,
_: &mut Self::AfterLayout,
cx: &mut ElementContext,
) {
self.paint(cx)
}
}
@@ -502,10 +484,6 @@ impl Element for AnyElement {
impl IntoElement for AnyElement {
type Element = Self;
fn element_id(&self) -> Option<ElementId> {
None
}
fn into_element(self) -> Self::Element {
self
}
@@ -521,30 +499,32 @@ pub struct Empty;
impl IntoElement for Empty {
type Element = Self;
fn element_id(&self) -> Option<ElementId> {
None
}
fn into_element(self) -> Self::Element {
self
}
}
impl Element for Empty {
type State = ();
type BeforeLayout = ();
type AfterLayout = ();
fn request_layout(
&mut self,
_state: Option<Self::State>,
cx: &mut ElementContext,
) -> (LayoutId, Self::State) {
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
(cx.request_layout(&crate::Style::default(), None), ())
}
fn after_layout(
&mut self,
_bounds: Bounds<Pixels>,
_state: &mut Self::BeforeLayout,
_cx: &mut ElementContext,
) {
}
fn paint(
&mut self,
_bounds: Bounds<Pixels>,
_state: &mut Self::State,
_before_layout: &mut Self::BeforeLayout,
_after_layout: &mut Self::AfterLayout,
_cx: &mut ElementContext,
) {
}

View File

@@ -4,54 +4,68 @@ use crate::{Bounds, Element, ElementContext, IntoElement, Pixels, Style, StyleRe
/// Construct a canvas element with the given paint callback.
/// Useful for adding short term custom drawing to a view.
pub fn canvas(callback: impl 'static + FnOnce(&Bounds<Pixels>, &mut ElementContext)) -> Canvas {
pub fn canvas<T>(
after_layout: impl 'static + FnOnce(Bounds<Pixels>, &mut ElementContext) -> T,
paint: impl 'static + FnOnce(Bounds<Pixels>, T, &mut ElementContext),
) -> Canvas<T> {
Canvas {
paint_callback: Some(Box::new(callback)),
after_layout: Some(Box::new(after_layout)),
paint: Some(Box::new(paint)),
style: StyleRefinement::default(),
}
}
/// A canvas element, meant for accessing the low level paint API without defining a whole
/// custom element
pub struct Canvas {
paint_callback: Option<Box<dyn FnOnce(&Bounds<Pixels>, &mut ElementContext)>>,
pub struct Canvas<T> {
after_layout: Option<Box<dyn FnOnce(Bounds<Pixels>, &mut ElementContext) -> T>>,
paint: Option<Box<dyn FnOnce(Bounds<Pixels>, T, &mut ElementContext)>>,
style: StyleRefinement,
}
impl IntoElement for Canvas {
impl<T: 'static> IntoElement for Canvas<T> {
type Element = Self;
fn element_id(&self) -> Option<crate::ElementId> {
None
}
fn into_element(self) -> Self::Element {
self
}
}
impl Element for Canvas {
type State = Style;
impl<T: 'static> Element for Canvas<T> {
type BeforeLayout = Style;
type AfterLayout = Option<T>;
fn request_layout(
&mut self,
_: Option<Self::State>,
cx: &mut ElementContext,
) -> (crate::LayoutId, Self::State) {
fn before_layout(&mut self, cx: &mut ElementContext) -> (crate::LayoutId, Self::BeforeLayout) {
let mut style = Style::default();
style.refine(&self.style);
let layout_id = cx.request_layout(&style, []);
(layout_id, style)
}
fn paint(&mut self, bounds: Bounds<Pixels>, style: &mut Style, cx: &mut ElementContext) {
fn after_layout(
&mut self,
bounds: Bounds<Pixels>,
_before_layout: &mut Style,
cx: &mut ElementContext,
) -> Option<T> {
Some(self.after_layout.take().unwrap()(bounds, cx))
}
fn paint(
&mut self,
bounds: Bounds<Pixels>,
style: &mut Style,
after_layout: &mut Self::AfterLayout,
cx: &mut ElementContext,
) {
let after_layout = after_layout.take().unwrap();
style.paint(bounds, cx, |cx| {
(self.paint_callback.take().unwrap())(&bounds, cx)
(self.paint.take().unwrap())(bounds, after_layout, cx)
});
}
}
impl Styled for Canvas {
impl<T> Styled for Canvas<T> {
fn style(&mut self) -> &mut crate::StyleRefinement {
&mut self.style
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,8 @@ use std::path::PathBuf;
use std::sync::Arc;
use crate::{
point, size, Bounds, DevicePixels, Element, ElementContext, ImageData, InteractiveElement,
InteractiveElementState, Interactivity, IntoElement, LayoutId, Pixels, SharedUri, Size,
point, size, Bounds, DevicePixels, Element, ElementContext, Hitbox, ImageData,
InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, SharedUri, Size,
StyleRefinement, Styled, UriOrPath,
};
use futures::FutureExt;
@@ -88,86 +88,85 @@ impl Img {
}
impl Element for Img {
type State = InteractiveElementState;
type BeforeLayout = ();
type AfterLayout = Option<Hitbox>;
fn request_layout(
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
let layout_id = self
.interactivity
.before_layout(cx, |style, cx| cx.request_layout(&style, []));
(layout_id, ())
}
fn after_layout(
&mut self,
element_state: Option<Self::State>,
bounds: Bounds<Pixels>,
_before_layout: &mut Self::BeforeLayout,
cx: &mut ElementContext,
) -> (LayoutId, Self::State) {
) -> Option<Hitbox> {
self.interactivity
.layout(element_state, cx, |style, cx| cx.request_layout(&style, []))
.after_layout(bounds, bounds.size, cx, |_, _, hitbox, _| hitbox)
}
fn paint(
&mut self,
bounds: Bounds<Pixels>,
element_state: &mut Self::State,
_: &mut Self::BeforeLayout,
hitbox: &mut Self::AfterLayout,
cx: &mut ElementContext,
) {
let source = self.source.clone();
self.interactivity.paint(
bounds,
bounds.size,
element_state,
cx,
|style, _scroll_offset, cx| {
self.interactivity
.paint(bounds, hitbox.as_ref(), cx, |style, cx| {
let corner_radii = style.corner_radii.to_pixels(bounds.size, cx.rem_size());
cx.with_z_index(1, |cx| {
match source {
ImageSource::Uri(_) | ImageSource::File(_) => {
let uri_or_path: UriOrPath = match source {
ImageSource::Uri(uri) => uri.into(),
ImageSource::File(path) => path.into(),
_ => unreachable!(),
};
match source {
ImageSource::Uri(_) | ImageSource::File(_) => {
let uri_or_path: UriOrPath = match source {
ImageSource::Uri(uri) => uri.into(),
ImageSource::File(path) => path.into(),
_ => unreachable!(),
};
let image_future = cx.image_cache.get(uri_or_path.clone(), cx);
if let Some(data) = image_future
.clone()
.now_or_never()
.and_then(|result| result.ok())
{
let new_bounds = preserve_aspect_ratio(bounds, data.size());
cx.paint_image(new_bounds, corner_radii, data, self.grayscale)
.log_err();
} else {
cx.spawn(|mut cx| async move {
if image_future.await.ok().is_some() {
cx.on_next_frame(|cx| cx.refresh());
}
})
.detach();
}
}
ImageSource::Data(data) => {
let image_future = cx.image_cache.get(uri_or_path.clone(), cx);
if let Some(data) = image_future
.clone()
.now_or_never()
.and_then(|result| result.ok())
{
let new_bounds = preserve_aspect_ratio(bounds, data.size());
cx.paint_image(new_bounds, corner_radii, data, self.grayscale)
.log_err();
} else {
cx.spawn(|mut cx| async move {
if image_future.await.ok().is_some() {
cx.on_next_frame(|cx| cx.refresh());
}
})
.detach();
}
}
#[cfg(target_os = "macos")]
ImageSource::Surface(surface) => {
let size = size(surface.width().into(), surface.height().into());
let new_bounds = preserve_aspect_ratio(bounds, size);
// TODO: Add support for corner_radii and grayscale.
cx.paint_surface(new_bounds, surface);
}
};
});
},
)
ImageSource::Data(data) => {
let new_bounds = preserve_aspect_ratio(bounds, data.size());
cx.paint_image(new_bounds, corner_radii, data, self.grayscale)
.log_err();
}
#[cfg(target_os = "macos")]
ImageSource::Surface(surface) => {
let size = size(surface.width().into(), surface.height().into());
let new_bounds = preserve_aspect_ratio(bounds, size);
// TODO: Add support for corner_radii and grayscale.
cx.paint_surface(new_bounds, surface);
}
}
})
}
}
impl IntoElement for Img {
type Element = Self;
fn element_id(&self) -> Option<crate::ElementId> {
self.interactivity.element_id.clone()
}
fn into_element(self) -> Self::Element {
self
}

View File

@@ -8,7 +8,7 @@
use crate::{
point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges,
Element, ElementContext, IntoElement, Pixels, Point, ScrollWheelEvent, Size, Style,
Element, ElementContext, Hitbox, IntoElement, Pixels, Point, ScrollWheelEvent, Size, Style,
StyleRefinement, Styled, WindowContext,
};
use collections::VecDeque;
@@ -96,6 +96,12 @@ struct LayoutItemsResponse {
item_elements: VecDeque<AnyElement>,
}
/// Frame state used by the [List] element after layout.
pub struct ListAfterLayoutState {
hitbox: Hitbox,
layout: LayoutItemsResponse,
}
#[derive(Clone)]
enum ListItem {
Unrendered,
@@ -302,7 +308,6 @@ impl StateInner {
height: Pixels,
delta: Point<Pixels>,
cx: &mut WindowContext,
padding: Edges<Pixels>,
) {
// Drop scroll events after a reset, since we can't calculate
// the new logical scroll top without the item heights
@@ -310,6 +315,7 @@ impl StateInner {
return;
}
let padding = self.last_padding.unwrap_or_default();
let scroll_max =
(self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
let new_scroll_top = (self.scroll_top(scroll_top) - delta.y)
@@ -516,13 +522,13 @@ pub struct ListOffset {
}
impl Element for List {
type State = ();
type BeforeLayout = ();
type AfterLayout = ListAfterLayoutState;
fn request_layout(
fn before_layout(
&mut self,
_state: Option<Self::State>,
cx: &mut crate::ElementContext,
) -> (crate::LayoutId, Self::State) {
) -> (crate::LayoutId, Self::BeforeLayout) {
let layout_id = match self.sizing_behavior {
ListSizingBehavior::Infer => {
let mut style = Style::default();
@@ -583,18 +589,20 @@ impl Element for List {
(layout_id, ())
}
fn paint(
fn after_layout(
&mut self,
bounds: Bounds<crate::Pixels>,
_state: &mut Self::State,
cx: &mut crate::ElementContext,
) {
bounds: Bounds<Pixels>,
_: &mut Self::BeforeLayout,
cx: &mut ElementContext,
) -> ListAfterLayoutState {
let state = &mut *self.state.0.borrow_mut();
state.reset = false;
let mut style = Style::default();
style.refine(&self.style);
let hitbox = cx.insert_hitbox(bounds, false);
// If the width of the list has changed, invalidate all cached item heights
if state.last_layout_bounds.map_or(true, |last_bounds| {
last_bounds.size.width != bounds.size.width
@@ -615,33 +623,46 @@ impl Element for List {
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
let mut item_origin = bounds.origin + Point::new(px(0.), padding.top);
item_origin.y -= layout_response.scroll_top.offset_in_item;
for item_element in &mut layout_response.item_elements {
let item_height = item_element
.measure(layout_response.available_item_space, cx)
.height;
item_element.draw(item_origin, layout_response.available_item_space, cx);
item_origin.y += item_height;
for mut item_element in &mut layout_response.item_elements {
let item_size = item_element.measure(layout_response.available_item_space, cx);
item_element.layout(item_origin, layout_response.available_item_space, cx);
item_origin.y += item_size.height;
}
});
}
state.last_layout_bounds = Some(bounds);
state.last_padding = Some(padding);
ListAfterLayoutState {
hitbox,
layout: layout_response,
}
}
fn paint(
&mut self,
bounds: Bounds<crate::Pixels>,
_: &mut Self::BeforeLayout,
after_layout: &mut Self::AfterLayout,
cx: &mut crate::ElementContext,
) {
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
for item in &mut after_layout.layout.item_elements {
item.paint(cx);
}
});
let list_state = self.state.clone();
let height = bounds.size.height;
let scroll_top = after_layout.layout.scroll_top;
let hitbox_id = after_layout.hitbox.id;
cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& bounds.contains(&event.position)
&& cx.was_top_layer(&event.position, cx.stacking_order())
{
if phase == DispatchPhase::Bubble && hitbox_id.is_hovered(cx) {
list_state.0.borrow_mut().scroll(
&layout_response.scroll_top,
&scroll_top,
height,
event.delta.pixel_delta(px(20.)),
cx,
padding,
)
}
});
@@ -651,10 +672,6 @@ impl Element for List {
impl IntoElement for List {
type Element = Self;
fn element_id(&self) -> Option<crate::ElementId> {
None
}
fn into_element(self) -> Self::Element {
self
}
@@ -761,7 +778,7 @@ mod test {
cx.draw(
point(px(0.), px(0.)),
size(px(100.), px(20.)).into(),
|_| list(state.clone()).w_full().h_full().z_index(10).into_any(),
|_| list(state.clone()).w_full().h_full().into_any(),
);
// Reset

View File

@@ -9,6 +9,7 @@ use crate::{
/// The state that the overlay element uses to track its children.
pub struct OverlayState {
child_layout_ids: SmallVec<[LayoutId; 4]>,
offset: Point<Pixels>,
}
/// An overlay element that can be used to display UI that
@@ -69,17 +70,14 @@ impl ParentElement for Overlay {
}
impl Element for Overlay {
type State = OverlayState;
type BeforeLayout = OverlayState;
type AfterLayout = ();
fn request_layout(
&mut self,
_: Option<Self::State>,
cx: &mut ElementContext,
) -> (crate::LayoutId, Self::State) {
fn before_layout(&mut self, cx: &mut ElementContext) -> (crate::LayoutId, Self::BeforeLayout) {
let child_layout_ids = self
.children
.iter_mut()
.map(|child| child.request_layout(cx))
.map(|child| child.before_layout(cx))
.collect::<SmallVec<_>>();
let overlay_style = Style {
@@ -90,22 +88,28 @@ impl Element for Overlay {
let layout_id = cx.request_layout(&overlay_style, child_layout_ids.iter().copied());
(layout_id, OverlayState { child_layout_ids })
(
layout_id,
OverlayState {
child_layout_ids,
offset: Point::default(),
},
)
}
fn paint(
fn after_layout(
&mut self,
bounds: crate::Bounds<crate::Pixels>,
element_state: &mut Self::State,
bounds: Bounds<Pixels>,
before_layout: &mut Self::BeforeLayout,
cx: &mut ElementContext,
) {
if element_state.child_layout_ids.is_empty() {
if before_layout.child_layout_ids.is_empty() {
return;
}
let mut child_min = point(Pixels::MAX, Pixels::MAX);
let mut child_max = Point::default();
for child_layout_id in &element_state.child_layout_ids {
for child_layout_id in &before_layout.child_layout_ids {
let child_bounds = cx.layout_bounds(*child_layout_id);
child_min = child_min.min(&child_bounds.origin);
child_max = child_max.max(&child_bounds.lower_right());
@@ -165,25 +169,30 @@ impl Element for Overlay {
desired.origin.y = limits.origin.y;
}
let mut offset = cx.element_offset() + desired.origin - bounds.origin;
offset = point(offset.x.round(), offset.y.round());
cx.with_absolute_element_offset(offset, |cx| {
cx.break_content_mask(|cx| {
for child in &mut self.children {
child.paint(cx);
}
})
})
before_layout.offset = cx.element_offset() + desired.origin - bounds.origin;
before_layout.offset = point(
before_layout.offset.x.round(),
before_layout.offset.y.round(),
);
for child in self.children.drain(..) {
cx.defer_draw(child, before_layout.offset, 1);
}
}
fn paint(
&mut self,
_bounds: crate::Bounds<crate::Pixels>,
_before_layout: &mut Self::BeforeLayout,
_after_layout: &mut Self::AfterLayout,
_cx: &mut ElementContext,
) {
}
}
impl IntoElement for Overlay {
type Element = Self;
fn element_id(&self) -> Option<crate::ElementId> {
None
}
fn into_element(self) -> Self::Element {
self
}

View File

@@ -1,6 +1,6 @@
use crate::{
Bounds, Element, ElementContext, ElementId, InteractiveElement, InteractiveElementState,
Interactivity, IntoElement, LayoutId, Pixels, SharedString, StyleRefinement, Styled,
Bounds, Element, ElementContext, Hitbox, InteractiveElement, Interactivity, IntoElement,
LayoutId, Pixels, SharedString, StyleRefinement, Styled,
};
use util::ResultExt;
@@ -27,28 +27,37 @@ impl Svg {
}
impl Element for Svg {
type State = InteractiveElementState;
type BeforeLayout = ();
type AfterLayout = Option<Hitbox>;
fn request_layout(
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
let layout_id = self
.interactivity
.before_layout(cx, |style, cx| cx.request_layout(&style, None));
(layout_id, ())
}
fn after_layout(
&mut self,
element_state: Option<Self::State>,
bounds: Bounds<Pixels>,
_before_layout: &mut Self::BeforeLayout,
cx: &mut ElementContext,
) -> (LayoutId, Self::State) {
self.interactivity.layout(element_state, cx, |style, cx| {
cx.request_layout(&style, None)
})
) -> Option<Hitbox> {
self.interactivity
.after_layout(bounds, bounds.size, cx, |_, _, hitbox, _| hitbox)
}
fn paint(
&mut self,
bounds: Bounds<Pixels>,
element_state: &mut Self::State,
_before_layout: &mut Self::BeforeLayout,
hitbox: &mut Option<Hitbox>,
cx: &mut ElementContext,
) where
Self: Sized,
{
self.interactivity
.paint(bounds, bounds.size, element_state, cx, |style, _, cx| {
.paint(bounds, hitbox.as_ref(), cx, |style, cx| {
if let Some((path, color)) = self.path.as_ref().zip(style.text.color) {
cx.paint_svg(bounds, path.clone(), color).log_err();
}
@@ -59,10 +68,6 @@ impl Element for Svg {
impl IntoElement for Svg {
type Element = Self;
fn element_id(&self) -> Option<ElementId> {
self.interactivity.element_id.clone()
}
fn into_element(self) -> Self::Element {
self
}

View File

@@ -1,7 +1,7 @@
use crate::{
ActiveTooltip, AnyTooltip, AnyView, Bounds, DispatchPhase, Element, ElementContext, ElementId,
HighlightStyle, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
Point, SharedString, Size, TextRun, TextStyle, WhiteSpace, WindowContext, WrappedLine,
HighlightStyle, Hitbox, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
Pixels, Point, SharedString, Size, TextRun, TextStyle, WhiteSpace, WindowContext, WrappedLine,
TOOLTIP_DELAY,
};
use anyhow::anyhow;
@@ -17,30 +17,37 @@ use std::{
use util::ResultExt;
impl Element for &'static str {
type State = TextState;
type BeforeLayout = TextState;
type AfterLayout = ();
fn request_layout(
&mut self,
_: Option<Self::State>,
cx: &mut ElementContext,
) -> (LayoutId, Self::State) {
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
let mut state = TextState::default();
let layout_id = state.layout(SharedString::from(*self), None, cx);
(layout_id, state)
}
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut TextState, cx: &mut ElementContext) {
state.paint(bounds, self, cx)
fn after_layout(
&mut self,
_bounds: Bounds<Pixels>,
_text_state: &mut Self::BeforeLayout,
_cx: &mut ElementContext,
) {
}
fn paint(
&mut self,
bounds: Bounds<Pixels>,
text_state: &mut TextState,
_: &mut (),
cx: &mut ElementContext,
) {
text_state.paint(bounds, self, cx)
}
}
impl IntoElement for &'static str {
type Element = Self;
fn element_id(&self) -> Option<ElementId> {
None
}
fn into_element(self) -> Self::Element {
self
}
@@ -49,41 +56,44 @@ impl IntoElement for &'static str {
impl IntoElement for String {
type Element = SharedString;
fn element_id(&self) -> Option<ElementId> {
None
}
fn into_element(self) -> Self::Element {
self.into()
}
}
impl Element for SharedString {
type State = TextState;
type BeforeLayout = TextState;
type AfterLayout = ();
fn request_layout(
&mut self,
_: Option<Self::State>,
cx: &mut ElementContext,
) -> (LayoutId, Self::State) {
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
let mut state = TextState::default();
let layout_id = state.layout(self.clone(), None, cx);
(layout_id, state)
}
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut TextState, cx: &mut ElementContext) {
fn after_layout(
&mut self,
_bounds: Bounds<Pixels>,
_text_state: &mut Self::BeforeLayout,
_cx: &mut ElementContext,
) {
}
fn paint(
&mut self,
bounds: Bounds<Pixels>,
text_state: &mut Self::BeforeLayout,
_: &mut Self::AfterLayout,
cx: &mut ElementContext,
) {
let text_str: &str = self.as_ref();
state.paint(bounds, text_str, cx)
text_state.paint(bounds, text_str, cx)
}
}
impl IntoElement for SharedString {
type Element = Self;
fn element_id(&self) -> Option<ElementId> {
None
}
fn into_element(self) -> Self::Element {
self
}
@@ -138,30 +148,37 @@ impl StyledText {
}
impl Element for StyledText {
type State = TextState;
type BeforeLayout = TextState;
type AfterLayout = ();
fn request_layout(
&mut self,
_: Option<Self::State>,
cx: &mut ElementContext,
) -> (LayoutId, Self::State) {
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
let mut state = TextState::default();
let layout_id = state.layout(self.text.clone(), self.runs.take(), cx);
(layout_id, state)
}
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut Self::State, cx: &mut ElementContext) {
state.paint(bounds, &self.text, cx)
fn after_layout(
&mut self,
_bounds: Bounds<Pixels>,
_state: &mut Self::BeforeLayout,
_cx: &mut ElementContext,
) {
}
fn paint(
&mut self,
bounds: Bounds<Pixels>,
text_state: &mut Self::BeforeLayout,
_: &mut Self::AfterLayout,
cx: &mut ElementContext,
) {
text_state.paint(bounds, &self.text, cx)
}
}
impl IntoElement for StyledText {
type Element = Self;
fn element_id(&self) -> Option<crate::ElementId> {
None
}
fn into_element(self) -> Self::Element {
self
}
@@ -324,8 +341,8 @@ struct InteractiveTextClickEvent {
}
#[doc(hidden)]
#[derive(Default)]
pub struct InteractiveTextState {
text_state: TextState,
mouse_down_index: Rc<Cell<Option<usize>>>,
hovered_index: Rc<Cell<Option<usize>>>,
active_tooltip: Rc<RefCell<Option<ActiveTooltip>>>,
@@ -385,179 +402,184 @@ impl InteractiveText {
}
impl Element for InteractiveText {
type State = InteractiveTextState;
type BeforeLayout = TextState;
type AfterLayout = Hitbox;
fn request_layout(
&mut self,
state: Option<Self::State>,
cx: &mut ElementContext,
) -> (LayoutId, Self::State) {
if let Some(InteractiveTextState {
mouse_down_index,
hovered_index,
active_tooltip,
..
}) = state
{
let (layout_id, text_state) = self.text.request_layout(None, cx);
let element_state = InteractiveTextState {
text_state,
mouse_down_index,
hovered_index,
active_tooltip,
};
(layout_id, element_state)
} else {
let (layout_id, text_state) = self.text.request_layout(None, cx);
let element_state = InteractiveTextState {
text_state,
mouse_down_index: Rc::default(),
hovered_index: Rc::default(),
active_tooltip: Rc::default(),
};
(layout_id, element_state)
}
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
self.text.before_layout(cx)
}
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut Self::State, cx: &mut ElementContext) {
if let Some(click_listener) = self.click_listener.take() {
let mouse_position = cx.mouse_position();
if let Some(ix) = state.text_state.index_for_position(bounds, mouse_position) {
if self
.clickable_ranges
.iter()
.any(|range| range.contains(&ix))
{
let stacking_order = cx.stacking_order().clone();
cx.set_cursor_style(crate::CursorStyle::PointingHand, stacking_order);
}
}
fn after_layout(
&mut self,
bounds: Bounds<Pixels>,
state: &mut Self::BeforeLayout,
cx: &mut ElementContext,
) -> Hitbox {
self.text.after_layout(bounds, state, cx);
cx.insert_hitbox(bounds, false)
}
let text_state = state.text_state.clone();
let mouse_down = state.mouse_down_index.clone();
if let Some(mouse_down_index) = mouse_down.get() {
let clickable_ranges = mem::take(&mut self.clickable_ranges);
cx.on_mouse_event(move |event: &MouseUpEvent, phase, cx| {
if phase == DispatchPhase::Bubble {
if let Some(mouse_up_index) =
text_state.index_for_position(bounds, event.position)
fn paint(
&mut self,
bounds: Bounds<Pixels>,
text_state: &mut Self::BeforeLayout,
hitbox: &mut Hitbox,
cx: &mut ElementContext,
) {
cx.with_element_state::<InteractiveTextState, _>(
Some(self.element_id.clone()),
|interactive_state, cx| {
let mut interactive_state = interactive_state.unwrap().unwrap_or_default();
if let Some(click_listener) = self.click_listener.take() {
let mouse_position = cx.mouse_position();
if let Some(ix) = text_state.index_for_position(bounds, mouse_position) {
if self
.clickable_ranges
.iter()
.any(|range| range.contains(&ix))
{
click_listener(
&clickable_ranges,
InteractiveTextClickEvent {
mouse_down_index,
mouse_up_index,
},
cx,
)
}
mouse_down.take();
cx.refresh();
}
});
} else {
cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| {
if phase == DispatchPhase::Bubble {
if let Some(mouse_down_index) =
text_state.index_for_position(bounds, event.position)
{
mouse_down.set(Some(mouse_down_index));
cx.refresh();
cx.set_cursor_style(crate::CursorStyle::PointingHand, hitbox)
}
}
});
}
}
if let Some(hover_listener) = self.hover_listener.take() {
let text_state = state.text_state.clone();
let hovered_index = state.hovered_index.clone();
cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
if phase == DispatchPhase::Bubble {
let current = hovered_index.get();
let updated = text_state.index_for_position(bounds, event.position);
if current != updated {
hovered_index.set(updated);
hover_listener(updated, event.clone(), cx);
cx.refresh();
}
}
});
}
if let Some(tooltip_builder) = self.tooltip_builder.clone() {
let active_tooltip = state.active_tooltip.clone();
let pending_mouse_down = state.mouse_down_index.clone();
let text_state = state.text_state.clone();
cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
let position = text_state.index_for_position(bounds, event.position);
let is_hovered = position.is_some() && pending_mouse_down.get().is_none();
if !is_hovered {
active_tooltip.take();
return;
}
let position = position.unwrap();
let text_state = text_state.clone();
let mouse_down = interactive_state.mouse_down_index.clone();
if let Some(mouse_down_index) = mouse_down.get() {
let hitbox = hitbox.clone();
let clickable_ranges = mem::take(&mut self.clickable_ranges);
cx.on_mouse_event(move |event: &MouseUpEvent, phase, cx| {
if phase == DispatchPhase::Bubble && hitbox.is_hovered(cx) {
if let Some(mouse_up_index) =
text_state.index_for_position(bounds, event.position)
{
click_listener(
&clickable_ranges,
InteractiveTextClickEvent {
mouse_down_index,
mouse_up_index,
},
cx,
)
}
if phase != DispatchPhase::Bubble {
return;
}
if active_tooltip.borrow().is_none() {
let task = cx.spawn({
let active_tooltip = active_tooltip.clone();
let tooltip_builder = tooltip_builder.clone();
move |mut cx| async move {
cx.background_executor().timer(TOOLTIP_DELAY).await;
cx.update(|cx| {
let new_tooltip =
tooltip_builder(position, cx).map(|tooltip| ActiveTooltip {
tooltip: Some(AnyTooltip {
view: tooltip,
cursor_offset: cx.mouse_position(),
}),
_task: None,
});
*active_tooltip.borrow_mut() = new_tooltip;
mouse_down.take();
cx.refresh();
})
.ok();
}
});
} else {
let hitbox = hitbox.clone();
cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| {
if phase == DispatchPhase::Bubble && hitbox.is_hovered(cx) {
if let Some(mouse_down_index) =
text_state.index_for_position(bounds, event.position)
{
mouse_down.set(Some(mouse_down_index));
cx.refresh();
}
}
});
}
}
cx.on_mouse_event({
let mut hover_listener = self.hover_listener.take();
let hitbox = hitbox.clone();
let text_state = text_state.clone();
let hovered_index = interactive_state.hovered_index.clone();
move |event: &MouseMoveEvent, phase, cx| {
if phase == DispatchPhase::Bubble && hitbox.is_hovered(cx) {
let current = hovered_index.get();
let updated = text_state.index_for_position(bounds, event.position);
if current != updated {
hovered_index.set(updated);
if let Some(hover_listener) = hover_listener.as_ref() {
hover_listener(updated, event.clone(), cx);
}
cx.refresh();
}
}
}
});
if let Some(tooltip_builder) = self.tooltip_builder.clone() {
let hitbox = hitbox.clone();
let active_tooltip = interactive_state.active_tooltip.clone();
let pending_mouse_down = interactive_state.mouse_down_index.clone();
let text_state = text_state.clone();
cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
let position = text_state.index_for_position(bounds, event.position);
let is_hovered = position.is_some()
&& hitbox.is_hovered(cx)
&& pending_mouse_down.get().is_none();
if !is_hovered {
active_tooltip.take();
return;
}
let position = position.unwrap();
if phase != DispatchPhase::Bubble {
return;
}
if active_tooltip.borrow().is_none() {
let task = cx.spawn({
let active_tooltip = active_tooltip.clone();
let tooltip_builder = tooltip_builder.clone();
move |mut cx| async move {
cx.background_executor().timer(TOOLTIP_DELAY).await;
cx.update(|cx| {
let new_tooltip =
tooltip_builder(position, cx).map(|tooltip| {
ActiveTooltip {
tooltip: Some(AnyTooltip {
view: tooltip,
cursor_offset: cx.mouse_position(),
}),
_task: None,
}
});
*active_tooltip.borrow_mut() = new_tooltip;
cx.refresh();
})
.ok();
}
});
*active_tooltip.borrow_mut() = Some(ActiveTooltip {
tooltip: None,
_task: Some(task),
});
}
});
*active_tooltip.borrow_mut() = Some(ActiveTooltip {
tooltip: None,
_task: Some(task),
let active_tooltip = interactive_state.active_tooltip.clone();
cx.on_mouse_event(move |_: &MouseDownEvent, _, _| {
active_tooltip.take();
});
if let Some(tooltip) = interactive_state
.active_tooltip
.clone()
.borrow()
.as_ref()
.and_then(|at| at.tooltip.clone())
{
cx.set_tooltip(tooltip);
}
}
});
let active_tooltip = state.active_tooltip.clone();
cx.on_mouse_event(move |_: &MouseDownEvent, _, _| {
active_tooltip.take();
});
self.text.paint(bounds, text_state, &mut (), cx);
if let Some(tooltip) = state
.active_tooltip
.clone()
.borrow()
.as_ref()
.and_then(|at| at.tooltip.clone())
{
cx.set_tooltip(tooltip);
}
}
self.text.paint(bounds, &mut state.text_state, cx)
((), Some(interactive_state))
},
);
}
}
impl IntoElement for InteractiveText {
type Element = Self;
fn element_id(&self) -> Option<ElementId> {
Some(self.element_id.clone())
}
fn into_element(self) -> Self::Element {
self
}

View File

@@ -6,8 +6,8 @@
use crate::{
point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, Element, ElementContext,
ElementId, InteractiveElement, InteractiveElementState, Interactivity, IntoElement, LayoutId,
Pixels, Render, Size, StyleRefinement, Styled, View, ViewContext, WindowContext,
ElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, Render,
ScrollHandle, Size, StyleRefinement, Styled, View, ViewContext, WindowContext,
};
use smallvec::SmallVec;
use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
@@ -42,13 +42,13 @@ where
};
UniformList {
id: id.clone(),
item_count,
item_to_measure_index: 0,
render_items: Box::new(render_range),
interactivity: Interactivity {
element_id: Some(id),
base_style: Box::new(base_style),
occlude_mouse: true,
#[cfg(debug_assertions)]
location: Some(*core::panic::Location::caller()),
@@ -61,7 +61,6 @@ where
/// A list element for efficiently laying out and displaying a list of uniform-height elements.
pub struct UniformList {
id: ElementId,
item_count: usize,
item_to_measure_index: usize,
render_items:
@@ -70,10 +69,17 @@ pub struct UniformList {
scroll_handle: Option<UniformListScrollHandle>,
}
/// Frame state used by the [UniformList].
pub struct UniformListFrameState {
item_size: Size<Pixels>,
items: SmallVec<[AnyElement; 32]>,
}
/// A handle for controlling the scroll position of a uniform list.
/// This should be stored in your view and passed to the uniform_list on each frame.
#[derive(Clone, Default)]
pub struct UniformListScrollHandle {
base_handle: ScrollHandle,
deferred_scroll_to_item: Rc<RefCell<Option<usize>>>,
}
@@ -81,6 +87,7 @@ impl UniformListScrollHandle {
/// Create a new scroll handle to bind to a uniform list.
pub fn new() -> Self {
Self {
base_handle: ScrollHandle::new(),
deferred_scroll_to_item: Rc::new(RefCell::new(None)),
}
}
@@ -97,72 +104,47 @@ impl Styled for UniformList {
}
}
#[doc(hidden)]
#[derive(Default)]
pub struct UniformListState {
interactive: InteractiveElementState,
item_size: Size<Pixels>,
}
impl Element for UniformList {
type State = UniformListState;
type BeforeLayout = UniformListFrameState;
type AfterLayout = Option<Hitbox>;
fn request_layout(
&mut self,
state: Option<Self::State>,
cx: &mut ElementContext,
) -> (LayoutId, Self::State) {
fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) {
let max_items = self.item_count;
let item_size = state
.as_ref()
.map(|s| s.item_size)
.unwrap_or_else(|| self.measure_item(None, cx));
let item_size = self.measure_item(None, cx);
let layout_id = self.interactivity.before_layout(cx, |style, cx| {
cx.request_measured_layout(style, move |known_dimensions, available_space, _cx| {
let desired_height = item_size.height * max_items;
let width = known_dimensions
.width
.unwrap_or(match available_space.width {
AvailableSpace::Definite(x) => x,
AvailableSpace::MinContent | AvailableSpace::MaxContent => item_size.width,
});
let (layout_id, interactive) =
self.interactivity
.layout(state.map(|s| s.interactive), cx, |style, cx| {
cx.request_measured_layout(
style,
move |known_dimensions, available_space, _cx| {
let desired_height = item_size.height * max_items;
let width =
known_dimensions
.width
.unwrap_or(match available_space.width {
AvailableSpace::Definite(x) => x,
AvailableSpace::MinContent | AvailableSpace::MaxContent => {
item_size.width
}
});
let height = match available_space.height {
AvailableSpace::Definite(height) => desired_height.min(height),
AvailableSpace::MinContent | AvailableSpace::MaxContent => desired_height,
};
size(width, height)
})
});
let height = match available_space.height {
AvailableSpace::Definite(height) => desired_height.min(height),
AvailableSpace::MinContent | AvailableSpace::MaxContent => {
desired_height
}
};
size(width, height)
},
)
});
let element_state = UniformListState {
interactive,
item_size,
};
(layout_id, element_state)
(
layout_id,
UniformListFrameState {
item_size,
items: SmallVec::new(),
},
)
}
fn paint(
fn after_layout(
&mut self,
bounds: Bounds<crate::Pixels>,
element_state: &mut Self::State,
bounds: Bounds<Pixels>,
before_layout: &mut Self::BeforeLayout,
cx: &mut ElementContext,
) {
let style =
self.interactivity
.compute_style(Some(bounds), &mut element_state.interactive, cx);
) -> Option<Hitbox> {
let style = self.interactivity.compute_style(None, cx);
let border = style.border_widths.to_pixels(cx.rem_size());
let padding = style.padding.to_pixels(bounds.size.into(), cx.rem_size());
@@ -172,17 +154,12 @@ impl Element for UniformList {
- point(border.right + padding.right, border.bottom + padding.bottom),
);
let item_size = element_state.item_size;
let content_size = Size {
width: padded_bounds.size.width,
height: item_size.height * self.item_count + padding.top + padding.bottom,
height: before_layout.item_size.height * self.item_count + padding.top + padding.bottom,
};
let shared_scroll_offset = element_state
.interactive
.scroll_offset
.get_or_insert_with(Rc::default)
.clone();
let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap();
let item_height = self.measure_item(Some(padded_bounds.size.width), cx).height;
let shared_scroll_to_item = self
@@ -190,12 +167,11 @@ impl Element for UniformList {
.as_mut()
.and_then(|handle| handle.deferred_scroll_to_item.take());
self.interactivity.paint(
self.interactivity.after_layout(
bounds,
content_size,
&mut element_state.interactive,
cx,
|style, mut scroll_offset, cx| {
|style, mut scroll_offset, hitbox, cx| {
let border = style.border_widths.to_pixels(cx.rem_size());
let padding = style.padding.to_pixels(bounds.size.into(), cx.rem_size());
@@ -238,36 +214,45 @@ impl Element for UniformList {
..cmp::min(last_visible_element_ix, self.item_count);
let mut items = (self.render_items)(visible_range.clone(), cx);
cx.with_z_index(1, |cx| {
let content_mask = ContentMask { bounds };
cx.with_content_mask(Some(content_mask), |cx| {
for (item, ix) in items.iter_mut().zip(visible_range) {
let item_origin = padded_bounds.origin
+ point(
px(0.),
item_height * ix + scroll_offset.y + padding.top,
);
let available_space = size(
AvailableSpace::Definite(padded_bounds.size.width),
AvailableSpace::Definite(item_height),
);
item.draw(item_origin, available_space, cx);
}
});
let content_mask = ContentMask { bounds };
cx.with_content_mask(Some(content_mask), |cx| {
for (mut item, ix) in items.into_iter().zip(visible_range) {
let item_origin = padded_bounds.origin
+ point(px(0.), item_height * ix + scroll_offset.y + padding.top);
let available_space = size(
AvailableSpace::Definite(padded_bounds.size.width),
AvailableSpace::Definite(item_height),
);
item.layout(item_origin, available_space, cx);
before_layout.items.push(item);
}
});
}
hitbox
},
)
}
fn paint(
&mut self,
bounds: Bounds<crate::Pixels>,
before_layout: &mut Self::BeforeLayout,
hitbox: &mut Option<Hitbox>,
cx: &mut ElementContext,
) {
self.interactivity
.paint(bounds, hitbox.as_ref(), cx, |_, cx| {
for item in &mut before_layout.items {
item.paint(cx);
}
})
}
}
impl IntoElement for UniformList {
type Element = Self;
fn element_id(&self) -> Option<crate::ElementId> {
Some(self.id.clone())
}
fn into_element(self) -> Self::Element {
self
}
@@ -299,6 +284,7 @@ impl UniformList {
/// Track and render scroll state of this list with reference to the given scroll handle.
pub fn track_scroll(mut self, handle: UniformListScrollHandle) -> Self {
self.interactivity.tracked_scroll_handle = Some(handle.base_handle.clone());
self.scroll_handle = Some(handle);
self
}

View File

@@ -348,6 +348,12 @@ impl BackgroundExecutor {
self.dispatcher.as_test().unwrap().allow_parking();
}
/// undoes the effect of [`allow_parking`].
#[cfg(any(test, feature = "test-support"))]
pub fn forbid_parking(&self) {
self.dispatcher.as_test().unwrap().forbid_parking();
}
/// in tests, returns the rng used by the dispatcher and seeded by the `SEED` environment variable
#[cfg(any(test, feature = "test-support"))]
pub fn rng(&self) -> StdRng {

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