Compare commits

..

85 Commits

Author SHA1 Message Date
Mikayla
63e9a7069f WIP: Start on accesskit integration 2024-02-20 10:58:25 -08:00
Mikayla Maki
1c361ac579 Remove comment (#7922)
Per https://github.com/zed-industries/zed/pull/7814, this is more
trouble than it's worth. As these functions are never exposed to the
user of GPUI, we can just manually audit and enforce the relevant rules.

Release Notes:

- N/A
2024-02-16 10:39:50 -08:00
Rom Grk
bea36918f4 Linux: file dialogs (#7852)
This PR implements linux file dialogs and open/reveal actions.

| Open folder | Reveal path |
| --- | --- |
| ![Screenshot from 2024-02-15
16-50-49](https://github.com/zed-industries/zed/assets/1423607/b4260574-d841-4ded-821d-521f507916d1)
| ![Screenshot from 2024-02-15
16-51-36](https://github.com/zed-industries/zed/assets/1423607/1f32f451-7def-423a-9d69-de2876285b60)
|

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
2024-02-16 10:34:54 -08:00
Conrad Irwin
43e8fdbe82 Fix a missing follower update on re-activate (#7918)
This could cause following to get into a bad state temporarily

Release Notes:

- Fixed a bug around following if the follow started while the workspace
was inactive.
2024-02-16 11:33:49 -07:00
Antonio Scandurra
5df1318e75 Don't use stale layout when view cache is invalidated in GPUI (#7914)
When a view is invalidated, we want to participate in Taffy layout with
an accurate style rather than the dummy style we use when a view is
cached. Previously, we only detected invalidation during paint. This
adds logic to layout as well to avoid using the dummy style when dirty.

Release Notes:

- N/A

---------

Co-authored-by: Nathan <nathan@zed.dev>
2024-02-16 19:06:11 +01:00
Joseph T. Lyons
577b244b03 Add button link to extension repository (#7880)
Fixes: https://github.com/zed-industries/zed/issues/7873

<img width="1165" alt="image"
src="https://github.com/zed-industries/zed/assets/19867440/2338519c-bd48-4716-9f88-ed155b0dad67">

Release Notes:

- Added a button to link to an extension's repository
2024-02-16 11:49:05 -05:00
Max Brunsfeld
2e0d18ee76 Don't support cloning the extensions view (#7875)
Fixes https://github.com/zed-industries/zed/issues/7840

We could support this later, but for now, I don't think we need to.

Release Notes:

- N/A

Co-authored-by: Nathan <nathan@zed.dev>
2024-02-16 11:48:47 -05:00
Marshall Bowers
c5a23faf7c Fall back to One themes if the selected theme doesn't exist (#7911)
This PR makes it so the One themes—One Dark and One Light—are used as a
fallback when trying to reload a theme that no longer exists in the
registry.

This makes it so when an extension providing the current theme is
removed, the active theme will change to either One Dark or One Light
(based on the system appearance) instead of retaining a cached version
of the theme.

Release Notes:

- Changed the behavior when uninstalling a theme to default to One Dark
or One Light (based on system appearance) rather than keeping a cached
version of the old theme.
2024-02-16 11:28:34 -05:00
Marshall Bowers
86f81c4db3 Mention Docker Compose in the collab docs (#7908)
This PR adds a note about using Docker Compose to run the `collab`
dependencies.

Release Notes:

- N/A
2024-02-16 11:02:24 -05:00
Marshall Bowers
07501d9cfa Add LiveKit server to Docker Compose (#7907)
This PR adds the LiveKit server to the Docker Compose setup.

All of the dependencies needed to run the collab server are now
encapsulated within Docker Compose.

Release Notes:

- N/A
2024-02-16 10:49:48 -05:00
Subash Chandra Sapkota
07c7778cff Add cancel button on GitHub Copilot actions (#7850)
Release Notes:

- Added cancel button on Copilot actions
([#6878](https://github.com/zed-industries/zed/issues/6878)).

Here is the screenshot of the UI change:

<img width="545" alt="Screenshot 2024-02-15 at 13 00 53"
src="https://github.com/zed-industries/zed/assets/29421465/7a4e1641-1822-47ad-819e-f3d83bc3cc74">
2024-02-16 10:45:55 -05:00
Antonio Scandurra
aa6926e57a Revert "Upgrade to Taffy 0.4" (#7896)
Reverts zed-industries/zed#7868

@nicoburns: this seems to break text input in the chat panel, so
reverting this for now.

<img width="365" alt="image"
src="https://github.com/zed-industries/zed/assets/482957/fc47eee9-6049-49c2-be2c-fb91f7d35caf">

It would be great to upgrade to Taffy 0.4 though, so we should figure
out why that particular input breaks.

Release notes:

- N/A
2024-02-16 14:30:31 +01:00
Thorsten Ball
ae577c9d5c Highlight escape sequences in TypeScript/JavaScript (#7892)
Ran into this while hacking on TypeScript/React/TSX...

Release Notes:


- N/A

![screenshot-2024-02-16-07 03
56@2x](https://github.com/zed-industries/zed/assets/1185253/e6725a1e-7653-4316-abd0-280ea8818d66)
2024-02-16 09:12:39 +01:00
Marshall Bowers
f4f72a1136 Add blob store to Docker Compose (#7889)
This PR adds the local blob store—backed by
[MinIO](https://github.com/minio/minio)—to the Docker Compose setup.

This allows running the blob store locally all within a container.

Release Notes:

- N/A
2024-02-15 23:19:03 -05:00
Marshall Bowers
4310b0b8de Replace full with size_full (#7888)
This PR removes the `full` style method and replaces it with
`size_full`, as the two do the same thing.

This is the generated code for `size_full`:

```rs
#[doc = "Sets the width and height of the element.\n\n100%"]
fn size_full(mut self) -> Self {
    let style = self.style();
    style.size.width = Some((gpui::relative(1.)).into());
    style.size.height = Some((gpui::relative(1.)).into());
    self
}
```

Release Notes:

- N/A
2024-02-15 22:26:49 -05:00
Marshall Bowers
a161a7d0c9 Format YAML files (#7887)
This PR formats the YAML files in the repo with Prettier.

Release Notes:

- N/A
2024-02-15 22:04:57 -05:00
Marshall Bowers
ab6b9196e1 Fix Cargo.toml formatting (#7886)
This PR disables the formatting for `.toml` files within the Zed repo,
as the formatter provided by the TOML language server messes things up.

Release Notes:

- N/A
2024-02-15 21:54:43 -05:00
Marshall Bowers
ef551cedef Add CheckboxWithLabel component (#7881)
This PR builds on top of #7878 by adding a general-purpose
`CheckboxWithLabel` component to use for checkboxes that have attached
labels.

This component encompasses the functionality of allowing to click on the
label to toggle the value of the checkbox.

There was only one other occurrence of a checkbox with a label—the
"Public" checkbox in the channel management modal—and this has been
updated to use `CheckboxWithLabel`.

Resolves #7794.

Release Notes:

- Added support for clicking the label of the "Public" checkbox in the
channel management modal to toggle the value
([#7794](https://github.com/zed-industries/zed/issues/7794)).
2024-02-15 21:00:30 -05:00
Marshall Bowers
9ef83a2557 Make the labels of the checkboxes on the welcome screen clickable (#7878)
This PR makes the labels of the checkboxes on the welcome screen
clickable.

Release Notes:

- Added support for clicking the labels of the checkboxes on the welcome
screen to toggle the value
([#7794](https://github.com/zed-industries/zed/issues/7794)).
2024-02-15 20:37:31 -05:00
Joseph T. Lyons
32fdff0285 Update issue template configuration (again) 2024-02-15 19:35:23 -05:00
Joseph T. Lyons
4094562321 Update issue template configuration 2024-02-15 18:47:01 -05:00
Marshall Bowers
6869b62af3 Update .mailmap (#7871)
This PR updates the `.mailmap` file to merge some commit authors using
multiple emails.

Release Notes:

- N/A
2024-02-15 18:35:43 -05:00
Joseph T. Lyons
21a7421ee0 Update 1_language_support.yml 2024-02-15 18:25:13 -05:00
Vishal Bhavsar
96dcc385dd vim: Implement Go To Previous Word End (#7505)
Activated by keystrokes g-e.



Release Notes:

- vim: Added `ge` and `gE` for go to Previous Word End.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2024-02-15 16:15:31 -07:00
Marshall Bowers
e6766e102e Return descriptions for extensions (#7869)
This PR updates the extensions API to return descriptions for
extensions.

Release Notes:

- N/A
2024-02-15 17:43:47 -05:00
Nico Burns
694e18417e Upgrade to Taffy 0.4 (#7868)
Upgraded Taffy to v0.4.0 from crates.io (previously using prerelease
version from git).

Code changes required were minor as gpui was already using a recent
version of Taffy.

Release Notes:

- N/A
2024-02-15 14:30:39 -08:00
Joey Smith
94426c4393 Better logic for copying themed player colors into registry (#7867)
Release Notes:

- Fixed a potential panic when themes did not contain enough player
colors ([#7733](https://github.com/zed-industries/zed/issues/7733)).

Thanks to @maxdeviant for the code review and improvements!
2024-02-15 17:22:23 -05:00
Marshall Bowers
bf1bcd027c Secondarily sort by extension name instead of ID (#7866)
This PR makes it so extensions are secondarily sorted by their name
(instead of by ID) after we sort them by their download count.

Release Notes:

- N/A
2024-02-15 16:51:41 -05:00
Marshall Bowers
23132b5ab1 Display extension download counts (#7864)
This PR adds the download count for extensions to the extensions view.

Release Notes:

- Added download counts for extensions to the extensions view.
2024-02-15 16:41:54 -05:00
Conrad Irwin
a8d5864524 Fix panic when loading hover state. (#7861)
Release Notes:

- Fixed a panic when hovering over an identifier in the editor
2024-02-15 14:20:10 -07:00
Conrad Irwin
ea322e1d1c Add "code_actions_on_format" (#7860)
This lets Go programmers configure `"code_actions_on_format": {
  "source.organizeImports": true,
}` so that they don't have to manage their imports manually

I landed on `code_actions_on_format` instead of `code_actions_on_save`
(the
VSCode version of this) because I want to run these when I explicitly
format
(and not if `format_on_save` is disabled).

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

Release Notes:

- Added `"code_actions_on_format"` to control additional formatting
steps on format/save
([#5232](https://github.com/zed-industries/zed/issues/5232)).
- Added a `"code_actions_on_format"` of `"source.organizeImports"` for
Go ([#4886](https://github.com/zed-industries/zed/issues/4886)).

Co-authored-by: Thorsten <thorsten@zed.dev>
2024-02-15 14:19:57 -07:00
Max Brunsfeld
e1ae0d46da Add an extensions API to the collaboration server (#7807)
This PR adds a REST API to the collab server for searching and
downloading extensions. Previously, we had implemented this API in
zed.dev directly, but this implementation is better, because we use the
collab database to store the download counts for extensions.

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Conrad <conrad@zed.dev>
2024-02-15 12:53:57 -08:00
Kirill Bulatov
bdc2558eac Add default language server settings to display inlay hints for Go and TypeScript (#7854)
Hints are still disabled by default in Zed, but when those get enabled,
the language server settings allow to display those instantly without
further server configuration, which might be not obvious. Also add the
documentation enties for those settings and their defaults in Zed.

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

Release Notes:

- Added default settings for TypeScript and Go LSP servers to enable
inlay hints when those are turned on in Zed
([7821](https://github.com/zed-industries/zed/issues/7821))
2024-02-15 22:01:49 +02:00
Dzmitry Malyshau
a41fb29e01 Linux/x11 input handling (#7811)
Implements the basics of keyboard and mouse handling.
Some keys will need special treatment, like Backspace/Delete. In this
PR, all keys are treated as append-only. Leaving this for a follow-up.

I used @gabydd 's branch as a reference (thank you!) as well as
https://github.com/xkbcommon/libxkbcommon/blob/master/doc/quick-guide.md
For future work, I'll also use
https://github.com/xkbcommon/libxkbcommon/blob/master/tools/interactive-x11.c

All commits are separately compileable and reviewable.

Release Notes:
- N/A

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
2024-02-15 11:58:47 -08:00
Andrew Lygin
aa319ccfd0 Fix buffer search invalid regexp indicator (#7795)
This PR fixes the issue with invalid regexp highlighting (red border)
when switching to the simple text searching mode (#7658).

Implementation details:

- `update_matches()` always relied on the caller to reset the
`query_contains_error` flag, which wasn't always the case. Now, it
resets the flag itself.

Release Notes:

- Fix issue with switching between invalid regexp and simple text buffer
search (#7658).

How it works now:


https://github.com/zed-industries/zed/assets/2101250/ac868a5d-5e2f-49a0-90fc-00e62a1d5ee8
2024-02-15 13:33:26 -05:00
Michal Příhoda
f01763a1fa Allow both integer and string request IDs in LSP (#7662)
Zed's LSP support expects request messages to have integer ID, Metals
LSP uses string. According to specification, both is acceptable:

interface RequestMessage extends Message {

	/**
	 * The request id.
	 */
	id: integer | string;

...
This pull requests modifies the types and serialization/deserialization
so that string IDs are accepted.

Release Notes:

- Make Zed LSP request ids compliant to the LSP specification
2024-02-15 20:26:23 +02:00
Thorsten Ball
2dffc5f6e1 Fix case-only renaming of files in project panel (#7835)
This is a follow-up to #7768 but now also fixes #5211.

Explanation is relatively simple: case-only renames previously failed
because while Zed would think that `foobar` and `FOOBAR` are different,
the filesystem would give us an file-already-exists error when renaming.

So what we're doing here is to check whether we're on a case-insensitive
filesystem and if so, we overwrite the old file.

Release Notes:

- Fixed case-only renaming of files in project panel.
([#5211](https://github.com/zed-industries/zed/issues/5211)).

Proof:



https://github.com/zed-industries/zed/assets/1185253/57d5063f-09d9-47b1-a2df-3d7edefca97d
2024-02-15 19:07:10 +01:00
Thorsten Ball
ed791c4fc1 Improve ruby highlighting (#7829)
Release Notes:

- Improved syntax-highlighting of identifiers in Ruby.

Before:

![screenshot-2024-02-15-14 40
19@2x](https://github.com/zed-industries/zed/assets/1185253/29fca0eb-7c0c-4aee-9f31-a8a3c6680cb9)

After:

![screenshot-2024-02-15-14 40
56@2x](https://github.com/zed-industries/zed/assets/1185253/2ce0e0c9-8689-4ff8-9f40-2ea5f6ccc2f6)
2024-02-15 15:12:41 +01:00
Kirill Bulatov
7c6b34cb73 Close modals and menus before dispathing actions (#7830)
Fixes https://github.com/zed-industries/zed/issues/7799 by forcing the
modal to close before dispatching the action.
While not needed specifically for this case, changed the context menus
to do the same, to be uniform — context menu actions seem to work
properly after this change too.

Release Notes:

- Fixed markdown preview action not working
([7799](https://github.com/zed-industries/zed/issues/7799))
2024-02-15 15:57:32 +02:00
Thorsten Ball
3921259b6c Extend Ruby word characters to include valid identifier chars (#7824)
In Ruby `_$=@!:?` can all be part of an identifier. If we don't have
them in this list, autocomplete doesn't work as expected.

See: https://gist.github.com/misfo/1072693

This fixes one part of #7819 but not the whole ticket.

Release Notes:

- Fixed completions in Ruby not working for identifiers that start or
end with special characters (e.g.: `@`)
2024-02-15 14:25:18 +01:00
Manohar_nalluri
e93dca5ec3 Incorrect file icons for .mjs, .mts, .cjs, .cts #7804 (#7815)
Release Notes:

- Added file extensions ([7804](https://github.com/zed-industries/zed/issues/7804))
2024-02-15 15:13:46 +02:00
Antonio Scandurra
c6626627c2 Remove existing gzip files before compressing dSYMs (#7818)
This should fix the build.

Release Notes:

- N/A
2024-02-15 11:32:08 +01:00
Antonio Scandurra
db86f4006e Staple notarization ticket to .dmg and .app bundle (#7775)
This should eliminate a pretty significant (multiple seconds) slowdown
that new users (or users after restarting their OS) have been
experiencing.

Previously, we would just notarize the application, which meant that
every user of the application had to perform an integrity check against
Apple's servers to ensure the app wasn't malicious.

With this commit, we are now using `xcrun stapler staple`, which
attaches the notarization ticket to both the app bundle as well as the
DMG. This should prevent users from needing to reach out to Apple's
notarization service in order to verify the app's integrity.

You can confirm the quarantine status of the application by running `ls
-l@` in `Terminal.app`:

    ls -l@ /Applications/Zed.app/Contents/MacOS/zed

Release Notes:

- Improved startup time when opening Zed for the first time or after
restarting the operating system.

Co-authored-by: Thorsten <thorsten@zed.dev>
Co-authored-by: bennetbo <bennetbo@gmx.de>
Co-authored-by: Martin Palma <m@palma.bz>
Co-authored-by: evrsen <146845123+evrsen@users.noreply.github.com>
2024-02-15 06:47:12 +01:00
Roman
41372a96ed Linux: Fix x11 crash (#7805)
Release Notes:

- N/A
2024-02-14 16:03:03 -08:00
Roman
f62baeda64 gpui: Add Wayland support (#7664)
This PR adds Wayland support to gpui using
[wayland-rs](https://github.com/Smithay/wayland-rs). It is based on
[#7598](https://github.com/zed-industries/zed/pull/7598).

It detects Wayland support at runtime by checking the existence of the
`WAYLAND_DISPLAY` environment variable. If it does not exist or is
empty, the X11 backend will be used. To use the X11 backend in a Wayland
session (for development purposes), you just need to unset
WAYLAND_DISPLAY (`WAYLAND_DISPLAY= cargo run ...`).

At the moment it only creates the window and renders the initial content
provided by `BladeRenderer`, so it can run "Hello world" example.


![image](https://github.com/zed-industries/zed/assets/40907255/1655bc64-4d36-4178-9851-bfe42f03f716)

Todo:
- [x] Add basic Wayland support.
- [x] Add window resizing.
- [x] Add window closing.
- [x] Add window updating.
- [ ] Implement input handling, fractional scaling, and support other
Wayland protocols.
- [ ] Implement all unimplemented todo!(linux).
- [ ] Add window decorations or use custom decorations (like on MacOS).
- [ ] Address other missing functionality.

Release Notes:
- N/A

---------

Co-authored-by: gabydd <gabydinnerdavid@gmail.com>
Co-authored-by: Mikayla Maki <mikayla@zed.dev>
2024-02-14 14:50:11 -08:00
gmorenz
6e6ae0ef21 Ignore 'DROP COLUMN enviroment' in typo checker (#7803)
In dropping a misspelled "enviroment" column in #7742, a new "typo" was
introduced breaking CI. This fixes that.

Release Notes:

- N/A
2024-02-14 15:34:58 -07:00
gmorenz
a3300aed31 Don't reinstall dependencies on arch linux (#7801)
Very minor quality of life update, passing the --needed flag to pacman
to skip reinstalling up to date dependencies.

Release Notes:

- N/A
2024-02-14 14:28:29 -08:00
Conrad Irwin
cbbc8cad84 Try and make collab deploys faster (#7746)
Use github machines and build on host instead of in container

Release Notes:

- N/A
2024-02-14 15:11:57 -07:00
Conrad Irwin
8e52cf1495 Add netrw bindings for vim (#7757)
This is now possible after #7647

Release Notes:

- Added vim bindings for project panel
([#4270](https://github.com/zed-industries/zed/issues/4270)).
2024-02-14 14:38:07 -07:00
Conrad Irwin
65a1938e52 panics (#7793)
Release Notes:

- Fix a panic in rename
([#7509](https://github.com/zed-industries/zed/issues/7509)).

---------

Co-authored-by: Max <max@zed.dev>
2024-02-14 14:36:40 -07:00
Conrad Irwin
855acb948c drop columns (#7742)
Blocked on: #7741

Release Notes:

- N/A
2024-02-14 14:30:48 -07:00
Leon
54f82eb166 Add double quote wrap in activate_python_virtual_environment (#7787)
Added double quote wrap in activate_python_virtual_environment to handle
spaces in path.
Would fail to find venv activate script when spaces exist.


Release Notes:

- Fixed venv_detection activation not finding directory if space is in
the path

![image](https://github.com/zed-industries/zed/assets/52263845/9bf95b92-c6d4-4e2c-9158-d91a41c10216)


- With changes

![image](https://github.com/zed-industries/zed/assets/52263845/93602ae7-d991-494b-89c3-5afcce556888)
2024-02-14 12:42:06 -08:00
Joseph T. Lyons
ac59b9b02f Add a line instructing users to include media screenshots (#7790)
Making media up for release notes / tweets is becoming very time
consuming. I spoke to Max and suggested we ask users to submit media for
their features, to reduce what we need to produce for tweets and such. I
dont know if this is the best way to signal it; I don't like adding more
to the PR template, but I'm not sure of a better way at the moment.


Release Notes:

- N/a
2024-02-14 15:36:56 -05:00
Dzmitry Malyshau
8f7a26f397 X11: Continuous Presentation (#7762)
Alternative to #7758, which doesn't involve adding a new trait method
`request_draw`.
Somehow, my whole screen goes blinking black with this when moving the
window, so not ready for landing.

Release Notes:
- N/A

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
2024-02-14 12:24:12 -08:00
Conrad Irwin
181f556269 Fewer nightlys (#7784)
Remove an extraneous /nightly/ from our dSYM paths

Release Notes:

- N/A
2024-02-14 11:40:02 -07:00
Adrian Garcia Badaracco
a02bdd0a9f Fix link in python.md (#7735) 2024-02-14 10:30:30 -08:00
Joseph T. Lyons
6876ea44ac Remove 0-patch requirement on main in bump-zed-minor-versions 2024-02-14 13:13:44 -05:00
Joseph T. Lyons
16849f48e6 v0.124.x dev 2024-02-14 13:10:07 -05:00
Conrad Irwin
7a6d01e113 debug symbol upload (#7783)
This will let it become slowly eaasier to debug crashes

Release Notes:

- N/A
2024-02-14 10:55:37 -07:00
Marshall Bowers
b14fbd4ddc Fix extension list scrolling and add loading and empty states (#7782)
This PR fixes the scrolling of the extension list, as well as adds
various empty and loading states.

Release Notes:

- N/A
2024-02-14 12:47:58 -05:00
Thorsten Ball
b47aff4c14 go: enable completions with placeholders by default (#7780)
This fixes #7523 by enabling completions with placeholders by default.

This setting controls whether gopls sends back snippets with
placeholders. According to the documentation
(https://github.com/golang/tools/blob/master/gopls/doc/settings.md#useplaceholders-bool)
this only controls whether "placeholders for function parameters or
struct fields" are sent in completion responses.

In practice, though, this seems to also control whether any snippets
with *any* placeholders are being sent back.

Example: for the given Go code

    err := myFunction()
    i^

With the cursor being at `^`, this setting controls whether `gopls`
sends back statement snippets such as `if err != nil { return ... }`
with the `...` being dynamically matched to the return value of the
function.

So I think this setting controls far more than just function params and
struct fields. And since we *do* support placeholders in snippets, I
think this provides a better default experience.

Release Notes:

- Improved default Go experience by enabling snippets-with-placeholders
when initializing `gopls`.
([#7523](https://github.com/zed-industries/zed/issues/7523)).
2024-02-14 18:29:42 +01:00
Marshall Bowers
7ac055627e Reorganize extensions_ui.rs (#7779)
This PR reorganizes `extensions_ui.rs` by moving the `Render` impl down
below the primary `ExtensionsPage` impl.

Release Notes:

- N/A
2024-02-14 11:44:51 -05:00
Marshall Bowers
db0455beb0 Give explicit heights to items in the extension list (#7777)
This PR gives the items in the extension list an explicit height so that
they work properly within the uniform list when descriptions are
missing.

<img width="1235" alt="Screenshot 2024-02-14 at 10 19 14 AM"
src="https://github.com/zed-industries/zed/assets/1486634/01222902-6b05-4e9a-bb5a-bada14b1fd45">

I think we may want to consider using a `list` here instead of a
`uniform_list` to allow them to have variable heights.

Fixes #7756.

Release Notes:

- N/A
2024-02-14 10:43:11 -05:00
Thorsten Ball
017b2db630 Fix case-only renaming of files (#7768)
This fixes #5211 and #7732 by fixing the case-only file renaming.

The fix here works by checking hooking into function that produces the
data to populate the project panel.

It checks whether we're on a case-insensitive file system (default on
macOS, but you can have case-sensitive FS on macOS too) and if so, it
ignores the metadata for files for which the absolute path (returned by
the FS scanner) and canonicalized path do NOT match.

That's the case for (a) symlinks and (b) case-only renames of files.

It only does this check for case-only renames.

Release Notes:

- Fixed case-only renaming of files producing duplicate entries in
project panel.
([#5211](https://github.com/zed-industries/zed/issues/5211)).

Co-authored-by: Antonio <antonio@zed.dev>
2024-02-14 16:00:31 +01:00
Piotr Osiewicz
75eac4783b gpui: patch pathfinder_simd to fix nightly build, bump ahash for the … (#7770)
…same reason

Fixes #7644 

Release Notes:

- N/A
2024-02-14 12:55:31 +01:00
Conrad Irwin
7956a9a547 Don't wait to dispatch commands (#7755)
I added this when porting vim mode to gpui2 to work around life-cycle
problems.
Since #7647, this is no longer needed for vim mode, and causes other
problems (c.f. #7748)

Release Notes:

- Improved command to drop fewer keystrokes
2024-02-13 21:45:46 -07:00
Max Brunsfeld
c357e37dde Reload extensions more robustly when manually modifying installed extensions directory (#7749)
Release Notes:

- N/A

---------

Co-authored-by: Marshall <marshall@zed.dev>
2024-02-13 16:15:19 -08:00
Marshall Bowers
c3176392c6 Remove themes from the registry when the extension is uninstalled (#7745)
This PR makes it so uninstalling an extension will remove any themes
provided by that extension from the theme registry.

Release Notes:

- N/A
2024-02-13 16:41:34 -05:00
Conrad Irwin
e9b95fde68 Force upgrade people on nightly (#7744)
Release Notes:

- N/A
2024-02-13 13:34:49 -07:00
Max Brunsfeld
e73e93f333 Unload languages when uninstalling their extension (#7743)
Release Notes:

- N/A

Co-authored-by: Marshall <marshall@zed.dev>
2024-02-13 15:25:54 -05:00
Conrad Irwin
a2144faf9c Remove environment guards (#7741)
Release Notes:

- N/A
2024-02-13 13:20:14 -07:00
Conrad Irwin
d744aa896f v0.123.1 2024-02-13 13:13:07 -07:00
Conrad Irwin
2294d99046 revert single channel click (#7738)
- Revert "collab tweaks (#7706)"
- Revert "2112 (#7640)"
- Revert "single click channel (#7596)"
- Reserve protobufs
- Don't revert migrations

Release Notes:

- N/A

**or**

- N/A
2024-02-13 12:53:49 -07:00
Yohann
ecd9b93cb1 Add C-w and C-u keymaps in vim mode (Fix #7691) (#7736)
Release Notes:
- Added C-w and C-u keymaps in vim mode
([#7691](https://github.com/zed-industries/zed/issues/7691))
2024-02-13 12:35:01 -07:00
Carlos Lopez
fecb5a82f1 Add an extensions installation view (#7689)
This PR adds a view for installing extensions within Zed.

My subtasks:

- [X] Page Extensions and assign in App Menu
- [X] List extensions 
- [X] Button to Install/Uninstall
- [x] Search Input to search in extensions registry API
- [x] Get Extensions from API
- [x] Action install to download extension and copy in /extensions
folder
- [x] Action uninstall to remove from /extensions folder
- [x] Filtering
- [x] Better UI Design

Open to collab!

Release Notes:

- Added an extension installation view. Open it using the `zed:
extensions` action in the command palette
([#7096](https://github.com/zed-industries/zed/issues/7096)).

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Carlos <foxkdev@gmail.com>
Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
Co-authored-by: Max <max@zed.dev>
2024-02-13 14:09:02 -05:00
Thorsten Ball
33f713a8ab Optimize construction and insertion of large SumTrees (#7731)
This does two things:

1. It optimizes the constructions of `SumTree`s to not insert nodes
one-by-one, but instead inserts them level-by-level. That makes it more
efficient to construct large `SumTree`s.
2. It adds a `from_par_iter` constructor that parallelizes the
construction of `SumTree`s.

In combination, **loading a 500MB plain text file went from from
~18seconds down to ~2seconds**.

Disclaimer: I didn't write any of this code, lol! It's all @as-cii and
@nathansobo.

Release Notes:

- Improved performance when opening very large files.

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Julia <julia@zed.dev>
2024-02-13 16:24:40 +01:00
Thorsten Ball
798c9a7d8b Improve sorting of completion results (#7727)
This is an attempt to fix #5013 by doing two things:

1. Rank "obvious" matches in completions higher (see the code comment)
2. When tied: rank keywords higher than variables

Release Notes:

- Improved sorting of completion results to prefer literal matches.
([#5013](https://github.com/zed-industries/zed/issues/5013)).

### Before

![screenshot-2024-02-13-13 08
13@2x](https://github.com/zed-industries/zed/assets/1185253/77decb0b-5b47-45de-ab69-f7b333072b45)
![screenshot-2024-02-13-13 10
42@2x](https://github.com/zed-industries/zed/assets/1185253/ae33d0fe-06f5-4fc1-84f8-ddf6dbe80ba5)


### After

![screenshot-2024-02-13-13 06
22@2x](https://github.com/zed-industries/zed/assets/1185253/3c526bab-6392-4eeb-a2f2-dd73ccf228e8)
![screenshot-2024-02-13-13 06
50@2x](https://github.com/zed-industries/zed/assets/1185253/b5b9d513-766d-4a53-94de-b46271f5978c)

Co-authored-by: Antonio <antonio@zed.dev>
Co-authored-by: bennetbo <bennetbo@gmx.de>
2024-02-13 14:08:03 +01:00
Andrew Lygin
98fff014da Fix minor buffer search bar design issues (#7715)
This PR fixes the buffer search bar design issues mentioned in #7703.

It doesn't affect the project search bar.

Changes:

<img width="943" alt="zed-search-bar-design-issues"
src="https://github.com/zed-industries/zed/assets/2101250/af3bd0da-36cb-46ee-9af6-6b69911863d0">

Release Notes:

- N/A
2024-02-13 09:45:24 +01:00
Conrad Irwin
ea51536e0f Disable vim in the feedback modal (#7716)
Fixes #7000 by disabling vim in this context

Release Notes:

- Fixed feedback modal in vim mode
([#7000](https://github.com/zed-industries/zed/issues/7000)).

**or**

- N/A
2024-02-12 22:28:36 -07:00
Conrad Irwin
a1899bac4e vim: Fix renaming (#7714)
This was broken by #7647

Release Notes:

- N/A
2024-02-12 22:28:26 -07:00
Derrick Laird
04fc0dde1a languages: go.mod/go.work fix highlighting no longer working (#7705)
At some point go.mod and go.work syntax highlighting quit working. Looks
like the grammars weren't matching for some reason and I'm not sure how
they were working originally.

Not sure if we could write a test to make sure the tree-sitter queries
are being loaded for the grammars or not but seems like something that
could be useful to avoid something like this in the future.
2024-02-12 21:01:08 -07:00
Conrad Irwin
b800fe96d2 Fix dealloc of MacWindow (#7708)
We see some panics in the Drop handler for MacWindow

Looking into this, I noticed that our drop implementation was not
correctly
cleaning up the window state.

Release Notes:

- N/A
2024-02-12 20:01:09 -07:00
Conrad Irwin
21d2b5fe50 collab tweaks (#7706)
- Don't leave call when clicking on channel
- Don't prompt to leave a call you're not in

Release Notes:

- N/A
2024-02-12 16:08:35 -07:00
Conrad Irwin
d13a731cd6 Crash sooner on invalid background highlights (#7702)
Release Notes:

- N/A
2024-02-12 14:44:10 -07:00
Max Brunsfeld
ede9600ab4 Improve panic message for invalid anchors (#7700)
Release Notes:

- N/A

Co-authored-by: Marshall <marshall@zed.dev>
2024-02-12 12:58:30 -08:00
181 changed files with 7836 additions and 3012 deletions

View File

@@ -2,23 +2,23 @@ name: Feature Request
description: "Tip: open this issue template from within Zed with the `request feature` command palette action"
labels: ["admin read", "triage", "enhancement"]
body:
- type: checkboxes
attributes:
label: Check for existing issues
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it.
options:
- label: Completed
required: true
- type: textarea
attributes:
label: Describe the feature
description: A clear and concise description of what you want to happen.
validations:
- type: checkboxes
attributes:
label: Check for existing issues
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it.
options:
- label: Completed
required: true
- type: textarea
attributes:
label: |
If applicable, add mockups / screenshots to help present your vision of the feature
description: Drag images into the text input below
validations:
required: false
- type: textarea
attributes:
label: Describe the feature
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
attributes:
label: |
If applicable, add mockups / screenshots to help present your vision of the feature
description: Drag images into the text input below
validations:
required: false

View File

@@ -2,46 +2,46 @@ name: Language Support
description: Request language support
title: "<name_of_language> support"
labels:
[
"admin read",
"triage",
"enhancement",
"language",
"unsupported language",
"potential plugin",
]
[
"admin read",
"triage",
"enhancement",
"language",
"unsupported language",
"potential extension",
]
body:
- type: checkboxes
attributes:
label: Check for existing issues
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it.
options:
- label: Completed
required: true
- type: input
attributes:
label: Language
description: What language do you want support for?
placeholder: HTML
validations:
- type: checkboxes
attributes:
label: Check for existing issues
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it.
options:
- label: Completed
required: true
- type: input
attributes:
label: Tree Sitter parser link
description: If applicable, provide a link to the appropriate tree sitter parser. Look here first - https://tree-sitter.github.io/tree-sitter/#available-parsers
placeholder: https://github.com/tree-sitter/tree-sitter-html
validations:
required: false
- type: input
attributes:
label: Language server link
description: If applicable, provide a link to the appropriate language server. Look here first - https://microsoft.github.io/language-server-protocol/implementors/servers/
placeholder: https://github.com/Microsoft/vscode/tree/main/extensions/html-language-features/server
validations:
required: false
- type: textarea
attributes:
label: Misc notes
description: Provide any additional things the team should consider when adding support for this language
validations:
required: false
- type: input
attributes:
label: Language
description: What language do you want support for?
placeholder: HTML
validations:
required: true
- type: input
attributes:
label: Tree Sitter parser link
description: If applicable, provide a link to the appropriate tree sitter parser. Look here first - https://tree-sitter.github.io/tree-sitter/#available-parsers
placeholder: https://github.com/tree-sitter/tree-sitter-html
validations:
required: false
- type: input
attributes:
label: Language server link
description: If applicable, provide a link to the appropriate language server. Look here first - https://microsoft.github.io/language-server-protocol/implementors/servers/
placeholder: https://github.com/Microsoft/vscode/tree/main/extensions/html-language-features/server
validations:
required: false
- type: textarea
attributes:
label: Misc notes
description: Provide any additional things the team should consider when adding support for this language
validations:
required: false

View File

@@ -2,37 +2,37 @@ name: Bug Report
description: "Tip: open this issue template from within Zed with the `file bug report` command palette action"
labels: ["admin read", "triage", "defect"]
body:
- type: checkboxes
attributes:
label: Check for existing issues
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it.
options:
- label: Completed
required: true
- type: textarea
attributes:
label: Describe the bug / provide steps to reproduce it
description: A clear and concise description of what the bug is.
validations:
- type: checkboxes
attributes:
label: Check for existing issues
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it.
options:
- label: Completed
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Run the `copy system specs into clipboard` command palette action and paste the output in the field below.
validations:
required: true
- type: textarea
attributes:
label: If applicable, add mockups / screenshots to help explain present your vision of the feature
description: Drag issues into the text input below
validations:
required: false
- type: textarea
attributes:
label: |
If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue.
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
- type: textarea
attributes:
label: Describe the bug / provide steps to reproduce it
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Run the `copy system specs into clipboard` command palette action and paste the output in the field below.
validations:
required: true
- type: textarea
attributes:
label: If applicable, add mockups / screenshots to help explain present your vision of the feature
description: Drag issues into the text input below
validations:
required: false
- type: textarea
attributes:
label: |
If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue.
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

@@ -1,10 +1,13 @@
contact_links:
- name: Top-Ranking Issues
url: https://github.com/zed-industries/zed/issues/5393
about: See an overview of the most popular Zed issues
- name: Platform Support
url: https://github.com/zed-industries/zed/issues/5391
about: A quick note on platform support
- name: Positive Feedback
url: https://github.com/zed-industries/zed/discussions/5397
about: A central location for kind words about Zed
- name: Theme Request
url: https://github.com/zed-industries/extensions/issues/new/choose
about: Request a theme in the extensions repository
- name: Top-Ranking Issues
url: https://github.com/zed-industries/zed/issues/5393
about: See an overview of the most popular Zed issues
- name: Platform Support
url: https://github.com/zed-industries/zed/issues/5391
about: A quick note on platform support
- name: Positive Feedback
url: https://github.com/zed-industries/zed/discussions/5397
about: A central location for kind words about Zed

View File

@@ -2,14 +2,14 @@ name: "Check formatting"
description: "Checks code formatting use cargo fmt"
runs:
using: "composite"
steps:
- name: cargo fmt
shell: bash -euxo pipefail {0}
run: cargo fmt --all -- --check
using: "composite"
steps:
- name: cargo fmt
shell: bash -euxo pipefail {0}
run: cargo fmt --all -- --check
- name: Find modified migrations
shell: bash -euxo pipefail {0}
run: |
export SQUAWK_GITHUB_TOKEN=${{ github.token }}
. ./script/squawk
- name: Find modified migrations
shell: bash -euxo pipefail {0}
run: |
export SQUAWK_GITHUB_TOKEN=${{ github.token }}
. ./script/squawk

View File

@@ -2,22 +2,22 @@ name: "Run tests"
description: "Runs the tests"
runs:
using: "composite"
steps:
- name: Install Rust
shell: bash -euxo pipefail {0}
run: |
cargo install cargo-nextest
using: "composite"
steps:
- name: Install Rust
shell: bash -euxo pipefail {0}
run: |
cargo install cargo-nextest
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Limit target directory size
shell: bash -euxo pipefail {0}
run: script/clear-target-dir-if-larger-than 100
- name: Limit target directory size
shell: bash -euxo pipefail {0}
run: script/clear-target-dir-if-larger-than 100
- name: Run tests
shell: bash -euxo pipefail {0}
run: cargo nextest run --workspace --no-fail-fast
- name: Run tests
shell: bash -euxo pipefail {0}
run: cargo nextest run --workspace --no-fail-fast

View File

@@ -7,3 +7,5 @@ Release Notes:
**or**
- N/A
Optionally, include screenshots / media showcasing your addition that can be included in the release notes.

View File

@@ -1,204 +1,209 @@
name: CI
on:
push:
branches:
- main
- "v[0-9]+.[0-9]+.x"
tags:
- "v*"
pull_request:
branches:
- "**"
push:
branches:
- main
- "v[0-9]+.[0-9]+.x"
tags:
- "v*"
pull_request:
branches:
- "**"
concurrency:
# Allow only one workflow per any non-`main` branch.
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
cancel-in-progress: true
# Allow only one workflow per any non-`main` branch.
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
jobs:
style:
name: Check formatting and spelling
runs-on:
- self-hosted
- test
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
fetch-depth: 0
style:
name: Check formatting and spelling
runs-on:
- self-hosted
- test
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
fetch-depth: 0
- name: Set up default .cargo/config.toml
run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml
- name: Remove untracked files
run: git clean -df
- name: Check spelling
run: |
if ! which typos > /dev/null; then
cargo install typos-cli
fi
typos
- name: Set up default .cargo/config.toml
run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml
- name: Run style checks
uses: ./.github/actions/check_style
- name: Check spelling
run: |
if ! which typos > /dev/null; then
cargo install typos-cli
fi
typos
- name: Ensure fresh merge
shell: bash -euxo pipefail {0}
run: |
if [ -z "$GITHUB_BASE_REF" ];
then
echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> $GITHUB_ENV
else
git checkout -B temp
git merge -q origin/$GITHUB_BASE_REF -m "merge main into temp"
echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> $GITHUB_ENV
fi
- name: Run style checks
uses: ./.github/actions/check_style
- uses: bufbuild/buf-setup-action@v1
- uses: bufbuild/buf-breaking-action@v1
with:
input: "crates/rpc/proto/"
against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/rpc/proto/"
- name: Ensure fresh merge
shell: bash -euxo pipefail {0}
run: |
if [ -z "$GITHUB_BASE_REF" ];
then
echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> $GITHUB_ENV
else
git checkout -B temp
git merge -q origin/$GITHUB_BASE_REF -m "merge main into temp"
echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> $GITHUB_ENV
fi
macos_tests:
name: (macOS) Run Clippy and tests
runs-on:
- self-hosted
- test
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- uses: bufbuild/buf-setup-action@v1
- uses: bufbuild/buf-breaking-action@v1
with:
input: "crates/rpc/proto/"
against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/rpc/proto/"
- name: cargo clippy
shell: bash -euxo pipefail {0}
run: script/clippy
macos_tests:
name: (macOS) Run Clippy and tests
runs-on:
- self-hosted
- test
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: Run tests
uses: ./.github/actions/run_tests
- name: cargo clippy
shell: bash -euxo pipefail {0}
run: script/clippy
- name: Build collab
run: cargo build -p collab
- name: Run tests
uses: ./.github/actions/run_tests
- name: Build other binaries
run: cargo build --workspace --bins --all-features
- name: Build collab
run: cargo build -p collab
# todo!(linux): Actually run the tests
linux_tests:
name: (Linux) Run Clippy and tests
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: Build other binaries
run: cargo build --workspace --bins --all-features
- name: Restore from cache
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/rust-toolchain.toml') }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-${{ hashFiles('**/rust-toolchain.toml') }}-
# todo!(linux): Actually run the tests
linux_tests:
name: (Linux) Run Clippy and tests
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: configure linux
shell: bash -euxo pipefail {0}
run: script/linux
- name: Restore from cache
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/rust-toolchain.toml') }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-${{ hashFiles('**/rust-toolchain.toml') }}-
- name: cargo clippy
shell: bash -euxo pipefail {0}
run: script/clippy
- name: configure linux
shell: bash -euxo pipefail {0}
run: script/linux
- name: Build Zed
run: cargo build -p zed
bundle:
name: Bundle app
runs-on:
- self-hosted
- bundle
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
needs: [macos_tests, linux_tests]
- name: cargo clippy
shell: bash -euxo pipefail {0}
run: script/clippy
- name: Build Zed
run: cargo build -p zed
bundle:
name: Bundle macOS app
runs-on:
- self-hosted
- bundle
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
needs: [macos_tests]
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
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: Install Node
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: Limit target directory size
run: script/clear-target-dir-if-larger-than 100
- 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
- name: Generate license file
run: script/generate-licenses
- name: Create app bundle
run: script/bundle
- 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') }}
with:
name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg
path: target/release/Zed.dmg
- 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:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
steps:
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: Limit target directory size
run: script/clear-target-dir-if-larger-than 100
- 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
- name: Generate license file
run: script/generate-licenses
- name: Create app bundle
run: script/bundle
- 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') }}
with:
name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg
path: target/release/Zed.dmg
- 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 }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,35 +1,35 @@
name: Danger
on:
pull_request:
branches: [main]
types:
- opened
- synchronize
- reopened
- edited
pull_request:
branches: [main]
types:
- opened
- synchronize
- reopened
- edited
jobs:
danger:
runs-on: ubuntu-latest
danger:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 8
- uses: pnpm/action-setup@v3
with:
version: 8
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
cache-dependency-path: "script/danger/pnpm-lock.yaml"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
cache-dependency-path: "script/danger/pnpm-lock.yaml"
- run: pnpm install --dir script/danger
- run: pnpm install --dir script/danger
- name: Run Danger
run: pnpm run --dir script/danger danger ci
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Run Danger
run: pnpm run --dir script/danger danger ci
env:
GITHUB_TOKEN: ${{ github.token }}

View File

@@ -45,8 +45,18 @@ jobs:
submodules: "recursive"
fetch-depth: 0
- name: Install cargo nextest
shell: bash -euxo pipefail {0}
run: |
cargo install cargo-nextest
- name: Limit target directory size
shell: bash -euxo pipefail {0}
run: script/clear-target-dir-if-larger-than 100
- name: Run tests
uses: ./.github/actions/run_tests
shell: bash -euxo pipefail {0}
run: cargo nextest run --package collab --no-fail-fast
publish:
name: Publish collab server image
@@ -63,9 +73,6 @@ jobs:
- name: Sign into DigitalOcean docker registry
run: doctl registry login
- name: Prune Docker system
run: docker system prune --filter 'until=720h' -f
- name: Checkout repo
uses: actions/checkout@v4
with:
@@ -78,6 +85,9 @@ jobs:
- name: Publish docker image
run: docker push registry.digitalocean.com/zed/collab:${GITHUB_SHA}
- name: Prune Docker system
run: docker system prune --filter 'until=72h' -f
deploy:
name: Deploy new server image
needs:
@@ -90,22 +100,26 @@ jobs:
- name: Sign into Kubernetes
run: doctl kubernetes cluster kubeconfig save --expiry-seconds 600 ${{ secrets.CLUSTER_NAME }}
- name: Determine namespace
- name: Start rollout
run: |
set -eu
if [[ $GITHUB_REF_NAME = "collab-production" ]]; then
echo "Deploying collab:$GITHUB_SHA to production"
echo "KUBE_NAMESPACE=production" >> $GITHUB_ENV
export ZED_KUBE_NAMESPACE=production
elif [[ $GITHUB_REF_NAME = "collab-staging" ]]; then
echo "Deploying collab:$GITHUB_SHA to staging"
echo "KUBE_NAMESPACE=staging" >> $GITHUB_ENV
export ZED_KUBE_NAMESPACE=staging
else
echo "cowardly refusing to deploy from an unknown branch"
exit 1
fi
- name: Start rollout
run: kubectl -n "$KUBE_NAMESPACE" set image deployment/collab collab=registry.digitalocean.com/zed/collab:${GITHUB_SHA}
echo "Deploying collab:$GITHUB_SHA to $ZED_KUBE_NAMESPACE"
- name: Wait for rollout to finish
run: kubectl -n "$KUBE_NAMESPACE" rollout status deployment/collab
source script/lib/deploy-helpers.sh
export_vars_for_environment $ZED_KUBE_NAMESPACE
export ZED_DO_CERTIFICATE_ID=$(doctl compute certificate list --format ID --no-header)
export ZED_IMAGE_ID="registry.digitalocean.com/zed/collab:${GITHUB_SHA}"
envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f -
kubectl -n "$ZED_KUBE_NAMESPACE" rollout status deployment/collab --watch
echo "deployed collab.template.yml to ${ZED_KUBE_NAMESPACE}"

View File

@@ -3,35 +3,35 @@ name: Randomized Tests
concurrency: randomized-tests
on:
push:
branches:
- randomized-tests-runner
# schedule:
# - cron: '0 * * * *'
push:
branches:
- randomized-tests-runner
# schedule:
# - cron: '0 * * * *'
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
ZED_SERVER_URL: https://zed.dev
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
ZED_SERVER_URL: https://zed.dev
jobs:
tests:
name: Run randomized tests
runs-on:
- self-hosted
- randomized-tests
steps:
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "18"
tests:
name: Run randomized tests
runs-on:
- self-hosted
- randomized-tests
steps:
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: Run randomized tests
run: script/randomized-test-ci
- name: Run randomized tests
run: script/randomized-test-ci

View File

@@ -1,98 +1,98 @@
name: Release Nightly
on:
schedule:
# Fire every day at 7:00am UTC (Roughly before EU workday and after US workday)
- cron: "0 7 * * *"
push:
tags:
- "nightly"
schedule:
# Fire every day at 7:00am UTC (Roughly before EU workday and after US workday)
- cron: "0 7 * * *"
push:
tags:
- "nightly"
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
jobs:
style:
name: Check formatting and Clippy lints
if: github.repository_owner == 'zed-industries'
runs-on:
- self-hosted
- test
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
fetch-depth: 0
style:
name: Check formatting and Clippy lints
if: github.repository_owner == 'zed-industries'
runs-on:
- self-hosted
- test
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
fetch-depth: 0
- name: Run style checks
uses: ./.github/actions/check_style
- name: Run style checks
uses: ./.github/actions/check_style
- name: Run clippy
shell: bash -euxo pipefail {0}
run: script/clippy
tests:
name: Run tests
if: github.repository_owner == 'zed-industries'
runs-on:
- self-hosted
- test
needs: style
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: Run clippy
shell: bash -euxo pipefail {0}
run: script/clippy
tests:
name: Run tests
if: github.repository_owner == 'zed-industries'
runs-on:
- self-hosted
- test
needs: style
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: Run tests
uses: ./.github/actions/run_tests
- name: Run tests
uses: ./.github/actions/run_tests
bundle:
name: Bundle app
if: github.repository_owner == 'zed-industries'
runs-on:
- self-hosted
- bundle
needs: tests
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
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: Install Node
uses: actions/setup-node@v4
with:
node-version: "18"
bundle:
name: Bundle app
if: github.repository_owner == 'zed-industries'
runs-on:
- self-hosted
- bundle
needs: tests
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
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: Install Node
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: Checkout repo
uses: actions/checkout@v4
with:
clean: false
submodules: "recursive"
- name: Limit target directory size
run: script/clear-target-dir-if-larger-than 100
- name: Limit target directory size
run: script/clear-target-dir-if-larger-than 100
- name: Set release channel to nightly
run: |
set -eu
version=$(git rev-parse --short HEAD)
echo "Publishing version: ${version} on release channel nightly"
echo "nightly" > crates/zed/RELEASE_CHANNEL
- name: Set release channel to nightly
run: |
set -eu
version=$(git rev-parse --short HEAD)
echo "Publishing version: ${version} on release channel nightly"
echo "nightly" > crates/zed/RELEASE_CHANNEL
- name: Generate license file
run: script/generate-licenses
- name: Generate license file
run: script/generate-licenses
- name: Create app bundle
run: script/bundle
- name: Create app bundle
run: script/bundle
- name: Upload Zed Nightly
run: script/upload-nightly
- name: Upload Zed Nightly
run: script/upload-nightly

View File

@@ -1,18 +1,18 @@
on:
schedule:
- cron: "0 */12 * * *"
workflow_dispatch:
schedule:
- cron: "0 */12 * * *"
workflow_dispatch:
jobs:
update_top_ranking_issues:
runs-on: ubuntu-latest
if: github.repository_owner == 'zed-industries'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.10.5"
architecture: "x64"
cache: "pip"
- run: pip install -r script/update_top_ranking_issues/requirements.txt
- run: python script/update_top_ranking_issues/main.py 5393 --github-token ${{ secrets.GITHUB_TOKEN }} --prod
update_top_ranking_issues:
runs-on: ubuntu-latest
if: github.repository_owner == 'zed-industries'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.10.5"
architecture: "x64"
cache: "pip"
- run: pip install -r script/update_top_ranking_issues/requirements.txt
- run: python script/update_top_ranking_issues/main.py 5393 --github-token ${{ secrets.GITHUB_TOKEN }} --prod

View File

@@ -1,18 +1,18 @@
on:
schedule:
- cron: "0 15 * * *"
workflow_dispatch:
schedule:
- cron: "0 15 * * *"
workflow_dispatch:
jobs:
update_top_ranking_issues:
runs-on: ubuntu-latest
if: github.repository_owner == 'zed-industries'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.10.5"
architecture: "x64"
cache: "pip"
- run: pip install -r script/update_top_ranking_issues/requirements.txt
- run: python script/update_top_ranking_issues/main.py 6952 --github-token ${{ secrets.GITHUB_TOKEN }} --prod --query-day-interval 7
update_top_ranking_issues:
runs-on: ubuntu-latest
if: github.repository_owner == 'zed-industries'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.10.5"
architecture: "x64"
cache: "pip"
- run: pip install -r script/update_top_ranking_issues/requirements.txt
- run: python script/update_top_ranking_issues/main.py 6952 --github-token ${{ secrets.GITHUB_TOKEN }} --prod --query-day-interval 7

5
.gitignore vendored
View File

@@ -5,12 +5,8 @@
.DS_Store
/plugins/bin
/script/node_modules
/styles/node_modules
/styles/src/types/zed.ts
/crates/theme/schemas/theme.json
/crates/collab/static/styles.css
/crates/collab/.admins.json
/vendor/bin
/assets/*licenses.md
**/venv
.build
@@ -25,3 +21,4 @@ DerivedData/
**/*.db
.pytest_cache
.venv
.blob_store

View File

@@ -11,6 +11,8 @@
Antonio Scandurra <me@as-cii.com>
Antonio Scandurra <me@as-cii.com> <antonio@zed.dev>
Christian Bergschneider <christian.bergschneider@gmx.de>
Christian Bergschneider <christian.bergschneider@gmx.de> <magiclake@gmx.de>
Conrad Irwin <conrad@zed.dev>
Conrad Irwin <conrad@zed.dev> <conrad.irwin@gmail.com>
Greg Morenz <greg-morenz@droid.cafe>

View File

@@ -1,6 +1,12 @@
{
"JSON": {
"tab_size": 4
"languages": {
"TOML": {
"formatter": "prettier",
"format_on_save": "off"
},
"YAML": {
"formatter": "prettier"
}
},
"formatter": "auto"
}

1410
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ members = [
"crates/diagnostics",
"crates/editor",
"crates/extension",
"crates/extensions_ui",
"crates/feature_flags",
"crates/feedback",
"crates/file_finder",
@@ -113,6 +114,7 @@ db = { path = "crates/db" }
diagnostics = { path = "crates/diagnostics" }
editor = { path = "crates/editor" }
extension = { path = "crates/extension" }
extensions_ui = { path = "crates/extensions_ui" }
feature_flags = { path = "crates/feature_flags" }
feedback = { path = "crates/feedback" }
file_finder = { path = "crates/file_finder" }
@@ -177,6 +179,7 @@ zed_actions = { path = "crates/zed_actions" }
anyhow = "1.0.57"
async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
async-tar = "0.4.2"
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
ctor = "0.2.6"
@@ -187,10 +190,7 @@ git2 = { version = "0.15", default-features = false }
globset = "0.4"
indoc = "1"
# We explicitly disable a 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"] }
lazy_static = "1.4.0"
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
ordered-float = "2.1.1"
@@ -205,13 +205,11 @@ regex = "1.5"
rusqlite = { version = "0.29.0", features = ["blob", "array", "modern_sqlite"] }
rust-embed = { version = "8.0", features = ["include-exclude"] }
schemars = "0.8"
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"
smallvec = { version = "1.6", features = ["union"] }
smol = "1.2"
@@ -220,14 +218,14 @@ 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"] }
time = { version = "0.3", features = ["serde", "serde-well-known", "formatting"] }
toml = "0.5"
tree-sitter = { version = "0.20", features = ["wasm"] }
tree-sitter-astro = { git = "https://github.com/virchau13/tree-sitter-astro.git", rev = "e924787e12e8a03194f36a113290ac11d6dc10f3" }
tree-sitter-bash = { git = "https://github.com/tree-sitter/tree-sitter-bash", rev = "7331995b19b8f8aba2d5e26deb51d2195c18bc94" }
tree-sitter-beancount = { git = "https://github.com/polarmutex/tree-sitter-beancount", rev = "da1bf8c6eb0ae7a97588affde7227630bcd678b6" }
tree-sitter-c = "0.20.1"
tree-sitter-clojure = { git = "https://github.com/prcastro/tree-sitter-clojure", branch = "update-ts"}
tree-sitter-clojure = { git = "https://github.com/prcastro/tree-sitter-clojure", branch = "update-ts" }
tree-sitter-c-sharp = { git = "https://github.com/tree-sitter/tree-sitter-c-sharp", rev = "dd5e59721a5f8dae34604060833902b882023aaf" }
tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "f44509141e7e483323d2ec178f2d2e6c0fc041c1" }
tree-sitter-css = { git = "https://github.com/tree-sitter/tree-sitter-css", rev = "769203d0f9abe1a9a691ac2b9fe4bb4397a73c51" }
@@ -275,6 +273,8 @@ wasmtime = "16"
[patch.crates-io]
tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "1d8975319c2d5de1bf710e7e21db25b0eee4bc66" }
wasmtime = { git = "https://github.com/bytecodealliance/wasmtime", rev = "v16.0.0" }
# Workaround for a broken nightly build of gpui: See #7644 and revisit once 0.5.3 is released.
pathfinder_simd = { git = "https://github.com/servo/pathfinder.git", rev = "e4fcda0d5259d0acf902aee6de7d2501f2bd6629" }
[profile.dev]
split-debuginfo = "unpacked"

View File

@@ -11,6 +11,7 @@ ARG GITHUB_SHA
ENV GITHUB_SHA=$GITHUB_SHA
RUN --mount=type=cache,target=./script/node_modules \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=./target \
cargo build --release --package collab --bin collab

View File

@@ -1,2 +1,3 @@
collab: cd crates/collab && RUST_LOG=${RUST_LOG:-warn,collab=info} cargo run serve
collab: RUST_LOG=${RUST_LOG:-warn,collab=info} cargo run --package=collab serve
livekit: livekit-server --dev
blob_store: MINIO_ROOT_USER=the-blob-store-access-key MINIO_ROOT_PASSWORD=the-blob-store-secret-key minio server .blob_store

View File

@@ -16,10 +16,12 @@
"bmp": "image",
"c": "code",
"cc": "code",
"cjs": "code",
"conf": "settings",
"cpp": "code",
"css": "css",
"csv": "storage",
"cts": "typescript",
"dat": "storage",
"db": "storage",
"dbf": "storage",
@@ -80,12 +82,14 @@
"mdf": "storage",
"mdx": "document",
"mkv": "video",
"mjs": "code",
"mka": "audio",
"ml": "ocaml",
"mli": "ocaml",
"mov": "video",
"mp3": "audio",
"mp4": "video",
"mts": "typescript",
"myd": "storage",
"myi": "storage",
"odp": "document",

View File

@@ -117,6 +117,8 @@
"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",
@@ -485,7 +487,9 @@
"ctrl-x ctrl-a": "assistant::InlineAssist", // zed specific
"ctrl-x ctrl-c": "copilot::Suggest", // zed specific
"ctrl-x ctrl-l": "editor::ToggleCodeActions", // zed specific
"ctrl-x ctrl-z": "editor::Cancel"
"ctrl-x ctrl-z": "editor::Cancel",
"ctrl-w": "editor::DeleteToPreviousWordStart",
"ctrl-u": "editor::DeleteToBeginningOfLine"
}
},
{
@@ -503,5 +507,33 @@
"enter": "vim::SearchSubmit",
"escape": "buffer_search::Dismiss"
}
},
{
// Directory expansion
"context": "ProjectPanel && not_editing",
"bindings": {
"escape": "project_panel::ToggleFocus",
"enter": "project_panel::Open",
"o": "project_panel::Open",
"t": "project_panel::Open",
"v": "project_panel::Open",
"d": "project_panel::NewDirectory",
"%": "project_panel::NewFile",
"shift-r": "project_panel::Rename",
"m m": "project_panel::Cut",
"m c": "project_panel::Copy",
"m t": "project_panel::Paste",
"x": "project_panel::RevealInFinder",
"l": "project_panel::ExpandSelectedEntry",
"h": "project_panel::CollapseSelectedEntry",
// Move up and down
"j": "menu::SelectNext",
"k": "menu::SelectPrev",
"shift-d": "project_panel::Delete",
"/": "project_panel::NewSearchInDirectory",
// zed specific
"q p": "project_panel::CopyPath",
"q r": "project_panel::CopyRelativePath"
}
}
]

View File

@@ -104,8 +104,10 @@
"show_whitespaces": "selection",
// Settings related to calls in Zed
"calls": {
// Join calls with the microphone muted by default
"mute_on_join": false
// Join calls with the microphone live by default
"mute_on_join": false,
// Share your project when you are the first to join a channel
"share_on_join": true
},
// Toolbar related settings
"toolbar": {
@@ -480,6 +482,7 @@
"deno": {
"enable": false
},
"code_actions_on_format": {},
// Different settings for specific languages.
"languages": {
"Plain Text": {
@@ -490,7 +493,10 @@
},
"Go": {
"tab_size": 4,
"hard_tabs": true
"hard_tabs": true,
"code_actions_on_format": {
"source.organizeImports": true
}
},
"Markdown": {
"soft_wrap": "preferred_line_length"

View File

@@ -84,7 +84,6 @@ pub struct ActiveCall {
),
client: Arc<Client>,
user_store: Model<UserStore>,
pending_channel_id: Option<u64>,
_subscriptions: Vec<client::Subscription>,
}
@@ -98,7 +97,6 @@ impl ActiveCall {
location: None,
pending_invites: Default::default(),
incoming_call: watch::channel(),
pending_channel_id: None,
_join_debouncer: OneAtATime { cancel: None },
_subscriptions: vec![
client.add_request_handler(cx.weak_model(), Self::handle_incoming_call),
@@ -113,10 +111,6 @@ impl ActiveCall {
self.room()?.read(cx).channel_id()
}
pub fn pending_channel_id(&self) -> Option<u64> {
self.pending_channel_id
}
async fn handle_incoming_call(
this: Model<Self>,
envelope: TypedEnvelope<proto::IncomingCall>,
@@ -345,13 +339,11 @@ impl ActiveCall {
channel_id: u64,
cx: &mut ModelContext<Self>,
) -> Task<Result<Option<Model<Room>>>> {
let mut leave = None;
if let Some(room) = self.room().cloned() {
if room.read(cx).channel_id() == Some(channel_id) {
return Task::ready(Ok(Some(room)));
} else {
let (room, _) = self.room.take().unwrap();
leave = room.update(cx, |room, cx| Some(room.leave(cx)));
room.update(cx, |room, cx| room.clear_state(cx));
}
}
@@ -361,21 +353,14 @@ impl ActiveCall {
let client = self.client.clone();
let user_store = self.user_store.clone();
self.pending_channel_id = Some(channel_id);
let join = self._join_debouncer.spawn(cx, move |cx| async move {
if let Some(task) = leave {
task.await?
}
Room::join_channel(channel_id, client, user_store, cx).await
});
cx.spawn(|this, mut cx| async move {
let room = join.await?;
this.update(&mut cx, |this, cx| {
this.pending_channel_id.take();
this.set_room(room.clone(), cx)
})?
.await?;
this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))?
.await?;
this.update(&mut cx, |this, cx| {
this.report_call_event("join channel", cx)
})?;

View File

@@ -7,6 +7,7 @@ use settings::Settings;
#[derive(Deserialize, Debug)]
pub struct CallSettings {
pub mute_on_join: bool,
pub share_on_join: bool,
}
/// Configuration of voice calls in Zed.
@@ -16,6 +17,11 @@ pub struct CallSettingsContent {
///
/// Default: false
pub mute_on_join: Option<bool>,
/// Whether your current project should be shared when joining an empty channel.
///
/// Default: true
pub share_on_join: Option<bool>,
}
impl Settings for CallSettings {

View File

@@ -49,7 +49,6 @@ pub struct RemoteParticipant {
pub participant_index: ParticipantIndex,
pub muted: bool,
pub speaking: bool,
pub in_call: bool,
pub video_tracks: HashMap<live_kit_client::Sid, Arc<RemoteVideoTrack>>,
pub audio_tracks: HashMap<live_kit_client::Sid, Arc<RemoteAudioTrack>>,
}

View File

@@ -61,7 +61,6 @@ pub struct Room {
id: u64,
channel_id: Option<u64>,
live_kit: Option<LiveKitRoom>,
live_kit_connection_info: Option<proto::LiveKitConnectionInfo>,
status: RoomStatus,
shared_projects: HashSet<WeakModel<Project>>,
joined_projects: HashSet<WeakModel<Project>>,
@@ -113,18 +112,91 @@ impl Room {
user_store: Model<UserStore>,
cx: &mut ModelContext<Self>,
) -> Self {
let live_kit_room = if let Some(connection_info) = live_kit_connection_info {
let room = live_kit_client::Room::new();
let mut status = room.status();
// Consume the initial status of the room.
let _ = status.try_recv();
let _maintain_room = cx.spawn(|this, mut cx| async move {
while let Some(status) = status.next().await {
let this = if let Some(this) = this.upgrade() {
this
} else {
break;
};
if status == live_kit_client::ConnectionState::Disconnected {
this.update(&mut cx, |this, cx| this.leave(cx).log_err())
.ok();
break;
}
}
});
let _handle_updates = cx.spawn({
let room = room.clone();
move |this, mut cx| async move {
let mut updates = room.updates();
while let Some(update) = updates.next().await {
let this = if let Some(this) = this.upgrade() {
this
} else {
break;
};
this.update(&mut cx, |this, cx| {
this.live_kit_room_updated(update, cx).log_err()
})
.ok();
}
}
});
let connect = room.connect(&connection_info.server_url, &connection_info.token);
cx.spawn(|this, mut cx| async move {
connect.await?;
this.update(&mut cx, |this, cx| {
if !this.read_only() {
if let Some(live_kit) = &this.live_kit {
if !live_kit.muted_by_user && !live_kit.deafened {
return this.share_microphone(cx);
}
}
}
Task::ready(Ok(()))
})?
.await
})
.detach_and_log_err(cx);
Some(LiveKitRoom {
room,
screen_track: LocalTrack::None,
microphone_track: LocalTrack::None,
next_publish_id: 0,
muted_by_user: Self::mute_on_join(cx),
deafened: false,
speaking: false,
_maintain_room,
_handle_updates,
})
} else {
None
};
let maintain_connection = cx.spawn({
let client = client.clone();
move |this, cx| Self::maintain_connection(this, client.clone(), cx).log_err()
});
Audio::play_sound(Sound::Joined, cx);
let (room_update_completed_tx, room_update_completed_rx) = watch::channel();
let mut this = Self {
Self {
id,
channel_id,
live_kit: None,
live_kit_connection_info,
live_kit: live_kit_room,
status: RoomStatus::Online,
shared_projects: Default::default(),
joined_projects: Default::default(),
@@ -148,11 +220,7 @@ impl Room {
maintain_connection: Some(maintain_connection),
room_update_completed_tx,
room_update_completed_rx,
};
if this.live_kit_connection_info.is_some() {
this.join_call(cx).detach_and_log_err(cx);
}
this
}
pub(crate) fn create(
@@ -211,7 +279,7 @@ impl Room {
cx: AsyncAppContext,
) -> Result<Model<Self>> {
Self::from_join_response(
client.request(proto::JoinChannel2 { channel_id }).await?,
client.request(proto::JoinChannel { channel_id }).await?,
client,
user_store,
cx,
@@ -256,7 +324,7 @@ impl Room {
}
pub fn mute_on_join(cx: &AppContext) -> bool {
CallSettings::get_global(cx).mute_on_join
CallSettings::get_global(cx).mute_on_join || client::IMPERSONATE_LOGIN.is_some()
}
fn from_join_response(
@@ -306,9 +374,7 @@ impl Room {
}
log::info!("leaving room");
if self.live_kit.is_some() {
Audio::play_sound(Sound::Leave, cx);
}
Audio::play_sound(Sound::Leave, cx);
self.clear_state(cx);
@@ -527,24 +593,6 @@ impl Room {
&self.remote_participants
}
pub fn call_participants(&self, cx: &AppContext) -> Vec<Arc<User>> {
self.remote_participants()
.values()
.filter_map(|participant| {
if participant.in_call {
Some(participant.user.clone())
} else {
None
}
})
.chain(if self.in_call() {
self.user_store.read(cx).current_user()
} else {
None
})
.collect()
}
pub fn remote_participant_for_peer_id(&self, peer_id: PeerId) -> Option<&RemoteParticipant> {
self.remote_participants
.values()
@@ -569,6 +617,10 @@ impl Room {
self.local_participant.role == proto::ChannelRole::Admin
}
pub fn local_participant_is_guest(&self) -> bool {
self.local_participant.role == proto::ChannelRole::Guest
}
pub fn set_participant_role(
&mut self,
user_id: u64,
@@ -776,7 +828,6 @@ impl Room {
}
let role = participant.role();
let in_call = participant.in_call;
let location = ParticipantLocation::from_proto(participant.location)
.unwrap_or(ParticipantLocation::External);
if let Some(remote_participant) =
@@ -787,15 +838,9 @@ impl Room {
remote_participant.participant_index = participant_index;
if location != remote_participant.location
|| role != remote_participant.role
|| in_call != remote_participant.in_call
{
if in_call && !remote_participant.in_call {
Audio::play_sound(Sound::Joined, cx);
}
remote_participant.location = location;
remote_participant.role = role;
remote_participant.in_call = participant.in_call;
cx.emit(Event::ParticipantLocationChanged {
participant_id: peer_id,
});
@@ -812,15 +857,12 @@ impl Room {
role,
muted: true,
speaking: false,
in_call: participant.in_call,
video_tracks: Default::default(),
audio_tracks: Default::default(),
},
);
if participant.in_call {
Audio::play_sound(Sound::Joined, cx);
}
Audio::play_sound(Sound::Joined, cx);
if let Some(live_kit) = this.live_kit.as_ref() {
let video_tracks =
@@ -1009,6 +1051,15 @@ impl Room {
}
RoomUpdate::SubscribedToRemoteAudioTrack(track, publication) => {
if let Some(live_kit) = &self.live_kit {
if live_kit.deafened {
track.stop();
cx.foreground_executor()
.spawn(publication.set_enabled(false))
.detach();
}
}
let user_id = track.publisher_id().parse()?;
let track_id = track.sid().to_string();
let participant = self
@@ -1155,7 +1206,7 @@ impl Room {
})
}
pub(crate) fn share_project(
pub fn share_project(
&mut self,
project: Model<Project>,
cx: &mut ModelContext<Self>,
@@ -1257,14 +1308,18 @@ impl Room {
})
}
pub fn is_sharing_mic(&self) -> bool {
self.live_kit.as_ref().map_or(false, |live_kit| {
!matches!(live_kit.microphone_track, LocalTrack::None)
})
}
pub fn is_muted(&self) -> bool {
self.live_kit
.as_ref()
.map_or(true, |live_kit| match &live_kit.microphone_track {
LocalTrack::None => true,
LocalTrack::Pending { .. } => true,
LocalTrack::Published { track_publication } => track_publication.is_muted(),
})
self.live_kit.as_ref().map_or(false, |live_kit| {
matches!(live_kit.microphone_track, LocalTrack::None)
|| live_kit.muted_by_user
|| live_kit.deafened
})
}
pub fn read_only(&self) -> bool {
@@ -1278,8 +1333,8 @@ impl Room {
.map_or(false, |live_kit| live_kit.speaking)
}
pub fn in_call(&self) -> bool {
self.live_kit.is_some()
pub fn is_deafened(&self) -> Option<bool> {
self.live_kit.as_ref().map(|live_kit| live_kit.deafened)
}
#[track_caller]
@@ -1332,8 +1387,12 @@ impl Room {
Ok(publication) => {
if canceled {
live_kit.room.unpublish_track(publication);
live_kit.microphone_track = LocalTrack::None;
} else {
if live_kit.muted_by_user || live_kit.deafened {
cx.background_executor()
.spawn(publication.set_mute(true))
.detach();
}
live_kit.microphone_track = LocalTrack::Published {
track_publication: publication,
};
@@ -1437,140 +1496,50 @@ impl Room {
}
pub fn toggle_mute(&mut self, cx: &mut ModelContext<Self>) {
let muted = !self.is_muted();
if let Some(task) = self.set_mute(muted, cx) {
task.detach_and_log_err(cx);
if let Some(live_kit) = self.live_kit.as_mut() {
// When unmuting, undeafen if the user was deafened before.
let was_deafened = live_kit.deafened;
if live_kit.muted_by_user
|| live_kit.deafened
|| matches!(live_kit.microphone_track, LocalTrack::None)
{
live_kit.muted_by_user = false;
live_kit.deafened = false;
} else {
live_kit.muted_by_user = true;
}
let muted = live_kit.muted_by_user;
let should_undeafen = was_deafened && !live_kit.deafened;
if let Some(task) = self.set_mute(muted, cx) {
task.detach_and_log_err(cx);
}
if should_undeafen {
if let Some(task) = self.set_deafened(false, cx) {
task.detach_and_log_err(cx);
}
}
}
}
pub fn join_call(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
if self.live_kit.is_some() {
return Task::ready(Ok(()));
}
pub fn toggle_deafen(&mut self, cx: &mut ModelContext<Self>) {
if let Some(live_kit) = self.live_kit.as_mut() {
// When deafening, mute the microphone if it was not already muted.
// When un-deafening, unmute the microphone, unless it was explicitly muted.
let deafened = !live_kit.deafened;
live_kit.deafened = deafened;
let should_change_mute = !live_kit.muted_by_user;
let room = live_kit_client::Room::new();
let mut status = room.status();
// Consume the initial status of the room.
let _ = status.try_recv();
let _maintain_room = cx.spawn(|this, mut cx| async move {
while let Some(status) = status.next().await {
let this = if let Some(this) = this.upgrade() {
this
} else {
break;
};
if let Some(task) = self.set_deafened(deafened, cx) {
task.detach_and_log_err(cx);
}
if status == live_kit_client::ConnectionState::Disconnected {
this.update(&mut cx, |this, cx| this.leave(cx).log_err())
.ok();
break;
if should_change_mute {
if let Some(task) = self.set_mute(deafened, cx) {
task.detach_and_log_err(cx);
}
}
});
let _handle_updates = cx.spawn({
let room = room.clone();
move |this, mut cx| async move {
let mut updates = room.updates();
while let Some(update) = updates.next().await {
let this = if let Some(this) = this.upgrade() {
this
} else {
break;
};
this.update(&mut cx, |this, cx| {
this.live_kit_room_updated(update, cx).log_err()
})
.ok();
}
}
});
self.live_kit = Some(LiveKitRoom {
room: room.clone(),
screen_track: LocalTrack::None,
microphone_track: LocalTrack::None,
next_publish_id: 0,
speaking: false,
_maintain_room,
_handle_updates,
});
cx.spawn({
let client = self.client.clone();
let share_microphone = !self.read_only() && !Self::mute_on_join(cx);
let connection_info = self.live_kit_connection_info.clone();
let channel_id = self.channel_id;
move |this, mut cx| async move {
let connection_info = if let Some(connection_info) = connection_info {
connection_info.clone()
} else if let Some(channel_id) = channel_id {
if let Some(connection_info) = client
.request(proto::JoinChannelCall { channel_id })
.await?
.live_kit_connection_info
{
connection_info
} else {
return Err(anyhow!("failed to get connection info from server"));
}
} else {
return Err(anyhow!(
"tried to connect to livekit without connection info"
));
};
room.connect(&connection_info.server_url, &connection_info.token)
.await?;
let track_updates = this.update(&mut cx, |this, cx| {
Audio::play_sound(Sound::Joined, cx);
let Some(live_kit) = this.live_kit.as_mut() else {
return vec![];
};
let mut track_updates = Vec::new();
for participant in this.remote_participants.values() {
for publication in live_kit
.room
.remote_audio_track_publications(&participant.user.id.to_string())
{
track_updates.push(publication.set_enabled(true));
}
for track in participant.audio_tracks.values() {
track.start();
}
}
track_updates
})?;
if share_microphone {
this.update(&mut cx, |this, cx| this.share_microphone(cx))?
.await?
};
for result in futures::future::join_all(track_updates).await {
result?;
}
anyhow::Ok(())
}
})
}
pub fn leave_call(&mut self, cx: &mut ModelContext<Self>) {
Audio::play_sound(Sound::Leave, cx);
if let Some(channel_id) = self.channel_id() {
let client = self.client.clone();
cx.background_executor()
.spawn(client.request(proto::LeaveChannelCall { channel_id }))
.detach_and_log_err(cx);
self.live_kit.take();
self.live_kit_connection_info.take();
cx.notify();
} else {
self.leave(cx).detach_and_log_err(cx)
}
}
@@ -1601,6 +1570,40 @@ impl Room {
}
}
fn set_deafened(
&mut self,
deafened: bool,
cx: &mut ModelContext<Self>,
) -> Option<Task<Result<()>>> {
let live_kit = self.live_kit.as_mut()?;
cx.notify();
let mut track_updates = Vec::new();
for participant in self.remote_participants.values() {
for publication in live_kit
.room
.remote_audio_track_publications(&participant.user.id.to_string())
{
track_updates.push(publication.set_enabled(!deafened));
}
for track in participant.audio_tracks.values() {
if deafened {
track.stop();
} else {
track.start();
}
}
}
Some(cx.foreground_executor().spawn(async move {
for result in futures::future::join_all(track_updates).await {
result?;
}
Ok(())
}))
}
fn set_mute(
&mut self,
should_mute: bool,
@@ -1645,6 +1648,9 @@ struct LiveKitRoom {
room: Arc<live_kit_client::Room>,
screen_track: LocalTrack,
microphone_track: LocalTrack,
/// Tracks whether we're currently in a muted state due to auto-mute from deafening or manual mute performed by user.
muted_by_user: bool,
deafened: bool,
speaking: bool,
next_publish_id: usize,
_maintain_room: Task<()>,

View File

@@ -7,6 +7,11 @@ ZED_ENVIRONMENT = "development"
LIVE_KIT_SERVER = "http://localhost:7880"
LIVE_KIT_KEY = "devkey"
LIVE_KIT_SECRET = "secret"
BLOB_STORE_ACCESS_KEY = "the-blob-store-access-key"
BLOB_STORE_SECRET_KEY = "the-blob-store-secret-key"
BLOB_STORE_BUCKET = "the-extensions-bucket"
BLOB_STORE_URL = "http://127.0.0.1:9000"
BLOB_STORE_REGION = "the-region"
# RUST_LOG=info
# LOG_JSON=true

View File

@@ -17,6 +17,8 @@ required-features = ["seed-support"]
[dependencies]
anyhow.workspace = true
async-tungstenite = "0.16"
aws-config = { version = "1.1.5" }
aws-sdk-s3 = { version = "1.15.0" }
axum = { version = "0.5", features = ["json", "headers", "ws"] }
axum-extra = { version = "0.3", features = ["erased-json"] }
base64 = "0.13"
@@ -41,6 +43,7 @@ reqwest = { version = "0.11", features = ["json"], optional = true }
rpc.workspace = true
scrypt = "0.7"
sea-orm = { version = "0.12.x", features = ["sqlx-postgres", "postgres-array", "runtime-tokio-rustls", "with-uuid"] }
semver.workspace = true
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
@@ -61,7 +64,6 @@ util.workspace = true
uuid.workspace = true
[dev-dependencies]
release_channel.workspace = true
async-trait.workspace = true
audio.workspace = true
call = { workspace = true, features = ["test-support"] }
@@ -86,6 +88,7 @@ node_runtime.workspace = true
notifications = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
release_channel.workspace = true
rpc = { workspace = true, features = ["test-support"] }
sea-orm = { version = "0.12.x", features = ["sqlx-sqlite"] }
serde_json.workspace = true

View File

@@ -105,6 +105,31 @@ spec:
secretKeyRef:
name: livekit
key: secret
- name: BLOB_STORE_ACCESS_KEY
valueFrom:
secretKeyRef:
name: blob-store
key: access_key
- name: BLOB_STORE_SECRET_KEY
valueFrom:
secretKeyRef:
name: blob-store
key: secret_key
- name: BLOB_STORE_URL
valueFrom:
secretKeyRef:
name: blob-store
key: url
- name: BLOB_STORE_REGION
valueFrom:
secretKeyRef:
name: blob-store
key: region
- name: BLOB_STORE_BUCKET
valueFrom:
secretKeyRef:
name: blob-store
key: bucket
- name: INVITE_LINK_PREFIX
value: ${INVITE_LINK_PREFIX}
- name: RUST_BACKTRACE

View File

@@ -353,3 +353,25 @@ CREATE TABLE contributors (
signed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id)
);
CREATE TABLE extensions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_id TEXT NOT NULL,
name TEXT NOT NULL,
latest_version TEXT NOT NULL,
total_download_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE extension_versions (
extension_id INTEGER REFERENCES extensions(id),
version TEXT NOT NULL,
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
authors TEXT NOT NULL,
repository TEXT NOT NULL,
description TEXT NOT NULL,
download_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (extension_id, version)
);
CREATE UNIQUE INDEX "index_extensions_external_id" ON "extensions" ("external_id");
CREATE INDEX "index_extensions_total_download_count" ON "extensions" ("total_download_count");

View File

@@ -0,0 +1,4 @@
-- Add migration script here
ALTER TABLE rooms DROP COLUMN enviroment;
ALTER TABLE rooms DROP COLUMN environment;
ALTER TABLE room_participants DROP COLUMN in_call;

View File

@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS extensions (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
external_id TEXT NOT NULL,
latest_version TEXT NOT NULL,
total_download_count BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS extension_versions (
extension_id INTEGER REFERENCES extensions(id),
version TEXT NOT NULL,
published_at TIMESTAMP NOT NULL DEFAULT now(),
authors TEXT NOT NULL,
repository TEXT NOT NULL,
description TEXT NOT NULL,
download_count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY(extension_id, version)
);
CREATE UNIQUE INDEX "index_extensions_external_id" ON "extensions" ("external_id");
CREATE INDEX "trigram_index_extensions_name" ON "extensions" USING GIN(name gin_trgm_ops);
CREATE INDEX "index_extensions_total_download_count" ON "extensions" ("total_download_count");

View File

@@ -1,3 +1,5 @@
mod extensions;
use crate::{
auth,
db::{ContributorSelector, User, UserId},
@@ -20,6 +22,8 @@ use std::sync::Arc;
use tower::ServiceBuilder;
use tracing::instrument;
pub use extensions::fetch_extensions_from_blob_store_periodically;
pub fn routes(rpc_server: Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
Router::new()
.route("/user", get(get_authenticated_user))
@@ -28,6 +32,7 @@ pub fn routes(rpc_server: Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body
.route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
.route("/contributors", get(get_contributors).post(add_contributor))
.route("/contributor", get(check_is_contributor))
.merge(extensions::router())
.layer(
ServiceBuilder::new()
.layer(Extension(state))

View File

@@ -0,0 +1,237 @@
use crate::{
db::{ExtensionMetadata, NewExtensionVersion},
executor::Executor,
AppState, Error, Result,
};
use anyhow::{anyhow, Context as _};
use aws_sdk_s3::presigning::PresigningConfig;
use axum::{
extract::{Path, Query},
response::Redirect,
routing::get,
Extension, Json, Router,
};
use collections::HashMap;
use hyper::StatusCode;
use serde::{Deserialize, Serialize};
use std::{sync::Arc, time::Duration};
use time::PrimitiveDateTime;
use util::ResultExt;
pub fn router() -> Router {
Router::new()
.route("/extensions", get(get_extensions))
.route(
"/extensions/:extension_id/:version/download",
get(download_extension),
)
}
#[derive(Debug, Deserialize)]
struct GetExtensionsParams {
filter: Option<String>,
}
#[derive(Debug, Deserialize)]
struct DownloadExtensionParams {
extension_id: String,
version: String,
}
#[derive(Debug, Serialize)]
struct GetExtensionsResponse {
pub data: Vec<ExtensionMetadata>,
}
#[derive(Deserialize)]
struct ExtensionManifest {
name: String,
version: String,
description: Option<String>,
authors: Vec<String>,
repository: String,
}
async fn get_extensions(
Extension(app): Extension<Arc<AppState>>,
Query(params): Query<GetExtensionsParams>,
) -> Result<Json<GetExtensionsResponse>> {
let extensions = app.db.get_extensions(params.filter.as_deref(), 30).await?;
Ok(Json(GetExtensionsResponse { data: extensions }))
}
async fn download_extension(
Extension(app): Extension<Arc<AppState>>,
Path(params): Path<DownloadExtensionParams>,
) -> Result<Redirect> {
let Some((blob_store_client, bucket)) = app
.blob_store_client
.clone()
.zip(app.config.blob_store_bucket.clone())
else {
Err(Error::Http(
StatusCode::NOT_IMPLEMENTED,
"not supported".into(),
))?
};
let DownloadExtensionParams {
extension_id,
version,
} = params;
let version_exists = app
.db
.record_extension_download(&extension_id, &version)
.await?;
if !version_exists {
Err(Error::Http(
StatusCode::NOT_FOUND,
"unknown extension version".into(),
))?;
}
let url = blob_store_client
.get_object()
.bucket(bucket)
.key(format!(
"extensions/{extension_id}/{version}/archive.tar.gz"
))
.presigned(PresigningConfig::expires_in(EXTENSION_DOWNLOAD_URL_LIFETIME).unwrap())
.await
.map_err(|e| anyhow!("failed to create presigned extension download url {e}"))?;
Ok(Redirect::temporary(url.uri()))
}
const EXTENSION_FETCH_INTERVAL: Duration = Duration::from_secs(5 * 60);
const EXTENSION_DOWNLOAD_URL_LIFETIME: Duration = Duration::from_secs(3 * 60);
pub fn fetch_extensions_from_blob_store_periodically(app_state: Arc<AppState>, executor: Executor) {
let Some(blob_store_client) = app_state.blob_store_client.clone() else {
log::info!("no blob store client");
return;
};
let Some(blob_store_bucket) = app_state.config.blob_store_bucket.clone() else {
log::info!("no blob store bucket");
return;
};
executor.spawn_detached({
let executor = executor.clone();
async move {
loop {
fetch_extensions_from_blob_store(
&blob_store_client,
&blob_store_bucket,
&app_state,
)
.await
.log_err();
executor.sleep(EXTENSION_FETCH_INTERVAL).await;
}
}
});
}
async fn fetch_extensions_from_blob_store(
blob_store_client: &aws_sdk_s3::Client,
blob_store_bucket: &String,
app_state: &Arc<AppState>,
) -> anyhow::Result<()> {
let list = blob_store_client
.list_objects()
.bucket(blob_store_bucket)
.prefix("extensions/")
.send()
.await?;
let objects = list
.contents
.ok_or_else(|| anyhow!("missing bucket contents"))?;
let mut published_versions = HashMap::<&str, Vec<&str>>::default();
for object in &objects {
let Some(key) = object.key.as_ref() else {
continue;
};
let mut parts = key.split('/');
let Some(_) = parts.next().filter(|part| *part == "extensions") else {
continue;
};
let Some(extension_id) = parts.next() else {
continue;
};
let Some(version) = parts.next() else {
continue;
};
published_versions
.entry(extension_id)
.or_default()
.push(version);
}
let known_versions = app_state.db.get_known_extension_versions().await?;
let mut new_versions = HashMap::<&str, Vec<NewExtensionVersion>>::default();
let empty = Vec::new();
for (extension_id, published_versions) in published_versions {
let known_versions = known_versions.get(extension_id).unwrap_or(&empty);
for published_version in published_versions {
if known_versions
.binary_search_by_key(&published_version, String::as_str)
.is_err()
{
let object = blob_store_client
.get_object()
.bucket(blob_store_bucket)
.key(format!(
"extensions/{extension_id}/{published_version}/manifest.json"
))
.send()
.await?;
let manifest_bytes = object
.body
.collect()
.await
.map(|data| data.into_bytes())
.with_context(|| format!("failed to download manifest for extension {extension_id} version {published_version}"))?
.to_vec();
let manifest = serde_json::from_slice::<ExtensionManifest>(&manifest_bytes)
.with_context(|| format!("invalid manifest for extension {extension_id} version {published_version}: {}", String::from_utf8_lossy(&manifest_bytes)))?;
let published_at = object.last_modified.ok_or_else(|| anyhow!("missing last modified timestamp for extension {extension_id} version {published_version}"))?;
let published_at =
time::OffsetDateTime::from_unix_timestamp_nanos(published_at.as_nanos())?;
let published_at = PrimitiveDateTime::new(published_at.date(), published_at.time());
let version = semver::Version::parse(&manifest.version).with_context(|| {
format!(
"invalid version for extension {extension_id} version {published_version}"
)
})?;
new_versions
.entry(extension_id)
.or_default()
.push(NewExtensionVersion {
name: manifest.name,
version,
description: manifest.description.unwrap_or_default(),
authors: manifest.authors,
repository: manifest.repository,
published_at,
});
}
}
}
app_state
.db
.insert_extension_versions(&new_versions)
.await?;
Ok(())
}

View File

@@ -1,12 +1,8 @@
#[cfg(test)]
pub mod tests;
#[cfg(test)]
pub use tests::TestDb;
mod ids;
mod queries;
mod tables;
#[cfg(test)]
pub mod tests;
use crate::{executor::Executor, Error, Result};
use anyhow::anyhow;
@@ -25,7 +21,7 @@ use sea_orm::{
FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, QueryOrder, QuerySelect, Statement,
TransactionTrait,
};
use serde::{Deserialize, Serialize};
use serde::{ser::Error as _, Deserialize, Serialize, Serializer};
use sqlx::{
migrate::{Migrate, Migration, MigrationSource},
Connection,
@@ -40,13 +36,17 @@ use std::{
sync::Arc,
time::Duration,
};
pub use tables::*;
use time::{format_description::well_known::iso8601, PrimitiveDateTime};
use tokio::sync::{Mutex, OwnedMutexGuard};
#[cfg(test)]
pub use tests::TestDb;
pub use ids::*;
pub use queries::contributors::ContributorSelector;
pub use sea_orm::ConnectOptions;
pub use tables::user::Model as User;
pub use tables::*;
/// Database gives you a handle that lets you access the database.
/// It handles pooling internally.
@@ -717,3 +717,43 @@ pub struct WorktreeSettingsFile {
pub path: String,
pub content: String,
}
pub struct NewExtensionVersion {
pub name: String,
pub version: semver::Version,
pub description: String,
pub authors: Vec<String>,
pub repository: String,
pub published_at: PrimitiveDateTime,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct ExtensionMetadata {
pub id: String,
pub name: String,
pub version: String,
pub authors: Vec<String>,
pub description: String,
pub repository: String,
#[serde(serialize_with = "serialize_iso8601")]
pub published_at: PrimitiveDateTime,
pub download_count: u64,
}
pub fn serialize_iso8601<S: Serializer>(
datetime: &PrimitiveDateTime,
serializer: S,
) -> Result<S::Ok, S::Error> {
const SERDE_CONFIG: iso8601::EncodedConfig = iso8601::Config::DEFAULT
.set_year_is_six_digits(false)
.set_time_precision(iso8601::TimePrecision::Second {
decimal_digits: None,
})
.encode();
datetime
.assume_utc()
.format(&time::format_description::well_known::Iso8601::<SERDE_CONFIG>)
.map_err(S::Error::custom)?
.serialize(serializer)
}

View File

@@ -85,6 +85,7 @@ id_type!(SignupId);
id_type!(UserId);
id_type!(ChannelBufferCollaboratorId);
id_type!(FlagId);
id_type!(ExtensionId);
id_type!(NotificationId);
id_type!(NotificationKindId);

View File

@@ -5,6 +5,7 @@ pub mod buffers;
pub mod channels;
pub mod contacts;
pub mod contributors;
pub mod extensions;
pub mod messages;
pub mod notifications;
pub mod projects;

View File

@@ -97,59 +97,12 @@ impl Database {
.await
}
pub async fn set_in_channel_call(
&self,
channel_id: ChannelId,
user_id: UserId,
in_call: bool,
) -> Result<(proto::Room, ChannelRole)> {
self.transaction(move |tx| async move {
let channel = self.get_channel_internal(channel_id, &*tx).await?;
let role = self.channel_role_for_user(&channel, user_id, &*tx).await?;
if role.is_none() || role == Some(ChannelRole::Banned) {
Err(ErrorCode::Forbidden.anyhow())?
}
let role = role.unwrap();
let Some(room) = room::Entity::find()
.filter(room::Column::ChannelId.eq(channel_id))
.one(&*tx)
.await?
else {
Err(anyhow!("no room exists"))?
};
let result = room_participant::Entity::update_many()
.filter(
Condition::all()
.add(room_participant::Column::RoomId.eq(room.id))
.add(room_participant::Column::UserId.eq(user_id)),
)
.set(room_participant::ActiveModel {
in_call: ActiveValue::Set(in_call),
..Default::default()
})
.exec(&*tx)
.await?;
if result.rows_affected != 1 {
Err(anyhow!("not in channel"))?
}
let room = self.get_room(room.id, &*tx).await?;
Ok((room, role))
})
.await
}
/// Adds a user to the specified channel.
pub async fn join_channel(
&self,
channel_id: ChannelId,
user_id: UserId,
autojoin: bool,
connection: ConnectionId,
environment: &str,
) -> Result<(JoinRoom, Option<MembershipUpdated>, ChannelRole)> {
self.transaction(move |tx| async move {
let channel = self.get_channel_internal(channel_id, &*tx).await?;
@@ -209,10 +162,10 @@ impl Database {
let live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
let room_id = self
.get_or_create_channel_room(channel_id, &live_kit_room, environment, &*tx)
.get_or_create_channel_room(channel_id, &live_kit_room, &*tx)
.await?;
self.join_channel_room_internal(room_id, user_id, autojoin, connection, role, &*tx)
self.join_channel_room_internal(room_id, user_id, connection, role, &*tx)
.await
.map(|jr| (jr, accept_invite_result, role))
})
@@ -979,7 +932,6 @@ impl Database {
&self,
channel_id: ChannelId,
live_kit_room: &str,
environment: &str,
tx: &DatabaseTransaction,
) -> Result<RoomId> {
let room = room::Entity::find()
@@ -988,19 +940,11 @@ impl Database {
.await?;
let room_id = if let Some(room) = room {
if let Some(env) = room.environment {
if &env != environment {
Err(ErrorCode::WrongReleaseChannel
.with_tag("required", &env)
.anyhow())?;
}
}
room.id
} else {
let result = room::Entity::insert(room::ActiveModel {
channel_id: ActiveValue::Set(Some(channel_id)),
live_kit_room: ActiveValue::Set(live_kit_room.to_string()),
environment: ActiveValue::Set(Some(environment.to_string())),
..Default::default()
})
.exec(&*tx)

View File

@@ -0,0 +1,206 @@
use super::*;
impl Database {
pub async fn get_extensions(
&self,
filter: Option<&str>,
limit: usize,
) -> Result<Vec<ExtensionMetadata>> {
self.transaction(|tx| async move {
let mut condition = Condition::all();
if let Some(filter) = filter {
let fuzzy_name_filter = Self::fuzzy_like_string(filter);
condition = condition.add(Expr::cust_with_expr("name ILIKE $1", fuzzy_name_filter));
}
let extensions = extension::Entity::find()
.filter(condition)
.order_by_desc(extension::Column::TotalDownloadCount)
.order_by_asc(extension::Column::Name)
.limit(Some(limit as u64))
.filter(
extension::Column::LatestVersion
.into_expr()
.eq(extension_version::Column::Version.into_expr()),
)
.inner_join(extension_version::Entity)
.select_also(extension_version::Entity)
.all(&*tx)
.await?;
Ok(extensions
.into_iter()
.filter_map(|(extension, latest_version)| {
let version = latest_version?;
Some(ExtensionMetadata {
id: extension.external_id,
name: extension.name,
version: version.version,
authors: version
.authors
.split(',')
.map(|author| author.trim().to_string())
.collect::<Vec<_>>(),
description: version.description,
repository: version.repository,
published_at: version.published_at,
download_count: extension.total_download_count as u64,
})
})
.collect())
})
.await
}
pub async fn get_known_extension_versions<'a>(&self) -> Result<HashMap<String, Vec<String>>> {
self.transaction(|tx| async move {
let mut extension_external_ids_by_id = HashMap::default();
let mut rows = extension::Entity::find().stream(&*tx).await?;
while let Some(row) = rows.next().await {
let row = row?;
extension_external_ids_by_id.insert(row.id, row.external_id);
}
drop(rows);
let mut known_versions_by_extension_id: HashMap<String, Vec<String>> =
HashMap::default();
let mut rows = extension_version::Entity::find().stream(&*tx).await?;
while let Some(row) = rows.next().await {
let row = row?;
let Some(extension_id) = extension_external_ids_by_id.get(&row.extension_id) else {
continue;
};
let versions = known_versions_by_extension_id
.entry(extension_id.clone())
.or_default();
if let Err(ix) = versions.binary_search(&row.version) {
versions.insert(ix, row.version);
}
}
drop(rows);
Ok(known_versions_by_extension_id)
})
.await
}
pub async fn insert_extension_versions(
&self,
versions_by_extension_id: &HashMap<&str, Vec<NewExtensionVersion>>,
) -> Result<()> {
self.transaction(|tx| async move {
for (external_id, versions) in versions_by_extension_id {
if versions.is_empty() {
continue;
}
let latest_version = versions
.iter()
.max_by_key(|version| &version.version)
.unwrap();
let insert = extension::Entity::insert(extension::ActiveModel {
name: ActiveValue::Set(latest_version.name.clone()),
external_id: ActiveValue::Set(external_id.to_string()),
id: ActiveValue::NotSet,
latest_version: ActiveValue::Set(latest_version.version.to_string()),
total_download_count: ActiveValue::NotSet,
})
.on_conflict(
OnConflict::columns([extension::Column::ExternalId])
.update_column(extension::Column::ExternalId)
.to_owned(),
);
let extension = if tx.support_returning() {
insert.exec_with_returning(&*tx).await?
} else {
// Sqlite
insert.exec_without_returning(&*tx).await?;
extension::Entity::find()
.filter(extension::Column::ExternalId.eq(*external_id))
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("failed to insert extension"))?
};
extension_version::Entity::insert_many(versions.iter().map(|version| {
extension_version::ActiveModel {
extension_id: ActiveValue::Set(extension.id),
published_at: ActiveValue::Set(version.published_at),
version: ActiveValue::Set(version.version.to_string()),
authors: ActiveValue::Set(version.authors.join(", ")),
repository: ActiveValue::Set(version.repository.clone()),
description: ActiveValue::Set(version.description.clone()),
download_count: ActiveValue::NotSet,
}
}))
.on_conflict(OnConflict::new().do_nothing().to_owned())
.exec_without_returning(&*tx)
.await?;
if let Ok(db_version) = semver::Version::parse(&extension.latest_version) {
if db_version >= latest_version.version {
continue;
}
}
let mut extension = extension.into_active_model();
extension.latest_version = ActiveValue::Set(latest_version.version.to_string());
extension.name = ActiveValue::set(latest_version.name.clone());
extension::Entity::update(extension).exec(&*tx).await?;
}
Ok(())
})
.await
}
pub async fn record_extension_download(&self, extension: &str, version: &str) -> Result<bool> {
self.transaction(|tx| async move {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryId {
Id,
}
let extension_id: Option<ExtensionId> = extension::Entity::find()
.filter(extension::Column::ExternalId.eq(extension))
.select_only()
.column(extension::Column::Id)
.into_values::<_, QueryId>()
.one(&*tx)
.await?;
let Some(extension_id) = extension_id else {
return Ok(false);
};
extension_version::Entity::update_many()
.col_expr(
extension_version::Column::DownloadCount,
extension_version::Column::DownloadCount.into_expr().add(1),
)
.filter(
extension_version::Column::ExtensionId
.eq(extension_id)
.and(extension_version::Column::Version.eq(version)),
)
.exec(&*tx)
.await?;
extension::Entity::update_many()
.col_expr(
extension::Column::TotalDownloadCount,
extension::Column::TotalDownloadCount.into_expr().add(1),
)
.filter(extension::Column::Id.eq(extension_id))
.exec(&*tx)
.await?;
Ok(true)
})
.await
}
}

View File

@@ -110,12 +110,10 @@ impl Database {
user_id: UserId,
connection: ConnectionId,
live_kit_room: &str,
release_channel: &str,
) -> Result<proto::Room> {
self.transaction(|tx| async move {
let room = room::ActiveModel {
live_kit_room: ActiveValue::set(live_kit_room.into()),
environment: ActiveValue::set(Some(release_channel.to_string())),
..Default::default()
}
.insert(&*tx)
@@ -135,7 +133,6 @@ impl Database {
))),
participant_index: ActiveValue::set(Some(0)),
role: ActiveValue::set(Some(ChannelRole::Admin)),
in_call: ActiveValue::set(true),
id: ActiveValue::NotSet,
location_kind: ActiveValue::NotSet,
@@ -188,7 +185,6 @@ impl Database {
))),
initial_project_id: ActiveValue::set(initial_project_id),
role: ActiveValue::set(Some(called_user_role)),
in_call: ActiveValue::set(true),
id: ActiveValue::NotSet,
answering_connection_id: ActiveValue::NotSet,
@@ -304,31 +300,21 @@ impl Database {
room_id: RoomId,
user_id: UserId,
connection: ConnectionId,
environment: &str,
) -> Result<RoomGuard<JoinRoom>> {
self.room_transaction(room_id, |tx| async move {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryChannelIdAndEnvironment {
enum QueryChannelId {
ChannelId,
Environment,
}
let (channel_id, release_channel): (Option<ChannelId>, Option<String>) =
room::Entity::find()
.select_only()
.column(room::Column::ChannelId)
.column(room::Column::Environment)
.filter(room::Column::Id.eq(room_id))
.into_values::<_, QueryChannelIdAndEnvironment>()
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such room"))?;
if let Some(release_channel) = release_channel {
if &release_channel != environment {
Err(anyhow!("must join using the {} release", release_channel))?;
}
}
let channel_id: Option<ChannelId> = room::Entity::find()
.select_only()
.column(room::Column::ChannelId)
.filter(room::Column::Id.eq(room_id))
.into_values::<_, QueryChannelId>()
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such room"))?;
if channel_id.is_some() {
Err(anyhow!("tried to join channel call directly"))?
@@ -416,7 +402,6 @@ impl Database {
&self,
room_id: RoomId,
user_id: UserId,
autojoin: bool,
connection: ConnectionId,
role: ChannelRole,
tx: &DatabaseTransaction,
@@ -440,8 +425,6 @@ impl Database {
))),
participant_index: ActiveValue::Set(Some(participant_index)),
role: ActiveValue::set(Some(role)),
in_call: ActiveValue::set(autojoin),
id: ActiveValue::NotSet,
location_kind: ActiveValue::NotSet,
location_project_id: ActiveValue::NotSet,
@@ -1263,7 +1246,6 @@ impl Database {
location: Some(proto::ParticipantLocation { variant: location }),
participant_index: participant_index as u32,
role: db_participant.role.unwrap_or(ChannelRole::Member).into(),
in_call: db_participant.in_call,
},
);
} else {

View File

@@ -10,6 +10,8 @@ pub mod channel_message;
pub mod channel_message_mention;
pub mod contact;
pub mod contributor;
pub mod extension;
pub mod extension_version;
pub mod feature_flag;
pub mod follower;
pub mod language_server;

View File

@@ -0,0 +1,27 @@
use crate::db::ExtensionId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "extensions")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ExtensionId,
pub external_id: String,
pub name: String,
pub latest_version: String,
pub total_download_count: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_one = "super::extension_version::Entity")]
LatestVersion,
}
impl Related<super::extension_version::Entity> for Entity {
fn to() -> RelationDef {
Relation::LatestVersion.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,36 @@
use crate::db::ExtensionId;
use sea_orm::entity::prelude::*;
use time::PrimitiveDateTime;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "extension_versions")]
pub struct Model {
#[sea_orm(primary_key)]
pub extension_id: ExtensionId,
#[sea_orm(primary_key)]
pub version: String,
pub published_at: PrimitiveDateTime,
pub authors: String,
pub repository: String,
pub description: String,
pub download_count: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::extension::Entity",
from = "Column::ExtensionId",
to = "super::extension::Column::Id"
on_condition = r#"super::extension::Column::LatestVersion.into_expr().eq(Column::Version.into_expr())"#
)]
Extension,
}
impl Related<super::extension::Entity> for Entity {
fn to() -> RelationDef {
Relation::Extension.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -8,7 +8,6 @@ pub struct Model {
pub id: RoomId,
pub live_kit_room: String,
pub channel_id: Option<ChannelId>,
pub environment: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -20,7 +20,6 @@ pub struct Model {
pub calling_connection_server_id: Option<ServerId>,
pub participant_index: Option<i32>,
pub role: Option<ChannelRole>,
pub in_call: bool,
}
impl Model {

View File

@@ -2,6 +2,7 @@ mod buffer_tests;
mod channel_tests;
mod contributor_tests;
mod db_tests;
mod extension_tests;
mod feature_flag_tests;
mod message_tests;
@@ -15,8 +16,6 @@ use std::sync::{
Arc,
};
const TEST_RELEASE_CHANNEL: &'static str = "test";
pub struct TestDb {
pub db: Option<Arc<Database>>,
pub connection: Option<sqlx::AnyConnection>,

View File

@@ -1,6 +1,6 @@
use crate::{
db::{
tests::{channel_tree, new_test_connection, new_test_user, TEST_RELEASE_CHANNEL},
tests::{channel_tree, new_test_connection, new_test_user},
Channel, ChannelId, ChannelRole, Database, NewUserParams, RoomId,
},
test_both_dbs,
@@ -135,13 +135,7 @@ async fn test_joining_channels(db: &Arc<Database>) {
// can join a room with membership to its channel
let (joined_room, _, _) = db
.join_channel(
channel_1,
user_1,
false,
ConnectionId { owner_id, id: 1 },
TEST_RELEASE_CHANNEL,
)
.join_channel(channel_1, user_1, ConnectionId { owner_id, id: 1 })
.await
.unwrap();
assert_eq!(joined_room.room.participants.len(), 1);
@@ -150,12 +144,7 @@ async fn test_joining_channels(db: &Arc<Database>) {
drop(joined_room);
// cannot join a room without membership to its channel
assert!(db
.join_room(
room_id,
user_2,
ConnectionId { owner_id, id: 1 },
TEST_RELEASE_CHANNEL
)
.join_room(room_id, user_2, ConnectionId { owner_id, id: 1 },)
.await
.is_err());
}
@@ -733,15 +722,9 @@ async fn test_guest_access(db: &Arc<Database>) {
.await
.is_err());
db.join_channel(
zed_channel,
guest,
false,
guest_connection,
TEST_RELEASE_CHANNEL,
)
.await
.unwrap();
db.join_channel(zed_channel, guest, guest_connection)
.await
.unwrap();
assert!(db
.join_channel_chat(zed_channel, guest_connection, guest)

View File

@@ -517,7 +517,7 @@ async fn test_project_count(db: &Arc<Database>) {
.unwrap();
let room_id = RoomId::from_proto(
db.create_room(user1.user_id, ConnectionId { owner_id, id: 0 }, "", "test")
db.create_room(user1.user_id, ConnectionId { owner_id, id: 0 }, "")
.await
.unwrap()
.id,
@@ -531,14 +531,9 @@ async fn test_project_count(db: &Arc<Database>) {
)
.await
.unwrap();
db.join_room(
room_id,
user2.user_id,
ConnectionId { owner_id, id: 1 },
"test",
)
.await
.unwrap();
db.join_room(room_id, user2.user_id, ConnectionId { owner_id, id: 1 })
.await
.unwrap();
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0);
db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[])
@@ -616,80 +611,3 @@ async fn test_fuzzy_search_users(cx: &mut TestAppContext) {
.collect::<Vec<_>>()
}
}
test_both_dbs!(
test_non_matching_release_channels,
test_non_matching_release_channels_postgres,
test_non_matching_release_channels_sqlite
);
async fn test_non_matching_release_channels(db: &Arc<Database>) {
let owner_id = db.create_server("test").await.unwrap().0 as u32;
let user1 = db
.create_user(
&format!("admin@example.com"),
true,
NewUserParams {
github_login: "admin".into(),
github_user_id: 0,
},
)
.await
.unwrap();
let user2 = db
.create_user(
&format!("user@example.com"),
false,
NewUserParams {
github_login: "user".into(),
github_user_id: 1,
},
)
.await
.unwrap();
let room = db
.create_room(
user1.user_id,
ConnectionId { owner_id, id: 0 },
"",
"stable",
)
.await
.unwrap();
db.call(
RoomId::from_proto(room.id),
user1.user_id,
ConnectionId { owner_id, id: 0 },
user2.user_id,
None,
)
.await
.unwrap();
// User attempts to join from preview
let result = db
.join_room(
RoomId::from_proto(room.id),
user2.user_id,
ConnectionId { owner_id, id: 1 },
"preview",
)
.await;
assert!(result.is_err());
// User switches to stable
let result = db
.join_room(
RoomId::from_proto(room.id),
user2.user_id,
ConnectionId { owner_id, id: 1 },
"stable",
)
.await;
assert!(result.is_ok())
}

View File

@@ -0,0 +1,225 @@
use super::Database;
use crate::{
db::{ExtensionMetadata, NewExtensionVersion},
test_both_dbs,
};
use std::sync::Arc;
use time::{OffsetDateTime, PrimitiveDateTime};
test_both_dbs!(
test_extensions,
test_extensions_postgres,
test_extensions_sqlite
);
async fn test_extensions(db: &Arc<Database>) {
let versions = db.get_known_extension_versions().await.unwrap();
assert!(versions.is_empty());
let extensions = db.get_extensions(None, 5).await.unwrap();
assert!(extensions.is_empty());
let t0 = OffsetDateTime::from_unix_timestamp_nanos(0).unwrap();
let t0 = PrimitiveDateTime::new(t0.date(), t0.time());
db.insert_extension_versions(
&[
(
"ext1",
vec![
NewExtensionVersion {
name: "Extension 1".into(),
version: semver::Version::parse("0.0.1").unwrap(),
description: "an extension".into(),
authors: vec!["max".into()],
repository: "ext1/repo".into(),
published_at: t0,
},
NewExtensionVersion {
name: "Extension One".into(),
version: semver::Version::parse("0.0.2").unwrap(),
description: "a good extension".into(),
authors: vec!["max".into(), "marshall".into()],
repository: "ext1/repo".into(),
published_at: t0,
},
],
),
(
"ext2",
vec![NewExtensionVersion {
name: "Extension Two".into(),
version: semver::Version::parse("0.2.0").unwrap(),
description: "a great extension".into(),
authors: vec!["marshall".into()],
repository: "ext2/repo".into(),
published_at: t0,
}],
),
]
.into_iter()
.collect(),
)
.await
.unwrap();
let versions = db.get_known_extension_versions().await.unwrap();
assert_eq!(
versions,
[
("ext1".into(), vec!["0.0.1".into(), "0.0.2".into()]),
("ext2".into(), vec!["0.2.0".into()])
]
.into_iter()
.collect()
);
// The latest version of each extension is returned.
let extensions = db.get_extensions(None, 5).await.unwrap();
assert_eq!(
extensions,
&[
ExtensionMetadata {
id: "ext1".into(),
name: "Extension One".into(),
version: "0.0.2".into(),
authors: vec!["max".into(), "marshall".into()],
description: "a good extension".into(),
repository: "ext1/repo".into(),
published_at: t0,
download_count: 0,
},
ExtensionMetadata {
id: "ext2".into(),
name: "Extension Two".into(),
version: "0.2.0".into(),
authors: vec!["marshall".into()],
description: "a great extension".into(),
repository: "ext2/repo".into(),
published_at: t0,
download_count: 0
},
]
);
// Record extensions being downloaded.
for _ in 0..7 {
assert!(db.record_extension_download("ext2", "0.0.2").await.unwrap());
}
for _ in 0..3 {
assert!(db.record_extension_download("ext1", "0.0.1").await.unwrap());
}
for _ in 0..2 {
assert!(db.record_extension_download("ext1", "0.0.2").await.unwrap());
}
// Record download returns false if the extension does not exist.
assert!(!db
.record_extension_download("no-such-extension", "0.0.2")
.await
.unwrap());
// Extensions are returned in descending order of total downloads.
let extensions = db.get_extensions(None, 5).await.unwrap();
assert_eq!(
extensions,
&[
ExtensionMetadata {
id: "ext2".into(),
name: "Extension Two".into(),
version: "0.2.0".into(),
authors: vec!["marshall".into()],
description: "a great extension".into(),
repository: "ext2/repo".into(),
published_at: t0,
download_count: 7
},
ExtensionMetadata {
id: "ext1".into(),
name: "Extension One".into(),
version: "0.0.2".into(),
authors: vec!["max".into(), "marshall".into()],
description: "a good extension".into(),
repository: "ext1/repo".into(),
published_at: t0,
download_count: 5,
},
]
);
// Add more extensions, including a new version of `ext1`, and backfilling
// an older version of `ext2`.
db.insert_extension_versions(
&[
(
"ext1",
vec![NewExtensionVersion {
name: "Extension One".into(),
version: semver::Version::parse("0.0.3").unwrap(),
description: "a real good extension".into(),
authors: vec!["max".into(), "marshall".into()],
repository: "ext1/repo".into(),
published_at: t0,
}],
),
(
"ext2",
vec![NewExtensionVersion {
name: "Extension Two".into(),
version: semver::Version::parse("0.1.0").unwrap(),
description: "an old extension".into(),
authors: vec!["marshall".into()],
repository: "ext2/repo".into(),
published_at: t0,
}],
),
]
.into_iter()
.collect(),
)
.await
.unwrap();
let versions = db.get_known_extension_versions().await.unwrap();
assert_eq!(
versions,
[
(
"ext1".into(),
vec!["0.0.1".into(), "0.0.2".into(), "0.0.3".into()]
),
("ext2".into(), vec!["0.1.0".into(), "0.2.0".into()])
]
.into_iter()
.collect()
);
let extensions = db.get_extensions(None, 5).await.unwrap();
assert_eq!(
extensions,
&[
ExtensionMetadata {
id: "ext2".into(),
name: "Extension Two".into(),
version: "0.2.0".into(),
authors: vec!["marshall".into()],
description: "a great extension".into(),
repository: "ext2/repo".into(),
published_at: t0,
download_count: 7
},
ExtensionMetadata {
id: "ext1".into(),
name: "Extension One".into(),
version: "0.0.3".into(),
authors: vec!["max".into(), "marshall".into()],
description: "a real good extension".into(),
repository: "ext1/repo".into(),
published_at: t0,
download_count: 5,
},
]
);
}

View File

@@ -3,7 +3,8 @@ use std::fs;
pub fn load_dotenv() -> anyhow::Result<()> {
let env: toml::map::Map<String, toml::Value> = toml::de::from_str(
&fs::read_to_string("./.env.toml").map_err(|_| anyhow!("no .env.toml file found"))?,
&fs::read_to_string("./crates/collab/.env.toml")
.map_err(|_| anyhow!("no .env.toml file found"))?,
)?;
for (key, value) in env {

View File

@@ -8,11 +8,14 @@ pub mod rpc;
#[cfg(test)]
mod tests;
use anyhow::anyhow;
use aws_config::{BehaviorVersion, Region};
use axum::{http::StatusCode, response::IntoResponse};
use db::Database;
use executor::Executor;
use serde::Deserialize;
use std::{path::PathBuf, sync::Arc};
use util::ResultExt;
pub type Result<T, E = Error> = std::result::Result<T, E>;
@@ -100,6 +103,11 @@ pub struct Config {
pub live_kit_secret: Option<String>,
pub rust_log: Option<String>,
pub log_json: Option<bool>,
pub blob_store_url: Option<String>,
pub blob_store_region: Option<String>,
pub blob_store_access_key: Option<String>,
pub blob_store_secret_key: Option<String>,
pub blob_store_bucket: Option<String>,
pub zed_environment: Arc<str>,
}
@@ -118,6 +126,7 @@ pub struct MigrateConfig {
pub struct AppState {
pub db: Arc<Database>,
pub live_kit_client: Option<Arc<dyn live_kit_server::api::Client>>,
pub blob_store_client: Option<aws_sdk_s3::Client>,
pub config: Config,
}
@@ -146,8 +155,44 @@ impl AppState {
let this = Self {
db: Arc::new(db),
live_kit_client,
blob_store_client: build_blob_store_client(&config).await.log_err(),
config,
};
Ok(Arc::new(this))
}
}
async fn build_blob_store_client(config: &Config) -> anyhow::Result<aws_sdk_s3::Client> {
let keys = aws_sdk_s3::config::Credentials::new(
config
.blob_store_access_key
.clone()
.ok_or_else(|| anyhow!("missing blob_store_access_key"))?,
config
.blob_store_secret_key
.clone()
.ok_or_else(|| anyhow!("missing blob_store_secret_key"))?,
None,
None,
"env",
);
let s3_config = aws_config::defaults(BehaviorVersion::latest())
.endpoint_url(
config
.blob_store_url
.as_ref()
.ok_or_else(|| anyhow!("missing blob_store_url"))?,
)
.region(Region::new(
config
.blob_store_region
.clone()
.ok_or_else(|| anyhow!("missing blob_store_region"))?,
))
.credentials_provider(keys)
.load()
.await;
Ok(aws_sdk_s3::Client::new(&s3_config))
}

View File

@@ -1,6 +1,9 @@
use anyhow::anyhow;
use axum::{routing::get, Extension, Router};
use collab::{db, env, executor::Executor, AppState, Config, MigrateConfig, Result};
use collab::{
api::fetch_extensions_from_blob_store_periodically, db, env, executor::Executor, AppState,
Config, MigrateConfig, Result,
};
use db::Database;
use std::{
env::args,
@@ -50,6 +53,8 @@ async fn main() -> Result<()> {
let rpc_server = collab::rpc::Server::new(epoch, state.clone(), Executor::Production);
rpc_server.start().await?;
fetch_extensions_from_blob_store_periodically(state.clone(), Executor::Production);
let app = collab::api::routes(rpc_server.clone(), state.clone())
.merge(collab::rpc::routes(rpc_server.clone()))
.merge(

View File

@@ -102,10 +102,8 @@ impl<R: RequestMessage> Response<R> {
#[derive(Clone)]
struct Session {
zed_environment: Arc<str>,
user_id: UserId,
connection_id: ConnectionId,
zed_version: SemanticVersion,
db: Arc<tokio::sync::Mutex<DbHandle>>,
peer: Arc<Peer>,
connection_pool: Arc<parking_lot::Mutex<ConnectionPool>>,
@@ -132,19 +130,6 @@ impl Session {
_not_send: PhantomData,
}
}
fn endpoint_removed_in(&self, endpoint: &str, version: SemanticVersion) -> anyhow::Result<()> {
if self.zed_version > version {
Err(anyhow!(
"{} was removed in {} (you're on {})",
endpoint,
version,
self.zed_version
))
} else {
Ok(())
}
}
}
impl fmt::Debug for Session {
@@ -288,11 +273,8 @@ impl Server {
.add_request_handler(get_channel_members)
.add_request_handler(respond_to_channel_invite)
.add_request_handler(join_channel)
.add_request_handler(join_channel2)
.add_request_handler(join_channel_chat)
.add_message_handler(leave_channel_chat)
.add_request_handler(join_channel_call)
.add_request_handler(leave_channel_call)
.add_request_handler(send_channel_message)
.add_request_handler(remove_channel_message)
.add_request_handler(get_channel_messages)
@@ -576,7 +558,6 @@ impl Server {
connection: Connection,
address: String,
user: User,
zed_version: SemanticVersion,
impersonator: Option<User>,
mut send_connection_id: Option<oneshot::Sender<ConnectionId>>,
executor: Executor,
@@ -634,9 +615,7 @@ impl Server {
let session = Session {
user_id,
connection_id,
zed_version,
db: Arc::new(tokio::sync::Mutex::new(DbHandle(this.app_state.db.clone()))),
zed_environment: this.app_state.config.zed_environment.clone(),
peer: this.peer.clone(),
connection_pool: this.connection_pool.clone(),
live_kit_client: this.app_state.live_kit_client.clone(),
@@ -900,11 +879,18 @@ pub async fn handle_websocket_request(
.into_response();
}
// zed 0.122.x was the first version that sent an app header, so once that hits stable
// we can return UPGRADE_REQUIRED instead of unwrap_or_default();
let app_version = app_version_header
.map(|header| header.0 .0)
.unwrap_or_default();
// the first version of zed that sent this header was 0.121.x
if let Some(version) = app_version_header.map(|header| header.0 .0) {
// 0.123.0 was a nightly version with incompatible collab changes
// that were reverted.
if version == "0.123.0".parse().unwrap() {
return (
StatusCode::UPGRADE_REQUIRED,
"client must be upgraded".to_string(),
)
.into_response();
}
}
let socket_address = socket_address.to_string();
ws.on_upgrade(move |socket| {
@@ -920,7 +906,6 @@ pub async fn handle_websocket_request(
connection,
socket_address,
user,
app_version,
impersonator.0,
None,
Executor::Production,
@@ -1035,12 +1020,7 @@ async fn create_room(
let room = session
.db()
.await
.create_room(
session.user_id,
session.connection_id,
&live_kit_room,
&session.zed_environment,
)
.create_room(session.user_id, session.connection_id, &live_kit_room)
.await?;
response.send(proto::CreateRoomResponse {
@@ -1063,19 +1043,14 @@ async fn join_room(
let channel_id = session.db().await.channel_id_for_room(room_id).await?;
if let Some(channel_id) = channel_id {
return join_channel_internal(channel_id, true, Box::new(response), session).await;
return join_channel_internal(channel_id, Box::new(response), session).await;
}
let joined_room = {
let room = session
.db()
.await
.join_room(
room_id,
session.user_id,
session.connection_id,
session.zed_environment.as_ref(),
)
.join_room(room_id, session.user_id, session.connection_id)
.await?;
room_updated(&room.room, &session.peer);
room.into_inner()
@@ -2726,67 +2701,14 @@ async fn respond_to_channel_invite(
Ok(())
}
/// Join the channels' call
/// Join the channels' room
async fn join_channel(
request: proto::JoinChannel,
response: Response<proto::JoinChannel>,
session: Session,
) -> Result<()> {
session.endpoint_removed_in("join_channel", "0.123.0".parse().unwrap())?;
let channel_id = ChannelId::from_proto(request.channel_id);
join_channel_internal(channel_id, true, Box::new(response), session).await
}
async fn join_channel2(
request: proto::JoinChannel2,
response: Response<proto::JoinChannel2>,
session: Session,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
join_channel_internal(channel_id, false, Box::new(response), session).await
}
async fn join_channel_call(
request: proto::JoinChannelCall,
response: Response<proto::JoinChannelCall>,
session: Session,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
let db = session.db().await;
let (joined_room, role) = db
.set_in_channel_call(channel_id, session.user_id, true)
.await?;
let Some(connection_info) = session.live_kit_client.as_ref().and_then(|live_kit| {
live_kit_info_for_user(live_kit, &session.user_id, role, &joined_room.live_kit_room)
}) else {
Err(anyhow!("no live kit token info"))?
};
room_updated(&joined_room, &session.peer);
response.send(proto::JoinChannelCallResponse {
live_kit_connection_info: Some(connection_info),
})?;
Ok(())
}
async fn leave_channel_call(
request: proto::LeaveChannelCall,
response: Response<proto::LeaveChannelCall>,
session: Session,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
let db = session.db().await;
let (joined_room, _) = db
.set_in_channel_call(channel_id, session.user_id, false)
.await?;
room_updated(&joined_room, &session.peer);
response.send(proto::Ack {})?;
Ok(())
join_channel_internal(channel_id, Box::new(response), session).await
}
trait JoinChannelInternalResponse {
@@ -2802,15 +2724,9 @@ impl JoinChannelInternalResponse for Response<proto::JoinRoom> {
Response::<proto::JoinRoom>::send(self, result)
}
}
impl JoinChannelInternalResponse for Response<proto::JoinChannel2> {
fn send(self, result: proto::JoinRoomResponse) -> Result<()> {
Response::<proto::JoinChannel2>::send(self, result)
}
}
async fn join_channel_internal(
channel_id: ChannelId,
autojoin: bool,
response: Box<impl JoinChannelInternalResponse>,
session: Session,
) -> Result<()> {
@@ -2819,25 +2735,37 @@ async fn join_channel_internal(
let db = session.db().await;
let (joined_room, membership_updated, role) = db
.join_channel(
channel_id,
session.user_id,
autojoin,
session.connection_id,
session.zed_environment.as_ref(),
)
.join_channel(channel_id, session.user_id, session.connection_id)
.await?;
let live_kit_connection_info = session.live_kit_client.as_ref().and_then(|live_kit| {
if !autojoin {
return None;
}
live_kit_info_for_user(
live_kit,
&session.user_id,
role,
&joined_room.room.live_kit_room,
)
let (can_publish, token) = if role == ChannelRole::Guest {
(
false,
live_kit
.guest_token(
&joined_room.room.live_kit_room,
&session.user_id.to_string(),
)
.trace_err()?,
)
} else {
(
true,
live_kit
.room_token(
&joined_room.room.live_kit_room,
&session.user_id.to_string(),
)
.trace_err()?,
)
};
Some(LiveKitConnectionInfo {
server_url: live_kit.url().into(),
token,
can_publish,
})
});
response.send(proto::JoinRoomResponse {
@@ -2873,35 +2801,6 @@ async fn join_channel_internal(
Ok(())
}
fn live_kit_info_for_user(
live_kit: &Arc<dyn live_kit_server::api::Client>,
user_id: &UserId,
role: ChannelRole,
live_kit_room: &String,
) -> Option<LiveKitConnectionInfo> {
let (can_publish, token) = if role == ChannelRole::Guest {
(
false,
live_kit
.guest_token(live_kit_room, &user_id.to_string())
.trace_err()?,
)
} else {
(
true,
live_kit
.room_token(live_kit_room, &user_id.to_string())
.trace_err()?,
)
};
Some(LiveKitConnectionInfo {
server_url: live_kit.url().into(),
token,
can_publish,
})
}
/// Start editing the channel notes
async fn join_channel_buffer(
request: proto::JoinChannelBuffer,

View File

@@ -1,7 +1,4 @@
use crate::{
db::ChannelId,
tests::{test_server::join_channel_call, TestServer},
};
use crate::{db::ChannelId, tests::TestServer};
use call::ActiveCall;
use editor::Editor;
use gpui::{BackgroundExecutor, TestAppContext};
@@ -35,7 +32,7 @@ async fn test_channel_guests(
cx_a.executor().run_until_parked();
// Client B joins channel A as a guest
cx_b.update(|cx| workspace::open_channel(channel_id, client_b.app_state.clone(), None, cx))
cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
.await
.unwrap();
@@ -75,7 +72,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
.await;
let project_a = client_a.build_test_project(cx_a).await;
cx_a.update(|cx| workspace::open_channel(channel_id, client_a.app_state.clone(), None, cx))
cx_a.update(|cx| workspace::join_channel(channel_id, client_a.app_state.clone(), None, cx))
.await
.unwrap();
@@ -87,13 +84,11 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
cx_a.run_until_parked();
// Client B joins channel A as a guest
cx_b.update(|cx| workspace::open_channel(channel_id, client_b.app_state.clone(), None, cx))
cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
.await
.unwrap();
cx_a.run_until_parked();
join_channel_call(cx_b).await.unwrap();
// client B opens 1.txt as a guest
let (workspace_b, cx_b) = client_b.active_workspace(cx_b);
let room_b = cx_b

View File

@@ -1,7 +1,7 @@
use crate::{
db::{self, UserId},
rpc::RECONNECT_TIMEOUT,
tests::{room_participants, test_server::join_channel_call, RoomParticipants, TestServer},
tests::{room_participants, RoomParticipants, TestServer},
};
use call::ActiveCall;
use channel::{ChannelId, ChannelMembership, ChannelStore};
@@ -382,7 +382,6 @@ async fn test_channel_room(
.update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
.await
.unwrap();
join_channel_call(cx_a).await.unwrap();
// Give everyone a chance to observe user A joining
executor.run_until_parked();
@@ -430,7 +429,7 @@ async fn test_channel_room(
.update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx))
.await
.unwrap();
join_channel_call(cx_b).await.unwrap();
executor.run_until_parked();
cx_a.read(|cx| {
@@ -553,9 +552,6 @@ async fn test_channel_room(
.await
.unwrap();
join_channel_call(cx_a).await.unwrap();
join_channel_call(cx_b).await.unwrap();
executor.run_until_parked();
let room_a =

View File

@@ -24,7 +24,7 @@ use workspace::{
use super::TestClient;
#[gpui::test]
#[gpui::test(iterations = 10)]
async fn test_basic_following(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
@@ -437,7 +437,6 @@ async fn test_basic_following(
})
.await
.unwrap();
executor.run_until_parked();
let shared_screen = workspace_a.update(cx_a, |workspace, cx| {
workspace
@@ -523,7 +522,6 @@ async fn test_basic_following(
workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)),
None
);
executor.run_until_parked();
}
#[gpui::test]
@@ -2006,7 +2004,7 @@ async fn join_channel(
client: &TestClient,
cx: &mut TestAppContext,
) -> anyhow::Result<()> {
cx.update(|cx| workspace::open_channel(channel_id, client.app_state.clone(), None, cx))
cx.update(|cx| workspace::join_channel(channel_id, client.app_state.clone(), None, cx))
.await
}
@@ -2079,3 +2077,66 @@ async fn test_following_to_channel_notes_other_workspace(
assert_eq!(editor.tab_description(0, cx).unwrap(), "1.txt");
});
}
#[gpui::test]
async fn test_following_while_deactivated(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let (_, client_a, client_b, channel) = TestServer::start2(cx_a, cx_b).await;
let mut cx_a2 = cx_a.clone();
let (workspace_a, cx_a) = client_a.build_test_workspace(cx_a).await;
join_channel(channel, &client_a, cx_a).await.unwrap();
share_workspace(&workspace_a, cx_a).await.unwrap();
// a opens 1.txt
cx_a.simulate_keystrokes("cmd-p 1 enter");
cx_a.run_until_parked();
workspace_a.update(cx_a, |workspace, cx| {
let editor = workspace.active_item(cx).unwrap();
assert_eq!(editor.tab_description(0, cx).unwrap(), "1.txt");
});
// b joins channel and is following a
join_channel(channel, &client_b, cx_b).await.unwrap();
cx_b.run_until_parked();
let (workspace_b, cx_b) = client_b.active_workspace(cx_b);
workspace_b.update(cx_b, |workspace, cx| {
let editor = workspace.active_item(cx).unwrap();
assert_eq!(editor.tab_description(0, cx).unwrap(), "1.txt");
});
// stop following
cx_b.simulate_keystrokes("down");
// a opens a different file while not followed
cx_a.simulate_keystrokes("cmd-p 2 enter");
workspace_b.update(cx_b, |workspace, cx| {
let editor = workspace.active_item_as::<Editor>(cx).unwrap();
assert_eq!(editor.tab_description(0, cx).unwrap(), "1.txt");
});
// a opens a file in a new window
let (_, cx_a2) = client_a.build_test_workspace(&mut cx_a2).await;
cx_a2.update(|cx| cx.activate_window());
cx_a2.simulate_keystrokes("cmd-p 3 enter");
cx_a2.run_until_parked();
// b starts following a again
cx_b.simulate_keystrokes("cmd-ctrl-alt-f");
cx_a.run_until_parked();
// a returns to the shared project
cx_a.update(|cx| cx.activate_window());
cx_a.run_until_parked();
workspace_a.update(cx_a, |workspace, cx| {
let editor = workspace.active_item(cx).unwrap();
assert_eq!(editor.tab_description(0, cx).unwrap(), "2.js");
});
// b should follow a back
workspace_b.update(cx_b, |workspace, cx| {
let editor = workspace.active_item_as::<Editor>(cx).unwrap();
assert_eq!(editor.tab_description(0, cx).unwrap(), "2.js");
});
}

View File

@@ -1881,7 +1881,7 @@ fn active_call_events(cx: &mut TestAppContext) -> Rc<RefCell<Vec<room::Event>>>
}
#[gpui::test]
async fn test_mute(
async fn test_mute_deafen(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
@@ -1920,7 +1920,7 @@ async fn test_mute(
room_a.read_with(cx_a, |room, _| assert!(!room.is_muted()));
room_b.read_with(cx_b, |room, _| assert!(!room.is_muted()));
// Users A and B are both unmuted.
// Users A and B are both muted.
assert_eq!(
participant_audio_state(&room_a, cx_a),
&[ParticipantAudioState {
@@ -1962,6 +1962,30 @@ async fn test_mute(
}]
);
// User A deafens
room_a.update(cx_a, |room, cx| room.toggle_deafen(cx));
executor.run_until_parked();
// User A does not hear user B.
room_a.read_with(cx_a, |room, _| assert!(room.is_muted()));
room_b.read_with(cx_b, |room, _| assert!(!room.is_muted()));
assert_eq!(
participant_audio_state(&room_a, cx_a),
&[ParticipantAudioState {
user_id: client_b.user_id().unwrap(),
is_muted: false,
audio_tracks_playing: vec![false],
}]
);
assert_eq!(
participant_audio_state(&room_b, cx_b),
&[ParticipantAudioState {
user_id: client_a.user_id().unwrap(),
is_muted: true,
audio_tracks_playing: vec![true],
}]
);
// User B calls user C, C joins.
active_call_b
.update(cx_b, |call, cx| {
@@ -1976,6 +2000,22 @@ async fn test_mute(
.unwrap();
executor.run_until_parked();
// User A does not hear users B or C.
assert_eq!(
participant_audio_state(&room_a, cx_a),
&[
ParticipantAudioState {
user_id: client_b.user_id().unwrap(),
is_muted: false,
audio_tracks_playing: vec![false],
},
ParticipantAudioState {
user_id: client_c.user_id().unwrap(),
is_muted: false,
audio_tracks_playing: vec![false],
}
]
);
assert_eq!(
participant_audio_state(&room_b, cx_b),
&[

View File

@@ -37,7 +37,7 @@ use std::{
Arc,
},
};
use util::{http::FakeHttpClient, SemanticVersion};
use util::http::FakeHttpClient;
use workspace::{Workspace, WorkspaceStore};
pub struct TestServer {
@@ -231,7 +231,6 @@ impl TestServer {
server_conn,
client_name,
user,
SemanticVersion::default(),
None,
Some(connection_id_tx),
Executor::Deterministic(cx.background_executor().clone()),
@@ -480,6 +479,7 @@ impl TestServer {
Arc::new(AppState {
db: test_db.db().clone(),
live_kit_client: Some(Arc::new(fake_server.create_api_client())),
blob_store_client: None,
config: Config {
http_port: 0,
database_url: "".into(),
@@ -492,6 +492,11 @@ impl TestServer {
rust_log: None,
log_json: None,
zed_environment: "test".into(),
blob_store_url: None,
blob_store_region: None,
blob_store_access_key: None,
blob_store_secret_key: None,
blob_store_bucket: None,
},
})
}
@@ -687,7 +692,7 @@ impl TestClient {
channel_id: u64,
cx: &'a mut TestAppContext,
) -> (View<Workspace>, &'a mut VisualTestContext) {
cx.update(|cx| workspace::open_channel(channel_id, self.app_state.clone(), None, cx))
cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, cx))
.await
.unwrap();
cx.run_until_parked();
@@ -762,11 +767,6 @@ impl TestClient {
}
}
pub fn join_channel_call(cx: &mut TestAppContext) -> Task<anyhow::Result<()>> {
let room = cx.read(|cx| ActiveCall::global(cx).read(cx).room().cloned());
room.unwrap().update(cx, |room, cx| room.join_call(cx))
}
pub fn open_channel_notes(
channel_id: u64,
cx: &mut VisualTestContext,

View File

@@ -733,7 +733,7 @@ impl Render for ChatPanel {
v_flex()
.key_context("ChatPanel")
.track_focus(&self.focus_handle)
.full()
.size_full()
.on_action(cx.listener(Self::send))
.child(
h_flex().z_index(1).child(
@@ -755,11 +755,11 @@ impl Render for ChatPanel {
)
.child(div().flex_grow().px_2().map(|this| {
if self.active_chat.is_some() {
this.child(list(self.message_list.clone()).full())
this.child(list(self.message_list.clone()).size_full())
} else {
this.child(
div()
.full()
.size_full()
.p_4()
.child(
Label::new("Select a channel to chat in.")

View File

@@ -40,7 +40,7 @@ use util::{maybe, ResultExt, TryFutureExt};
use workspace::{
dock::{DockPosition, Panel, PanelEvent},
notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt},
Workspace,
OpenChannelNotes, Workspace,
};
actions!(
@@ -69,6 +69,19 @@ pub fn init(cx: &mut AppContext) {
workspace.register_action(|workspace, _: &ToggleFocus, cx| {
workspace.toggle_panel_focus::<CollabPanel>(cx);
});
workspace.register_action(|_, _: &OpenChannelNotes, cx| {
let channel_id = ActiveCall::global(cx)
.read(cx)
.room()
.and_then(|room| room.read(cx).channel_id());
if let Some(channel_id) = channel_id {
let workspace = cx.view().clone();
cx.window_context().defer(move |cx| {
ChannelView::open(channel_id, None, workspace, cx).detach_and_log_err(cx)
});
}
});
})
.detach();
}
@@ -162,9 +175,6 @@ enum ListEntry {
depth: usize,
has_children: bool,
},
ChannelCall {
channel_id: ChannelId,
},
ChannelNotes {
channel_id: ChannelId,
},
@@ -372,7 +382,6 @@ impl CollabPanel {
if query.is_empty() {
if let Some(channel_id) = room.channel_id() {
self.entries.push(ListEntry::ChannelCall { channel_id });
self.entries.push(ListEntry::ChannelNotes { channel_id });
self.entries.push(ListEntry::ChannelChat { channel_id });
}
@@ -470,7 +479,7 @@ impl CollabPanel {
&& participant.video_tracks.is_empty(),
});
}
if room.in_call() && !participant.video_tracks.is_empty() {
if !participant.video_tracks.is_empty() {
self.entries.push(ListEntry::ParticipantScreen {
peer_id: Some(participant.peer_id),
is_last: true,
@@ -504,20 +513,6 @@ impl CollabPanel {
role: proto::ChannelRole::Member,
}));
}
} else if let Some(channel_id) = ActiveCall::global(cx).read(cx).pending_channel_id() {
self.entries.push(ListEntry::Header(Section::ActiveCall));
if !old_entries
.iter()
.any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
{
scroll_to_top = true;
}
if query.is_empty() {
self.entries.push(ListEntry::ChannelCall { channel_id });
self.entries.push(ListEntry::ChannelNotes { channel_id });
self.entries.push(ListEntry::ChannelChat { channel_id });
}
}
let mut request_entries = Vec::new();
@@ -837,6 +832,8 @@ impl CollabPanel {
cx: &mut ViewContext<Self>,
) -> ListItem {
let user_id = user.id;
let is_current_user =
self.user_store.read(cx).current_user().map(|user| user.id) == Some(user_id);
let tooltip = format!("Follow {}", user.github_login);
let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| {
@@ -849,6 +846,12 @@ impl CollabPanel {
.selected(is_selected)
.end_slot(if is_pending {
Label::new("Calling").color(Color::Muted).into_any_element()
} else if is_current_user {
IconButton::new("leave-call", IconName::Exit)
.style(ButtonStyle::Subtle)
.on_click(move |_, cx| Self::leave_call(cx))
.tooltip(|cx| Tooltip::text("Leave Call", cx))
.into_any_element()
} else if role == proto::ChannelRole::Guest {
Label::new("Guest").color(Color::Muted).into_any_element()
} else {
@@ -950,88 +953,12 @@ impl CollabPanel {
}
}
fn render_channel_call(
&self,
channel_id: ChannelId,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let (is_in_call, call_participants) = ActiveCall::global(cx)
.read(cx)
.room()
.map(|room| (room.read(cx).in_call(), room.read(cx).call_participants(cx)))
.unwrap_or_default();
const FACEPILE_LIMIT: usize = 3;
let face_pile = if !call_participants.is_empty() {
let extra_count = call_participants.len().saturating_sub(FACEPILE_LIMIT);
let result = FacePile::new(
call_participants
.iter()
.map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
.take(FACEPILE_LIMIT)
.chain(if extra_count > 0 {
Some(
div()
.ml_2()
.child(Label::new(format!("+{extra_count}")))
.into_any_element(),
)
} else {
None
})
.collect::<SmallVec<_>>(),
);
Some(result)
} else {
None
};
ListItem::new("channel-call")
.selected(is_selected)
.start_slot(
h_flex()
.gap_1()
.child(render_tree_branch(false, true, cx))
.child(IconButton::new(0, IconName::AudioOn)),
)
.when(is_in_call, |el| {
el.end_slot(
IconButton::new(1, IconName::Exit)
.style(ButtonStyle::Filled)
.shape(ui::IconButtonShape::Square)
.tooltip(|cx| Tooltip::text("Leave call", cx))
.on_click(cx.listener(|this, _, cx| this.leave_channel_call(cx))),
)
})
.when(!is_in_call, |el| {
el.tooltip(move |cx| Tooltip::text("Join audio call", cx))
.on_click(cx.listener(move |this, _, cx| {
this.join_channel_call(channel_id, cx);
}))
})
.child(
div()
.text_ui()
.when(!call_participants.is_empty(), |el| {
el.font_weight(FontWeight::SEMIBOLD)
})
.child("call"),
)
.children(face_pile)
}
fn render_channel_notes(
&self,
channel_id: ChannelId,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let channel_store = self.channel_store.read(cx);
let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
ListItem::new("channel-notes")
.selected(is_selected)
.on_click(cx.listener(move |this, _, cx| {
@@ -1043,14 +970,7 @@ impl CollabPanel {
.child(render_tree_branch(false, true, cx))
.child(IconButton::new(0, IconName::File)),
)
.child(
div()
.text_ui()
.when(has_notes_notification, |el| {
el.font_weight(FontWeight::SEMIBOLD)
})
.child("notes"),
)
.child(Label::new("notes"))
.tooltip(move |cx| Tooltip::text("Open Channel Notes", cx))
}
@@ -1060,8 +980,6 @@ impl CollabPanel {
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let channel_store = self.channel_store.read(cx);
let has_messages_notification = channel_store.has_new_messages(channel_id);
ListItem::new("channel-chat")
.selected(is_selected)
.on_click(cx.listener(move |this, _, cx| {
@@ -1073,14 +991,7 @@ impl CollabPanel {
.child(render_tree_branch(false, false, cx))
.child(IconButton::new(0, IconName::MessageBubbles)),
)
.child(
div()
.text_ui()
.when(has_messages_notification, |el| {
el.font_weight(FontWeight::SEMIBOLD)
})
.child("chat"),
)
.child(Label::new("chat"))
.tooltip(move |cx| Tooltip::text("Open Chat", cx))
}
@@ -1338,14 +1249,12 @@ impl CollabPanel {
cx: &mut ViewContext<Self>,
) {
let this = cx.view().clone();
let room = ActiveCall::global(cx).read(cx).room();
let in_room = room.is_some();
let in_call = room.is_some_and(|room| room.read(cx).in_call());
let in_room = ActiveCall::global(cx).read(cx).room().is_some();
let context_menu = ContextMenu::build(cx, |mut context_menu, _| {
let user_id = contact.user.id;
if contact.online && !contact.busy && (!in_room || in_call) {
if contact.online && !contact.busy {
let label = if in_room {
format!("Invite {} to join", contact.user.github_login)
} else {
@@ -1493,7 +1402,7 @@ impl CollabPanel {
if is_active {
self.open_channel_notes(channel.id, cx)
} else {
self.open_channel(channel.id, cx)
self.join_channel(channel.id, cx)
}
}
ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
@@ -1512,9 +1421,6 @@ impl CollabPanel {
ListEntry::ChannelInvite(channel) => {
self.respond_to_channel_invite(channel.id, true, cx)
}
ListEntry::ChannelCall { channel_id } => {
self.join_channel_call(*channel_id, cx)
}
ListEntry::ChannelNotes { channel_id } => {
self.open_channel_notes(*channel_id, cx)
}
@@ -1977,14 +1883,14 @@ impl CollabPanel {
.detach_and_prompt_err("Call failed", cx, |_, _| None);
}
fn open_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
return;
};
workspace::open_channel(
workspace::join_channel(
channel_id,
workspace.read(cx).app_state().clone(),
Some(handle),
@@ -1993,23 +1899,6 @@ impl CollabPanel {
.detach_and_prompt_err("Failed to join channel", cx, |_, _| None)
}
fn join_channel_call(&mut self, _channel_id: ChannelId, cx: &mut ViewContext<Self>) {
let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else {
return;
};
room.update(cx, |room, cx| room.join_call(cx))
.detach_and_prompt_err("Failed to join call", cx, |_, _| None)
}
fn leave_channel_call(&mut self, cx: &mut ViewContext<Self>) {
let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else {
return;
};
room.update(cx, |room, cx| room.leave_call(cx));
}
fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
let Some(workspace) = self.workspace.upgrade() else {
return;
@@ -2135,9 +2024,6 @@ impl CollabPanel {
ListEntry::ParticipantScreen { peer_id, is_last } => self
.render_participant_screen(*peer_id, *is_last, is_selected, cx)
.into_any_element(),
ListEntry::ChannelCall { channel_id } => self
.render_channel_call(*channel_id, is_selected, cx)
.into_any_element(),
ListEntry::ChannelNotes { channel_id } => self
.render_channel_notes(*channel_id, is_selected, cx)
.into_any_element(),
@@ -2150,7 +2036,7 @@ impl CollabPanel {
fn render_signed_in(&mut self, cx: &mut ViewContext<Self>) -> Div {
v_flex()
.size_full()
.child(list(self.list_state.clone()).full())
.child(list(self.list_state.clone()).size_full())
.child(
v_flex()
.child(div().mx_2().border_primary(cx).border_t())
@@ -2203,25 +2089,24 @@ impl CollabPanel {
is_collapsed: bool,
cx: &ViewContext<Self>,
) -> impl IntoElement {
let mut channel_link = None;
let mut channel_tooltip_text = None;
let mut channel_icon = None;
let text = match section {
Section::ActiveCall => {
let channel_name = maybe!({
let channel_id = ActiveCall::global(cx)
.read(cx)
.channel_id(cx)
.or_else(|| ActiveCall::global(cx).read(cx).pending_channel_id())?;
let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
channel_link = Some(channel.link());
(channel_icon, channel_tooltip_text) = match channel.visibility {
proto::ChannelVisibility::Public => {
(Some(IconName::Public), Some("Close Channel"))
(Some("icons/public.svg"), Some("Copy public channel link."))
}
proto::ChannelVisibility::Members => {
(Some(IconName::Hash), Some("Close Channel"))
(Some("icons/hash.svg"), Some("Copy private channel link."))
}
};
@@ -2243,10 +2128,17 @@ impl CollabPanel {
};
let button = match section {
Section::ActiveCall => channel_icon.map(|_| {
IconButton::new("channel-link", IconName::Close)
.on_click(move |_, cx| Self::leave_call(cx))
.tooltip(|cx| Tooltip::text("Close channel", cx))
Section::ActiveCall => channel_link.map(|channel_link| {
let channel_link_copy = channel_link.clone();
IconButton::new("channel-link", IconName::Copy)
.icon_size(IconSize::Small)
.size(ButtonSize::None)
.visible_on_hover("section-header")
.on_click(move |_, cx| {
let item = ClipboardItem::new(channel_link_copy.clone());
cx.write_to_clipboard(item)
})
.tooltip(|cx| Tooltip::text("Copy channel link", cx))
.into_any_element()
}),
Section::Contacts => Some(
@@ -2281,9 +2173,6 @@ impl CollabPanel {
this.toggle_section_expanded(section, cx);
}))
})
.when_some(channel_icon, |el, channel_icon| {
el.start_slot(Icon::new(channel_icon).color(Color::Muted))
})
.inset(true)
.end_slot::<AnyElement>(button)
.selected(is_selected),
@@ -2589,9 +2478,11 @@ impl CollabPanel {
}),
)
.on_click(cx.listener(move |this, _, cx| {
this.open_channel(channel_id, cx);
this.open_channel_notes(channel_id, cx);
this.join_channel_chat(channel_id, cx);
if is_active {
this.open_channel_notes(channel_id, cx)
} else {
this.join_channel(channel_id, cx)
}
}))
.on_secondary_mouse_down(cx.listener(
move |this, event: &MouseDownEvent, cx| {
@@ -2608,24 +2499,61 @@ impl CollabPanel {
.color(Color::Muted),
)
.child(
h_flex().id(channel_id as usize).child(
div()
.text_ui()
.when(has_messages_notification || has_notes_notification, |el| {
el.font_weight(FontWeight::SEMIBOLD)
})
.child(channel.name.clone()),
),
h_flex()
.id(channel_id as usize)
.child(Label::new(channel.name.clone()))
.children(face_pile.map(|face_pile| face_pile.p_1())),
),
)
.children(face_pile.map(|face_pile| {
.child(
h_flex()
.absolute()
.right(rems(0.))
.z_index(1)
.h_full()
.child(face_pile.p_1())
}))
.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))
.when(!has_messages_notification, |this| {
this.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))
.when(!has_notes_notification, |this| {
this.visible_on_hover("")
}),
),
),
)
.tooltip({
let channel_store = self.channel_store.clone();
move |cx| {
@@ -2829,14 +2757,6 @@ impl PartialEq for ListEntry {
return channel_1.id == channel_2.id;
}
}
ListEntry::ChannelCall { channel_id } => {
if let ListEntry::ChannelCall {
channel_id: other_id,
} = other
{
return channel_id == other_id;
}
}
ListEntry::ChannelNotes { channel_id } => {
if let ListEntry::ChannelNotes {
channel_id: other_id,
@@ -2935,7 +2855,7 @@ impl Render for JoinChannelTooltip {
.read(cx)
.channel_participants(self.channel_id);
div.child(Label::new("Open Channel"))
div.child(Label::new("Join Channel"))
.children(participants.iter().map(|participant| {
h_flex()
.gap_2()

View File

@@ -11,7 +11,7 @@ use gpui::{
};
use picker::{Picker, PickerDelegate};
use std::sync::Arc;
use ui::{prelude::*, Avatar, Checkbox, ContextMenu, ListItem, ListItemSpacing};
use ui::{prelude::*, Avatar, CheckboxWithLabel, ContextMenu, ListItem, ListItemSpacing};
use util::TryFutureExt;
use workspace::{notifications::DetachAndPromptErr, ModalView};
@@ -177,22 +177,16 @@ impl Render for ChannelModal {
.h(rems(22. / 16.))
.justify_between()
.line_height(rems(1.25))
.child(
h_flex()
.gap_2()
.child(
Checkbox::new(
"is-public",
if visibility == ChannelVisibility::Public {
ui::Selection::Selected
} else {
ui::Selection::Unselected
},
)
.on_click(cx.listener(Self::set_channel_visibility)),
)
.child(Label::new("Public").size(LabelSize::Small)),
)
.child(CheckboxWithLabel::new(
"is-public",
Label::new("Public").size(LabelSize::Small),
if visibility == ChannelVisibility::Public {
ui::Selection::Selected
} else {
ui::Selection::Unselected
},
cx.listener(Self::set_channel_visibility),
))
.children(
Some(
Button::new("copy-link", "Copy Link")

View File

@@ -102,10 +102,6 @@ impl Render for CollabTitlebarItem {
room.remote_participants().values().collect::<Vec<_>>();
remote_participants.sort_by_key(|p| p.participant_index.0);
if !room.in_call() {
return this;
}
let current_user_face_pile = self.render_collaborator(
&current_user,
peer_id,
@@ -137,10 +133,6 @@ impl Render for CollabTitlebarItem {
== ParticipantLocation::SharedProject { project_id }
});
if !collaborator.in_call {
return None;
}
let face_pile = self.render_collaborator(
&collaborator.user,
collaborator.peer_id,
@@ -193,7 +185,7 @@ impl Render for CollabTitlebarItem {
let is_local = project.is_local();
let is_shared = is_local && project.is_shared();
let is_muted = room.is_muted();
let is_connected_to_livekit = room.in_call();
let is_deafened = room.is_deafened().unwrap_or(false);
let is_screen_sharing = room.is_screen_sharing();
let read_only = room.read_only();
@@ -228,28 +220,22 @@ impl Render for CollabTitlebarItem {
)),
)
})
.when(is_connected_to_livekit, |el| {
el.child(
div()
.child(
IconButton::new("leave-call", ui::IconName::Exit)
.style(ButtonStyle::Subtle)
.tooltip(|cx| Tooltip::text("Leave call", cx))
.icon_size(IconSize::Small)
.on_click(move |_, cx| {
ActiveCall::global(cx).update(cx, |call, cx| {
if let Some(room) = call.room() {
room.update(cx, |room, cx| {
room.leave_call(cx)
})
}
})
}),
)
.pl_2(),
)
})
.when(!read_only && is_connected_to_livekit, |this| {
.child(
div()
.child(
IconButton::new("leave-call", ui::IconName::Exit)
.style(ButtonStyle::Subtle)
.tooltip(|cx| Tooltip::text("Leave call", cx))
.icon_size(IconSize::Small)
.on_click(move |_, cx| {
ActiveCall::global(cx)
.update(cx, |call, cx| call.hang_up(cx))
.detach_and_log_err(cx);
}),
)
.pr_2(),
)
.when(!read_only, |this| {
this.child(
IconButton::new(
"mute-microphone",
@@ -276,7 +262,34 @@ impl Render for CollabTitlebarItem {
.on_click(move |_, cx| crate::toggle_mute(&Default::default(), cx)),
)
})
.when(!read_only && is_connected_to_livekit, |this| {
.child(
IconButton::new(
"mute-sound",
if is_deafened {
ui::IconName::AudioOff
} else {
ui::IconName::AudioOn
},
)
.style(ButtonStyle::Subtle)
.selected_style(ButtonStyle::Tinted(TintColor::Negative))
.icon_size(IconSize::Small)
.selected(is_deafened)
.tooltip(move |cx| {
if !read_only {
Tooltip::with_meta(
"Deafen Audio",
None,
"Mic will be muted",
cx,
)
} else {
Tooltip::text("Deafen Audio", cx)
}
})
.on_click(move |_, cx| crate::toggle_deafen(&Default::default(), cx)),
)
.when(!read_only, |this| {
this.child(
IconButton::new("screen-share", ui::IconName::Screen)
.style(ButtonStyle::Subtle)
@@ -553,10 +566,7 @@ impl CollabTitlebarItem {
ActiveCall::global(cx)
.update(cx, |call, cx| call.set_location(Some(&self.project), cx))
.detach_and_log_err(cx);
return;
}
if cx.active_window().is_none() {
} else if cx.active_window().is_none() {
ActiveCall::global(cx)
.update(cx, |call, cx| call.set_location(None, cx))
.detach_and_log_err(cx);

View File

@@ -22,7 +22,10 @@ pub use panel_settings::{
use settings::Settings;
use workspace::{notifications::DetachAndPromptErr, AppState};
actions!(collab, [ToggleScreenSharing, ToggleMute, LeaveCall]);
actions!(
collab,
[ToggleScreenSharing, ToggleMute, ToggleDeafen, LeaveCall]
);
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
CollaborationPanelSettings::register(cx);
@@ -82,6 +85,12 @@ pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) {
}
}
pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) {
if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
room.update(cx, |room, cx| room.toggle_deafen(cx));
}
}
fn notification_window_options(
screen: Rc<dyn PlatformDisplay>,
window_size: Size<Pixels>,

View File

@@ -317,10 +317,8 @@ impl PickerDelegate for CommandPaletteDelegate {
});
let action = command.action;
cx.focus(&self.previous_focus_handle);
cx.window_context()
.spawn(move |mut cx| async move { cx.update(|cx| cx.dispatch_action(action)) })
.detach_and_log_err(cx);
self.dismissed(cx);
cx.dispatch_action(action);
}
fn render_match(

View File

@@ -116,6 +116,11 @@ impl CopilotCodeVerification {
.full_width()
.style(ButtonStyle::Filled),
)
.child(
Button::new("copilot-enable-cancel-button", "Cancel")
.full_width()
.on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))),
)
}
fn render_enabled_modal(cx: &mut ViewContext<Self>) -> impl Element {
v_flex()
@@ -131,7 +136,7 @@ impl CopilotCodeVerification {
)
}
fn render_unauthorized_modal() -> impl Element {
fn render_unauthorized_modal(cx: &mut ViewContext<Self>) -> impl Element {
v_flex()
.child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large))
@@ -143,6 +148,11 @@ impl CopilotCodeVerification {
.full_width()
.on_click(|_, cx| cx.open_url(COPILOT_SIGN_UP_URL)),
)
.child(
Button::new("copilot-subscribe-cancel-button", "Cancel")
.full_width()
.on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))),
)
}
fn render_disabled_modal() -> impl Element {
@@ -160,7 +170,7 @@ impl Render for CopilotCodeVerification {
} => Self::render_prompting_modal(self.connect_clicked, &prompt, cx).into_any_element(),
Status::Unauthorized => {
self.connect_clicked = false;
Self::render_unauthorized_modal().into_any_element()
Self::render_unauthorized_modal(cx).into_any_element()
}
Status::Authorized => {
self.connect_clicked = false;

View File

@@ -3,7 +3,7 @@
//! Not literally though - rendering, layout and all that jazz is a responsibility of [`EditorElement`][EditorElement].
//! Instead, [`DisplayMap`] decides where Inlays/Inlay hints are displayed, when
//! to apply a soft wrap, where to add fold indicators, whether there are any tabs in the buffer that
//! we display as spaces and where to display custom blocks (like diagnostics)
//! we display as spaces and where to display custom blocks (like diagnostics).
//! Seems like a lot? That's because it is. [`DisplayMap`] is conceptually made up
//! of several smaller structures that form a hierarchy (starting at the bottom):
//! - [`InlayMap`] that decides where the [`Inlay`]s should be displayed.

View File

@@ -399,6 +399,7 @@ pub struct Editor {
workspace: Option<(WeakView<Workspace>, i64)>,
keymap_context_layers: BTreeMap<TypeId, KeyContext>,
input_enabled: bool,
use_modal_editing: bool,
read_only: bool,
leader_peer_id: Option<PeerId>,
remote_id: Option<ViewId>,
@@ -773,11 +774,11 @@ impl CompletionsMenu {
cx,
);
cx.spawn(move |this, mut cx| async move {
return cx.spawn(move |this, mut cx| async move {
if let Some(true) = resolve_task.await.log_err() {
this.update(&mut cx, |_, cx| cx.notify()).ok();
}
})
});
}
fn attempt_resolve_selected_completion_documentation(
@@ -1016,12 +1017,53 @@ impl CompletionsMenu {
let completions = self.completions.read();
matches.sort_unstable_by_key(|mat| {
// We do want to strike a balance here between what the language server tells us
// to sort by (the sort_text) and what are "obvious" good matches (i.e. when you type
// `Creat` and there is a local variable called `CreateComponent`).
// So what we do is: we bucket all matches into two buckets
// - Strong matches
// - Weak matches
// Strong matches are the ones with a high fuzzy-matcher score (the "obvious" matches)
// and the Weak matches are the rest.
//
// For the strong matches, we sort by the language-servers score first and for the weak
// matches, we prefer our fuzzy finder first.
//
// The thinking behind that: it's useless to take the sort_text the language-server gives
// us into account when it's obviously a bad match.
#[derive(PartialEq, Eq, PartialOrd, Ord)]
enum MatchScore<'a> {
Strong {
sort_text: Option<&'a str>,
score: Reverse<OrderedFloat<f64>>,
sort_key: (usize, &'a str),
},
Weak {
score: Reverse<OrderedFloat<f64>>,
sort_text: Option<&'a str>,
sort_key: (usize, &'a str),
},
}
let completion = &completions[mat.candidate_id];
(
completion.lsp_completion.sort_text.as_ref(),
Reverse(OrderedFloat(mat.score)),
completion.sort_key(),
)
let sort_key = completion.sort_key();
let sort_text = completion.lsp_completion.sort_text.as_deref();
let score = Reverse(OrderedFloat(mat.score));
if mat.score >= 0.2 {
MatchScore::Strong {
sort_text,
score,
sort_key,
}
} else {
MatchScore::Weak {
score,
sort_text,
sort_key,
}
}
});
for mat in &mut matches {
@@ -1482,6 +1524,7 @@ impl Editor {
workspace: None,
keymap_context_layers: Default::default(),
input_enabled: true,
use_modal_editing: mode == EditorMode::Full,
read_only: false,
use_autoclose: true,
leader_peer_id: None,
@@ -1782,6 +1825,14 @@ impl Editor {
self.show_copilot_suggestions = show_copilot_suggestions;
}
pub fn set_use_modal_editing(&mut self, to: bool) {
self.use_modal_editing = to;
}
pub fn use_modal_editing(&self) -> bool {
self.use_modal_editing
}
fn selections_did_change(
&mut self,
local: bool,
@@ -7609,8 +7660,9 @@ impl Editor {
let snapshot = cursor_buffer.read(cx).snapshot();
let cursor_buffer_offset = cursor_buffer_position.to_offset(&snapshot);
let prepare_rename = project.update(cx, |project, cx| {
project.prepare_rename(cursor_buffer, cursor_buffer_offset, cx)
project.prepare_rename(cursor_buffer.clone(), cursor_buffer_offset, cx)
});
drop(snapshot);
Some(cx.spawn(|this, mut cx| async move {
let rename_range = if let Some(range) = prepare_rename.await? {
@@ -7630,11 +7682,12 @@ impl Editor {
})?
};
if let Some(rename_range) = rename_range {
let rename_buffer_range = rename_range.to_offset(&snapshot);
let cursor_offset_in_rename_range =
cursor_buffer_offset.saturating_sub(rename_buffer_range.start);
this.update(&mut cx, |this, cx| {
let snapshot = cursor_buffer.read(cx).snapshot();
let rename_buffer_range = rename_range.to_offset(&snapshot);
let cursor_offset_in_rename_range =
cursor_buffer_offset.saturating_sub(rename_buffer_range.start);
this.take_rename(false, cx);
let buffer = this.buffer.read(cx).read(cx);
let cursor_offset = selection.head().to_offset(&buffer);
@@ -7836,7 +7889,6 @@ impl Editor {
Some(rename)
}
#[cfg(any(test, feature = "test-support"))]
pub fn pending_rename(&self) -> Option<&RenameState> {
self.pending_rename.as_ref()
}
@@ -8505,6 +8557,17 @@ impl Editor {
color_fetcher: fn(&ThemeColors) -> Hsla,
cx: &mut ViewContext<Self>,
) {
let snapshot = self.snapshot(cx);
// this is to try and catch a panic sooner
for range in &ranges {
snapshot
.buffer_snapshot
.summary_for_anchor::<usize>(&range.start);
snapshot
.buffer_snapshot
.summary_for_anchor::<usize>(&range.end);
}
self.background_highlights
.insert(TypeId::of::<T>(), (color_fetcher, ranges));
cx.notify();

View File

@@ -374,7 +374,7 @@ impl EditorElement {
) {
let mouse_position = cx.mouse_position();
if !text_bounds.contains(&mouse_position)
|| !cx.is_top_layer(&mouse_position, stacking_order)
|| !cx.was_top_layer(&mouse_position, stacking_order)
{
return;
}
@@ -406,7 +406,7 @@ impl EditorElement {
} else if !text_bounds.contains(&event.position) {
return;
}
if !cx.is_top_layer(&event.position, stacking_order) {
if !cx.was_top_layer(&event.position, stacking_order) {
return;
}
@@ -478,11 +478,11 @@ impl EditorElement {
editor.select(SelectPhase::End, cx);
}
if interactive_bounds.did_visibly_contains(&event.position, cx)
if interactive_bounds.visibly_contains(&event.position, cx)
&& !pending_nonempty_selections
&& event.modifiers.command
&& text_bounds.contains(&event.position)
&& cx.is_top_layer(&event.position, stacking_order)
&& cx.was_top_layer(&event.position, stacking_order)
{
let point = position_map.point_for_position(text_bounds, event.position);
editor.handle_click_hovered_link(point, event.modifiers, cx);
@@ -550,7 +550,7 @@ impl EditorElement {
let modifiers = event.modifiers;
let text_hovered = text_bounds.contains(&event.position);
let gutter_hovered = gutter_bounds.contains(&event.position);
let was_top = cx.is_top_layer(&event.position, stacking_order);
let was_top = cx.was_top_layer(&event.position, stacking_order);
editor.set_gutter_hovered(gutter_hovered, cx);
@@ -903,7 +903,7 @@ impl EditorElement {
bounds: text_bounds,
stacking_order: cx.stacking_order().clone(),
};
if interactive_text_bounds.did_visibly_contains(&cx.mouse_position(), cx) {
if interactive_text_bounds.visibly_contains(&cx.mouse_position(), cx) {
if self
.editor
.read(cx)
@@ -1242,7 +1242,7 @@ impl EditorElement {
popover_origin.x = popover_origin.x + x_out_of_bounds;
}
if cx.is_top_layer(&popover_origin, cx.stacking_order()) {
if cx.was_top_layer(&popover_origin, cx.stacking_order()) {
cx.break_content_mask(|cx| {
hover_popover.draw(popover_origin, available_space, cx)
});
@@ -1537,7 +1537,7 @@ impl EditorElement {
stacking_order: cx.stacking_order().clone(),
};
let mut mouse_position = cx.mouse_position();
if interactive_track_bounds.did_visibly_contains(&mouse_position, cx) {
if interactive_track_bounds.visibly_contains(&mouse_position, cx) {
cx.set_cursor_style(CursorStyle::Arrow);
}
@@ -1567,7 +1567,7 @@ impl EditorElement {
cx.stop_propagation();
} else {
editor.scroll_manager.set_is_dragging_scrollbar(false, cx);
if interactive_track_bounds.did_visibly_contains(&event.position, cx) {
if interactive_track_bounds.visibly_contains(&event.position, cx) {
editor.scroll_manager.show_scrollbar(cx);
}
}
@@ -2652,14 +2652,14 @@ impl EditorElement {
move |event: &ScrollWheelEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& interactive_bounds.did_visibly_contains(&event.position, cx)
&& interactive_bounds.visibly_contains(&event.position, cx)
{
delta = delta.coalesce(event.delta);
editor.update(cx, |editor, cx| {
let position = event.position;
let position_map: &PositionMap = &position_map;
let bounds = &interactive_bounds;
if !bounds.did_visibly_contains(&position, cx) {
if !bounds.visibly_contains(&position, cx) {
return;
}
@@ -2719,7 +2719,7 @@ impl EditorElement {
move |event: &MouseDownEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& interactive_bounds.did_visibly_contains(&event.position, cx)
&& interactive_bounds.visibly_contains(&event.position, cx)
{
match event.button {
MouseButton::Left => editor.update(cx, |editor, cx| {
@@ -2786,7 +2786,7 @@ impl EditorElement {
)
}
if interactive_bounds.did_visibly_contains(&event.position, cx) {
if interactive_bounds.visibly_contains(&event.position, cx) {
Self::mouse_moved(
editor,
event,
@@ -3067,7 +3067,7 @@ impl Element for EditorElement {
) {
let editor = self.editor.clone();
cx.with_view(self.editor.entity_id(), |cx| {
cx.paint_view(self.editor.entity_id(), |cx| {
cx.with_text_style(
Some(gpui::TextStyleRefinement {
font_size: Some(self.style.text.font_size),

View File

@@ -289,6 +289,7 @@ fn show_hover(
})?;
let hover_result = hover_request.await.ok().flatten();
let snapshot = this.update(&mut cx, |this, cx| this.snapshot(cx))?;
let hover_popover = match hover_result {
Some(hover_result) if !hover_result.is_empty() => {
// Create symbol range of anchors for highlighting and filtering of future requests.

View File

@@ -704,10 +704,12 @@ impl Item for Editor {
fn save(&mut self, project: Model<Project>, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
self.report_editor_event("save", None, cx);
let format = self.perform_format(project.clone(), FormatTrigger::Save, cx);
let buffers = self.buffer().clone().read(cx).all_buffers();
cx.spawn(|_, mut cx| async move {
format.await?;
cx.spawn(|this, mut cx| async move {
this.update(&mut cx, |this, cx| {
this.perform_format(project.clone(), FormatTrigger::Save, cx)
})?
.await?;
if buffers.len() == 1 {
project

View File

@@ -5,6 +5,7 @@ use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint};
use crate::{char_kind, scroll::ScrollAnchor, CharKind, EditorStyle, ToOffset, ToPoint};
use gpui::{px, Pixels, WindowTextSystem};
use language::Point;
use multi_buffer::MultiBufferSnapshot;
use std::{ops::Range, sync::Arc};
@@ -254,7 +255,7 @@ pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> Displa
let raw_point = point.to_point(map);
let scope = map.buffer_snapshot.language_scope_at(raw_point);
find_preceding_boundary(map, point, FindRange::MultiLine, |left, right| {
find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| {
(char_kind(&scope, left) != char_kind(&scope, right) && !right.is_whitespace())
|| left == '\n'
})
@@ -267,7 +268,7 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis
let raw_point = point.to_point(map);
let scope = map.buffer_snapshot.language_scope_at(raw_point);
find_preceding_boundary(map, point, FindRange::MultiLine, |left, right| {
find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| {
let is_word_start =
char_kind(&scope, left) != char_kind(&scope, right) && !right.is_whitespace();
let is_subword_start =
@@ -366,16 +367,16 @@ pub fn end_of_paragraph(
/// indicated by the given predicate returning true.
/// The predicate is called with the character to the left and right of the candidate boundary location.
/// If FindRange::SingleLine is specified and no boundary is found before the start of the current line, the start of the current line will be returned.
pub fn find_preceding_boundary(
map: &DisplaySnapshot,
from: DisplayPoint,
pub fn find_preceding_boundary_point(
buffer_snapshot: &MultiBufferSnapshot,
from: Point,
find_range: FindRange,
mut is_boundary: impl FnMut(char, char) -> bool,
) -> DisplayPoint {
) -> Point {
let mut prev_ch = None;
let mut offset = from.to_point(map).to_offset(&map.buffer_snapshot);
let mut offset = from.to_offset(&buffer_snapshot);
for ch in map.buffer_snapshot.reversed_chars_at(offset) {
for ch in buffer_snapshot.reversed_chars_at(offset) {
if find_range == FindRange::SingleLine && ch == '\n' {
break;
}
@@ -389,7 +390,26 @@ pub fn find_preceding_boundary(
prev_ch = Some(ch);
}
map.clip_point(offset.to_display_point(map), Bias::Left)
offset.to_point(&buffer_snapshot)
}
/// Scans for a boundary preceding the given start point `from` until a boundary is found,
/// indicated by the given predicate returning true.
/// The predicate is called with the character to the left and right of the candidate boundary location.
/// If FindRange::SingleLine is specified and no boundary is found before the start of the current line, the start of the current line will be returned.
pub fn find_preceding_boundary_display_point(
map: &DisplaySnapshot,
from: DisplayPoint,
find_range: FindRange,
is_boundary: impl FnMut(char, char) -> bool,
) -> DisplayPoint {
let result = find_preceding_boundary_point(
&map.buffer_snapshot,
from.to_point(map),
find_range,
is_boundary,
);
map.clip_point(result.to_display_point(map), Bias::Left)
}
/// Scans for a boundary following the given start point until a boundary is found, indicated by the
@@ -626,7 +646,7 @@ mod tests {
) {
let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
assert_eq!(
find_preceding_boundary(
find_preceding_boundary_display_point(
&snapshot,
display_points[1],
FindRange::MultiLine,
@@ -700,7 +720,7 @@ mod tests {
});
assert_eq!(
find_preceding_boundary(
find_preceding_boundary_display_point(
&snapshot,
buffer_snapshot.len().to_display_point(&snapshot),
FindRange::MultiLine,

View File

@@ -14,20 +14,26 @@ path = "src/extension_json_schemas.rs"
[dependencies]
anyhow.workspace = true
async-compression.workspace = true
async-tar.workspace = true
client.workspace = true
collections.workspace = true
fs.workspace = true
futures.workspace = true
gpui.workspace = true
language.workspace = true
log.workspace = true
parking_lot.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
theme.workspace = true
toml.workspace = true
util.workspace = true
[dev-dependencies]
client = { workspace = true, features = ["test-support"] }
fs = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] }

View File

@@ -1,13 +1,20 @@
use anyhow::{Context as _, Result};
use collections::HashMap;
use fs::Fs;
use anyhow::{anyhow, bail, Context as _, Result};
use async_compression::futures::bufread::GzipDecoder;
use async_tar::Archive;
use client::ClientSettings;
use collections::{BTreeMap, HashSet};
use fs::{Fs, RemoveOptions};
use futures::channel::mpsc::unbounded;
use futures::StreamExt as _;
use futures::{io::BufReader, AsyncReadExt as _};
use gpui::{actions, AppContext, Context, Global, Model, ModelContext, Task};
use language::{
LanguageConfig, LanguageMatcher, LanguageQueries, LanguageRegistry, QUERY_FILENAME_PREFIXES,
};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use settings::Settings as _;
use std::cmp::Ordering;
use std::{
ffi::OsStr,
path::{Path, PathBuf},
@@ -15,18 +22,51 @@ use std::{
time::Duration,
};
use theme::{ThemeRegistry, ThemeSettings};
use util::{paths::EXTENSIONS_DIR, ResultExt};
use util::http::AsyncBody;
use util::TryFutureExt;
use util::{http::HttpClient, paths::EXTENSIONS_DIR, ResultExt};
#[cfg(test)]
mod extension_store_test;
#[derive(Deserialize)]
pub struct ExtensionsApiResponse {
pub data: Vec<Extension>,
}
#[derive(Deserialize)]
pub struct Extension {
pub id: Arc<str>,
pub version: Arc<str>,
pub name: String,
pub description: Option<String>,
pub authors: Vec<String>,
pub repository: String,
pub download_count: usize,
}
#[derive(Clone)]
pub enum ExtensionStatus {
NotInstalled,
Installing,
Upgrading,
Installed(Arc<str>),
Removing,
}
pub struct ExtensionStore {
manifest: Arc<RwLock<Manifest>>,
fs: Arc<dyn Fs>,
http_client: Arc<dyn HttpClient>,
extensions_dir: PathBuf,
extensions_being_installed: HashSet<Arc<str>>,
extensions_being_uninstalled: HashSet<Arc<str>>,
manifest_path: PathBuf,
language_registry: Arc<LanguageRegistry>,
theme_registry: Arc<ThemeRegistry>,
extension_changes: ExtensionChanges,
reload_task: Option<Task<Option<()>>>,
needs_reload: bool,
_watch_extensions_dir: [Task<()>; 2],
}
@@ -34,11 +74,12 @@ struct GlobalExtensionStore(Model<ExtensionStore>);
impl Global for GlobalExtensionStore {}
#[derive(Deserialize, Serialize, Default)]
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct Manifest {
pub grammars: HashMap<Arc<str>, GrammarManifestEntry>,
pub languages: HashMap<Arc<str>, LanguageManifestEntry>,
pub themes: HashMap<String, ThemeManifestEntry>,
pub extensions: BTreeMap<Arc<str>, Arc<str>>,
pub grammars: BTreeMap<Arc<str>, GrammarManifestEntry>,
pub languages: BTreeMap<Arc<str>, LanguageManifestEntry>,
pub themes: BTreeMap<Arc<str>, ThemeManifestEntry>,
}
#[derive(PartialEq, Eq, Debug, PartialOrd, Ord, Deserialize, Serialize)]
@@ -61,10 +102,18 @@ pub struct ThemeManifestEntry {
path: PathBuf,
}
#[derive(Default)]
struct ExtensionChanges {
languages: HashSet<Arc<str>>,
grammars: HashSet<Arc<str>>,
themes: HashSet<Arc<str>>,
}
actions!(zed, [ReloadExtensions]);
pub fn init(
fs: Arc<fs::RealFs>,
http_client: Arc<dyn HttpClient>,
language_registry: Arc<LanguageRegistry>,
theme_registry: Arc<ThemeRegistry>,
cx: &mut AppContext,
@@ -73,6 +122,7 @@ pub fn init(
ExtensionStore::new(
EXTENSIONS_DIR.clone(),
fs.clone(),
http_client.clone(),
language_registry.clone(),
theme_registry,
cx,
@@ -81,18 +131,21 @@ pub fn init(
cx.on_action(|_: &ReloadExtensions, cx| {
let store = cx.global::<GlobalExtensionStore>().0.clone();
store
.update(cx, |store, cx| store.reload(cx))
.detach_and_log_err(cx);
store.update(cx, |store, cx| store.reload(cx))
});
cx.set_global(GlobalExtensionStore(store));
}
impl ExtensionStore {
pub fn global(cx: &AppContext) -> Model<Self> {
cx.global::<GlobalExtensionStore>().0.clone()
}
pub fn new(
extensions_dir: PathBuf,
fs: Arc<dyn Fs>,
http_client: Arc<dyn HttpClient>,
language_registry: Arc<LanguageRegistry>,
theme_registry: Arc<ThemeRegistry>,
cx: &mut ModelContext<Self>,
@@ -101,7 +154,13 @@ impl ExtensionStore {
manifest: Default::default(),
extensions_dir: extensions_dir.join("installed"),
manifest_path: extensions_dir.join("manifest.json"),
extensions_being_installed: Default::default(),
extensions_being_uninstalled: Default::default(),
reload_task: None,
needs_reload: false,
extension_changes: ExtensionChanges::default(),
fs,
http_client,
language_registry,
theme_registry,
_watch_extensions_dir: [Task::ready(()), Task::ready(())],
@@ -136,19 +195,218 @@ impl ExtensionStore {
};
if should_reload {
self.reload(cx).detach_and_log_err(cx);
self.reload(cx)
}
}
pub fn extensions_dir(&self) -> PathBuf {
self.extensions_dir.clone()
}
pub fn extension_status(&self, extension_id: &str) -> ExtensionStatus {
let is_uninstalling = self.extensions_being_uninstalled.contains(extension_id);
if is_uninstalling {
return ExtensionStatus::Removing;
}
let installed_version = self.manifest.read().extensions.get(extension_id).cloned();
let is_installing = self.extensions_being_installed.contains(extension_id);
match (installed_version, is_installing) {
(Some(_), true) => ExtensionStatus::Upgrading,
(Some(version), false) => ExtensionStatus::Installed(version.clone()),
(None, true) => ExtensionStatus::Installing,
(None, false) => ExtensionStatus::NotInstalled,
}
}
pub fn fetch_extensions(
&self,
search: Option<&str>,
cx: &mut ModelContext<Self>,
) -> Task<Result<Vec<Extension>>> {
let url = format!(
"{}/{}{query}",
ClientSettings::get_global(cx).server_url,
"api/extensions",
query = search
.map(|search| format!("?filter={search}"))
.unwrap_or_default()
);
let http_client = self.http_client.clone();
cx.spawn(move |_, _| async move {
let mut response = http_client.get(&url, AsyncBody::empty(), true).await?;
let mut body = Vec::new();
response
.body_mut()
.read_to_end(&mut body)
.await
.context("error reading extensions")?;
if response.status().is_client_error() {
let text = String::from_utf8_lossy(body.as_slice());
bail!(
"status error {}, response: {text:?}",
response.status().as_u16()
);
}
let response: ExtensionsApiResponse = serde_json::from_slice(&body)?;
Ok(response.data)
})
}
pub fn install_extension(
&mut self,
extension_id: Arc<str>,
version: Arc<str>,
cx: &mut ModelContext<Self>,
) {
log::info!("installing extension {extension_id} {version}");
let url = format!(
"{}/api/extensions/{extension_id}/{version}/download",
ClientSettings::get_global(cx).server_url
);
let extensions_dir = self.extensions_dir();
let http_client = self.http_client.clone();
self.extensions_being_installed.insert(extension_id.clone());
cx.spawn(move |this, mut cx| async move {
let mut response = http_client
.get(&url, Default::default(), true)
.await
.map_err(|err| anyhow!("error downloading extension: {}", err))?;
let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
let archive = Archive::new(decompressed_bytes);
archive
.unpack(extensions_dir.join(extension_id.as_ref()))
.await?;
this.update(&mut cx, |this, cx| {
this.extensions_being_installed
.remove(extension_id.as_ref());
this.reload(cx)
})
})
.detach_and_log_err(cx);
}
pub fn uninstall_extension(&mut self, extension_id: Arc<str>, cx: &mut ModelContext<Self>) {
let extensions_dir = self.extensions_dir();
let fs = self.fs.clone();
self.extensions_being_uninstalled
.insert(extension_id.clone());
cx.spawn(move |this, mut cx| async move {
fs.remove_dir(
&extensions_dir.join(extension_id.as_ref()),
RemoveOptions {
recursive: true,
ignore_if_not_exists: true,
},
)
.await?;
this.update(&mut cx, |this, cx| {
this.extensions_being_uninstalled
.remove(extension_id.as_ref());
this.reload(cx)
})
})
.detach_and_log_err(cx)
}
/// Updates the set of installed extensions.
///
/// First, this unloads any themes, languages, or grammars that are
/// no longer in the manifest, or whose files have changed on disk.
/// Then it loads any themes, languages, or grammars that are newly
/// added to the manifest, or whose files have changed on disk.
fn manifest_updated(&mut self, manifest: Manifest, cx: &mut ModelContext<Self>) {
fn diff<'a, T, I1, I2>(
old_keys: I1,
new_keys: I2,
modified_keys: &HashSet<Arc<str>>,
) -> (Vec<Arc<str>>, Vec<Arc<str>>)
where
T: PartialEq,
I1: Iterator<Item = (&'a Arc<str>, T)>,
I2: Iterator<Item = (&'a Arc<str>, T)>,
{
let mut removed_keys = Vec::default();
let mut added_keys = Vec::default();
let mut old_keys = old_keys.peekable();
let mut new_keys = new_keys.peekable();
loop {
match (old_keys.peek(), new_keys.peek()) {
(None, None) => return (removed_keys, added_keys),
(None, Some(_)) => {
added_keys.push(new_keys.next().unwrap().0.clone());
}
(Some(_), None) => {
removed_keys.push(old_keys.next().unwrap().0.clone());
}
(Some((old_key, _)), Some((new_key, _))) => match old_key.cmp(&new_key) {
Ordering::Equal => {
let (old_key, old_value) = old_keys.next().unwrap();
let (new_key, new_value) = new_keys.next().unwrap();
if old_value != new_value || modified_keys.contains(old_key) {
removed_keys.push(old_key.clone());
added_keys.push(new_key.clone());
}
}
Ordering::Less => {
removed_keys.push(old_keys.next().unwrap().0.clone());
}
Ordering::Greater => {
added_keys.push(new_keys.next().unwrap().0.clone());
}
},
}
}
}
let old_manifest = self.manifest.read();
let (languages_to_remove, languages_to_add) = diff(
old_manifest.languages.iter(),
manifest.languages.iter(),
&self.extension_changes.languages,
);
let (grammars_to_remove, grammars_to_add) = diff(
old_manifest.grammars.iter(),
manifest.grammars.iter(),
&self.extension_changes.grammars,
);
let (themes_to_remove, themes_to_add) = diff(
old_manifest.themes.iter(),
manifest.themes.iter(),
&self.extension_changes.themes,
);
self.extension_changes.clear();
drop(old_manifest);
let themes_to_remove = &themes_to_remove
.into_iter()
.map(|theme| theme.into())
.collect::<Vec<_>>();
self.theme_registry.remove_user_themes(&themes_to_remove);
self.language_registry
.register_wasm_grammars(manifest.grammars.iter().map(|(grammar_name, grammar)| {
.remove_languages(&languages_to_remove, &grammars_to_remove);
self.language_registry
.register_wasm_grammars(grammars_to_add.iter().map(|grammar_name| {
let grammar = manifest.grammars.get(grammar_name).unwrap();
let mut grammar_path = self.extensions_dir.clone();
grammar_path.extend([grammar.extension.as_ref(), grammar.path.as_path()]);
(grammar_name.clone(), grammar_path)
}));
for (language_name, language) in &manifest.languages {
for language_name in &languages_to_add {
let language = manifest.languages.get(language_name.as_ref()).unwrap();
let mut language_path = self.extensions_dir.clone();
language_path.extend([language.extension.as_ref(), language.path.as_path()]);
self.language_registry.register_language(
@@ -164,13 +422,18 @@ impl ExtensionStore {
},
);
}
let (reload_theme_tx, mut reload_theme_rx) = unbounded();
let fs = self.fs.clone();
let root_dir = self.extensions_dir.clone();
let theme_registry = self.theme_registry.clone();
let themes = manifest.themes.clone();
let themes = themes_to_add
.iter()
.filter_map(|name| manifest.themes.get(name).cloned())
.collect::<Vec<_>>();
cx.background_executor()
.spawn(async move {
for theme in themes.values() {
for theme in &themes {
let mut theme_path = root_dir.clone();
theme_path.extend([theme.extension.as_ref(), theme.path.as_path()]);
@@ -179,26 +442,40 @@ impl ExtensionStore {
.await
.log_err();
}
reload_theme_tx.unbounded_send(()).ok();
})
.detach();
cx.spawn(|_, cx| async move {
while let Some(_) = reload_theme_rx.next().await {
if cx
.update(|cx| ThemeSettings::reload_current_theme(cx))
.is_err()
{
break;
}
}
})
.detach();
*self.manifest.write() = manifest;
cx.notify();
}
fn watch_extensions_dir(&self, cx: &mut ModelContext<Self>) -> [Task<()>; 2] {
let manifest = self.manifest.clone();
let fs = self.fs.clone();
let language_registry = self.language_registry.clone();
let theme_registry = self.theme_registry.clone();
let extensions_dir = self.extensions_dir.clone();
let (reload_theme_tx, mut reload_theme_rx) = futures::channel::mpsc::unbounded();
let (changes_tx, mut changes_rx) = unbounded();
let events_task = cx.background_executor().spawn(async move {
let mut events = fs.watch(&extensions_dir, Duration::from_millis(250)).await;
while let Some(events) = events.next().await {
let mut changed_grammars = Vec::default();
let mut changed_languages = Vec::default();
let mut changed_themes = Vec::default();
let mut changed_grammars = HashSet::default();
let mut changed_languages = HashSet::default();
let mut changed_themes = HashSet::default();
{
let manifest = manifest.read();
@@ -208,7 +485,7 @@ impl ExtensionStore {
grammar_path
.extend([grammar.extension.as_ref(), grammar.path.as_path()]);
if event.path.starts_with(&grammar_path) || event.path == grammar_path {
changed_grammars.push(grammar_name.clone());
changed_grammars.insert(grammar_name.clone());
}
}
@@ -218,40 +495,37 @@ impl ExtensionStore {
.extend([language.extension.as_ref(), language.path.as_path()]);
if event.path.starts_with(&language_path) || event.path == language_path
{
changed_languages.push(language_name.clone());
changed_languages.insert(language_name.clone());
}
}
for (_theme_name, theme) in &manifest.themes {
for (theme_name, theme) in &manifest.themes {
let mut theme_path = extensions_dir.clone();
theme_path.extend([theme.extension.as_ref(), theme.path.as_path()]);
if event.path.starts_with(&theme_path) || event.path == theme_path {
changed_themes.push(theme_path.clone());
changed_themes.insert(theme_name.clone());
}
}
}
}
language_registry.reload_languages(&changed_languages, &changed_grammars);
for theme_path in &changed_themes {
theme_registry
.load_user_theme(&theme_path, fs.clone())
.await
.context("failed to load user theme")
.log_err();
}
if !changed_themes.is_empty() {
reload_theme_tx.unbounded_send(()).ok();
}
changes_tx
.unbounded_send(ExtensionChanges {
languages: changed_languages,
grammars: changed_grammars,
themes: changed_themes,
})
.ok();
}
});
let reload_theme_task = cx.spawn(|_, cx| async move {
while let Some(_) = reload_theme_rx.next().await {
if cx
.update(|cx| ThemeSettings::reload_current_theme(cx))
let reload_task = cx.spawn(|this, mut cx| async move {
while let Some(changes) = changes_rx.next().await {
if this
.update(&mut cx, |this, cx| {
this.extension_changes.merge(changes);
this.reload(cx);
})
.is_err()
{
break;
@@ -259,123 +533,179 @@ impl ExtensionStore {
}
});
[events_task, reload_theme_task]
[events_task, reload_task]
}
pub fn reload(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
fn reload(&mut self, cx: &mut ModelContext<Self>) {
if self.reload_task.is_some() {
self.needs_reload = true;
return;
}
let fs = self.fs.clone();
let extensions_dir = self.extensions_dir.clone();
let manifest_path = self.manifest_path.clone();
cx.spawn(|this, mut cx| async move {
let manifest = cx
.background_executor()
.spawn(async move {
let mut manifest = Manifest::default();
self.needs_reload = false;
self.reload_task = Some(cx.spawn(|this, mut cx| {
async move {
let manifest = cx
.background_executor()
.spawn(async move {
let mut manifest = Manifest::default();
let mut extension_paths = fs
.read_dir(&extensions_dir)
.await
.context("failed to read extensions directory")?;
while let Some(extension_dir) = extension_paths.next().await {
let extension_dir = extension_dir?;
let Some(extension_name) =
extension_dir.file_name().and_then(OsStr::to_str)
else {
continue;
};
fs.create_dir(&extensions_dir).await.log_err();
if let Ok(mut grammar_paths) =
fs.read_dir(&extension_dir.join("grammars")).await
{
while let Some(grammar_path) = grammar_paths.next().await {
let grammar_path = grammar_path?;
let Ok(relative_path) = grammar_path.strip_prefix(&extension_dir)
else {
let extension_paths = fs.read_dir(&extensions_dir).await;
if let Ok(mut extension_paths) = extension_paths {
while let Some(extension_dir) = extension_paths.next().await {
let Ok(extension_dir) = extension_dir else {
continue;
};
let Some(grammar_name) =
grammar_path.file_stem().and_then(OsStr::to_str)
else {
continue;
};
manifest.grammars.insert(
grammar_name.into(),
GrammarManifestEntry {
extension: extension_name.into(),
path: relative_path.into(),
},
);
Self::add_extension_to_manifest(
fs.clone(),
extension_dir,
&mut manifest,
)
.await
.log_err();
}
}
if let Ok(mut language_paths) =
fs.read_dir(&extension_dir.join("languages")).await
{
while let Some(language_path) = language_paths.next().await {
let language_path = language_path?;
let Ok(relative_path) = language_path.strip_prefix(&extension_dir)
else {
continue;
};
let config = fs.load(&language_path.join("config.toml")).await?;
let config = ::toml::from_str::<LanguageConfig>(&config)?;
manifest.languages.insert(
config.name.clone(),
LanguageManifestEntry {
extension: extension_name.into(),
path: relative_path.into(),
matcher: config.matcher,
grammar: config.grammar,
},
);
}
if let Ok(manifest_json) = serde_json::to_string_pretty(&manifest) {
fs.save(
&manifest_path,
&manifest_json.as_str().into(),
Default::default(),
)
.await
.context("failed to save extension manifest")
.log_err();
}
if let Ok(mut theme_paths) =
fs.read_dir(&extension_dir.join("themes")).await
{
while let Some(theme_path) = theme_paths.next().await {
let theme_path = theme_path?;
let Ok(relative_path) = theme_path.strip_prefix(&extension_dir)
else {
continue;
};
manifest
})
.await;
let Some(theme_family) =
ThemeRegistry::read_user_theme(&theme_path, fs.clone())
.await
.log_err()
else {
continue;
};
for theme in theme_family.themes {
let location = ThemeManifestEntry {
extension: extension_name.into(),
path: relative_path.into(),
};
manifest.themes.insert(theme.name, location);
}
}
}
this.update(&mut cx, |this, cx| {
this.manifest_updated(manifest, cx);
this.reload_task.take();
if this.needs_reload {
this.reload(cx);
}
fs.save(
&manifest_path,
&serde_json::to_string_pretty(&manifest)?.as_str().into(),
Default::default(),
)
.await
.context("failed to save extension manifest")?;
anyhow::Ok(manifest)
})
.await?;
this.update(&mut cx, |this, cx| this.manifest_updated(manifest, cx))
})
}
.log_err()
}));
}
async fn add_extension_to_manifest(
fs: Arc<dyn Fs>,
extension_dir: PathBuf,
manifest: &mut Manifest,
) -> Result<()> {
let extension_name = extension_dir
.file_name()
.and_then(OsStr::to_str)
.ok_or_else(|| anyhow!("invalid extension name"))?;
#[derive(Deserialize)]
struct ExtensionJson {
pub version: String,
}
let extension_json_path = extension_dir.join("extension.json");
let extension_json = fs
.load(&extension_json_path)
.await
.context("failed to load extension.json")?;
let extension_json: ExtensionJson =
serde_json::from_str(&extension_json).context("invalid extension.json")?;
manifest
.extensions
.insert(extension_name.into(), extension_json.version.into());
if let Ok(mut grammar_paths) = fs.read_dir(&extension_dir.join("grammars")).await {
while let Some(grammar_path) = grammar_paths.next().await {
let grammar_path = grammar_path?;
let Ok(relative_path) = grammar_path.strip_prefix(&extension_dir) else {
continue;
};
let Some(grammar_name) = grammar_path.file_stem().and_then(OsStr::to_str) else {
continue;
};
manifest.grammars.insert(
grammar_name.into(),
GrammarManifestEntry {
extension: extension_name.into(),
path: relative_path.into(),
},
);
}
}
if let Ok(mut language_paths) = fs.read_dir(&extension_dir.join("languages")).await {
while let Some(language_path) = language_paths.next().await {
let language_path = language_path?;
let Ok(relative_path) = language_path.strip_prefix(&extension_dir) else {
continue;
};
let config = fs.load(&language_path.join("config.toml")).await?;
let config = ::toml::from_str::<LanguageConfig>(&config)?;
manifest.languages.insert(
config.name.clone(),
LanguageManifestEntry {
extension: extension_name.into(),
path: relative_path.into(),
matcher: config.matcher,
grammar: config.grammar,
},
);
}
}
if let Ok(mut theme_paths) = fs.read_dir(&extension_dir.join("themes")).await {
while let Some(theme_path) = theme_paths.next().await {
let theme_path = theme_path?;
let Ok(relative_path) = theme_path.strip_prefix(&extension_dir) else {
continue;
};
let Some(theme_family) = ThemeRegistry::read_user_theme(&theme_path, fs.clone())
.await
.log_err()
else {
continue;
};
for theme in theme_family.themes {
let location = ThemeManifestEntry {
extension: extension_name.into(),
path: relative_path.into(),
};
manifest.themes.insert(theme.name.into(), location);
}
}
}
Ok(())
}
}
impl ExtensionChanges {
fn clear(&mut self) {
self.grammars.clear();
self.languages.clear();
self.themes.clear();
}
fn merge(&mut self, other: Self) {
self.grammars.extend(other.grammars);
self.languages.extend(other.languages);
self.themes.extend(other.themes);
}
}

View File

@@ -5,18 +5,32 @@ use fs::FakeFs;
use gpui::{Context, TestAppContext};
use language::{LanguageMatcher, LanguageRegistry};
use serde_json::json;
use settings::SettingsStore;
use std::{path::PathBuf, sync::Arc};
use theme::ThemeRegistry;
use util::http::FakeHttpClient;
#[gpui::test]
async fn test_extension_store(cx: &mut TestAppContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
theme::init(theme::LoadThemes::JustBase, cx);
});
let fs = FakeFs::new(cx.executor());
let http_client = FakeHttpClient::with_200_response();
fs.insert_tree(
"/the-extension-dir",
json!({
"installed": {
"zed-monokai": {
"extension.json": r#"{
"id": "zed-monokai",
"name": "Zed Monokai",
"version": "2.0.0"
}"#,
"themes": {
"monokai.json": r#"{
"name": "Monokai",
@@ -53,6 +67,11 @@ async fn test_extension_store(cx: &mut TestAppContext) {
}
},
"zed-ruby": {
"extension.json": r#"{
"id": "zed-ruby",
"name": "Zed Ruby",
"version": "1.0.0"
}"#,
"grammars": {
"ruby.wasm": "",
"embedded_template.wasm": "",
@@ -82,6 +101,12 @@ async fn test_extension_store(cx: &mut TestAppContext) {
.await;
let mut expected_manifest = Manifest {
extensions: [
("zed-ruby".into(), "1.0.0".into()),
("zed-monokai".into(), "2.0.0".into()),
]
.into_iter()
.collect(),
grammars: [
(
"embedded_template".into(),
@@ -169,6 +194,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
ExtensionStore::new(
PathBuf::from("/the-extension-dir"),
fs.clone(),
http_client.clone(),
language_registry.clone(),
theme_registry.clone(),
cx,
@@ -201,6 +227,11 @@ async fn test_extension_store(cx: &mut TestAppContext) {
fs.insert_tree(
"/the-extension-dir/installed/zed-gruvbox",
json!({
"extension.json": r#"{
"id": "zed-gruvbox",
"name": "Zed Gruvbox",
"version": "1.0.0"
}"#,
"themes": {
"gruvbox.json": r#"{
"name": "Gruvbox",
@@ -226,10 +257,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
},
);
store
.update(cx, |store, cx| store.reload(cx))
.await
.unwrap();
store.update(cx, |store, cx| store.reload(cx));
cx.executor().run_until_parked();
store.read_with(cx, |store, _| {
@@ -260,6 +288,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
ExtensionStore::new(
PathBuf::from("/the-extension-dir"),
fs.clone(),
http_client.clone(),
language_registry.clone(),
theme_registry.clone(),
cx,
@@ -277,6 +306,10 @@ async fn test_extension_store(cx: &mut TestAppContext) {
language_registry.language_names(),
["ERB", "Plain Text", "Ruby"]
);
assert_eq!(
language_registry.grammar_names(),
["embedded_template".into(), "ruby".into()]
);
assert_eq!(
theme_registry.list_names(false),
[
@@ -294,4 +327,25 @@ async fn test_extension_store(cx: &mut TestAppContext) {
assert_eq!(fs.read_dir_call_count(), prev_fs_read_dir_call_count);
assert_eq!(fs.metadata_call_count(), prev_fs_metadata_call_count + 2);
});
store.update(cx, |store, cx| {
store.uninstall_extension("zed-ruby".into(), cx)
});
cx.executor().run_until_parked();
expected_manifest.extensions.remove("zed-ruby");
expected_manifest.languages.remove("Ruby");
expected_manifest.languages.remove("ERB");
expected_manifest.grammars.remove("ruby");
expected_manifest.grammars.remove("embedded_template");
store.read_with(cx, |store, _| {
let manifest = store.manifest.read();
assert_eq!(manifest.grammars, expected_manifest.grammars);
assert_eq!(manifest.languages, expected_manifest.languages);
assert_eq!(manifest.themes, expected_manifest.themes);
assert_eq!(language_registry.language_names(), ["Plain Text"]);
assert_eq!(language_registry.grammar_names(), []);
});
}

View File

@@ -0,0 +1,38 @@
[package]
name = "extensions_ui"
version = "0.1.0"
edition = "2021"
publish = false
license = "GPL-3.0-or-later"
[lib]
path = "src/extensions_ui.rs"
[features]
test-support = []
[dependencies]
anyhow.workspace = true
async-compression.workspace = true
async-tar.workspace = true
client.workspace = true
db.workspace = true
editor.workspace = true
extension.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
log.workspace = true
picker.workspace = true
project.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,478 @@
use client::telemetry::Telemetry;
use editor::{Editor, EditorElement, EditorStyle};
use extension::{Extension, ExtensionStatus, ExtensionStore};
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,
};
use settings::Settings;
use std::time::Duration;
use std::{ops::Range, sync::Arc};
use theme::ThemeSettings;
use ui::prelude::*;
use workspace::{
item::{Item, ItemEvent},
Workspace, WorkspaceId,
};
actions!(zed, [Extensions]);
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(Box::new(extensions_page), cx)
});
})
.detach();
}
pub struct ExtensionsPage {
list: UniformListScrollHandle,
telemetry: Arc<Telemetry>,
is_fetching_extensions: bool,
extensions_entries: Vec<Extension>,
query_editor: View<Editor>,
query_contains_error: bool,
_subscription: gpui::Subscription,
extension_fetch_task: Option<Task<()>>,
}
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 query_editor = cx.new_view(|cx| Editor::single_line(cx));
cx.subscribe(&query_editor, Self::on_query_change).detach();
let mut this = Self {
list: UniformListScrollHandle::new(),
telemetry: workspace.client().telemetry().clone(),
is_fetching_extensions: false,
extensions_entries: Vec::new(),
query_contains_error: false,
extension_fetch_task: None,
_subscription: subscription,
query_editor,
};
this.fetch_extensions(None, cx);
this
})
}
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)
});
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>) {
self.is_fetching_extensions = true;
cx.notify();
let extensions =
ExtensionStore::global(cx).update(cx, |store, cx| store.fetch_extensions(search, 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.extensions_entries = extensions;
this.is_fetching_extensions = false;
cx.notify();
}),
Err(err) => {
this.update(&mut cx, |this, cx| {
this.is_fetching_extensions = false;
cx.notify();
})
.ok();
Err(err)
}
}
})
.detach_and_log_err(cx);
}
fn render_extensions(&mut self, range: Range<usize>, cx: &mut ViewContext<Self>) -> Vec<Div> {
self.extensions_entries[range]
.iter()
.map(|extension| self.render_entry(extension, cx))
.collect()
}
fn render_entry(&self, extension: &Extension, cx: &mut ViewContext<Self>) -> Div {
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",
)
.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);
}
}))
.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()), "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());
this.install_extension(extension_id.clone(), version.clone(), cx);
}
}))
.disabled(matches!(status, ExtensionStatus::Installing))
}
ExtensionStatus::Installed(_)
| ExtensionStatus::Upgrading
| ExtensionStatus::Removing => {
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());
this.uninstall_extension(extension_id.clone(), cx);
}
}))
.disabled(matches!(
status,
ExtensionStatus::Upgrading | ExtensionStatus::Removing
))
}
}
.color(Color::Accent);
let repository_url = extension.repository.clone();
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(", ")
))
.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,
)
.icon_color(Color::Accent)
.icon_size(IconSize::Small)
.style(ButtonStyle::Filled)
.on_click(cx.listener(move |_, _, cx| {
cx.open_url(&repository_url);
})),
),
),
)
}
fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
let mut key_context = KeyContext::default();
key_context.add("BufferSearchBar");
let editor_border = if self.query_contains_error {
Color::Error.color(cx)
} else {
cx.theme().colors().border
};
h_flex()
.w_full()
.gap_2()
.key_context(key_context)
// .capture_action(cx.listener(Self::tab))
// .on_action(cx.listener(Self::dismiss))
.child(
h_flex()
.flex_1()
.px_2()
.py_1()
.gap_2()
.border_1()
.border_color(editor_border)
.min_w(rems(384. / 16.))
.rounded_lg()
.child(Icon::new(IconName::MagnifyingGlass))
.child(self.render_text_input(&self.query_editor, cx)),
)
}
fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
let settings = ThemeSettings::get_global(cx);
let text_style = TextStyle {
color: if editor.read(cx).read_only(cx) {
cx.theme().colors().text_disabled
} else {
cx.theme().colors().text
},
font_family: settings.ui_font.family.clone(),
font_features: settings.ui_font.features,
font_size: rems(0.875).into(),
font_weight: FontWeight::NORMAL,
font_style: FontStyle::Normal,
line_height: relative(1.3).into(),
background_color: None,
underline: None,
strikethrough: None,
white_space: WhiteSpace::Normal,
};
EditorElement::new(
&editor,
EditorStyle {
background: cx.theme().colors().editor_background,
local_player: cx.theme().players().local(),
text: text_style,
..Default::default()
},
)
}
fn on_query_change(
&mut self,
_: View<Editor>,
event: &editor::EditorEvent,
cx: &mut ViewContext<Self>,
) {
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();
}));
}
}
pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
let search = self.query_editor.read(cx).text(cx);
if search.trim().is_empty() {
None
} else {
Some(search)
}
}
}
impl Render for ExtensionsPage {
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
v_flex()
.size_full()
.p_4()
.gap_4()
.bg(cx.theme().colors().editor_background)
.child(
h_flex()
.w_full()
.child(Headline::new("Extensions").size(HeadlineSize::XLarge)),
)
.child(h_flex().w_56().child(self.render_search(cx)))
.child(v_flex().size_full().overflow_y_hidden().map(|this| {
if self.extensions_entries.is_empty() {
let message = if self.is_fetching_extensions {
"Loading extensions..."
} else if self.search_query(cx).is_some() {
"No extensions that match your search."
} else {
"No extensions."
};
return this.child(Label::new(message));
}
this.child(
canvas({
let view = cx.view().clone();
let scroll_handle = self.list.clone();
let item_count = self.extensions_entries.len();
move |bounds, cx| {
uniform_list::<_, Div, _>(
view,
"entries",
item_count,
Self::render_extensions,
)
.size_full()
.track_scroll(scroll_handle)
.into_any_element()
.draw(
bounds.origin,
bounds.size.map(AvailableSpace::Definite),
cx,
)
}
})
.size_full(),
)
}))
}
}
impl EventEmitter<ItemEvent> for ExtensionsPage {}
impl FocusableView for ExtensionsPage {
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
self.query_editor.read(cx).focus_handle(cx)
}
}
impl Item for ExtensionsPage {
type Event = ItemEvent;
fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
Label::new("Extensions")
.color(if selected {
Color::Default
} else {
Color::Muted
})
.into_any_element()
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("extensions page")
}
fn show_toolbar(&self) -> bool {
false
}
fn clone_on_split(
&self,
_workspace_id: WorkspaceId,
_: &mut ViewContext<Self>,
) -> Option<View<Self>> {
None
}
fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
f(*event)
}
}

View File

@@ -187,6 +187,7 @@ impl FeedbackModal {
editor.set_show_gutter(false, cx);
editor.set_show_copilot_suggestions(false);
editor.set_vertical_scroll_margin(5, cx);
editor.set_use_modal_editing(false);
editor
});

View File

@@ -26,7 +26,7 @@ use std::{
pin::Pin,
time::{Duration, SystemTime},
};
use tempfile::NamedTempFile;
use tempfile::{NamedTempFile, TempDir};
use text::LineEnding;
use util::ResultExt;
@@ -66,6 +66,7 @@ pub trait Fs: Send + Sync {
fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<Mutex<dyn GitRepository>>>;
fn is_fake(&self) -> bool;
async fn is_case_sensitive(&self) -> Result<bool>;
#[cfg(any(test, feature = "test-support"))]
fn as_fake(&self) -> &FakeFs;
}
@@ -339,6 +340,44 @@ impl Fs for RealFs {
fn is_fake(&self) -> bool {
false
}
/// Checks whether the file system is case sensitive by attempting to create two files
/// that have the same name except for the casing.
///
/// It creates both files in a temporary directory it removes at the end.
async fn is_case_sensitive(&self) -> Result<bool> {
let temp_dir = TempDir::new()?;
let test_file_1 = temp_dir.path().join("case_sensitivity_test.tmp");
let test_file_2 = temp_dir.path().join("CASE_SENSITIVITY_TEST.TMP");
let create_opts = CreateOptions {
overwrite: false,
ignore_if_exists: false,
};
// Create file1
self.create_file(&test_file_1, create_opts).await?;
// Now check whether it's possible to create file2
let case_sensitive = match self.create_file(&test_file_2, create_opts).await {
Ok(_) => Ok(true),
Err(e) => {
if let Some(io_error) = e.downcast_ref::<io::Error>() {
if io_error.kind() == io::ErrorKind::AlreadyExists {
Ok(false)
} else {
Err(e)
}
} else {
Err(e)
}
}
};
temp_dir.close()?;
case_sensitive
}
#[cfg(any(test, feature = "test-support"))]
fn as_fake(&self) -> &FakeFs {
panic!("called `RealFs::as_fake`")
@@ -1186,6 +1225,10 @@ impl Fs for FakeFs {
true
}
async fn is_case_sensitive(&self) -> Result<bool> {
Ok(true)
}
#[cfg(any(test, feature = "test-support"))]
fn as_fake(&self) -> &FakeFs {
self

View File

@@ -68,6 +68,7 @@ usvg = { version = "0.14", features = [] }
util.workspace = true
uuid = { version = "1.1.2", features = ["v4"] }
waker-fn = "1.1.0"
accesskit = { version = "0.12" }
[dev-dependencies]
backtrace = "0.3"
@@ -93,13 +94,21 @@ log.workspace = true
media.workspace = true
metal = "0.21.0"
objc = "0.2"
accesskit_macos = "0.11.0"
[target.'cfg(target_os = "linux")'.dependencies]
flume = "0.11"
xcb = { version = "1.3", features = ["as-raw-xcb-connection"] }
open = "5.0.1"
ashpd = "0.7.0"
# todo!(linux) - Technically do not use `randr`, but it doesn't compile otherwise
xcb = { version = "1.3", features = ["as-raw-xcb-connection", "present", "randr", "xkb"] }
wayland-client= { version = "0.31.2" }
wayland-protocols = { version = "0.31.2", features = ["client"] }
wayland-backend = { version = "0.3.3", features = ["client_system"] }
as-raw-xcb-connection = "1"
#TODO: use these on all platforms
blade-graphics = { git = "https://github.com/kvark/blade", rev = "c4f951a88b345724cb952e920ad30e39851f7760" }
blade-macros = { git = "https://github.com/kvark/blade", rev = "c4f951a88b345724cb952e920ad30e39851f7760" }
bytemuck = "1"
cosmic-text = "0.10.0"
cosmic-text = "0.10.0"
xkbcommon = { version = "0.7", features = ["x11"] }

View File

@@ -0,0 +1,58 @@
use std::hash::{Hash, Hasher};
use collections::{hash_map::Entry, HashMap};
use crate::{BorrowWindow, ElementContext, ElementId, GlobalElementId, WindowContext};
pub type AccessKitState = HashMap<accesskit::NodeId, accesskit::NodeBuilder>;
impl From<&GlobalElementId> for accesskit::NodeId {
fn from(value: &GlobalElementId) -> Self {
let mut hasher = std::hash::DefaultHasher::new();
value.0.hash(&mut hasher);
accesskit::NodeId(hasher.finish())
}
}
impl<'a> ElementContext<'a> {
// TODO: What's a good, useful signature for this? Need to expose this from the div as well.
fn accesskit_action(&mut self, id: impl Into<ElementId>, action: accesskit::Action, f: impl FnOnce(accesskit::ActionRequest)) {
self.with_element_id(Some(id), |cx| {
// Get the access kit actions from somewhere
// call f with the action request and cx
// egui impl:
// let accesskit_id = id.accesskit_id();
// self.events.iter().filter_map(move |event| {
// if let Event::AccessKitActionRequest(request) = event {
// if request.target == accesskit_id && request.action == action {
// return Some(request);
// }
// }
// None
// })
})
}
// TODO: Expose this through the div API
fn with_accesskit_node(&mut self, id: impl Into<ElementId>, f: impl FnOnce(&mut accesskit::NodeBuilder)) {
let id = id.into();
let window = self.window_mut();
let parent_id: accesskit::NodeId = (&window.element_id_stack).into();
self.with_element_id(Some(id), |cx| {
let window = cx.window_mut();
let this_id: accesskit::NodeId = (&window.element_id_stack).into();
window.next_frame.accesskit.as_mut().map(|nodes| {
if let Entry::Vacant(entry) = nodes.entry(this_id) {
entry.insert(Default::default());
let parent = nodes.get_mut(&parent_id).unwrap();
parent.push_child(this_id);
}
f(nodes.get_mut(&this_id).unwrap());
})
});
}
}

View File

@@ -1,33 +1,3 @@
mod async_context;
mod entity_map;
mod model_context;
#[cfg(any(test, feature = "test-support"))]
mod test_context;
pub use async_context::*;
use derive_more::{Deref, DerefMut};
pub use entity_map::*;
pub use model_context::*;
use refineable::Refineable;
use smol::future::FutureExt;
#[cfg(any(test, feature = "test-support"))]
pub use test_context::*;
use time::UtcOffset;
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,
};
use anyhow::{anyhow, Result};
use collections::{FxHashMap, FxHashSet, VecDeque};
use futures::{channel::oneshot, future::LocalBoxFuture, Future};
use slotmap::SlotMap;
use std::{
any::{type_name, TypeId},
cell::{Ref, RefCell, RefMut},
@@ -38,11 +8,42 @@ use std::{
sync::{atomic::Ordering::SeqCst, Arc},
time::Duration,
};
use anyhow::{anyhow, Result};
use derive_more::{Deref, DerefMut};
use futures::{channel::oneshot, future::LocalBoxFuture, Future};
use slotmap::SlotMap;
use smol::future::FutureExt;
use time::UtcOffset;
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::{
http::{self, HttpClient},
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,
};
mod async_context;
mod entity_map;
mod model_context;
#[cfg(any(test, feature = "test-support"))]
mod test_context;
/// The duration for which futures returned from [AppContext::on_app_context] or [ModelContext::on_app_quit] can run before the application fully quits.
pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
@@ -111,6 +112,9 @@ impl App {
/// Builds an app with the given asset source.
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
#[cfg(any(test, feature = "test-support"))]
log::info!("GPUI was compiled in test mode");
Self(AppContext::new(
current_platform(),
Arc::new(()),
@@ -237,6 +241,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) screen_reader_enabled: bool,
}
impl AppContext {
@@ -295,6 +300,7 @@ impl AppContext {
quit_observers: SubscriberSet::new(),
layout_id_buffer: Default::default(),
propagate_event: true,
screen_reader_enabled: false,
}),
});
@@ -310,6 +316,7 @@ impl AppContext {
app
}
/// Quit the application gracefully. Handlers registered with [`ModelContext::on_app_quit`]
/// will be given 100ms to complete before exiting.
pub fn shutdown(&mut self) {
@@ -676,7 +683,6 @@ impl AppContext {
self.update_window(window, |_, cx| cx.draw()).unwrap();
}
#[allow(clippy::collapsible_else_if)]
if self.pending_effects.is_empty() {
break;
}

View File

@@ -38,9 +38,10 @@ use crate::{
util::FluentBuilder, ArenaBox, AvailableSpace, Bounds, ElementContext, ElementId, LayoutId,
Pixels, Point, Size, ViewContext, WindowContext, ELEMENT_ARENA,
};
use collections::FxHashSet;
use derive_more::{Deref, DerefMut};
pub(crate) use smallvec::SmallVec;
use std::{any::{type_name_of_val, Any}, fmt::Debug, ops::DerefMut};
use std::{any::Any, fmt::Debug, hash::{Hash, Hasher, SipHasher}, 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.
@@ -59,19 +60,6 @@ pub trait Element: 'static + IntoElement {
cx: &mut ElementContext,
) -> (LayoutId, Self::State);
/// After layout is performed, we assign each element its bounds. Some elements may
/// need to use these bounds to render and layout the appropriate children. This is
/// also each element's opportunity to add opaque layers before painting [`crate::window::element_cx::add_opaque_layer`].
fn post_layout(
&mut self,
_bounds: Bounds<Pixels>,
_state: &mut Self::State,
_cx: &mut ElementContext,
) {
dbg!(type_name_of_val(self));
// TODO: Delete this default implementation once we've implemented it everywhere.
}
/// 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);
@@ -235,15 +223,14 @@ impl<C: RenderOnce> IntoElement for Component<C> {
/// A globally unique identifier for an element, used to track state across frames.
#[derive(Deref, DerefMut, Default, Clone, Debug, Eq, PartialEq, Hash)]
pub(crate) struct GlobalElementId(SmallVec<[ElementId; 32]>);
pub(crate) struct GlobalElementId(pub(crate) SmallVec<[ElementId; 32]>);
trait ElementObject {
fn element_id(&self) -> Option<ElementId>;
fn request_layout(&mut self, cx: &mut ElementContext) -> LayoutId;
fn post_layout(&mut self, cx: &mut ElementContext);
fn paint(&mut self, cx: &mut ElementContext);
fn measure(
@@ -316,44 +303,6 @@ impl<E: Element> DrawableElement<E> {
layout_id
}
fn post_layout(&mut self, cx: &mut ElementContext) {
println!("DrawableElement post_layout");
match &mut self.phase {
ElementDrawPhase::LayoutRequested {
layout_id,
frame_state,
..
}
| ElementDrawPhase::LayoutComputed {
layout_id,
frame_state,
..
} => {
let bounds = cx.layout_bounds(*layout_id);
let element = self.element.as_mut().unwrap();
if let Some(frame_state) = frame_state.as_mut() {
println!("Some");
element.post_layout(bounds, frame_state, cx);
} else {
println!("Else");
let element_id = element
.element_id()
.expect("if we don't have frame state, we should have element state");
cx.with_element_state(element_id, |element_state, cx| {
println!("with_element_state");
let mut element_state = element_state.unwrap();
element.post_layout(bounds, &mut element_state, cx);
((), element_state)
});
}
}
_ => panic!("must call layout before post_layout"),
}
}
fn paint(mut self, cx: &mut ElementContext) -> Option<E::State> {
match self.phase {
ElementDrawPhase::LayoutRequested {
@@ -460,11 +409,6 @@ where
DrawableElement::request_layout(self.as_mut().unwrap(), cx)
}
fn post_layout(&mut self, cx: &mut ElementContext) {
println!("ElementObject post_layout");
DrawableElement::post_layout(self.as_mut().unwrap(), cx)
}
fn paint(&mut self, cx: &mut ElementContext) {
DrawableElement::paint(self.take().unwrap(), cx);
}
@@ -508,12 +452,6 @@ impl AnyElement {
self.0.request_layout(cx)
}
/// TODO
pub fn post_layout(&mut self, cx: &mut ElementContext) {
println!("AnyElement post_layout");
self.0.post_layout(cx)
}
/// Paints the element stored in this `AnyElement`.
pub fn paint(&mut self, cx: &mut ElementContext) {
self.0.paint(cx)
@@ -555,16 +493,6 @@ impl Element for AnyElement {
let layout_id = self.request_layout(cx);
(layout_id, ())
}
fn post_layout(
&mut self,
_bounds: Bounds<Pixels>,
_state: &mut Self::State,
cx: &mut ElementContext,
) {
self.0.post_layout(cx);
}
fn paint(&mut self, _: Bounds<Pixels>, _: &mut Self::State, cx: &mut ElementContext) {
self.paint(cx)

View File

@@ -88,7 +88,7 @@ impl Interactivity {
.push(Box::new(move |event, bounds, phase, cx| {
if phase == DispatchPhase::Bubble
&& event.button == button
&& bounds.did_visibly_contains(&event.position, cx)
&& bounds.visibly_contains(&event.position, cx)
{
(listener)(event, cx)
}
@@ -105,9 +105,7 @@ impl Interactivity {
) {
self.mouse_down_listeners
.push(Box::new(move |event, bounds, phase, cx| {
if phase == DispatchPhase::Capture
&& bounds.did_visibly_contains(&event.position, cx)
{
if phase == DispatchPhase::Capture && bounds.visibly_contains(&event.position, cx) {
(listener)(event, cx)
}
}));
@@ -123,9 +121,7 @@ impl Interactivity {
) {
self.mouse_down_listeners
.push(Box::new(move |event, bounds, phase, cx| {
if phase == DispatchPhase::Bubble
&& bounds.did_visibly_contains(&event.position, cx)
{
if phase == DispatchPhase::Bubble && bounds.visibly_contains(&event.position, cx) {
(listener)(event, cx)
}
}));
@@ -144,7 +140,7 @@ impl Interactivity {
.push(Box::new(move |event, bounds, phase, cx| {
if phase == DispatchPhase::Bubble
&& event.button == button
&& bounds.did_visibly_contains(&event.position, cx)
&& bounds.visibly_contains(&event.position, cx)
{
(listener)(event, cx)
}
@@ -161,9 +157,7 @@ impl Interactivity {
) {
self.mouse_up_listeners
.push(Box::new(move |event, bounds, phase, cx| {
if phase == DispatchPhase::Capture
&& bounds.did_visibly_contains(&event.position, cx)
{
if phase == DispatchPhase::Capture && bounds.visibly_contains(&event.position, cx) {
(listener)(event, cx)
}
}));
@@ -179,9 +173,7 @@ impl Interactivity {
) {
self.mouse_up_listeners
.push(Box::new(move |event, bounds, phase, cx| {
if phase == DispatchPhase::Bubble
&& bounds.did_visibly_contains(&event.position, cx)
{
if phase == DispatchPhase::Bubble && bounds.visibly_contains(&event.position, cx) {
(listener)(event, cx)
}
}));
@@ -198,8 +190,7 @@ impl Interactivity {
) {
self.mouse_down_listeners
.push(Box::new(move |event, bounds, phase, cx| {
if phase == DispatchPhase::Capture
&& !bounds.did_visibly_contains(&event.position, cx)
if phase == DispatchPhase::Capture && !bounds.visibly_contains(&event.position, cx)
{
(listener)(event, cx)
}
@@ -220,7 +211,7 @@ impl Interactivity {
.push(Box::new(move |event, bounds, phase, cx| {
if phase == DispatchPhase::Capture
&& event.button == button
&& !bounds.did_visibly_contains(&event.position, cx)
&& !bounds.visibly_contains(&event.position, cx)
{
(listener)(event, cx);
}
@@ -237,9 +228,7 @@ impl Interactivity {
) {
self.mouse_move_listeners
.push(Box::new(move |event, bounds, phase, cx| {
if phase == DispatchPhase::Bubble
&& bounds.did_visibly_contains(&event.position, cx)
{
if phase == DispatchPhase::Bubble && bounds.visibly_contains(&event.position, cx) {
(listener)(event, cx);
}
}));
@@ -288,9 +277,7 @@ impl Interactivity {
) {
self.scroll_wheel_listeners
.push(Box::new(move |event, bounds, phase, cx| {
if phase == DispatchPhase::Bubble
&& bounds.did_visibly_contains(&event.position, cx)
{
if phase == DispatchPhase::Bubble && bounds.visibly_contains(&event.position, cx) {
(listener)(event, cx);
}
}));
@@ -577,7 +564,7 @@ pub trait InteractiveElement: Sized {
self
}
// #[cfg(any(test, feature = "test-support"))]
#[cfg(any(test, feature = "test-support"))]
/// Set a key that can be used to look up this element's bounds
/// in the [`VisualTestContext::debug_bounds`] map
/// This is a noop in release builds
@@ -586,14 +573,14 @@ pub trait InteractiveElement: Sized {
self
}
// #[cfg(not(any(test, feature = "test-support")))]
// /// Set a key that can be used to look up this element's bounds
// /// in the [`VisualTestContext::debug_bounds`] map
// /// This is a noop in release builds
// #[inline]
// fn debug_selector(self, _: impl FnOnce() -> String) -> Self {
// self
// }
#[cfg(not(any(test, feature = "test-support")))]
/// Set a key that can be used to look up this element's bounds
/// in the [`VisualTestContext::debug_bounds`] map
/// This is a noop in release builds
#[inline]
fn debug_selector(self, _: impl FnOnce() -> String) -> Self {
self
}
/// Bind the given callback to the mouse down event for any button, during the capture phase
/// the fluent API equivalent to [`Interactivity::capture_any_mouse_down`]
@@ -1085,7 +1072,6 @@ impl Element for Div {
})
},
);
(
layout_id,
DivState {
@@ -1095,30 +1081,6 @@ impl Element for Div {
)
}
fn post_layout(
&mut self,
bounds: Bounds<Pixels>,
element_state: &mut Self::State,
cx: &mut ElementContext,
) {
dbg!(self.children.len());
self.interactivity.post_layout(
bounds,
&mut element_state.interactive_state,
cx,
|_, scroll_offset, cx| {
println!("in continuation");
cx.with_element_offset(scroll_offset, |cx| {
println!("in with_element_offset");
for child in &mut self.children {
println!("child");
child.post_layout(cx);
}
})
},
);
}
fn paint(
&mut self,
bounds: Bounds<Pixels>,
@@ -1245,7 +1207,7 @@ pub struct Interactivity {
#[cfg(debug_assertions)]
pub(crate) location: Option<core::panic::Location<'static>>,
// #[cfg(any(test, feature = "test-support"))]
#[cfg(any(test, feature = "test-support"))]
pub(crate) debug_selector: Option<String>,
}
@@ -1260,7 +1222,7 @@ pub struct InteractiveBounds {
impl InteractiveBounds {
/// Checks whether this point was inside these bounds, and that these bounds where the topmost layer
pub fn did_visibly_contains(&self, point: &Point<Pixels>, cx: &WindowContext) -> bool {
pub fn visibly_contains(&self, point: &Point<Pixels>, cx: &WindowContext) -> bool {
self.bounds.contains(point) && cx.was_top_layer(point, &self.stacking_order)
}
@@ -1268,7 +1230,7 @@ impl InteractiveBounds {
/// under an active drag
pub fn drag_target_contains(&self, point: &Point<Pixels>, cx: &WindowContext) -> bool {
self.bounds.contains(point)
&& cx.is_top_layer_under_active_drag(point, &self.stacking_order)
&& cx.was_top_layer_under_active_drag(point, &self.stacking_order)
}
}
@@ -1312,29 +1274,6 @@ impl Interactivity {
(layout_id, element_state)
}
/// TODO
pub fn post_layout(
&mut self,
bounds: Bounds<Pixels>,
element_state: &mut InteractiveElementState,
cx: &mut ElementContext,
continuation: impl FnOnce(Bounds<Pixels>, Point<Pixels>, &mut ElementContext),
) {
let z_index = self.base_style.z_index.unwrap_or(0);
cx.with_z_index(z_index, |cx| {
println!("interactivity post_layout is adding an opaque layer");
cx.add_opaque_layer(bounds.intersect(&cx.content_mask().bounds));
let scroll_offset = element_state
.scroll_offset
.as_ref()
.map(|scroll_offset| *scroll_offset.borrow())
.unwrap_or_default();
continuation(bounds, scroll_offset, cx);
});
}
/// Paint this element according to this interactivity state's configured styles
/// and bind the element's mouse and keyboard events.
///
@@ -1349,8 +1288,11 @@ impl Interactivity {
content_size: Size<Pixels>,
element_state: &mut InteractiveElementState,
cx: &mut ElementContext,
continuation: impl FnOnce(&Style, Point<Pixels>, &mut ElementContext),
f: impl FnOnce(&Style, Point<Pixels>, &mut ElementContext),
) {
let style = self.compute_style(Some(bounds), element_state, cx);
let z_index = style.z_index.unwrap_or(0);
#[cfg(any(feature = "test-support", test))]
if let Some(debug_selector) = &self.debug_selector {
cx.window
@@ -1359,8 +1301,8 @@ impl Interactivity {
.insert(debug_selector.clone(), bounds);
}
let paint_hover_group_handler = |this: &Self, cx: &mut ElementContext| {
let hover_group_bounds = this
let paint_hover_group_handler = |cx: &mut ElementContext| {
let hover_group_bounds = self
.group_hover_style
.as_ref()
.and_then(|group_hover| GroupBounds::get(&group_hover.group, cx));
@@ -1377,15 +1319,12 @@ impl Interactivity {
}
};
let z_index = self.base_style.z_index.unwrap_or(0);
if style.visibility == Visibility::Hidden {
cx.with_z_index(z_index, |cx| paint_hover_group_handler(cx));
return;
}
cx.with_z_index(z_index, |cx| {
let style = self.compute_style(Some(bounds), element_state, cx);
if style.visibility == Visibility::Hidden {
paint_hover_group_handler(self, cx);
return;
}
style.paint(bounds, cx, |cx: &mut ElementContext| {
cx.with_text_style(style.text_style().cloned(), |cx| {
cx.with_content_mask(style.overflow_mask(bounds, cx.rem_size()), |cx| {
@@ -1500,19 +1439,19 @@ impl Interactivity {
stacking_order: cx.stacking_order().clone(),
};
// if self.block_mouse
// || style.background.as_ref().is_some_and(|fill| {
// fill.color().is_some_and(|color| !color.is_transparent())
// })
// {
// cx.add_opaque_layer(interactive_bounds.bounds);
// }
if self.block_mouse
|| style.background.as_ref().is_some_and(|fill| {
fill.color().is_some_and(|color| !color.is_transparent())
})
{
cx.add_opaque_layer(interactive_bounds.bounds);
}
if !cx.has_active_drag() {
if let Some(mouse_cursor) = style.mouse_cursor {
let mouse_position = &cx.mouse_position();
let hovered =
interactive_bounds.did_visibly_contains(mouse_position, cx);
interactive_bounds.visibly_contains(mouse_position, cx);
if hovered {
cx.set_cursor_style(mouse_cursor);
}
@@ -1528,8 +1467,7 @@ impl Interactivity {
move |event: &MouseDownEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& !cx.default_prevented()
&& interactive_bounds
.did_visibly_contains(&event.position, cx)
&& interactive_bounds.visibly_contains(&event.position, cx)
{
cx.focus(&focus_handle);
// If there is a parent that is also focusable, prevent it
@@ -1568,7 +1506,7 @@ impl Interactivity {
})
}
paint_hover_group_handler(self, cx);
paint_hover_group_handler(cx);
if self.hover_style.is_some()
|| self.base_style.mouse_cursor.is_some()
@@ -1647,8 +1585,7 @@ impl Interactivity {
move |event: &MouseDownEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& event.button == MouseButton::Left
&& interactive_bounds
.did_visibly_contains(&event.position, cx)
&& interactive_bounds.visibly_contains(&event.position, cx)
{
*pending_mouse_down.borrow_mut() = Some(event.clone());
cx.refresh();
@@ -1709,7 +1646,7 @@ impl Interactivity {
DispatchPhase::Bubble => {
if let Some(mouse_down) = captured_mouse_down.take() {
if interactive_bounds
.did_visibly_contains(&event.position, cx)
.visibly_contains(&event.position, cx)
{
let mouse_click = ClickEvent {
down: mouse_down,
@@ -1741,7 +1678,7 @@ impl Interactivity {
return;
}
let is_hovered = interactive_bounds
.did_visibly_contains(&event.position, cx)
.visibly_contains(&event.position, cx)
&& has_mouse_down.borrow().is_none()
&& !cx.has_active_drag();
let mut was_hovered = was_hovered.borrow_mut();
@@ -1768,7 +1705,7 @@ impl Interactivity {
cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
let is_hovered = interactive_bounds
.did_visibly_contains(&event.position, cx)
.visibly_contains(&event.position, cx)
&& pending_mouse_down.borrow().is_none();
if !is_hovered {
active_tooltip.borrow_mut().take();
@@ -1850,7 +1787,7 @@ impl Interactivity {
let group = active_group_bounds
.map_or(false, |bounds| bounds.contains(&down.position));
let element =
interactive_bounds.did_visibly_contains(&down.position, cx);
interactive_bounds.visibly_contains(&down.position, cx);
if group || element {
*active_state.borrow_mut() =
ElementClickedState { group, element };
@@ -1903,7 +1840,7 @@ impl Interactivity {
let interactive_bounds = interactive_bounds.clone();
cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& interactive_bounds.did_visibly_contains(&event.position, cx)
&& interactive_bounds.visibly_contains(&event.position, cx)
{
let mut scroll_offset = scroll_offset.borrow_mut();
let old_scroll_offset = *scroll_offset;
@@ -1973,7 +1910,7 @@ impl Interactivity {
cx.on_action(action_type, listener)
}
continuation(&style, scroll_offset.unwrap_or_default(), cx)
f(&style, scroll_offset.unwrap_or_default(), cx)
},
);
@@ -1996,107 +1933,101 @@ impl Interactivity {
let mut style = Style::default();
style.refine(&self.base_style);
// cx.with_z_index(style.z_index.unwrap_or(0), |cx| {
if let Some(focus_handle) = self.tracked_focus_handle.as_ref() {
if let Some(in_focus_style) = self.in_focus_style.as_ref() {
if focus_handle.within_focused(cx) {
style.refine(in_focus_style);
}
}
if let Some(focus_style) = self.focus_style.as_ref() {
if focus_handle.is_focused(cx) {
style.refine(focus_style);
}
}
}
if let Some(bounds) = bounds {
let mouse_position = cx.mouse_position();
if !cx.has_active_drag() {
if let Some(group_hover) = self.group_hover_style.as_ref() {
if let Some(group_bounds) = GroupBounds::get(&group_hover.group, cx.deref_mut())
{
if group_bounds.contains(&mouse_position)
&& cx.is_top_layer(&mouse_position, cx.stacking_order())
{
style.refine(&group_hover.style);
}
cx.with_z_index(style.z_index.unwrap_or(0), |cx| {
if let Some(focus_handle) = self.tracked_focus_handle.as_ref() {
if let Some(in_focus_style) = self.in_focus_style.as_ref() {
if focus_handle.within_focused(cx) {
style.refine(in_focus_style);
}
}
if let Some(hover_style) = self.hover_style.as_ref() {
let outer_button = self.debug_selector.as_deref() == Some("outer_button");
let inner_button = self.debug_selector.as_deref() == Some("inner_button");
if outer_button {
cx.is_top_layer_debug(&mouse_position, cx.stacking_order());
}
println!("\n\n\n");
if bounds
.intersect(&cx.content_mask().bounds)
.contains(&mouse_position)
&& cx.is_top_layer(&mouse_position, cx.stacking_order())
{
style.refine(hover_style);
if let Some(focus_style) = self.focus_style.as_ref() {
if focus_handle.is_focused(cx) {
style.refine(focus_style);
}
}
}
if let Some(drag) = cx.active_drag.take() {
let mut can_drop = true;
if let Some(can_drop_predicate) = &self.can_drop_predicate {
can_drop = can_drop_predicate(drag.value.as_ref(), cx.deref_mut());
}
if can_drop {
for (state_type, group_drag_style) in &self.group_drag_over_styles {
if let Some(bounds) = bounds {
let mouse_position = cx.mouse_position();
if !cx.has_active_drag() {
if let Some(group_hover) = self.group_hover_style.as_ref() {
if let Some(group_bounds) =
GroupBounds::get(&group_drag_style.group, cx.deref_mut())
GroupBounds::get(&group_hover.group, cx.deref_mut())
{
if *state_type == drag.value.as_ref().type_id()
&& group_bounds.contains(&mouse_position)
if group_bounds.contains(&mouse_position)
&& cx.was_top_layer(&mouse_position, cx.stacking_order())
{
style.refine(&group_drag_style.style);
style.refine(&group_hover.style);
}
}
}
for (state_type, build_drag_over_style) in &self.drag_over_styles {
if *state_type == drag.value.as_ref().type_id()
&& bounds
.intersect(&cx.content_mask().bounds)
.contains(&mouse_position)
&& cx.is_top_layer_under_active_drag(
&mouse_position,
cx.stacking_order(),
)
if let Some(hover_style) = self.hover_style.as_ref() {
if bounds
.intersect(&cx.content_mask().bounds)
.contains(&mouse_position)
&& cx.was_top_layer(&mouse_position, cx.stacking_order())
{
style.refine(&build_drag_over_style(drag.value.as_ref(), cx));
style.refine(hover_style);
}
}
}
cx.active_drag = Some(drag);
}
}
if let Some(drag) = cx.active_drag.take() {
let mut can_drop = true;
if let Some(can_drop_predicate) = &self.can_drop_predicate {
can_drop = can_drop_predicate(drag.value.as_ref(), cx.deref_mut());
}
let clicked_state = element_state
.clicked_state
.get_or_insert_with(Default::default)
.borrow();
if clicked_state.group {
if let Some(group) = self.group_active_style.as_ref() {
style.refine(&group.style)
}
}
if can_drop {
for (state_type, group_drag_style) in &self.group_drag_over_styles {
if let Some(group_bounds) =
GroupBounds::get(&group_drag_style.group, cx.deref_mut())
{
if *state_type == drag.value.as_ref().type_id()
&& group_bounds.contains(&mouse_position)
{
style.refine(&group_drag_style.style);
}
}
}
if let Some(active_style) = self.active_style.as_ref() {
if clicked_state.element {
style.refine(active_style)
for (state_type, build_drag_over_style) in &self.drag_over_styles {
if *state_type == drag.value.as_ref().type_id()
&& bounds
.intersect(&cx.content_mask().bounds)
.contains(&mouse_position)
&& cx.was_top_layer_under_active_drag(
&mouse_position,
cx.stacking_order(),
)
{
style.refine(&build_drag_over_style(drag.value.as_ref(), cx));
}
}
}
cx.active_drag = Some(drag);
}
}
}
// });
let clicked_state = element_state
.clicked_state
.get_or_insert_with(Default::default)
.borrow();
if clicked_state.group {
if let Some(group) = self.group_active_style.as_ref() {
style.refine(&group.style)
}
}
if let Some(active_style) = self.active_style.as_ref() {
if clicked_state.element {
style.refine(active_style)
}
}
});
style
}
@@ -2280,15 +2211,6 @@ where
self.element.request_layout(state, cx)
}
fn post_layout(
&mut self,
bounds: Bounds<Pixels>,
state: &mut Self::State,
cx: &mut ElementContext,
) {
self.element.post_layout(bounds, state, cx);
}
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut Self::State, cx: &mut ElementContext) {
self.element.paint(bounds, state, cx)
}

View File

@@ -520,7 +520,7 @@ impl Element for List {
cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& bounds.contains(&event.position)
&& cx.is_top_layer(&event.position, cx.stacking_order())
&& cx.was_top_layer(&event.position, cx.stacking_order())
{
list_state.0.borrow_mut().scroll(
&scroll_top,

View File

@@ -427,7 +427,7 @@ impl Element for InteractiveText {
.clickable_ranges
.iter()
.any(|range| range.contains(&ix))
&& cx.is_top_layer(&mouse_position, cx.stacking_order())
&& cx.was_top_layer(&mouse_position, cx.stacking_order())
{
cx.set_cursor_style(crate::CursorStyle::PointingHand)
}

View File

@@ -61,11 +61,13 @@
#![deny(missing_docs)]
#![allow(clippy::type_complexity)]
#![allow(clippy::collapsible_else_if)]
#[macro_use]
mod action;
mod app;
mod access_kit;
mod arena;
mod assets;
mod color;

View File

@@ -6,13 +6,13 @@ mod client_dispatcher;
mod dispatcher;
mod platform;
mod text_system;
mod wayland;
mod x11;
pub(crate) use blade_atlas::*;
pub(crate) use dispatcher::*;
pub(crate) use platform::*;
pub(crate) use text_system::*;
pub(crate) use x11::display::*;
pub(crate) use x11::*;
use blade_belt::*;

View File

@@ -200,7 +200,7 @@ impl BladeAtlasState {
}
fn upload_texture(&mut self, id: AtlasTextureId, bounds: Bounds<DevicePixels>, bytes: &[u8]) {
let data = self.upload_belt.alloc_data(bytes, &self.gpu);
let data = unsafe { self.upload_belt.alloc_data(bytes, &self.gpu) };
self.uploads.push(PendingUpload { id, bounds, data });
}

View File

@@ -75,8 +75,8 @@ impl BladeBelt {
chunk.into()
}
//todo!(linux): enforce T: bytemuck::Zeroable
pub fn alloc_data<T>(&mut self, data: &[T], gpu: &gpu::Context) -> gpu::BufferPiece {
// SAFETY: T should be zeroable and ordinary data, no references, pointers, cells or other complicated data type.
pub unsafe fn alloc_data<T>(&mut self, data: &[T], gpu: &gpu::Context) -> gpu::BufferPiece {
assert!(!data.is_empty());
let type_alignment = mem::align_of::<T>() as u64;
debug_assert_eq!(

View File

@@ -340,7 +340,7 @@ impl BladeRenderer {
pad: [0; 2],
};
let vertex_buf = self.instance_belt.alloc_data(&vertices, &self.gpu);
let vertex_buf = unsafe { self.instance_belt.alloc_data(&vertices, &self.gpu) };
let mut pass = self.command_encoder.render(gpu::RenderTargetSet {
colors: &[gpu::RenderTarget {
view: tex_info.raw_view,
@@ -389,7 +389,8 @@ impl BladeRenderer {
for batch in scene.batches() {
match batch {
PrimitiveBatch::Quads(quads) => {
let instance_buf = self.instance_belt.alloc_data(quads, &self.gpu);
let instance_buf =
unsafe { self.instance_belt.alloc_data(quads, &self.gpu) };
let mut encoder = pass.with(&self.pipelines.quads);
encoder.bind(
0,
@@ -401,7 +402,8 @@ impl BladeRenderer {
encoder.draw(0, 4, 0, quads.len() as u32);
}
PrimitiveBatch::Shadows(shadows) => {
let instance_buf = self.instance_belt.alloc_data(shadows, &self.gpu);
let instance_buf =
unsafe { self.instance_belt.alloc_data(shadows, &self.gpu) };
let mut encoder = pass.with(&self.pipelines.shadows);
encoder.bind(
0,
@@ -428,7 +430,8 @@ impl BladeRenderer {
tile: (*tile).clone(),
}];
let instance_buf = self.instance_belt.alloc_data(&sprites, &self.gpu);
let instance_buf =
unsafe { self.instance_belt.alloc_data(&sprites, &self.gpu) };
encoder.bind(
0,
&ShaderPathsData {
@@ -442,7 +445,8 @@ impl BladeRenderer {
}
}
PrimitiveBatch::Underlines(underlines) => {
let instance_buf = self.instance_belt.alloc_data(underlines, &self.gpu);
let instance_buf =
unsafe { self.instance_belt.alloc_data(underlines, &self.gpu) };
let mut encoder = pass.with(&self.pipelines.underlines);
encoder.bind(
0,
@@ -458,7 +462,8 @@ impl BladeRenderer {
sprites,
} => {
let tex_info = self.atlas.get_texture_info(texture_id);
let instance_buf = self.instance_belt.alloc_data(&sprites, &self.gpu);
let instance_buf =
unsafe { self.instance_belt.alloc_data(sprites, &self.gpu) };
let mut encoder = pass.with(&self.pipelines.mono_sprites);
encoder.bind(
0,
@@ -476,7 +481,8 @@ impl BladeRenderer {
sprites,
} => {
let tex_info = self.atlas.get_texture_info(texture_id);
let instance_buf = self.instance_belt.alloc_data(&sprites, &self.gpu);
let instance_buf =
unsafe { self.instance_belt.alloc_data(sprites, &self.gpu) };
let mut encoder = pass.with(&self.pipelines.poly_sprites);
encoder.bind(
0,

View File

@@ -1,5 +1,6 @@
#![allow(unused)]
use std::env;
use std::{
path::{Path, PathBuf},
rc::Rc,
@@ -7,22 +8,23 @@ use std::{
time::Duration,
};
use ashpd::desktop::file_chooser::{OpenFileRequest, SaveFileRequest};
use async_task::Runnable;
use flume::{Receiver, Sender};
use futures::channel::oneshot;
use parking_lot::Mutex;
use time::UtcOffset;
use collections::{HashMap, HashSet};
use wayland_client::Connection;
use crate::platform::linux::client::Client;
use crate::platform::linux::client_dispatcher::ClientDispatcher;
use crate::platform::linux::wayland::{WaylandClient, WaylandClientDispatcher};
use crate::platform::{X11Client, X11ClientDispatcher, XcbAtoms};
use crate::{
Action, AnyWindowHandle, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DisplayId,
Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId,
ForegroundExecutor, Keymap, LinuxDispatcher, LinuxTextSystem, Menu, PathPromptOptions,
Platform, PlatformDisplay, PlatformInput, PlatformTextSystem, PlatformWindow, Point, Result,
SemanticVersion, Size, Task, WindowAppearance, WindowOptions, X11Display, X11Window,
X11WindowState,
Platform, PlatformDisplay, PlatformInput, PlatformTextSystem, PlatformWindow, Result,
SemanticVersion, Task, WindowOptions,
};
#[derive(Default)]
@@ -48,8 +50,8 @@ pub(crate) struct LinuxPlatformInner {
}
pub(crate) struct LinuxPlatform {
client: Arc<dyn Client>,
inner: Arc<LinuxPlatformInner>,
client: Rc<dyn Client>,
inner: Rc<LinuxPlatformInner>,
}
pub(crate) struct LinuxPlatformState {
@@ -64,34 +66,93 @@ impl Default for LinuxPlatform {
impl LinuxPlatform {
pub(crate) fn new() -> Self {
let (xcb_connection, x_root_index) = xcb::Connection::connect(None).unwrap();
let atoms = XcbAtoms::intern_all(&xcb_connection).unwrap();
let wayland_display = env::var_os("WAYLAND_DISPLAY");
let use_wayland = wayland_display.is_some() && !wayland_display.unwrap().is_empty();
let xcb_connection = Arc::new(xcb_connection);
let (main_sender, main_receiver) = flume::unbounded::<Runnable>();
let client_dispatcher: Arc<dyn ClientDispatcher + Send + Sync> =
Arc::new(X11ClientDispatcher::new(&xcb_connection, x_root_index));
let dispatcher = LinuxDispatcher::new(main_sender, &client_dispatcher);
let dispatcher = Arc::new(dispatcher);
let text_system = Arc::new(LinuxTextSystem::new());
let callbacks = Mutex::new(Callbacks::default());
let state = Mutex::new(LinuxPlatformState {
quit_requested: false,
});
let inner = LinuxPlatformInner {
if use_wayland {
Self::new_wayland(main_sender, main_receiver, text_system, callbacks, state)
} else {
Self::new_x11(main_sender, main_receiver, text_system, callbacks, state)
}
}
fn new_wayland(
main_sender: Sender<Runnable>,
main_receiver: Receiver<Runnable>,
text_system: Arc<LinuxTextSystem>,
callbacks: Mutex<Callbacks>,
state: Mutex<LinuxPlatformState>,
) -> Self {
let conn = Arc::new(Connection::connect_to_env().unwrap());
let client_dispatcher: Arc<dyn ClientDispatcher + Send + Sync> =
Arc::new(WaylandClientDispatcher::new(&conn));
let dispatcher = Arc::new(LinuxDispatcher::new(main_sender, &client_dispatcher));
let inner = Rc::new(LinuxPlatformInner {
background_executor: BackgroundExecutor::new(dispatcher.clone()),
foreground_executor: ForegroundExecutor::new(dispatcher.clone()),
main_receiver,
text_system: Arc::new(LinuxTextSystem::new()),
callbacks: Mutex::new(Callbacks::default()),
state: Mutex::new(LinuxPlatformState {
quit_requested: false,
}),
};
let inner = Arc::new(inner);
let x11client = X11Client::new(Arc::clone(&inner), xcb_connection, x_root_index, atoms);
let x11client = Arc::new(x11client);
text_system,
callbacks,
state,
});
let client = Rc::new(WaylandClient::new(Rc::clone(&inner), Arc::clone(&conn)));
Self {
client: x11client,
inner: Arc::clone(&inner),
client,
inner: Rc::clone(&inner),
}
}
fn new_x11(
main_sender: Sender<Runnable>,
main_receiver: Receiver<Runnable>,
text_system: Arc<LinuxTextSystem>,
callbacks: Mutex<Callbacks>,
state: Mutex<LinuxPlatformState>,
) -> Self {
let (xcb_connection, x_root_index) = xcb::Connection::connect_with_extensions(
None,
&[xcb::Extension::Present, xcb::Extension::Xkb],
&[],
)
.unwrap();
let xkb_ver = xcb_connection
.wait_for_reply(xcb_connection.send_request(&xcb::xkb::UseExtension {
wanted_major: xcb::xkb::MAJOR_VERSION as u16,
wanted_minor: xcb::xkb::MINOR_VERSION as u16,
}))
.unwrap();
assert!(xkb_ver.supported());
let atoms = XcbAtoms::intern_all(&xcb_connection).unwrap();
let xcb_connection = Arc::new(xcb_connection);
let client_dispatcher: Arc<dyn ClientDispatcher + Send + Sync> =
Arc::new(X11ClientDispatcher::new(&xcb_connection, x_root_index));
let dispatcher = Arc::new(LinuxDispatcher::new(main_sender, &client_dispatcher));
let inner = Rc::new(LinuxPlatformInner {
background_executor: BackgroundExecutor::new(dispatcher.clone()),
foreground_executor: ForegroundExecutor::new(dispatcher.clone()),
main_receiver,
text_system,
callbacks,
state,
});
let client = Rc::new(X11Client::new(
Rc::clone(&inner),
xcb_connection,
x_root_index,
atoms,
));
Self {
client,
inner: Rc::clone(&inner),
}
}
}
@@ -154,7 +215,7 @@ impl Platform for LinuxPlatform {
}
fn open_url(&self, url: &str) {
unimplemented!()
open::that(url);
}
fn on_open_urls(&self, callback: Box<dyn FnMut(Vec<String>)>) {
@@ -165,15 +226,75 @@ impl Platform for LinuxPlatform {
&self,
options: PathPromptOptions,
) -> oneshot::Receiver<Option<Vec<PathBuf>>> {
unimplemented!()
let (done_tx, done_rx) = oneshot::channel();
self.foreground_executor()
.spawn(async move {
let title = if options.multiple {
if !options.files {
"Open folders"
} else {
"Open files"
}
} else {
if !options.files {
"Open folder"
} else {
"Open file"
}
};
let result = OpenFileRequest::default()
.modal(true)
.title(title)
.accept_label("Select")
.multiple(options.multiple)
.directory(options.directories)
.send()
.await
.ok()
.and_then(|request| request.response().ok())
.and_then(|response| {
response
.uris()
.iter()
.map(|uri| uri.to_file_path().ok())
.collect()
});
done_tx.send(result);
})
.detach();
done_rx
}
fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver<Option<PathBuf>> {
unimplemented!()
let (done_tx, done_rx) = oneshot::channel();
let directory = directory.to_owned();
self.foreground_executor()
.spawn(async move {
let result = SaveFileRequest::default()
.modal(true)
.title("Select new path")
.accept_label("Accept")
.send()
.await
.ok()
.and_then(|request| request.response().ok())
.and_then(|response| {
response
.uris()
.first()
.and_then(|uri| uri.to_file_path().ok())
});
done_tx.send(result);
})
.detach();
done_rx
}
fn reveal_path(&self, path: &Path) {
unimplemented!()
open::that(path);
}
fn on_become_active(&self, callback: Box<dyn FnMut()>) {

View File

@@ -0,0 +1,10 @@
//todo!(linux): remove this once the relevant functionality has been implemented
#![allow(unused_variables)]
pub(crate) use client::*;
pub(crate) use client_dispatcher::*;
mod client;
mod client_dispatcher;
mod display;
mod window;

View File

@@ -0,0 +1,253 @@
use std::rc::Rc;
use std::sync::Arc;
use parking_lot::Mutex;
use wayland_client::protocol::wl_callback::WlCallback;
use wayland_client::{
delegate_noop,
protocol::{
wl_buffer, wl_callback, wl_compositor, wl_keyboard, wl_registry, wl_seat, wl_shm,
wl_shm_pool,
wl_surface::{self, WlSurface},
},
Connection, Dispatch, EventQueue, Proxy, QueueHandle,
};
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
use crate::platform::linux::client::Client;
use crate::platform::linux::wayland::window::WaylandWindow;
use crate::platform::{LinuxPlatformInner, PlatformWindow};
use crate::{
platform::linux::wayland::window::WaylandWindowState, AnyWindowHandle, DisplayId,
PlatformDisplay, WindowOptions,
};
pub(crate) struct WaylandClientState {
compositor: Option<wl_compositor::WlCompositor>,
buffer: Option<wl_buffer::WlBuffer>,
wm_base: Option<xdg_wm_base::XdgWmBase>,
windows: Vec<(xdg_surface::XdgSurface, Rc<WaylandWindowState>)>,
platform_inner: Rc<LinuxPlatformInner>,
}
pub(crate) struct WaylandClient {
platform_inner: Rc<LinuxPlatformInner>,
conn: Arc<Connection>,
state: Mutex<WaylandClientState>,
event_queue: Mutex<EventQueue<WaylandClientState>>,
qh: Arc<QueueHandle<WaylandClientState>>,
}
impl WaylandClient {
pub(crate) fn new(linux_platform_inner: Rc<LinuxPlatformInner>, conn: Arc<Connection>) -> Self {
let state = WaylandClientState {
compositor: None,
buffer: None,
wm_base: None,
windows: Vec::new(),
platform_inner: Rc::clone(&linux_platform_inner),
};
let event_queue: EventQueue<WaylandClientState> = conn.new_event_queue();
let qh = event_queue.handle();
Self {
platform_inner: linux_platform_inner,
conn,
state: Mutex::new(state),
event_queue: Mutex::new(event_queue),
qh: Arc::new(qh),
}
}
}
impl Client for WaylandClient {
fn run(&self, on_finish_launching: Box<dyn FnOnce()>) {
let display = self.conn.display();
let mut eq = self.event_queue.lock();
let _registry = display.get_registry(&self.qh, ());
eq.roundtrip(&mut self.state.lock()).unwrap();
on_finish_launching();
while !self.platform_inner.state.lock().quit_requested {
eq.flush().unwrap();
eq.dispatch_pending(&mut self.state.lock()).unwrap();
if let Some(guard) = self.conn.prepare_read() {
guard.read().unwrap();
eq.dispatch_pending(&mut self.state.lock()).unwrap();
}
if let Ok(runnable) = self.platform_inner.main_receiver.try_recv() {
runnable.run();
}
}
}
fn displays(&self) -> Vec<Rc<dyn PlatformDisplay>> {
Vec::new()
}
fn display(&self, id: DisplayId) -> Option<Rc<dyn PlatformDisplay>> {
unimplemented!()
}
fn open_window(
&self,
handle: AnyWindowHandle,
options: WindowOptions,
) -> Box<dyn PlatformWindow> {
let mut state = self.state.lock();
let wm_base = state.wm_base.as_ref().unwrap();
let compositor = state.compositor.as_ref().unwrap();
let wl_surface = compositor.create_surface(&self.qh, ());
let xdg_surface = wm_base.get_xdg_surface(&wl_surface, &self.qh, ());
let toplevel = xdg_surface.get_toplevel(&self.qh, ());
let wl_surface = Arc::new(wl_surface);
wl_surface.frame(&self.qh, wl_surface.clone());
wl_surface.commit();
let window_state = Rc::new(WaylandWindowState::new(
&self.conn,
wl_surface.clone(),
Arc::new(toplevel),
options,
));
state.windows.push((xdg_surface, Rc::clone(&window_state)));
Box::new(WaylandWindow(window_state))
}
}
impl Dispatch<wl_registry::WlRegistry, ()> for WaylandClientState {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name, interface, ..
} = event
{
match &interface[..] {
"wl_compositor" => {
let compositor =
registry.bind::<wl_compositor::WlCompositor, _, _>(name, 1, qh, ());
state.compositor = Some(compositor);
}
"xdg_wm_base" => {
let wm_base = registry.bind::<xdg_wm_base::XdgWmBase, _, _>(name, 1, qh, ());
state.wm_base = Some(wm_base);
}
_ => {}
};
}
}
}
delegate_noop!(WaylandClientState: ignore wl_compositor::WlCompositor);
delegate_noop!(WaylandClientState: ignore wl_surface::WlSurface);
delegate_noop!(WaylandClientState: ignore wl_shm::WlShm);
delegate_noop!(WaylandClientState: ignore wl_shm_pool::WlShmPool);
delegate_noop!(WaylandClientState: ignore wl_buffer::WlBuffer);
delegate_noop!(WaylandClientState: ignore wl_seat::WlSeat);
delegate_noop!(WaylandClientState: ignore wl_keyboard::WlKeyboard);
impl Dispatch<WlCallback, Arc<WlSurface>> for WaylandClientState {
fn event(
state: &mut Self,
_: &WlCallback,
event: wl_callback::Event,
surf: &Arc<WlSurface>,
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_callback::Event::Done { .. } = event {
for window in &state.windows {
if window.1.surface.id() == surf.id() {
window.1.surface.frame(qh, surf.clone());
window.1.update();
window.1.surface.commit();
}
}
}
}
}
impl Dispatch<xdg_surface::XdgSurface, ()> for WaylandClientState {
fn event(
state: &mut Self,
xdg_surface: &xdg_surface::XdgSurface,
event: xdg_surface::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let xdg_surface::Event::Configure { serial, .. } = event {
xdg_surface.ack_configure(serial);
for window in &state.windows {
if &window.0 == xdg_surface {
window.1.update();
window.1.surface.commit();
return;
}
}
}
}
}
impl Dispatch<xdg_toplevel::XdgToplevel, ()> for WaylandClientState {
fn event(
state: &mut Self,
xdg_toplevel: &xdg_toplevel::XdgToplevel,
event: <xdg_toplevel::XdgToplevel as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let xdg_toplevel::Event::Configure {
width,
height,
states: _states,
} = event
{
if width == 0 || height == 0 {
return;
}
for window in &state.windows {
if window.1.toplevel.id() == xdg_toplevel.id() {
window.1.resize(width, height);
window.1.surface.commit();
return;
}
}
} else if let xdg_toplevel::Event::Close = event {
state.windows.retain(|(_, window)| {
if window.toplevel.id() == xdg_toplevel.id() {
window.toplevel.destroy();
false
} else {
true
}
});
state.platform_inner.state.lock().quit_requested |= state.windows.is_empty();
}
}
}
impl Dispatch<xdg_wm_base::XdgWmBase, ()> for WaylandClientState {
fn event(
_: &mut Self,
wm_base: &xdg_wm_base::XdgWmBase,
event: <xdg_wm_base::XdgWmBase as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let xdg_wm_base::Event::Ping { serial } = event {
wm_base.pong(serial);
}
}
}

View File

@@ -0,0 +1,30 @@
use std::sync::Arc;
use wayland_client::{Connection, EventQueue};
use crate::platform::linux::client_dispatcher::ClientDispatcher;
pub(crate) struct WaylandClientDispatcher {
conn: Arc<Connection>,
event_queue: Arc<EventQueue<Connection>>,
}
impl WaylandClientDispatcher {
pub(crate) fn new(conn: &Arc<Connection>) -> Self {
let event_queue = conn.new_event_queue();
Self {
conn: Arc::clone(conn),
event_queue: Arc::new(event_queue),
}
}
}
impl Drop for WaylandClientDispatcher {
fn drop(&mut self) {
//todo!(linux)
}
}
impl ClientDispatcher for WaylandClientDispatcher {
fn dispatch_on_main_thread(&self) {}
}

View File

@@ -0,0 +1,31 @@
use std::fmt::Debug;
use uuid::Uuid;
use crate::{Bounds, DisplayId, GlobalPixels, PlatformDisplay, Size};
#[derive(Debug)]
pub(crate) struct WaylandDisplay {}
impl PlatformDisplay for WaylandDisplay {
// todo!(linux)
fn id(&self) -> DisplayId {
DisplayId(123) // return some fake data so it doesn't panic
}
// todo!(linux)
fn uuid(&self) -> anyhow::Result<Uuid> {
Ok(Uuid::from_bytes([0; 16])) // return some fake data so it doesn't panic
}
// todo!(linux)
fn bounds(&self) -> Bounds<GlobalPixels> {
Bounds {
origin: Default::default(),
size: Size {
width: GlobalPixels(1000f32),
height: GlobalPixels(500f32),
},
} // return some fake data so it doesn't panic
}
}

View File

@@ -0,0 +1,350 @@
use std::any::Any;
use std::ffi::c_void;
use std::rc::Rc;
use std::sync::Arc;
use blade_graphics as gpu;
use blade_rwh::{HasRawDisplayHandle, HasRawWindowHandle, RawDisplayHandle, RawWindowHandle};
use futures::channel::oneshot::Receiver;
use parking_lot::Mutex;
use raw_window_handle::{
DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle,
};
use wayland_client::{protocol::wl_surface, Proxy};
use wayland_protocols::xdg::shell::client::xdg_toplevel;
use crate::platform::linux::blade_renderer::BladeRenderer;
use crate::platform::linux::wayland::display::WaylandDisplay;
use crate::platform::{PlatformAtlas, PlatformInputHandler, PlatformWindow};
use crate::scene::Scene;
use crate::{
px, Bounds, Modifiers, Pixels, PlatformDisplay, PlatformInput, Point, PromptLevel, Size,
WindowAppearance, WindowBounds, WindowOptions,
};
#[derive(Default)]
pub(crate) struct Callbacks {
request_frame: Option<Box<dyn FnMut()>>,
input: Option<Box<dyn FnMut(crate::PlatformInput) -> bool>>,
active_status_change: Option<Box<dyn FnMut(bool)>>,
resize: Option<Box<dyn FnMut(Size<Pixels>, f32)>>,
fullscreen: Option<Box<dyn FnMut(bool)>>,
moved: Option<Box<dyn FnMut()>>,
should_close: Option<Box<dyn FnMut() -> bool>>,
close: Option<Box<dyn FnOnce()>>,
appearance_changed: Option<Box<dyn FnMut()>>,
}
struct WaylandWindowInner {
renderer: BladeRenderer,
bounds: Bounds<i32>,
}
struct RawWindow {
window: *mut c_void,
display: *mut c_void,
}
unsafe impl HasRawWindowHandle for RawWindow {
fn raw_window_handle(&self) -> RawWindowHandle {
let mut wh = blade_rwh::WaylandWindowHandle::empty();
wh.surface = self.window;
wh.into()
}
}
unsafe impl HasRawDisplayHandle for RawWindow {
fn raw_display_handle(&self) -> RawDisplayHandle {
let mut dh = blade_rwh::WaylandDisplayHandle::empty();
dh.display = self.display;
dh.into()
}
}
impl WaylandWindowInner {
fn new(
conn: &Arc<wayland_client::Connection>,
wl_surf: &Arc<wl_surface::WlSurface>,
bounds: Bounds<i32>,
) -> Self {
let raw = RawWindow {
window: wl_surf.id().as_ptr() as *mut _,
display: conn.backend().display_ptr() as *mut _,
};
let gpu = Arc::new(
unsafe {
gpu::Context::init_windowed(
&raw,
gpu::ContextDesc {
validation: false,
capture: false,
},
)
}
.unwrap(),
);
let extent = gpu::Extent {
width: bounds.size.width as u32,
height: bounds.size.height as u32,
depth: 1,
};
Self {
renderer: BladeRenderer::new(gpu, extent),
bounds,
}
}
}
pub(crate) struct WaylandWindowState {
conn: Arc<wayland_client::Connection>,
inner: Mutex<WaylandWindowInner>,
pub(crate) callbacks: Mutex<Callbacks>,
pub(crate) surface: Arc<wl_surface::WlSurface>,
pub(crate) toplevel: Arc<xdg_toplevel::XdgToplevel>,
}
impl WaylandWindowState {
pub(crate) fn new(
conn: &Arc<wayland_client::Connection>,
wl_surf: Arc<wl_surface::WlSurface>,
toplevel: Arc<xdg_toplevel::XdgToplevel>,
options: WindowOptions,
) -> Self {
if options.bounds == WindowBounds::Maximized {
toplevel.set_maximized();
} else if options.bounds == WindowBounds::Fullscreen {
toplevel.set_fullscreen(None);
}
let bounds: Bounds<i32> = match options.bounds {
WindowBounds::Fullscreen | WindowBounds::Maximized => Bounds {
origin: Point::default(),
size: Size {
width: 500,
height: 500,
}, //todo!(implement)
},
WindowBounds::Fixed(bounds) => bounds.map(|p| p.0 as i32),
};
Self {
conn: Arc::clone(conn),
surface: Arc::clone(&wl_surf),
inner: Mutex::new(WaylandWindowInner::new(&Arc::clone(conn), &wl_surf, bounds)),
callbacks: Mutex::new(Callbacks::default()),
toplevel,
}
}
pub fn update(&self) {
let mut cb = self.callbacks.lock();
if let Some(mut fun) = cb.request_frame.take() {
drop(cb);
fun();
self.callbacks.lock().request_frame = Some(fun);
}
}
pub fn resize(&self, width: i32, height: i32) {
{
let mut inner = self.inner.lock();
inner.bounds.size.width = width;
inner.bounds.size.height = height;
inner.renderer.resize(gpu::Extent {
width: width as u32,
height: height as u32,
depth: 1,
});
}
let mut callbacks = self.callbacks.lock();
if let Some(ref mut fun) = callbacks.resize {
fun(
Size {
width: px(width as f32),
height: px(height as f32),
},
1.0,
);
}
if let Some(ref mut fun) = callbacks.moved {
fun()
}
}
pub fn close(&self) {
let mut callbacks = self.callbacks.lock();
if let Some(fun) = callbacks.close.take() {
fun()
}
self.toplevel.destroy();
}
}
#[derive(Clone)]
pub(crate) struct WaylandWindow(pub(crate) Rc<WaylandWindowState>);
impl HasWindowHandle for WaylandWindow {
fn window_handle(&self) -> Result<WindowHandle<'_>, HandleError> {
unimplemented!()
}
}
impl HasDisplayHandle for WaylandWindow {
fn display_handle(&self) -> Result<DisplayHandle<'_>, HandleError> {
unimplemented!()
}
}
impl PlatformWindow for WaylandWindow {
//todo!(linux)
fn bounds(&self) -> WindowBounds {
WindowBounds::Maximized
}
// todo!(linux)
fn content_size(&self) -> Size<Pixels> {
let inner = self.0.inner.lock();
Size {
width: Pixels(inner.bounds.size.width as f32),
height: Pixels(inner.bounds.size.height as f32),
}
}
// todo!(linux)
fn scale_factor(&self) -> f32 {
1f32
}
//todo!(linux)
fn titlebar_height(&self) -> Pixels {
unimplemented!()
}
// todo!(linux)
fn appearance(&self) -> WindowAppearance {
WindowAppearance::Light
}
// todo!(linux)
fn display(&self) -> Rc<dyn PlatformDisplay> {
Rc::new(WaylandDisplay {})
}
// todo!(linux)
fn mouse_position(&self) -> Point<Pixels> {
Point::default()
}
//todo!(linux)
fn modifiers(&self) -> Modifiers {
crate::Modifiers::default()
}
//todo!(linux)
fn as_any_mut(&mut self) -> &mut dyn Any {
unimplemented!()
}
fn set_input_handler(&mut self, input_handler: PlatformInputHandler) {
//todo!(linux)
}
//todo!(linux)
fn take_input_handler(&mut self) -> Option<PlatformInputHandler> {
None
}
//todo!(linux)
fn prompt(
&self,
level: PromptLevel,
msg: &str,
detail: Option<&str>,
answers: &[&str],
) -> Receiver<usize> {
unimplemented!()
}
fn activate(&self) {
//todo!(linux)
}
fn set_title(&mut self, title: &str) {
self.0.toplevel.set_title(title.to_string());
}
fn set_edited(&mut self, edited: bool) {
//todo!(linux)
}
fn show_character_palette(&self) {
//todo!(linux)
}
fn minimize(&self) {
//todo!(linux)
}
fn zoom(&self) {
//todo!(linux)
}
fn toggle_full_screen(&self) {
//todo!(linux)
}
fn on_request_frame(&self, callback: Box<dyn FnMut()>) {
self.0.callbacks.lock().request_frame = Some(callback);
}
fn on_input(&self, callback: Box<dyn FnMut(PlatformInput) -> bool>) {
//todo!(linux)
}
fn on_active_status_change(&self, callback: Box<dyn FnMut(bool)>) {
//todo!(linux)
}
fn on_resize(&self, callback: Box<dyn FnMut(Size<Pixels>, f32)>) {
self.0.callbacks.lock().resize = Some(callback);
}
fn on_fullscreen(&self, callback: Box<dyn FnMut(bool)>) {
//todo!(linux)
}
fn on_moved(&self, callback: Box<dyn FnMut()>) {
self.0.callbacks.lock().moved = Some(callback);
}
fn on_should_close(&self, callback: Box<dyn FnMut() -> bool>) {
self.0.callbacks.lock().should_close = Some(callback);
}
fn on_close(&self, callback: Box<dyn FnOnce()>) {
self.0.callbacks.lock().close = Some(callback);
}
fn on_appearance_changed(&self, callback: Box<dyn FnMut()>) {
//todo!(linux)
}
// todo!(linux)
fn is_topmost_for_position(&self, position: Point<Pixels>) -> bool {
false
}
fn draw(&self, scene: &Scene) {
let mut inner = self.0.inner.lock();
inner.renderer.draw(scene);
}
fn sprite_atlas(&self) -> Arc<dyn PlatformAtlas> {
let inner = self.0.inner.lock();
inner.renderer.atlas().clone()
}
fn set_graphics_profiler_enabled(&self, enabled: bool) {
//todo!(linux)
}
}

View File

@@ -1,9 +1,11 @@
mod client;
mod client_dispatcher;
pub mod display;
mod display;
mod event;
mod window;
pub(crate) use client::*;
pub(crate) use client_dispatcher::*;
pub(crate) use display::*;
pub(crate) use event::*;
pub(crate) use window::*;

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