Compare commits

..

95 Commits

Author SHA1 Message Date
Mikayla Maki
10e852c051 Remove the result from AsyncApp 2025-12-28 22:51:18 -08:00
Mikayla Maki
2fe854df51 Implement on other platforms 2025-12-28 22:51:18 -08:00
Mikayla Maki
068d6d814e remove useless tests 2025-12-28 22:51:01 -08:00
Mikayla Maki
ae9139ba5a Remove new types 2025-12-28 19:40:13 -08:00
Mikayla Maki
f6a406b8da Add test capturing the drop behavior of AppLiveness 2025-12-28 19:18:15 -08:00
Mikayla Maki
72bd8a78db Switch from using an Rc and forcing main thread access, to using an Arc
allowing this to be dropped from any thread.
2025-12-28 18:56:28 -08:00
Mikayla Maki
c00cd2c587 remove plans 2025-12-27 22:37:01 -08:00
Mikayla Maki
626bd86e62 Add tests for the basic executor cases 2025-12-27 22:26:58 -08:00
Mikayla Maki
98a0075c21 Add the new async cancellation behavior to the mac executor 2025-12-27 16:21:54 -08:00
Mikayla Maki
28dc83f076 Add thread safety constraints 2025-12-27 10:38:12 -08:00
Mikayla Maki
02b1387e61 Update AsyncApp result removal brief: remove AppContext::Result, macOS-first approach
- Remove AppContext::Result associated type entirely (all contexts return T directly)
- Remove Flatten trait (no longer needed without Result<Result<T>>)
- Restructure phases: macOS first, then other platforms, then API changes
- Split codebase migration (phases 3-5) to separate brief
- Add async-app-result-removal-migration.md for future ~500+ callsite updates
2025-12-26 18:45:39 -08:00
Mikayla Maki
688d2952c8 Add overall plans 2025-12-26 18:33:59 -08:00
Nathan Sobo
32621dc5de Fix race condition in update_last_checkpoint (#44801)
Release Notes:

- Fixed spurious "no checkpoint" error in agent panel

---

## Summary

`update_last_checkpoint` would call `last_user_message()` twice - once
at the start to capture the checkpoint, and again in an async closure
after the checkpoint comparison completed. If a new user message without
a checkpoint was added between these two calls, the second call would
find the new message and fail with "no checkpoint".

## Fix

Capture the user message ID at the start and use `user_message_mut(&id)`
in the async closure to find the specific message.

cc @mikayla-maki
2025-12-20 10:42:58 -07:00
Danilo Leal
215ac50bc8 agent_ui: Fix markdown block for tool call input and output content (#45454)
This PR fixes two issues with regards to markdown codeblocks rendered in
tool call input and output content display:
- the JSON code snippets weren't properly indented
- codeblocks weren't being rendered in unique containers; e.g., if you
hovered one scrollbar, all of them would also be hovered, even though
horizontal scrolling itself worked properly

Here's the end result:


https://github.com/user-attachments/assets/3d6daf64-0f88-4a16-a5a0-94998c1ba7e2

Release Notes:

- agent: Fix scrollbar and JSON indentation for tool call input/output
content's markdown codeblocks.
2025-12-20 16:29:13 +00:00
Danilo Leal
a5540a08fb ui: Make the NumberField in edit mode work (#45447)
- Make the buttons capable of changing the editor's content
(incrementing or decrementing the value)
- Make arrow key up and down increment and decrement the editor value
- Tried to apply a bit of DRY here by creating some functions that can
be reused across the buttons and editor given they all essentially do
the same thing (change the value)
- Fixed an issue where the editor would not allow focus to move
elsewhere, making it impossible to open a dropdown, for example, if your
focus was on the number field's editor

Release Notes:

- N/A
2025-12-20 11:15:46 -03:00
ᴀᴍᴛᴏᴀᴇʀ
3e8c25f5a9 Remove extra shortcut separator in default mode & model selection tooltips (#45439)
Closes #44118

Release Notes:

- N/A
2025-12-20 14:08:56 +00:00
Zachiah Sawyer
7f0842e3a6 proto: Add extend keyword (#45413)
Closes #45385

Release Notes:
- Add extend keyword to proto
2025-12-20 08:00:04 +02:00
Hidehiro Anto
6dad419cd5 Update version example in remote-development.md (#45427)
Updated the version example for maintaining the remote server binary.

Release Notes:

- N/A
2025-12-20 05:51:22 +00:00
Danilo Leal
0facdfa5ca editor: Make TextAlign::Center and TextAlign::Right work (#45417)
Closes https://github.com/zed-industries/zed/issues/43208

This PR essentially unblocks the editable number field. The function
that shapes editor lines was hard-coding text alignment to the left,
meaning that whatever different alignment we'd pass through
`EditorStyles`would be ignored. To solve this, I just added a text align
and align width fields to the line paint function and updated all call
sites keeping the default configuration. Had to also add an
`alignment_offset()` helper to make sure the cursor positioning, the
selection background element, and the click-to-focus functionality were
kept in-sync with the non-left aligned editor.

Then... the big star of the show here is being able to add the `mode`
method to the number field, which uses `TextAlign::Center`, thus making
it work as we designed it to work.


https://github.com/user-attachments/assets/3539c976-d7bf-4d94-8188-a14328f94fbf

Next up, is turning the number filed to edit mode where applicable.

Release Notes:

- Fixed a bug where different text alignment configurations (i.e.,
center and right-aligned) wouldn't take effect in editors.
2025-12-19 17:37:15 -08:00
Marshall Bowers
58461377ca ci: Disable automated docs on pushes to main (#45416)
This PR disables the automated docs on pushes to `main`, as it is
currently making CI red.

Release Notes:

- N/A
2025-12-20 01:01:19 +00:00
Lieunoir
42d5f7e73e Set override_redirect for PopUps (#42224)
Currently on x11, gpui PopUp windows only rely on the "notification"
type in order to indicate that they should spawn as floating window.
Several window managers (leftwm in my case, but it also seems to be the
case for dwm and ratpoison) do not this property into account thus not
spawning them as float. On the other hand, using Floating instead of
PopUp do make those windows spawn as floating, as these window manager
do take into account the (older) "dialog" type.

The [freedekstop
documentation](https://specifications.freedesktop.org/wm/1.5/ar01s05.html#id-1.6.7)
does seem to suggest that these windows should also have the override
redirect property :
> This property is typically used on override-redirect windows. 

Note that this also disables pretty much all interactions with the
window manager (such as moving the window, resizing etc...)

Release Notes:

- Fix popup windows not spawning floating sometime on x11
2025-12-19 16:38:45 -08:00
Mikayla Maki
5395197619 Separate out component_preview crate and add easy-to-use example binaries (#45382)
Release Notes:

- N/A
2025-12-20 00:34:14 +00:00
Mayank Verma
1d76539d28 gpui: Fix hover styles not being applied during layout (#43324)
Closes #43214

Release Notes:

- Fixed GPUI hover styles not being applied during layout

Here's the before/after:


https://github.com/user-attachments/assets/5b1828bb-234a-493b-a33d-368ca01a773b
2025-12-19 16:33:32 -08:00
jkugs
e5eb26e8d6 gpui: Reset mouse scroll state on FocusOut to prevent large jumps (#43841)
This fixes an X11 scrolling issue where Zed may jump by a large amount
due to the scroll valuator state not being reset when the window loses
focus. If you Alt-Tab away from Zed, scroll in another application, then
return, the first scroll event in Zed applies the entire accumulated
delta instead of a single step.

The missing FocusOut reset was originally identified in issue #34901.

Resetting scroll positions on FocusOut matches the behavior already
implemented in the XinputLeave handler and prevents this jump.

Closes #34901
Closes #40538

Release Notes:

- Fixed an X11 issue where Alt-Tabbing to another application,
scrolling, and returning to Zed could cause the next scroll event to
jump by a large amount.
2025-12-19 16:29:44 -08:00
Serophots
a86b0ab2e0 gpui: Improve the tab stop example by demonstrating tab_group (#44647)
I've just enriched the existing tab_stop.rs example for GPUI with a
demonstration of tab_group. I don't think tab groups existed when the
original example was written.

(I didn't understand the behaviour for tab_group from the doccomments
and the example was missing, so I think this is a productive PR)

Release Notes:

- N/A
2025-12-20 00:29:26 +00:00
Jason Lee
5fb220a19a gpui: Add a Popover example for test deferred (#44473)
Release Notes:

- N/A

<img width="1036" height="659" alt="image"
src="https://github.com/user-attachments/assets/8ca06306-719f-4495-92b3-2a609aa09249"
/>
2025-12-19 16:27:41 -08:00
Anthony Eid
12dbbdd1d3 git: Fix bug where opening a git blob from historic commit view could fail (#44226)
The failure would happen if the current version of the file was open as
an editor. This happened because the git blob and current version of the
buffer would have the same `ProjectPath`.

The fix was adding a new `DiskState::Historic` variant to represent
buffers that are past versions of a file (usually a snapshot from
version control). Historic buffers don't return a `ProjectPath` because
the file isn't real, thus there isn't and shouldn't be a `ProjectPath`
to it. (At least with the current way we represent a project path)

I also change the display name to use the local OS's path style instead
of being hardcoded to Posix, and cleaned up some code too.

Release Notes:

- N/A

---------

Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Co-authored-by: xipengjin <jinxp18@gmail.com>
2025-12-19 18:55:17 -05:00
Max Brunsfeld
6dfabddbb4 Revert "gpui: Enable direct-to-display optimization for metal" (#45405)
Reverts zed-industries/zed#44334

From my testing, this PR introduced screen tearing, or some kind of
strange visual artifact, when scrolling at medium speed on a large
display.

Release notes:

- N/A
2025-12-19 15:21:10 -08:00
Haojian Wu
895213a94d Support union declarations in C/C++ textobjects.scm (#45308)
Release Notes:

- C/C++: Add `union` declarations to the list of text objects
2025-12-19 17:37:13 -05:00
Richard Feldman
1c576ccf82 Fix OpenRouter giving errors for some Anthropic models (#45399)
Fixes #44032

Release Notes:

- Fix OpenRouter giving errors for some Anthropic models
2025-12-19 17:04:43 -05:00
Anthony Eid
3f4da03d38 settings ui: Change window kind from floating to normal (#45401)
#40291 made floating windows always stay on top, which made the settings
ui window always on top of Zed. To maintain the old behavior, this PR
changes the setting window to be a normal window.

Release Notes:

- N/A
2025-12-19 16:48:53 -05:00
Conrad Irwin
ff71f4d46d Run cargo fix as well as cargo clippy --fix (#45394)
Release Notes:

- N/A
2025-12-19 14:27:44 -07:00
morgankrey
71f4dc2481 docs: Stash local changes before branch checkout in droid auto docs CLI (#45395)
Stashes local changes before branch checkout in droid auto docs CLI

Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 14:10:16 -06:00
Nathan Sobo
b091cc4d9a Enforce 5MB per-image limit when converting images for language models (#45313)
## Problem

When users paste or drag large images into the agent panel, the encoded
payload can exceed upstream provider limits (e.g., Anthropic's 5MB
per-image limit), causing API errors.

## Solution

Enforce a default 5MB limit on encoded PNG bytes in
`LanguageModelImage::from_image`:

1. Apply existing Anthropic dimension limits first (1568px max in either
dimension)
2. Iteratively downscale by ~15% per pass until the encoded PNG is under
5MB
3. Return `None` if the image can't be shrunk within 8 passes
(fail-safe)

The limit is enforced at the `LanguageModelImage` conversion layer,
which is the choke point for all image ingestion paths (agent panel
paste/drag, file mentions, text threads, etc.).

## Future Work

The 5MB limit is a conservative default. Provider-specific limits can be
introduced later by adding a `from_image_with_constraints` API.

## Testing

Added a regression test that:
1. Generates a noisy 4096x4096 PNG (guaranteed >5MB)
2. Converts it via `LanguageModelImage::from_image`
3. Asserts the result is ≤5MB and was actually downscaled

---

**Note:** This PR builds on #45312 (prompt store fail-open fix). Please
merge that first.

cc @rtfeldman

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
2025-12-19 15:04:41 -05:00
Nathan Sobo
8e5d33ebc6 Make prompt store fail-open when DB contains undecodable records (#45312)
Release Notes

- N/A

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
2025-12-19 14:59:01 -05:00
morgankrey
99224ccc75 docs: Droid needs a real model (#45393)
Droid needs a specific model with a date
Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 13:43:10 -06:00
Michael Benfield
56646e6bc3 Inline assistant: Don't scroll up too high (#45171)
In the case of large vertical_scroll_margin, we could scroll up such
that the assistant was out of view. Now, keep it no lower than the
center of the editor.

Closes #18058

Release Notes:

- N/A
2025-12-19 11:37:57 -08:00
morgankrey
bb2f037407 docs: Droid doesn't know its own commands (#45391)
Correctly uses droid commands in auto docs actions

Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 13:20:05 -06:00
Cole Miller
07db88a327 git: Optimistically stage hunks when staging a file, take 2 (#45278)
Relanding #43434 with an improved approach.

Release Notes:

- N/A

---------

Co-authored-by: Ramon <55579979+van-sprundel@users.noreply.github.com>
2025-12-19 19:08:49 +00:00
morgankrey
e61f9081d4 docs: More droid docs debugging (#45388)
Path issues

Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 12:24:23 -06:00
Ichimura Tomoo
1bc3fa8154 Correct UTF-16 saving and add heuristic encoding detection (#45243)
This commit fixes an issue where saving UTF-16 files resulted in UTF-8
bytes due to `encoding_rs` default behavior. It also introduces a
heuristic to detect BOM-less UTF-16 and binary files.

Changes:
- Manually implement UTF-16LE/BE encoding during file save to avoid
implicit UTF-8 conversion.
- Add `analyze_byte_content` to guess UTF-16LE/BE or Binary based on
null byte distribution.
- Prevent loading binary files as text by returning an error when binary
content is detected.

Special thanks to @CrazyboyQCD for pointing out the `encoding_rs`
behavior and providing the fix, and to @ConradIrwin for the suggestion
on the detection heuristic.

Closes #14654

Release Notes:

- (nightly only) Fixed an issue where saving files with UTF-16 encoding
incorrectly wrote them as UTF-8. Also improved detection for binary
files and BOM-less UTF-16.
2025-12-19 18:18:20 +00:00
morgankrey
22916311cd ci: Fix Factory CLI installation URL (#45386)
Change from cli.factory.ai/install.sh to app.factory.ai/cli per official
Factory documentation.

Release Notes:

- N/A

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 12:12:14 -06:00
Miguel Raz Guzmán Macedo
1edd050baf Add script/triage_watcher.jl (#45384)
Release Notes:

- N/A
2025-12-19 18:09:40 +00:00
morgankrey
b53f661515 docs: Fix auto docs GitHub Action (#45383)
Small fixes to Droid workflow

Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 11:53:39 -06:00
Andrew Farkas
4ef5d2c814 Fix relative line numbers in sticky headers (#45164)
Closes #42586 

This includes a rewrite of `calculate_relative_line_numbers()`. Now it's
linear-time with respect to the number of rows displayed, instead of
linear time with respect to the number of rows displayed _plus_ the
distance to the base row.

Release Notes:

- Improved performance when using relative line numbers in large files
- Fixed relative line numbers not appearing in sticky headers
2025-12-19 17:32:38 +00:00
morgankrey
bfe3c66c3e docs: Automatic Documentation Github Action using Droid (#45374)
Adds a multi-step agentic loop to github actions for opening a
once-daily documentation PR that can be merged only be a Zedi

Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 11:19:12 -06:00
Julia Ryan
361b8e0ba9 Fix sticky header scroll offset (#45377)
Closes #43319

Release Notes:

- Sticky headers no longer obscure the cursor when it moves.

---------

Co-authored-by: HactarCE <6060305+HactarCE@users.noreply.github.com>
2025-12-19 09:04:15 -08:00
Agus Zubiaga
d7e41f74fb search: Respect macOS' find pasteboard (#45311)
Closes #17467

Release Notes:

- On macOS, buffer search now syncs with the system find pasteboard,
allowing <kbd>⌘E</kbd> and <kbd>⌘G</kbd> to work seamlessly across Zed
and other apps.
2025-12-19 13:31:27 -03:00
Ben Kunkle
e05dcecac4 Make pane::CloseAllItems best effort (#45368)
Closes #ISSUE

Release Notes:

- Fixed an issue where the `pane: close all items` action would give up
if you hit "Cancel" on the prompt for what to do with a dirty buffer
2025-12-19 16:21:56 +00:00
Danilo Leal
32600f255a gpui: Fix truncation flickering (#45373)
It's been a little that we've noticed some flickering and other weird
resizing behavior with text truncation in Zed:


https://github.com/user-attachments/assets/4d5691a3-cd3d-45e0-8b96-74a4e0e273d2


https://github.com/user-attachments/assets/d1d0e587-7676-4da0-8818-f4e50f0e294e

Initially, we suspected this could be due to how we calculate the length
of a line to insert truncation, which is based first on the length of
each individual character, and then second goes through a pass
calculating the line length as a whole. This could cause mismatch and
culminate in our bug.

However, even though that felt like a reasonable suspicion, I realized
something rather simple at some point: the `truncate` and
`truncate_start` methods in the `Label` didn't use `whitespace_nowrap`.
If you take Tailwind as an example, their `truncate` utility class takes
`overflow: hidden; text-overflow: ellipsis; white-space: nowrap;`. This
pointed out to a potential bug with `whitespace_nowrap` where that was
blocking truncation entirely, even though that's technically part of
what's necessary to truncate as you don't want text that will be
truncated to wrap.

Ultimately, what was happening was that the text element was caching its
layout based on its `wrap_width` but not considering its
`truncate_width`. The truncate width is essentially the new definitive
width of the text based on the available space, which was never being
computed. So the fix here was to add `truncate_width.is_none()` to the
cache validation check, so that it only uses the cached text element
size _if the truncation width is untouched_. But if that changes, we
need to account for the new width. Then, in the Label component, we
added `min_w_0` to allow the label div to shrink below its original
size, and finally, we added `whitespace_nowrap()` as the cache check
fundamentally fixed that method's problem.

In a future PR, we can basically remove the `single_line()` label method
because: 1) whenever you want a single label, you most likely want it to
truncate, and 2) most instances of `truncate` are already followed by
`single_line` in Zed today, so we can cut that part.

Result is no flickering with truncated labels!


https://github.com/user-attachments/assets/ae17cbde-0de7-42ca-98a4-22fcb452016b

Release Notes:

- Fixed a bug in GPUI where truncated text would flicker as you resized
the container in which the text was in.

Co-authored-by: Lukas Wirth <me@lukaswirth.dev>
2025-12-19 13:14:31 -03:00
Raduan A.
a7e07010e5 editor: Add automatic markdown list continuation on newline and indent on tab (#42800)
Closes #5089

Release notes:
- Markdown lists now continue automatically when you press Enter
(unordered, ordered, and task lists). This can be configured with
`extend_list_on_newline` (default: true).
- You can now indent list markers with Tab to quickly create nested
lists. This can be configured with `indent_list_on_tab` (default: true).

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-12-19 21:44:02 +05:30
feeiyu
ea34cc5324 Fix terminal doesn't switch to project directory when opening remote project on Windows (#45328)
Closes #45253

Release Notes:

- Fixed terminal doesn't switch to project directory when opening remote
project on Windows
2025-12-19 17:06:16 +01:00
Danilo Leal
a7d43063d4 workspace: Make title bar pickers render nearby the trigger when mouse-triggered (#45361)
From Zed's title bar, you can click on buttons to open three modal
pickers: remote projects, projects, and branches. All of these pickers
use the modal layer, which by default, renders them centered on the UI.
However, a UX issue we've been bothered by is that when you _click_ to
open them, they show up just way too far from where your mouse likely is
(nearby the trigger you just clicked). So, this PR introduces a
`ModalPlacement` enum to the modal layer, so that we can pick between
the "centered" and "anchored" options to render the picker. This way, we
can make the pickers use anchored positioning when triggered through a
mouse click and use the default centered positioning when triggered
through the keybinding.

One thing to note is that the anchored positioning here is not as
polished as regular popovers/dropdowns, because it simply uses the x and
y coordinates of the click to place the picker as opposed to using
GPUI's `Corner` enum, thus making them more connected to their triggers.
I chose to do it this way for now because it's a simpler and more
contained change, given it wouldn't require a tighter connection at the
code level between trigger and picker. But maybe we will want to do that
in the near future because we can bake in some other related behaviors
like automatically hiding the button trigger tooltip if the picker is
open and changing its text color to communicate which button triggered
the open picker.


https://github.com/user-attachments/assets/30d9c26a-24de-4702-8b7d-018b397f77e1

Release Notes:

- Improved the UX of title bar modal pickers (remote projects, projects,
and branches) by making them open closer to the trigger when triggering
them with the mouse.
2025-12-19 13:01:48 -03:00
AidanV
8001877df2 vim: Add :r[ead] [name] command (#45332)
This adds the following Vim commands: 
- `:r[ead] [name]`
- `:{range}r[ead] [name]`

The most important parts of this feature are outlined
[here](https://vimhelp.org/insert.txt.html#%3Ar).

The only intentional difference between this and Vim is that Vim only
allows `:read` (no filename) for buffers with a file attached. I am
allowing it for all buffers because I think that could be useful.

Release Notes:

- vim: Added the [`:r[ead] [name]` Vim
command](https://vimhelp.org/insert.txt.html#:read)

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-12-19 15:31:16 +00:00
Antonio Scandurra
b603372f44 Reduce GPU usage by activating VRR optimization only during high-rate input (#45369)
Fixes #29073

This PR reduces unnecessary GPU usage by being more selective about when
we present frames to prevent display underclocking (VRR optimization).

## Problem

Previously, we would keep presenting frames for 1 second after *any*
input event, regardless of whether it triggered a re-render. This caused
unnecessary GPU work when the user was idle or during low-frequency
interactions.

## Solution

1. **Only track input that triggers re-renders**: We now only record
input timestamps when the input actually causes the window to become
dirty, rather than on every input event.

2. **Rate-based activation**: The VRR optimization now only activates
when input arrives at a high rate (≥ 60fps over the last 100ms). This
means casual mouse movements or occasional keystrokes won't trigger
continuous frame presentation.

3. **Sustained optimization**: Once high-rate input is detected (e.g.,
during scrolling or dragging), we sustain frame presentation for 1
second to prevent display underclocking, even if input briefly pauses.

## Implementation

Added `InputRateTracker` which:
- Tracks input timestamps in a 100ms sliding window
- Activates when the window contains ≥ 6 events (60fps × 0.1s)
- Extends a `sustain_until` timestamp by 1 second each time high rate is
detected

Release Notes:

- Reduced GPU usage when idle by only presenting frames during bursts of
high-frequency input.
2025-12-19 16:06:28 +01:00
Yara 🏳️‍⚧️
7427924405 adjusted scheduler prioritization algorithm (#45367)
This fixes a number of issues where zed depends on the order of polling which changed when switching scheduler. We have adjusted the algorithm so it matches the previous order while keeping the prioritization feature.

Release Notes:
- N/A
2025-12-19 15:03:35 +00:00
Cole Miller
ae44c3c881 Fix extra terminal being created when a task replaces a terminal in the center pane (#45317)
Closes https://github.com/zed-industries/zed/issues/21144

Release Notes:

- Fixed spawned tasks creating an extra terminal in the dock in some
cases.
2025-12-19 09:39:58 -05:00
ozzy
4e0471cf66 git panel: Truncate file paths from the left (#43462)
https://github.com/user-attachments/assets/758e1ec9-6c34-4e13-b605-cf00c18ca16f

Release Notes:

- Improved: Git panel now truncates long file paths from the left,
showing "…path/filename" when space is limited, keeping filenames always
visible.

@cole-miller @mattermill

---------

Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-19 13:50:35 +00:00
Danilo Leal
62d36b22fd gpui: Add text_ellipsis_start method (#45122)
This PR is an additive change introducing the `truncate_start` method to
labels, which gives us the ability to add an ellipsis at the beginning
of the text as opposed to the regular `truncate`. This will be generally
used for truncating file paths, where the end is typically more relevant
than the beginning, but given it's a general method, there's the
possibility to be used anywhere else, too.

<img width="500" height="690" alt="Screenshot 2025-12-17 at 12  35@2x"
src="https://github.com/user-attachments/assets/f853f5a3-60b3-4380-a11c-bb47868a4470"
/>

Release Notes:

- N/A

---------

Co-authored-by: Lukas Wirth <lukas@zed.dev>
2025-12-19 10:25:19 -03:00
Piotr Osiewicz
69f6eeaa3a toolchains: Fix persistence by not relying on unstable worktree id (#45357)
Closes #42268
We've migrated user selections when a given workspace has a single
worktree (as then we could determine what the target worktree is).

Release Notes:

- python: Fixed selected virtual environments not being
persisted/deserialized correctly within long-running Zed sessions (where
multiple different projects might've been opened). This is a breaking
change for users of multi-worktree projects - your selected toolchain
for those projects will be reset.

Co-authored-by: Dino <dino@zed.dev>
2025-12-19 14:06:15 +01:00
Jakub Konka
1dc5de4592 workspace: Auto-switch git context when focus changed (#45354)
Closes #44955 

Release Notes:

- Fixed workspace incorrectly automatically switching Git
repository/branch context in multi-repository projects when repo/branch
switched manually from the Git panel.
2025-12-19 13:54:30 +01:00
Lena
b9aef75f2d Turn on the fixed stalebot (#45355)
It will run weekly and it promised not to touch issues of the wrong
types anymore.

Release Notes:

- N/A
2025-12-19 12:41:03 +00:00
Agus Zubiaga
95ae388c0c Fix title bar spacing when building on the macOS Tahoe SDK (#45351)
The size and spacing around the traffic light buttons changes after
macOS SDK 26. Our official builds aren't using this SDK yet, but dev
builds sometimes are and the official will in the future.

<table>
<tr>
<th>Before</th>
<th>After</th>
</tr>
<tr>
<td>
<img width="582" height="146" alt="CleanShot 2025-12-19 at 08 58 53@2x"
src="https://github.com/user-attachments/assets/1a28d74a-98a3-49d0-98d6-ab05b0580665"
/>
</td>
<td>
<img width="610" height="156" alt="CleanShot 2025-12-19 at 08 57 02@2x"
src="https://github.com/user-attachments/assets/7b7693b3-baa1-4d7e-9fc1-bd7a7bfacd36"
/>
</td>
</tr>
<tr>
<td>
<img width="532" height="154" alt="CleanShot 2025-12-19 at 08 59 40@2x"
src="https://github.com/user-attachments/assets/df7f40e7-7576-44f2-9cf3-047a5d00bb4e"
/>
</td>
<td>
<img width="520" height="150" alt="CleanShot 2025-12-19 at 09 01 17@2x"
src="https://github.com/user-attachments/assets/b0fbdeb6-1b1d-4e7a-95d0-3c78f0569df1"
/>
</td>
</tr>
</table>

Release Notes:

- N/A
2025-12-19 12:19:04 +00:00
Lena
1ac170e663 Upgrade stalebot and make testing it easier (#45350)
- adjust wording for the upcoming simplified process
- upgrade to the github action version that has a fix for configuring issue types the bot should look at
- add two inputs for the manual runs of stalebot that help testing it in a safe and controlled manner 

Release Notes:

- N/A
2025-12-19 12:46:20 +01:00
Angelo Verlain
3104482c6c languages: Detect .bst files as YAML (#45015)
These files are used by the BuildStream build project:
https://buildstream.build/index.html


Release Notes:

- Added recognition for .bst files as yaml.
2025-12-19 10:34:40 +00:00
Piotr Osiewicz
7ee56e1a18 chore: Add worktree_benchmarks to cargo workspace (#45344)
Idk why it was missing, but

Release Notes:

- N/A
2025-12-19 11:18:36 +01:00
Korbin de Man
f2495a6f98 Add Restore File action in project_panel for git modified files (#42490)
Co-authored-by: cameron <cameron.studdstreet@gmail.com>
2025-12-19 10:12:01 +00:00
prayansh_chhablani
6d776c3157 project: Sanitize single-line completions from trailing newlines (#44965)
Closes #43991
trim documentation string to prevent completion overlap


previous
[Screencast from 2025-12-16
14-55-58.webm](https://github.com/user-attachments/assets/d7674d82-63b0-4a85-a90f-b5c5091e4a82)
after change
[Screencast from 2025-12-16
14-50-05.webm](https://github.com/user-attachments/assets/109c22b5-3fff-49c8-a2ec-b1af467d6320)
Release Notes:

- Fixed an issue where completions in the completion menu would span
multiple lines.
2025-12-19 10:11:36 +01:00
Mayank Verma
596826f741 editor: Strip trailing newlines from completion documentation (#45342)
Closes #45337

Release Notes:

- Fixed broken completion menu layout caused by trailing newlines in ty
documentation

<table>
  <tr>
    <td>Before</td>
    <td>After</td>
  </tr>
  <tr>
    <td>
<img width="756" height="875" alt="before"
src="https://github.com/user-attachments/assets/1d9da7d8-437a-4f03-8158-32ff1af9a428"
/>
    </td>
    <td>  
<img width="755" height="875" alt="after"
src="https://github.com/user-attachments/assets/dca31af3-e571-445a-b4a9-c300bb4c63fa"
/>
    </td>
  </tr>
</table>
2025-12-19 08:43:35 +00:00
Mustaque Ahmed
e44529ed7b Hide inline overlays when context menu is open (#45266)
Closes #23367 

**Summary**
- Prevents inline diagnostics, code actions, blame annotations, and
hover popovers from overlapping with the right-click context menu by
checking for `mouse_context_menu` presence before rendering these UI
elements.

PS: Same behaviour is present in other editors like VS Code.


**Screen recording**


https://github.com/user-attachments/assets/8290412b-0f86-4985-8c70-13440686e530



Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-19 08:24:30 +00:00
rabsef-bicrym
e052127e1c terminal: Prevent scrollbar arithmetic underflow panic (#45282)
## Summary

Fixes arithmetic underflow panics in `terminal_scrollbar.rs` by
converting unsafe subtractions to `saturating_sub`.

Closes #45281

## Problem

Two locations perform raw subtraction on `usize` values that panic when
underflow occurs:

- `offset()`: `state.total_lines - state.viewport_lines -
state.display_offset`
- `set_offset()`: `state.total_lines - state.viewport_lines`

This happens when `total_lines < viewport_lines + display_offset`, which
can occur during terminal creation, with small window sizes, or when
display state becomes stale.

## Solution

Replace the two unsafe subtractions with `saturating_sub`, which returns
0 on underflow instead of panicking.

Also standardizes the existing `checked_sub().unwrap_or(0)` in
`max_offset()` to `saturating_sub` for consistency across the file.

## Changes

- N/A
2025-12-19 06:33:59 +00:00
Ryan Steil
0531035b86 docs: Fix link to Anthropic prompt engineering resource (#45329) 2025-12-19 01:47:40 -03:00
Ben Kunkle
05ce34eea4 ci: Fix docs build post #45130 (#45330)
Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-19 03:40:27 +00:00
Alvaro Parker
63c4406137 git: Add git clone open listener (#41669) 2025-12-19 00:21:46 -03:00
Ben Kunkle
3f67c5220d Remove zed dependency from docs_preprocessor (#45130)
Closes #ISSUE

Uses the existing `--dump-all-actions` arg on the Zed binary to generate
an asset of all of our actions so that the `docs_preprocessor` can
injest it, rather than depending on the Zed crate itself to collect all
action names

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
2025-12-18 21:59:05 -05:00
Ben Kunkle
435d4c5f24 vim: Make vaf include const for arrow functions in JS/TS/TSX (#45327)
Closes #24264

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-18 21:56:47 -05:00
Danilo Leal
e0ff995e2d agent ui: Make some UI elements more consistent (#45319)
- Both the mode, profile, and model selectors have the option to cycle
through its options with a keybinding. In the tooltip that shows it, in
some of them the "Cycle Through..." label was at the top, and in others
at the bottom. Now it's all at the bottom.
- We used different language in different places for "going to a file".
The tool call edit card's header said "_Jump_ to File" while the edit
files list said "_Go_ to File". Now it's both "Go to File".

Release Notes:

- N/A
2025-12-19 01:18:41 +00:00
Conrad Irwin
6976208e21 Move autofix stuff to zippy (#45304)
Although I wanted to avoid the dependency, it's hard to get github to do
what we want.

Release Notes:

- N/A
2025-12-18 15:23:09 -07:00
Richard Feldman
6055b45ee1 Add support for provider extensions (but no extensions yet) (#45277)
This adds support for provider extensions but doesn't actually add any
yet.

Release Notes:

- N/A
2025-12-18 17:05:04 -05:00
Joseph T. Lyons
88f90c12ed Add language server version in a tooltip on language server hover (#45302)
I wanted a way to make it easy to figure out which version of a language
server Zed is running. Now, you get a tooltip when hovering on a
language server in the Language Servers popover.

<img width="498" height="168" alt="SCR-20251218-ovln"
src="https://github.com/user-attachments/assets/1ced4214-b868-4405-8881-eb7c0b75a53e"
/>

This PR also fixes a bug. We had existing code to open a tooltip on
these language server entrees and display the language server message,
which was never fully wired up for `CustomEntry`s. Now, in this PR, we
will show show either version, message, or both, in the documentation
aside, depending on what the server has given us.

Mostly done with Droid (using GPT-5.2), with manual review and multiple
follow ups to guide it into using existing patterns in the codebase,
when it did something abnormal.

Release Notes:

- Added language server version in a tooltip on language server hover

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-18 21:59:21 +00:00
Marshall Bowers
0d74f982a5 danger: Upgrade danger-plugin-pr-hygiene to v0.7.1 (#45303)
This PR upgrades `danger-plugin-pr-hygiene` to v0.7.1.

Release Notes:

- N/A
2025-12-18 21:52:34 +00:00
Marshall Bowers
ca90b8555d docs: Remove local collaboration docs (#45301)
This PR removes the docs for running Collab locally, as they are
outdated and don't reflect the current state of affairs.

Release Notes:

- N/A
2025-12-18 21:42:28 +00:00
Richard Feldman
8516d81e13 Fix display name for Ollama models (#45287)
Closes #43646

Release Notes:

- Fixed display name for Ollama models
2025-12-18 16:32:59 -05:00
Danilo Leal
af589ff25f agent_ui: Simplify timestamp display (#45296)
This PR simplifies how we display thread timestamps in the agent panel's
history view. For threads that are older-than-yesterday, we just show
how many days ago that thread was had in. Hovering over the thread item
shows you both the title and the full date, if needed (time and date).

<img width="450" height="786" alt="Screenshot 2025-12-18 at 5  24@2x"
src="https://github.com/user-attachments/assets/11416e9b-f1b0-4307-9db0-988a95a316a1"
/>


Release Notes:

- N/A
2025-12-18 17:49:17 -03:00
Julia Ryan
d2bbfbb3bf lsp: Broadcast our capability for MessageActionItems (#45047)
Closes #37902

Release Notes:

- Enable LSP Message action items for more language servers. These are interactive prompts, often for things like downloading build inputs for a project.
2025-12-18 11:09:40 -08:00
Peter Tripp
413f4ea49c Redact environment variables from language server spawn errors (#44783)
Redact environment variables from zed logs when lsp fails to spawn.

Release Notes:

- N/A
2025-12-18 21:05:14 +02:00
Marshall Bowers
1b6d588413 danger: Deny conventional commits in PR titles (#45283)
This PR upgrades `danger-plugin-pr-hygiene` to v0.7.0 so that we can
have Danger deny conventional commits in PR titles.

Release Notes:

- N/A
2025-12-18 18:42:28 +00:00
Gaauwe Rombouts
334ca21857 Truncate code actions with a long label and show full label aside (#45268)
Closes #43355

Fixes the issue were code actions with long labels would get cut off
without being able to see the full description. We now properly truncate
those labels with an ellipsis and show the full description in an aside.

Release Notes:

- Added ellipsis to truncated code actions and an aside showing the full
action description.
2025-12-18 18:05:53 +01:00
Emmanuel Amoah
f58278aaf4 glossary: Fix grammar and typo (#45267)
Fixes grammar and a typo in `Picker` description.

Release Notes:

- N/A
2025-12-18 17:03:46 +00:00
Leo
e10b9b70ef git: Add global git integration enable/disable setting (#43326)
Closes #13304

Release Notes:

- Add global `git status` and `git diff` on/off in one place instead of
control everywhere

We can first review to ensure this change meets both `Zed` and user
requirements, as well as code rules. Currently, we only support
user-level settings. We can wait for this PR:
https://github.com/zed-industries/zed/pull/43173 to be merged, then
modify it to support both user and project levels.
2025-12-18 11:45:26 -05:00
Marco Mihai Condrache
098adf3bdd gpui: Enable direct-to-display optimization for metal (#44334)
When profiling Zed with Instruments, a warning appears indicating that
surfaces cannot be pushed directly to the display as they are
non-opaque. This happens because the metal layer is currently marked as
non-opaque by default, even though the window itself is not transparent.

<img width="590" height="55" alt="image"
src="https://github.com/user-attachments/assets/2647733e-c75b-4aec-aa19-e8b2ffd6194b"
/>

Metal on macOS can bypass compositing and present frames directly to the
display when several conditions are met. One of those conditions is that
the backing layer must be declared opaque. Apple’s documentation notes
that marking layers as opaque allows the system to avoid unnecessary
compositing work, reducing GPU load and improving frame pacing

Ref:
https://developer.apple.com/documentation/metal/managing-your-game-window-for-metal-in-macos

This PR updates the Metal renderer to mark the layer as opaque whenever
the window does not use transparency. This makes Zed eligible for
macOS’s direct-to-display optimization in scenarios where the system can
apply it.

Release Notes:

- gpui: Mark metal layers opaque for non-transparent windows to allow
direct-to-display when supported

---------

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>
2025-12-18 11:29:25 -05:00
Jakub Konka
a85c508f69 Fix self-referential symbolic link (#45265)
Release Notes:

- N/A
2025-12-18 17:26:20 +01:00
tidely
2a713c546b gpui: Small tab group performance improvements (#41885)
Closes #ISSUE

Removes a few eager container clones and iterations.

Added a todo to `get_prev_tab_group_window` and
`get_next_tab_group_window`. They seem to use `HashMap::keys()` for
choosing the previous tab group, however `.keys()` returns an arbitrary
order, so I'm not sure if previous actually means anything here. Conrad
seems to have worked on this part previously, maybe he has some
insights. That can possibly be a follow-up PR, but I'd be willing to
work on it here as well since the other changes are so simple.

Release Notes:

- N/A
2025-12-18 11:24:38 -05:00
Bennet Bo Fenner
f937c1931f rules_library: Only store built-in prompts when they are customized (#45112)
Follow up to #45004

Release Notes:

- N/A
2025-12-18 17:21:41 +01:00
Danilo Leal
7a62f01ea5 agent_ui: Use display name for the message editor placeholder (#45264)
Follow up to a regression that happened when we introduced agent servers
that made everywhere displaying agent names use the extension name
instead of the display name. This has been since fixed in other places
and this PR now updates the agent panel's message editor, too:

| Before | After |
|--------|--------|
| <img width="1154" height="254" alt="Screenshot 2025-12-18 at 12  54
2@2x"
src="https://github.com/user-attachments/assets/5f3de9f9-4e11-42f6-90c2-56fc8cdff32e"
/> | <img width="1154" height="254" alt="Screenshot 2025-12-18 at 12 
54@2x"
src="https://github.com/user-attachments/assets/46ed5c45-7e1d-4cc6-b219-b6cc19206d1b"
/> |

Release Notes:

- N/A
2025-12-18 13:08:46 -03:00
219 changed files with 9428 additions and 3817 deletions

View File

@@ -0,0 +1,55 @@
# Phase 2: Explore Repository
You are analyzing a codebase to understand its structure before reviewing documentation impact.
## Objective
Produce a structured overview of the repository to inform subsequent documentation analysis.
## Instructions
1. **Identify Primary Languages and Frameworks**
- Scan for Cargo.toml, package.json, or other manifest files
- Note the primary language(s) and key dependencies
2. **Map Documentation Structure**
- This project uses **mdBook** (https://rust-lang.github.io/mdBook/)
- Documentation is in `docs/src/`
- Table of contents: `docs/src/SUMMARY.md` (mdBook format: https://rust-lang.github.io/mdBook/format/summary.html)
- Style guide: `docs/.rules`
- Agent guidelines: `docs/AGENTS.md`
- Formatting: Prettier (config in `docs/.prettierrc`)
3. **Identify Build and Tooling**
- Note build systems (cargo, npm, etc.)
- Identify documentation tooling (mdbook, etc.)
4. **Output Format**
Produce a JSON summary:
```json
{
"primary_language": "Rust",
"frameworks": ["GPUI"],
"documentation": {
"system": "mdBook",
"location": "docs/src/",
"toc_file": "docs/src/SUMMARY.md",
"toc_format": "https://rust-lang.github.io/mdBook/format/summary.html",
"style_guide": "docs/.rules",
"agent_guidelines": "docs/AGENTS.md",
"formatter": "prettier",
"formatter_config": "docs/.prettierrc",
"custom_preprocessor": "docs_preprocessor (handles {#kb action::Name} syntax)"
},
"key_directories": {
"source": "crates/",
"docs": "docs/src/",
"extensions": "extensions/"
}
}
```
## Constraints
- Read-only: Do not modify any files
- Focus on structure, not content details
- Complete within 2 minutes

View File

@@ -0,0 +1,57 @@
# Phase 3: Analyze Changes
You are analyzing code changes to understand their nature and scope.
## Objective
Produce a clear, neutral summary of what changed in the codebase.
## Input
You will receive:
- List of changed files from the triggering commit/PR
- Repository structure from Phase 2
## Instructions
1. **Categorize Changed Files**
- Source code (which crates/modules)
- Configuration
- Tests
- Documentation (already existing)
- Other
2. **Analyze Each Change**
- Review diffs for files likely to impact documentation
- Focus on: public APIs, settings, keybindings, commands, user-visible behavior
3. **Identify What Did NOT Change**
- Note stable interfaces or behaviors
- Important for avoiding unnecessary documentation updates
4. **Output Format**
Produce a markdown summary:
```markdown
## Change Analysis
### Changed Files Summary
| Category | Files | Impact Level |
| --- | --- | --- |
| Source - [crate] | file1.rs, file2.rs | High/Medium/Low |
| Settings | settings.json | Medium |
| Tests | test_*.rs | None |
### Behavioral Changes
- **[Feature/Area]**: Description of what changed from user perspective
- **[Feature/Area]**: Description...
### Unchanged Areas
- [Area]: Confirmed no changes to [specific behavior]
### Files Requiring Deeper Review
- `path/to/file.rs`: Reason for deeper review
```
## Constraints
- Read-only: Do not modify any files
- Neutral tone: Describe what changed, not whether it's good/bad
- Do not propose documentation changes yet

View File

@@ -0,0 +1,76 @@
# Phase 4: Plan Documentation Impact
You are determining whether and how documentation should be updated based on code changes.
## Objective
Produce a structured documentation plan that will guide Phase 5 execution.
## Documentation System
This is an **mdBook** site (https://rust-lang.github.io/mdBook/):
- `docs/src/SUMMARY.md` defines book structure per https://rust-lang.github.io/mdBook/format/summary.html
- If adding new pages, they MUST be added to SUMMARY.md
- Use `{#kb action::ActionName}` syntax for keybindings (custom preprocessor expands these)
- Prettier formatting (80 char width) will be applied automatically
## Input
You will receive:
- Change analysis from Phase 3
- Repository structure from Phase 2
- Documentation guidelines from `docs/AGENTS.md`
## Instructions
1. **Review AGENTS.md**
- Load and apply all rules from `docs/AGENTS.md`
- Respect scope boundaries (in-scope vs out-of-scope)
2. **Evaluate Documentation Impact**
For each behavioral change from Phase 3:
- Does existing documentation cover this area?
- Is the documentation now inaccurate or incomplete?
- Classify per AGENTS.md "Change Classification" section
3. **Identify Specific Updates**
For each required update:
- Exact file path
- Specific section or heading
- Type of change (update existing, add new, deprecate)
- Description of the change
4. **Flag Uncertainty**
Explicitly mark:
- Assumptions you're making
- Areas where human confirmation is needed
- Ambiguous requirements
5. **Output Format**
Use the exact format specified in `docs/AGENTS.md` Phase 4 section:
```markdown
## Documentation Impact Assessment
### Summary
Brief description of code changes analyzed.
### Documentation Updates Required: [Yes/No]
### Planned Changes
#### 1. [File Path]
- **Section**: [Section name or "New section"]
- **Change Type**: [Update/Add/Deprecate]
- **Reason**: Why this change is needed
- **Description**: What will be added/modified
### Uncertainty Flags
- [ ] [Description of any assumptions or areas needing confirmation]
### No Changes Needed
- [List files reviewed but not requiring updates, with brief reason]
```
## Constraints
- Read-only: Do not modify any files
- Conservative: When uncertain, flag for human review rather than planning changes
- Scoped: Only plan changes that trace directly to code changes from Phase 3
- No scope expansion: Do not plan "improvements" unrelated to triggering changes

View File

@@ -0,0 +1,67 @@
# Phase 5: Apply Documentation Plan
You are executing a pre-approved documentation plan for an **mdBook** documentation site.
## Objective
Implement exactly the changes specified in the documentation plan from Phase 4.
## Documentation System
- **mdBook**: https://rust-lang.github.io/mdBook/
- **SUMMARY.md**: Follows mdBook format (https://rust-lang.github.io/mdBook/format/summary.html)
- **Prettier**: Will be run automatically after this phase (80 char line width)
- **Custom preprocessor**: Use `{#kb action::ActionName}` for keybindings instead of hardcoding
## Input
You will receive:
- Documentation plan from Phase 4
- Documentation guidelines from `docs/AGENTS.md`
- Style rules from `docs/.rules`
## Instructions
1. **Validate Plan**
- Confirm all planned files are within scope per AGENTS.md
- Verify no out-of-scope files are targeted
2. **Execute Each Planned Change**
For each item in "Planned Changes":
- Navigate to the specified file
- Locate the specified section
- Apply the described change
- Follow style rules from `docs/.rules`
3. **Style Compliance**
Every edit must follow `docs/.rules`:
- Second person, present tense
- No hedging words ("simply", "just", "easily")
- Proper keybinding format (`Cmd+Shift+P`)
- Settings Editor first, JSON second
- Correct terminology (folder not directory, etc.)
4. **Preserve Context**
- Maintain surrounding content structure
- Keep consistent heading levels
- Preserve existing cross-references
## Constraints
- Execute ONLY changes listed in the plan
- Do not discover new documentation targets
- Do not make stylistic improvements outside planned sections
- Do not expand scope beyond what Phase 4 specified
- If a planned change cannot be applied (file missing, section not found), skip and note it
## Output
After applying changes, output a summary:
```markdown
## Applied Changes
### Successfully Applied
- `path/to/file.md`: [Brief description of change]
### Skipped (Could Not Apply)
- `path/to/file.md`: [Reason - e.g., "Section not found"]
### Warnings
- [Any issues encountered during application]
```

View File

@@ -0,0 +1,54 @@
# Phase 6: Summarize Changes
You are generating a summary of documentation updates for PR review.
## Objective
Create a clear, reviewable summary of all documentation changes made.
## Input
You will receive:
- Applied changes report from Phase 5
- Original change analysis from Phase 3
- Git diff of documentation changes
## Instructions
1. **Gather Change Information**
- List all modified documentation files
- Identify the corresponding code changes that triggered each update
2. **Generate Summary**
Use the format specified in `docs/AGENTS.md` Phase 6 section:
```markdown
## Documentation Update Summary
### Changes Made
| File | Change | Related Code |
| --- | --- | --- |
| docs/src/path.md | Brief description | PR #123 or commit SHA |
### Rationale
Brief explanation of why these updates were made, linking back to the triggering code changes.
### Review Notes
- Items reviewers should pay special attention to
- Any uncertainty flags from Phase 4 that were addressed
- Assumptions made during documentation
```
3. **Add Context for Reviewers**
- Highlight any changes that might be controversial
- Note if any planned changes were skipped and why
- Flag areas where reviewer expertise is especially needed
## Output Format
The summary should be suitable for:
- PR description body
- Commit message (condensed version)
- Team communication
## Constraints
- Read-only (documentation changes already applied in Phase 5)
- Factual: Describe what was done, not justify why it's good
- Complete: Account for all changes, including skipped items

View File

@@ -0,0 +1,67 @@
# Phase 7: Commit and Open PR
You are creating a git branch, committing documentation changes, and opening a PR.
## Objective
Package documentation updates into a reviewable pull request.
## Input
You will receive:
- Summary from Phase 6
- List of modified files
## Instructions
1. **Create Branch**
```sh
git checkout -b docs/auto-update-{date}
```
Use format: `docs/auto-update-YYYY-MM-DD` or `docs/auto-update-{short-sha}`
2. **Stage and Commit**
- Stage only documentation files in `docs/src/`
- Do not stage any other files
Commit message format:
```
docs: auto-update documentation for [brief description]
[Summary from Phase 6, condensed]
Triggered by: [commit SHA or PR reference]
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
```
3. **Push Branch**
```sh
git push -u origin docs/auto-update-{date}
```
4. **Create Pull Request**
Use the Phase 6 summary as the PR body.
PR Title: `docs: [Brief description of documentation updates]`
Labels (if available): `documentation`, `automated`
Base branch: `main`
## Constraints
- Do NOT auto-merge
- Do NOT request specific reviewers (let CODEOWNERS handle it)
- Do NOT modify files outside `docs/src/`
- If no changes to commit, exit gracefully with message "No documentation changes to commit"
## Output
```markdown
## PR Created
- **Branch**: docs/auto-update-{date}
- **PR URL**: https://github.com/zed-industries/zed/pull/XXXX
- **Status**: Ready for review
### Commit
- SHA: {commit-sha}
- Files: {count} documentation files modified
```

View File

@@ -19,6 +19,18 @@ runs:
shell: bash -euxo pipefail {0}
run: ./script/linux
- name: Install mold linker
shell: bash -euxo pipefail {0}
run: ./script/install-mold
- name: Download WASI SDK
shell: bash -euxo pipefail {0}
run: ./script/download-wasi-sdk
- name: Generate action metadata
shell: bash -euxo pipefail {0}
run: ./script/generate-action-metadata
- name: Check for broken links (in MD)
uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1
with:

View File

@@ -54,6 +54,10 @@ jobs:
- name: autofix_pr::run_autofix::run_cargo_fmt
run: cargo fmt --all
shell: bash -euxo pipefail {0}
- name: autofix_pr::run_autofix::run_cargo_fix
if: ${{ inputs.run_clippy }}
run: cargo fix --workspace --release --all-targets --all-features --allow-dirty --allow-staged
shell: bash -euxo pipefail {0}
- name: autofix_pr::run_autofix::run_clippy_fix
if: ${{ inputs.run_clippy }}
run: cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged

View File

@@ -1,29 +1,40 @@
name: "Close Stale Issues"
on:
schedule:
- cron: "0 8 31 DEC *"
- cron: "0 2 * * 5"
workflow_dispatch:
inputs:
debug-only:
description: "Run in dry-run mode (no changes made)"
type: boolean
default: false
operations-per-run:
description: "Max number of issues to process (default: 1000)"
type: number
default: 1000
jobs:
stale:
if: github.repository_owner == 'zed-industries'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: >
Hi there! 👋
We're working to clean up our issue tracker by closing older bugs that might not be relevant anymore. If you are able to reproduce this issue in the latest version of Zed, please let us know by commenting on this issue, and it will be kept open. If you can't reproduce it, feel free to close the issue yourself. Otherwise, it will close automatically in 14 days.
Hi there!
Zed development moves fast and a significant number of bugs become outdated.
If you can reproduce this bug on the latest stable Zed, please let us know by leaving a comment with the Zed version.
If the bug doesn't appear for you anymore, feel free to close the issue yourself; otherwise, the bot will close it in a couple of weeks.
Thanks for your help!
close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please open a new issue with a link to this issue."
close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please leave a comment with your Zed version so that we can reopen the issue."
days-before-stale: 60
days-before-close: 14
only-issue-types: "Bug,Crash"
operations-per-run: 1000
operations-per-run: ${{ inputs.operations-per-run || 1000 }}
ascending: true
enable-statistics: true
debug-only: ${{ inputs.debug-only }}
stale-issue-label: "stale"
exempt-issue-labels: "never stale"

264
.github/workflows/docs_automation.yml vendored Normal file
View File

@@ -0,0 +1,264 @@
name: Documentation Automation
on:
# push:
# branches: [main]
# paths:
# - 'crates/**'
# - 'extensions/**'
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to analyze (gets full PR diff)'
required: false
type: string
trigger_sha:
description: 'Commit SHA to analyze (ignored if pr_number is set)'
required: false
type: string
permissions:
contents: write
pull-requests: write
env:
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
DROID_MODEL: claude-opus-4-5-20251101
jobs:
docs-automation:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Droid CLI
id: install-droid
run: |
curl -fsSL https://app.factory.ai/cli | sh
echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
echo "DROID_BIN=${HOME}/.local/bin/droid" >> "$GITHUB_ENV"
# Verify installation
"${HOME}/.local/bin/droid" --version
- name: Setup Node.js (for Prettier)
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Prettier
run: npm install -g prettier
- name: Get changed files
id: changed
run: |
if [ -n "${{ inputs.pr_number }}" ]; then
# Get full PR diff
echo "Analyzing PR #${{ inputs.pr_number }}"
echo "source=pr" >> "$GITHUB_OUTPUT"
echo "ref=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
gh pr diff "${{ inputs.pr_number }}" --name-only > /tmp/changed_files.txt
elif [ -n "${{ inputs.trigger_sha }}" ]; then
# Get single commit diff
SHA="${{ inputs.trigger_sha }}"
echo "Analyzing commit $SHA"
echo "source=commit" >> "$GITHUB_OUTPUT"
echo "ref=$SHA" >> "$GITHUB_OUTPUT"
git diff --name-only "${SHA}^" "$SHA" > /tmp/changed_files.txt
else
# Default to current commit
SHA="${{ github.sha }}"
echo "Analyzing commit $SHA"
echo "source=commit" >> "$GITHUB_OUTPUT"
echo "ref=$SHA" >> "$GITHUB_OUTPUT"
git diff --name-only "${SHA}^" "$SHA" > /tmp/changed_files.txt || git diff --name-only HEAD~1 HEAD > /tmp/changed_files.txt
fi
echo "Changed files:"
cat /tmp/changed_files.txt
env:
GH_TOKEN: ${{ github.token }}
# Phase 0: Guardrails are loaded via AGENTS.md in each phase
# Phase 2: Explore Repository (Read-Only - default)
- name: "Phase 2: Explore Repository"
id: phase2
run: |
"$DROID_BIN" exec \
-m "$DROID_MODEL" \
-f .factory/prompts/docs-automation/phase2-explore.md \
> /tmp/phase2-output.txt 2>&1 || true
echo "Repository exploration complete"
cat /tmp/phase2-output.txt
# Phase 3: Analyze Changes (Read-Only - default)
- name: "Phase 3: Analyze Changes"
id: phase3
run: |
CHANGED_FILES=$(tr '\n' ' ' < /tmp/changed_files.txt)
echo "Analyzing changes in: $CHANGED_FILES"
# Build prompt with context
cat > /tmp/phase3-prompt.md << 'EOF'
$(cat .factory/prompts/docs-automation/phase3-analyze.md)
## Context
### Changed Files
$CHANGED_FILES
### Phase 2 Output
$(cat /tmp/phase2-output.txt)
EOF
"$DROID_BIN" exec \
-m "$DROID_MODEL" \
"$(cat .factory/prompts/docs-automation/phase3-analyze.md)
Changed files: $CHANGED_FILES" \
> /tmp/phase3-output.md 2>&1 || true
echo "Change analysis complete"
cat /tmp/phase3-output.md
# Phase 4: Plan Documentation Impact (Read-Only - default)
- name: "Phase 4: Plan Documentation Impact"
id: phase4
run: |
"$DROID_BIN" exec \
-m "$DROID_MODEL" \
-f .factory/prompts/docs-automation/phase4-plan.md \
> /tmp/phase4-plan.md 2>&1 || true
echo "Documentation plan complete"
cat /tmp/phase4-plan.md
# Check if updates are required
if grep -q "NO_UPDATES_REQUIRED" /tmp/phase4-plan.md; then
echo "updates_required=false" >> "$GITHUB_OUTPUT"
else
echo "updates_required=true" >> "$GITHUB_OUTPUT"
fi
# Phase 5: Apply Plan (Write-Enabled with --auto medium)
- name: "Phase 5: Apply Documentation Plan"
id: phase5
if: steps.phase4.outputs.updates_required == 'true'
run: |
"$DROID_BIN" exec \
-m "$DROID_MODEL" \
--auto medium \
-f .factory/prompts/docs-automation/phase5-apply.md \
> /tmp/phase5-report.md 2>&1 || true
echo "Documentation updates applied"
cat /tmp/phase5-report.md
# Phase 5b: Format with Prettier
- name: "Phase 5b: Format with Prettier"
id: phase5b
if: steps.phase4.outputs.updates_required == 'true'
run: |
echo "Formatting documentation with Prettier..."
cd docs && prettier --write src/
echo "Verifying Prettier formatting passes..."
cd docs && prettier --check src/
echo "Prettier formatting complete"
# Phase 6: Summarize Changes (Read-Only - default)
- name: "Phase 6: Summarize Changes"
id: phase6
if: steps.phase4.outputs.updates_required == 'true'
run: |
# Get git diff of docs
git diff docs/src/ > /tmp/docs-diff.txt || true
"$DROID_BIN" exec \
-m "$DROID_MODEL" \
-f .factory/prompts/docs-automation/phase6-summarize.md \
> /tmp/phase6-summary.md 2>&1 || true
echo "Summary generated"
cat /tmp/phase6-summary.md
# Phase 7: Commit and Open PR
- name: "Phase 7: Create PR"
id: phase7
if: steps.phase4.outputs.updates_required == 'true'
run: |
# Check if there are actual changes
if git diff --quiet docs/src/; then
echo "No documentation changes detected"
exit 0
fi
# Configure git
git config user.name "factory-droid[bot]"
git config user.email "138933559+factory-droid[bot]@users.noreply.github.com"
# Daily batch branch - one branch per day, multiple commits accumulate
BRANCH_NAME="docs/auto-update-$(date +%Y-%m-%d)"
# Stash local changes from phase 5
git stash push -m "docs-automation-changes" -- docs/src/
# Check if branch already exists on remote
if git ls-remote --exit-code --heads origin "$BRANCH_NAME" > /dev/null 2>&1; then
echo "Branch $BRANCH_NAME exists, checking out and updating..."
git fetch origin "$BRANCH_NAME"
git checkout -B "$BRANCH_NAME" "origin/$BRANCH_NAME"
else
echo "Creating new branch $BRANCH_NAME..."
git checkout -b "$BRANCH_NAME"
fi
# Apply stashed changes
git stash pop || true
# Stage and commit
git add docs/src/
SUMMARY=$(head -50 < /tmp/phase6-summary.md)
git commit -m "docs: auto-update documentation
${SUMMARY}
Triggered by: ${{ steps.changed.outputs.source }} ${{ steps.changed.outputs.ref }}
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>"
# Push
git push -u origin "$BRANCH_NAME"
# Check if PR already exists for this branch
EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --json number --jq '.[0].number' || echo "")
if [ -n "$EXISTING_PR" ]; then
echo "PR #$EXISTING_PR already exists for branch $BRANCH_NAME, updated with new commit"
else
# Create new PR
gh pr create \
--title "docs: automated documentation update ($(date +%Y-%m-%d))" \
--body-file /tmp/phase6-summary.md \
--base main || true
echo "PR created on branch: $BRANCH_NAME"
fi
env:
GH_TOKEN: ${{ github.token }}
# Summary output
- name: "Summary"
if: always()
run: |
echo "## Documentation Automation Summary" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ "${{ steps.phase4.outputs.updates_required }}" == "false" ]; then
echo "No documentation updates required for this change." >> "$GITHUB_STEP_SUMMARY"
elif [ -f /tmp/phase6-summary.md ]; then
cat /tmp/phase6-summary.md >> "$GITHUB_STEP_SUMMARY"
else
echo "Workflow completed. Check individual phase outputs for details." >> "$GITHUB_STEP_SUMMARY"
fi

View File

@@ -61,8 +61,7 @@ jobs:
uses: namespacelabs/nscloud-cache-action@v1
with:
cache: rust
- id: cargo_fmt
name: steps::cargo_fmt
- name: steps::cargo_fmt
run: cargo fmt --all -- --check
shell: bash -euxo pipefail {0}
- name: extension_tests::run_clippy

View File

@@ -26,8 +26,7 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- name: steps::clear_target_dir_if_large
@@ -72,15 +71,9 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- id: record_clippy_failure
name: steps::record_clippy_failure
if: always()
run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
shell: bash -euxo pipefail {0}
- name: steps::cargo_install_nextest
uses: taiki-e/install-action@nextest
- name: steps::clear_target_dir_if_large
@@ -94,8 +87,6 @@ jobs:
run: |
rm -rf ./../.cargo
shell: bash -euxo pipefail {0}
outputs:
clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }}
timeout-minutes: 60
run_tests_windows:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
@@ -114,8 +105,7 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy.ps1
shell: pwsh
- name: steps::clear_target_dir_if_large

View File

@@ -20,8 +20,7 @@ jobs:
with:
clean: false
fetch-depth: 0
- id: cargo_fmt
name: steps::cargo_fmt
- name: steps::cargo_fmt
run: cargo fmt --all -- --check
shell: bash -euxo pipefail {0}
- name: ./script/clippy
@@ -45,8 +44,7 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy.ps1
shell: pwsh
- name: steps::clear_target_dir_if_large

View File

@@ -74,19 +74,12 @@ jobs:
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
with:
version: '9'
- id: prettier
name: steps::prettier
- name: steps::prettier
run: ./script/prettier
shell: bash -euxo pipefail {0}
- id: cargo_fmt
name: steps::cargo_fmt
- name: steps::cargo_fmt
run: cargo fmt --all -- --check
shell: bash -euxo pipefail {0}
- id: record_style_failure
name: steps::record_style_failure
if: always()
run: echo "failed=${{ steps.prettier.outcome == 'failure' || steps.cargo_fmt.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
shell: bash -euxo pipefail {0}
- name: ./script/check-todos
run: ./script/check-todos
shell: bash -euxo pipefail {0}
@@ -97,8 +90,6 @@ jobs:
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06
with:
config: ./typos.toml
outputs:
style_failed: ${{ steps.record_style_failure.outputs.failed == 'true' }}
timeout-minutes: 60
run_tests_windows:
needs:
@@ -119,8 +110,7 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy.ps1
shell: pwsh
- name: steps::clear_target_dir_if_large
@@ -167,15 +157,9 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- id: record_clippy_failure
name: steps::record_clippy_failure
if: always()
run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
shell: bash -euxo pipefail {0}
- name: steps::cargo_install_nextest
uses: taiki-e/install-action@nextest
- name: steps::clear_target_dir_if_large
@@ -189,8 +173,6 @@ jobs:
run: |
rm -rf ./../.cargo
shell: bash -euxo pipefail {0}
outputs:
clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }}
timeout-minutes: 60
run_tests_mac:
needs:
@@ -211,8 +193,7 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- name: steps::clear_target_dir_if_large
@@ -372,6 +353,9 @@ jobs:
- name: steps::download_wasi_sdk
run: ./script/download-wasi-sdk
shell: bash -euxo pipefail {0}
- name: ./script/generate-action-metadata
run: ./script/generate-action-metadata
shell: bash -euxo pipefail {0}
- name: run_tests::check_docs::install_mdbook
uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08
with:
@@ -592,24 +576,6 @@ jobs:
exit $EXIT_CODE
shell: bash -euxo pipefail {0}
call_autofix:
needs:
- check_style
- run_tests_linux
if: always() && (needs.check_style.outputs.style_failed == 'true' || needs.run_tests_linux.outputs.clippy_failed == 'true') && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: get-app-token
name: steps::authenticate_as_zippy
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
with:
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
- name: run_tests::call_autofix::dispatch_autofix
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=${{ needs.run_tests_linux.outputs.clippy_failed == 'true' }}
shell: bash -euxo pipefail {0}
env:
GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
cancel-in-progress: true

1
.gitignore vendored
View File

@@ -36,6 +36,7 @@
DerivedData/
Packages
xcuserdata/
crates/docs_preprocessor/actions.json
# Don't commit any secrets to the repo.
.env

47
Cargo.lock generated
View File

@@ -3525,6 +3525,33 @@ dependencies = [
"theme",
]
[[package]]
name = "component_preview"
version = "0.1.0"
dependencies = [
"anyhow",
"client",
"collections",
"component",
"db",
"fs",
"gpui",
"language",
"log",
"node_runtime",
"notifications",
"project",
"release_channel",
"reqwest_client",
"session",
"settings",
"theme",
"ui",
"ui_input",
"uuid",
"workspace",
]
[[package]]
name = "compression-codecs"
version = "0.4.31"
@@ -5021,8 +5048,6 @@ name = "docs_preprocessor"
version = "0.1.0"
dependencies = [
"anyhow",
"command_palette",
"gpui",
"mdbook",
"regex",
"serde",
@@ -5031,7 +5056,6 @@ dependencies = [
"task",
"theme",
"util",
"zed",
"zlog",
]
@@ -8932,6 +8956,8 @@ dependencies = [
"credentials_provider",
"deepseek",
"editor",
"extension",
"extension_host",
"fs",
"futures 0.3.31",
"google_ai",
@@ -12571,6 +12597,7 @@ dependencies = [
"gpui",
"language",
"menu",
"notifications",
"pretty_assertions",
"project",
"rayon",
@@ -12648,6 +12675,8 @@ dependencies = [
"paths",
"rope",
"serde",
"strum 0.27.2",
"tempfile",
"text",
"util",
"uuid",
@@ -20263,6 +20292,16 @@ dependencies = [
"zlog",
]
[[package]]
name = "worktree_benchmarks"
version = "0.1.0"
dependencies = [
"fs",
"gpui",
"settings",
"worktree",
]
[[package]]
name = "writeable"
version = "0.6.1"
@@ -20631,6 +20670,7 @@ dependencies = [
"collections",
"command_palette",
"component",
"component_preview",
"copilot",
"crashes",
"dap",
@@ -20736,7 +20776,6 @@ dependencies = [
"tree-sitter-md",
"tree-sitter-rust",
"ui",
"ui_input",
"ui_prompt",
"url",
"urlencoding",

View File

@@ -39,6 +39,7 @@ members = [
"crates/command_palette",
"crates/command_palette_hooks",
"crates/component",
"crates/component_preview",
"crates/context_server",
"crates/copilot",
"crates/crashes",
@@ -198,6 +199,7 @@ members = [
"crates/web_search_providers",
"crates/workspace",
"crates/worktree",
"crates/worktree_benchmarks",
"crates/x_ai",
"crates/zed",
"crates/zed_actions",
@@ -274,6 +276,7 @@ collections = { path = "crates/collections", version = "0.1.0" }
command_palette = { path = "crates/command_palette" }
command_palette_hooks = { path = "crates/command_palette_hooks" }
component = { path = "crates/component" }
component_preview = { path = "crates/component_preview" }
context_server = { path = "crates/context_server" }
copilot = { path = "crates/copilot" }
crashes = { path = "crates/crashes" }

View File

@@ -20,7 +20,6 @@ Other platforms are not yet available:
- [Building Zed for macOS](./docs/src/development/macos.md)
- [Building Zed for Linux](./docs/src/development/linux.md)
- [Building Zed for Windows](./docs/src/development/windows.md)
- [Running Collaboration Locally](./docs/src/development/local-collaboration.md)
### Contributing

View File

@@ -1178,6 +1178,10 @@
"remove_trailing_whitespace_on_save": true,
// Whether to start a new line with a comment when a previous line is a comment as well.
"extend_comment_on_newline": true,
// Whether to continue markdown lists when pressing enter.
"extend_list_on_newline": true,
// Whether to indent list items when pressing tab after a list marker.
"indent_list_on_tab": true,
// Removes any lines containing only whitespace at the end of the file and
// ensures just one newline at the end.
"ensure_final_newline_on_save": true,
@@ -1321,6 +1325,14 @@
"hidden_files": ["**/.*"],
// Git gutter behavior configuration.
"git": {
// Global switch to enable or disable all git integration features.
// If set to true, disables all git integration features.
// If set to false, individual git integration features below will be independently enabled or disabled.
"disable_git": false,
// Whether to enable git status tracking.
"enable_status": true,
// Whether to enable git diff display.
"enable_diff": true,
// Control whether the git gutter is shown. May take 2 values:
// 1. Show the gutter
// "git_gutter": "tracked_files"

View File

@@ -11,6 +11,7 @@ use language::language_settings::FormatOnSave;
pub use mention::*;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
use serde::{Deserialize, Serialize};
use serde_json::to_string_pretty;
use settings::Settings as _;
use task::{Shell, ShellBuilder};
pub use terminal::*;
@@ -1992,37 +1993,42 @@ impl AcpThread {
fn update_last_checkpoint(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
let git_store = self.project.read(cx).git_store().clone();
let old_checkpoint = if let Some((_, message)) = self.last_user_message() {
if let Some(checkpoint) = message.checkpoint.as_ref() {
checkpoint.git_checkpoint.clone()
} else {
return Task::ready(Ok(()));
}
} else {
let Some((_, message)) = self.last_user_message() else {
return Task::ready(Ok(()));
};
let Some(user_message_id) = message.id.clone() else {
return Task::ready(Ok(()));
};
let Some(checkpoint) = message.checkpoint.as_ref() else {
return Task::ready(Ok(()));
};
let old_checkpoint = checkpoint.git_checkpoint.clone();
let new_checkpoint = git_store.update(cx, |git, cx| git.checkpoint(cx));
cx.spawn(async move |this, cx| {
let new_checkpoint = new_checkpoint
let Some(new_checkpoint) = new_checkpoint
.await
.context("failed to get new checkpoint")
.log_err();
if let Some(new_checkpoint) = new_checkpoint {
let equal = git_store
.update(cx, |git, cx| {
git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx)
})?
.await
.unwrap_or(true);
this.update(cx, |this, cx| {
let (ix, message) = this.last_user_message().context("no user message")?;
let checkpoint = message.checkpoint.as_mut().context("no checkpoint")?;
checkpoint.show = !equal;
cx.emit(AcpThreadEvent::EntryUpdated(ix));
anyhow::Ok(())
})??;
}
.log_err()
else {
return Ok(());
};
let equal = git_store
.update(cx, |git, cx| {
git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx)
})?
.await
.unwrap_or(true);
this.update(cx, |this, cx| {
if let Some((ix, message)) = this.user_message_mut(&user_message_id) {
if let Some(checkpoint) = message.checkpoint.as_mut() {
checkpoint.show = !equal;
cx.emit(AcpThreadEvent::EntryUpdated(ix));
}
}
})?;
Ok(())
})
@@ -2422,8 +2428,10 @@ fn markdown_for_raw_output(
)
})),
value => Some(cx.new(|cx| {
let pretty_json = to_string_pretty(value).unwrap_or_else(|_| value.to_string());
Markdown::new(
format!("```json\n{}\n```", value).into(),
format!("```json\n{}\n```", pretty_json).into(),
Some(language_registry.clone()),
None,
cx,
@@ -4066,4 +4074,66 @@ mod tests {
"Should have exactly 2 terminals (the completed ones from before checkpoint)"
);
}
/// Tests that update_last_checkpoint correctly updates the original message's checkpoint
/// even when a new user message is added while the async checkpoint comparison is in progress.
///
/// This is a regression test for a bug where update_last_checkpoint would fail with
/// "no checkpoint" if a new user message (without a checkpoint) was added between when
/// update_last_checkpoint started and when its async closure ran.
#[gpui::test]
async fn test_update_last_checkpoint_with_new_message_added(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/test"), json!({".git": {}, "file.txt": "content"}))
.await;
let project = Project::test(fs.clone(), [Path::new(path!("/test"))], cx).await;
let handler_done = Arc::new(AtomicBool::new(false));
let handler_done_clone = handler_done.clone();
let connection = Rc::new(FakeAgentConnection::new().on_user_message(
move |_, _thread, _cx| {
handler_done_clone.store(true, SeqCst);
async move { Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }.boxed_local()
},
));
let thread = cx
.update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
.await
.unwrap();
let send_future = thread.update(cx, |thread, cx| thread.send_raw("First message", cx));
let send_task = cx.background_executor.spawn(send_future);
// Tick until handler completes, then a few more to let update_last_checkpoint start
while !handler_done.load(SeqCst) {
cx.executor().tick();
}
for _ in 0..5 {
cx.executor().tick();
}
thread.update(cx, |thread, cx| {
thread.push_entry(
AgentThreadEntry::UserMessage(UserMessage {
id: Some(UserMessageId::new()),
content: ContentBlock::Empty,
chunks: vec!["Injected message (no checkpoint)".into()],
checkpoint: None,
}),
cx,
);
});
cx.run_until_parked();
let result = send_task.await;
assert!(
result.is_ok(),
"send should succeed even when new message added during update_last_checkpoint: {:?}",
result.err()
);
}
}

View File

@@ -210,12 +210,21 @@ pub trait AgentModelSelector: 'static {
}
}
/// Icon for a model in the model selector.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentModelIcon {
/// A built-in icon from Zed's icon set.
Named(IconName),
/// Path to a custom SVG icon file.
Path(SharedString),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentModelInfo {
pub id: acp::ModelId,
pub name: SharedString,
pub description: Option<SharedString>,
pub icon: Option<IconName>,
pub icon: Option<AgentModelIcon>,
}
impl From<acp::ModelInfo> for AgentModelInfo {

View File

@@ -6,7 +6,7 @@ use futures::{FutureExt, StreamExt, channel::mpsc};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
};
use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint};
use language::{Anchor, Buffer, BufferEvent, Point, ToPoint};
use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle};
use std::{cmp, ops::Range, sync::Arc};
use text::{Edit, Patch, Rope};
@@ -150,7 +150,7 @@ impl ActionLog {
if buffer
.read(cx)
.file()
.is_some_and(|file| file.disk_state() == DiskState::Deleted)
.is_some_and(|file| file.disk_state().is_deleted())
{
// If the buffer had been edited by a tool, but it got
// deleted externally, we want to stop tracking it.
@@ -162,7 +162,7 @@ impl ActionLog {
if buffer
.read(cx)
.file()
.is_some_and(|file| file.disk_state() != DiskState::Deleted)
.is_some_and(|file| !file.disk_state().is_deleted())
{
// If the buffer had been deleted by a tool, but it got
// resurrected externally, we want to clear the edits we
@@ -769,7 +769,7 @@ impl ActionLog {
tracked.version != buffer.version
&& buffer
.file()
.is_some_and(|file| file.disk_state() != DiskState::Deleted)
.is_some_and(|file| !file.disk_state().is_deleted())
})
.map(|(buffer, _)| buffer)
}

View File

@@ -30,7 +30,7 @@ use futures::{StreamExt, future};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
};
use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry};
use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry};
use project::{Project, ProjectItem, ProjectPath, Worktree};
use prompt_store::{
ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext,
@@ -93,7 +93,7 @@ impl LanguageModels {
fn refresh_list(&mut self, cx: &App) {
let providers = LanguageModelRegistry::global(cx)
.read(cx)
.providers()
.visible_providers()
.into_iter()
.filter(|provider| provider.is_authenticated(cx))
.collect::<Vec<_>>();
@@ -153,7 +153,10 @@ impl LanguageModels {
id: Self::model_id(model),
name: model.name().0,
description: None,
icon: Some(provider.icon()),
icon: Some(match provider.icon() {
IconOrSvg::Svg(path) => acp_thread::AgentModelIcon::Path(path),
IconOrSvg::Icon(name) => acp_thread::AgentModelIcon::Named(name),
}),
}
}
@@ -164,7 +167,7 @@ impl LanguageModels {
fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> {
let authenticate_all_providers = LanguageModelRegistry::global(cx)
.read(cx)
.providers()
.visible_providers()
.iter()
.map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
.collect::<Vec<_>>();
@@ -426,7 +429,7 @@ impl NativeAgent {
.into_iter()
.flat_map(|(contents, prompt_metadata)| match contents {
Ok(contents) => Some(UserRulesContext {
uuid: prompt_metadata.id.user_id()?,
uuid: prompt_metadata.id.as_user()?,
title: prompt_metadata.title.map(|title| title.to_string()),
contents,
}),
@@ -1630,7 +1633,9 @@ mod internal_tests {
id: acp::ModelId::new("fake/fake"),
name: "Fake".into(),
description: None,
icon: Some(ui::IconName::ZedAssistant),
icon: Some(acp_thread::AgentModelIcon::Named(
ui::IconName::ZedAssistant
)),
}]
)])
);

View File

@@ -186,6 +186,17 @@ impl Render for ModeSelector {
move |_window, cx| {
v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new("Toggle Mode Menu"))
.child(KeyBinding::for_action_in(
&ToggleProfileSelector,
&focus_handle,
cx,
)),
)
.child(
h_flex()
.pb_1()
@@ -200,17 +211,6 @@ impl Render for ModeSelector {
cx,
)),
)
.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new("Toggle Mode Menu"))
.child(KeyBinding::for_action_in(
&ToggleProfileSelector,
&focus_handle,
cx,
)),
)
.into_any()
}
}),

View File

@@ -1,6 +1,6 @@
use std::{cmp::Reverse, rc::Rc, sync::Arc};
use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector};
use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelList, AgentModelSelector};
use agent_client_protocol::ModelId;
use agent_servers::AgentServer;
use agent_settings::AgentSettings;
@@ -350,7 +350,11 @@ impl PickerDelegate for AcpModelPickerDelegate {
})
.child(
ModelSelectorListItem::new(ix, model_info.name.clone())
.when_some(model_info.icon, |this, icon| this.icon(icon))
.map(|this| match &model_info.icon {
Some(AgentModelIcon::Path(path)) => this.icon_path(path.clone()),
Some(AgentModelIcon::Named(icon)) => this.icon(*icon),
None => this,
})
.is_selected(is_selected)
.is_focused(selected)
.when(supports_favorites, |this| {

View File

@@ -1,7 +1,7 @@
use std::rc::Rc;
use std::sync::Arc;
use acp_thread::{AgentModelInfo, AgentModelSelector};
use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector};
use agent_servers::AgentServer;
use agent_settings::AgentSettings;
use fs::Fs;
@@ -70,7 +70,7 @@ impl Render for AcpModelSelectorPopover {
.map(|model| model.name.clone())
.unwrap_or_else(|| SharedString::from("Select a Model"));
let model_icon = model.as_ref().and_then(|model| model.icon);
let model_icon = model.as_ref().and_then(|model| model.icon.clone());
let focus_handle = self.focus_handle.clone();
@@ -125,7 +125,14 @@ impl Render for AcpModelSelectorPopover {
ButtonLike::new("active-model")
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.when_some(model_icon, |this, icon| {
this.child(Icon::new(icon).color(color).size(IconSize::XSmall))
this.child(
match icon {
AgentModelIcon::Path(path) => Icon::from_external_svg(path),
AgentModelIcon::Named(icon_name) => Icon::new(icon_name),
}
.color(color)
.size(IconSize::XSmall),
)
})
.child(
Label::new(model_name)

View File

@@ -1,7 +1,7 @@
use crate::acp::AcpThreadView;
use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
use agent::{HistoryEntry, HistoryStore};
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
use editor::{Editor, EditorEvent};
use fuzzy::StringMatchCandidate;
use gpui::{
@@ -402,7 +402,22 @@ impl AcpThreadHistory {
let selected = ix == self.selected_index;
let hovered = Some(ix) == self.hovered_index;
let timestamp = entry.updated_at().timestamp();
let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
let display_text = match format {
EntryTimeFormat::DateAndTime => {
let entry_time = entry.updated_at();
let now = Utc::now();
let duration = now.signed_duration_since(entry_time);
let days = duration.num_days();
format!("{}d", days)
}
EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone),
};
let title = entry.title().clone();
let full_date =
EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone);
h_flex()
.w_full()
@@ -423,11 +438,14 @@ impl AcpThreadHistory {
.truncate(),
)
.child(
Label::new(thread_timestamp)
Label::new(display_text)
.color(Color::Muted)
.size(LabelSize::XSmall),
),
)
.tooltip(move |_, cx| {
Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
})
.on_hover(cx.listener(move |this, is_hovered, _window, cx| {
if *is_hovered {
this.hovered_index = Some(ix);

View File

@@ -338,7 +338,13 @@ impl AcpThreadView {
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
let available_commands = Rc::new(RefCell::new(vec![]));
let placeholder = placeholder_text(agent.name().as_ref(), false);
let agent_server_store = project.read(cx).agent_server_store().clone();
let agent_display_name = agent_server_store
.read(cx)
.agent_display_name(&ExternalAgentServerName(agent.name()))
.unwrap_or_else(|| agent.name());
let placeholder = placeholder_text(agent_display_name.as_ref(), false);
let message_editor = cx.new(|cx| {
let mut editor = MessageEditor::new(
@@ -377,7 +383,6 @@ impl AcpThreadView {
)
});
let agent_server_store = project.read(cx).agent_server_store().clone();
let subscriptions = [
cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
cx.observe_global_in::<AgentFontSize>(window, Self::agent_ui_font_size_changed),
@@ -1498,7 +1503,13 @@ impl AcpThreadView {
let has_commands = !available_commands.is_empty();
self.available_commands.replace(available_commands);
let new_placeholder = placeholder_text(self.agent.name().as_ref(), has_commands);
let agent_display_name = self
.agent_server_store
.read(cx)
.agent_display_name(&ExternalAgentServerName(self.agent.name()))
.unwrap_or_else(|| self.agent.name());
let new_placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
self.message_editor.update(cx, |editor, cx| {
editor.set_placeholder_text(&new_placeholder, window, cx);
@@ -2478,9 +2489,11 @@ impl AcpThreadView {
.border_color(self.tool_card_border_color(cx))
.child(input_output_header("Raw Input:".into()))
.children(tool_call.raw_input_markdown.clone().map(|input| {
self.render_markdown(
input,
default_markdown_style(false, false, window, cx),
div().id(("tool-call-raw-input-markdown", entry_ix)).child(
self.render_markdown(
input,
default_markdown_style(false, false, window, cx),
),
)
}))
.child(input_output_header("Output:".into())),
@@ -2488,15 +2501,17 @@ impl AcpThreadView {
})
.children(tool_call.content.iter().enumerate().map(
|(content_ix, content)| {
div().child(self.render_tool_call_content(
entry_ix,
content,
content_ix,
tool_call,
use_card_layout,
window,
cx,
))
div().id(("tool-call-output", entry_ix)).child(
self.render_tool_call_content(
entry_ix,
content,
content_ix,
tool_call,
use_card_layout,
window,
cx,
),
)
},
))
.into_any(),
@@ -2707,7 +2722,7 @@ impl AcpThreadView {
..default_markdown_style(false, true, window, cx)
},
))
.tooltip(Tooltip::text("Jump to File"))
.tooltip(Tooltip::text("Go to File"))
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tool_call_location(entry_ix, 0, window, cx);
}))

View File

@@ -22,7 +22,8 @@ use gpui::{
};
use language::LanguageRegistry;
use language_model::{
LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID,
IconOrSvg, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
ZED_CLOUD_PROVIDER_ID,
};
use language_models::AllLanguageModelSettings;
use notifications::status_toast::{StatusToast, ToastIcon};
@@ -117,7 +118,7 @@ impl AgentConfiguration {
}
fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let providers = LanguageModelRegistry::read_global(cx).providers();
let providers = LanguageModelRegistry::read_global(cx).visible_providers();
for provider in providers {
self.add_provider_configuration_view(&provider, window, cx);
}
@@ -261,9 +262,12 @@ impl AgentConfiguration {
.w_full()
.gap_1p5()
.child(
Icon::new(provider.icon())
.size(IconSize::Small)
.color(Color::Muted),
match provider.icon() {
IconOrSvg::Svg(path) => Icon::from_external_svg(path),
IconOrSvg::Icon(name) => Icon::new(name),
}
.size(IconSize::Small)
.color(Color::Muted),
)
.child(
h_flex()
@@ -416,7 +420,7 @@ impl AgentConfiguration {
&mut self,
cx: &mut Context<Self>,
) -> impl IntoElement {
let providers = LanguageModelRegistry::read_global(cx).providers();
let providers = LanguageModelRegistry::read_global(cx).visible_providers();
let popover_menu = PopoverMenu::new("add-provider-popover")
.trigger(

View File

@@ -17,7 +17,7 @@ use gpui::{
Global, SharedString, Subscription, Task, WeakEntity, Window, prelude::*,
};
use language::{Buffer, Capability, DiskState, OffsetRangeExt, Point};
use language::{Buffer, Capability, OffsetRangeExt, Point};
use multi_buffer::PathKey;
use project::{Project, ProjectItem, ProjectPath};
use settings::{Settings, SettingsStore};
@@ -192,7 +192,7 @@ impl AgentDiffPane {
&& buffer
.read(cx)
.file()
.is_some_and(|file| file.disk_state() == DiskState::Deleted)
.is_some_and(|file| file.disk_state().is_deleted())
{
editor.fold_buffer(snapshot.text.remote_id(), cx)
}

View File

@@ -4,6 +4,7 @@ use crate::{
};
use fs::Fs;
use gpui::{Entity, FocusHandle, SharedString};
use language_model::IconOrSvg;
use picker::popover_menu::PickerPopoverMenu;
use settings::update_settings_file;
use std::sync::Arc;
@@ -103,7 +104,14 @@ impl Render for AgentModelSelector {
self.selector.clone(),
ButtonLike::new("active-model")
.when_some(provider_icon, |this, icon| {
this.child(Icon::new(icon).color(color).size(IconSize::XSmall))
this.child(
match icon {
IconOrSvg::Svg(path) => Icon::from_external_svg(path),
IconOrSvg::Icon(name) => Icon::new(name),
}
.color(color)
.size(IconSize::XSmall),
)
})
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.child(
@@ -115,7 +123,7 @@ impl Render for AgentModelSelector {
.child(
Icon::new(IconName::ChevronDown)
.color(color)
.size(IconSize::Small),
.size(IconSize::XSmall),
),
move |_window, cx| {
Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)

View File

@@ -2428,7 +2428,7 @@ impl AgentPanel {
let history_is_empty = self.history_store.read(cx).is_empty(cx);
let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
.providers()
.visible_providers()
.iter()
.any(|provider| {
provider.is_authenticated(cx)

View File

@@ -348,7 +348,8 @@ fn init_language_model_settings(cx: &mut App) {
|_, event: &language_model::Event, cx| match event {
language_model::Event::ProviderStateChanged(_)
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
| language_model::Event::RemovedProvider(_)
| language_model::Event::ProvidersChanged => {
update_active_language_model_from_settings(cx);
}
_ => {}

View File

@@ -1586,7 +1586,7 @@ pub(crate) fn search_rules(
None
} else {
Some(RulesContextEntry {
prompt_id: metadata.id.user_id()?,
prompt_id: metadata.id.as_user()?,
title: metadata.title?,
})
}

View File

@@ -1259,28 +1259,26 @@ impl InlineAssistant {
let bottom = top + 1.0;
(top, bottom)
});
let mut scroll_target_top = scroll_target_range.0;
let mut scroll_target_bottom = scroll_target_range.1;
scroll_target_top -= editor.vertical_scroll_margin() as ScrollOffset;
scroll_target_bottom += editor.vertical_scroll_margin() as ScrollOffset;
let height_in_lines = editor.visible_line_count().unwrap_or(0.);
let vertical_scroll_margin = editor.vertical_scroll_margin() as ScrollOffset;
let scroll_target_top = (scroll_target_range.0 - vertical_scroll_margin)
// Don't scroll up too far in the case of a large vertical_scroll_margin.
.max(scroll_target_range.0 - height_in_lines / 2.0);
let scroll_target_bottom = (scroll_target_range.1 + vertical_scroll_margin)
// Don't scroll down past where the top would still be visible.
.min(scroll_target_top + height_in_lines);
let scroll_top = editor.scroll_position(cx).y;
let scroll_bottom = scroll_top + height_in_lines;
if scroll_target_top < scroll_top {
editor.set_scroll_position(point(0., scroll_target_top), window, cx);
} else if scroll_target_bottom > scroll_bottom {
if (scroll_target_bottom - scroll_target_top) <= height_in_lines {
editor.set_scroll_position(
point(0., scroll_target_bottom - height_in_lines),
window,
cx,
);
} else {
editor.set_scroll_position(point(0., scroll_target_top), window, cx);
}
editor.set_scroll_position(
point(0., scroll_target_bottom - height_in_lines),
window,
cx,
);
}
});
}

View File

@@ -7,8 +7,8 @@ use gpui::{
Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
};
use language_model::{
AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProvider,
LanguageModelProviderId, LanguageModelRegistry,
AuthenticateError, ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId,
LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
};
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
@@ -55,7 +55,7 @@ pub fn language_model_selector(
fn all_models(cx: &App) -> GroupedModels {
let lm_registry = LanguageModelRegistry::global(cx).read(cx);
let providers = lm_registry.providers();
let providers = lm_registry.visible_providers();
let mut favorites_index = FavoritesIndex::default();
@@ -94,7 +94,7 @@ type FavoritesIndex = HashMap<LanguageModelProviderId, HashSet<LanguageModelId>>
#[derive(Clone)]
struct ModelInfo {
model: Arc<dyn LanguageModel>,
icon: IconName,
icon: IconOrSvg,
is_favorite: bool,
}
@@ -203,7 +203,7 @@ impl LanguageModelPickerDelegate {
fn authenticate_all_providers(cx: &mut App) -> Task<()> {
let authenticate_all_providers = LanguageModelRegistry::global(cx)
.read(cx)
.providers()
.visible_providers()
.iter()
.map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
.collect::<Vec<_>>();
@@ -474,7 +474,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
let configured_providers = language_model_registry
.read(cx)
.providers()
.visible_providers()
.into_iter()
.filter(|provider| provider.is_authenticated(cx))
.collect::<Vec<_>>();
@@ -566,7 +566,10 @@ impl PickerDelegate for LanguageModelPickerDelegate {
Some(
ModelSelectorListItem::new(ix, model_info.model.name().0)
.icon(model_info.icon)
.map(|this| match &model_info.icon {
IconOrSvg::Icon(icon_name) => this.icon(*icon_name),
IconOrSvg::Svg(icon_path) => this.icon_path(icon_path.clone()),
})
.is_selected(is_selected)
.is_focused(selected)
.is_favorite(is_favorite)
@@ -702,7 +705,7 @@ mod tests {
.any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name);
ModelInfo {
model: Arc::new(TestLanguageModel::new(name, provider)),
icon: IconName::Ai,
icon: IconOrSvg::Icon(IconName::Ai),
is_favorite,
}
})

View File

@@ -191,6 +191,9 @@ impl Render for ProfileSelector {
let container = || h_flex().gap_1().justify_between();
v_flex()
.gap_1()
.child(container().child(Label::new("Toggle Profile Menu")).child(
KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx),
))
.child(
container()
.pb_1()
@@ -203,9 +206,6 @@ impl Render for ProfileSelector {
cx,
)),
)
.child(container().child(Label::new("Toggle Profile Menu")).child(
KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx),
))
.into_any()
}
}),

View File

@@ -33,7 +33,8 @@ use language::{
language_settings::{SoftWrap, all_language_settings},
};
use language_model::{
ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, Role,
ConfigurationError, IconOrSvg, LanguageModelExt, LanguageModelImage, LanguageModelRegistry,
Role,
};
use multi_buffer::MultiBufferRow;
use picker::{Picker, popover_menu::PickerPopoverMenu};
@@ -2231,10 +2232,10 @@ impl TextThreadEditor {
.default_model()
.map(|default| default.provider);
let provider_icon = match active_provider {
Some(provider) => provider.icon(),
None => IconName::Ai,
};
let provider_icon = active_provider
.as_ref()
.map(|p| p.icon())
.unwrap_or(IconOrSvg::Icon(IconName::Ai));
let focus_handle = self.editor().focus_handle(cx);
@@ -2244,6 +2245,13 @@ impl TextThreadEditor {
(Color::Muted, IconName::ChevronDown)
};
let provider_icon_element = match provider_icon {
IconOrSvg::Svg(path) => Icon::from_external_svg(path),
IconOrSvg::Icon(name) => Icon::new(name),
}
.color(color)
.size(IconSize::XSmall);
let tooltip = Tooltip::element({
move |_, cx| {
let focus_handle = focus_handle.clone();
@@ -2291,7 +2299,7 @@ impl TextThreadEditor {
.child(
h_flex()
.gap_0p5()
.child(Icon::new(provider_icon).color(color).size(IconSize::XSmall))
.child(provider_icon_element)
.child(
Label::new(model_name)
.color(color)

View File

@@ -27,7 +27,7 @@ impl RenderOnce for HoldForDefault {
PlatformStyle::platform(),
None,
Some(TextSize::Default.rems(cx).into()),
true,
false,
)))
.child(div().map(|this| {
if self.is_default {

View File

@@ -1,6 +1,11 @@
use gpui::{Action, FocusHandle, prelude::*};
use ui::{ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
enum ModelIcon {
Name(IconName),
Path(SharedString),
}
#[derive(IntoElement)]
pub struct ModelSelectorHeader {
title: SharedString,
@@ -39,7 +44,7 @@ impl RenderOnce for ModelSelectorHeader {
pub struct ModelSelectorListItem {
index: usize,
title: SharedString,
icon: Option<IconName>,
icon: Option<ModelIcon>,
is_selected: bool,
is_focused: bool,
is_favorite: bool,
@@ -60,7 +65,12 @@ impl ModelSelectorListItem {
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self.icon = Some(ModelIcon::Name(icon));
self
}
pub fn icon_path(mut self, path: SharedString) -> Self {
self.icon = Some(ModelIcon::Path(path));
self
}
@@ -105,9 +115,12 @@ impl RenderOnce for ModelSelectorListItem {
.gap_1p5()
.when_some(self.icon, |this, icon| {
this.child(
Icon::new(icon)
.color(model_icon_color)
.size(IconSize::Small),
match icon {
ModelIcon::Name(icon_name) => Icon::new(icon_name),
ModelIcon::Path(icon_path) => Icon::from_external_svg(icon_path),
}
.color(model_icon_color)
.size(IconSize::Small),
)
})
.child(Label::new(self.title).truncate()),

View File

@@ -1,5 +1,5 @@
use agent::{HistoryEntry, HistoryStore};
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
use editor::{Editor, EditorEvent};
use fuzzy::StringMatchCandidate;
use gpui::{
@@ -411,7 +411,22 @@ impl AcpThreadHistory {
let selected = ix == self.selected_index;
let hovered = Some(ix) == self.hovered_index;
let timestamp = entry.updated_at().timestamp();
let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
let display_text = match format {
EntryTimeFormat::DateAndTime => {
let entry_time = entry.updated_at();
let now = Utc::now();
let duration = now.signed_duration_since(entry_time);
let days = duration.num_days();
format!("{}d", days)
}
EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone),
};
let title = entry.title().clone();
let full_date =
EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone);
h_flex()
.w_full()
@@ -432,11 +447,14 @@ impl AcpThreadHistory {
.truncate(),
)
.child(
Label::new(thread_timestamp)
Label::new(display_text)
.color(Color::Muted)
.size(LabelSize::XSmall),
),
)
.tooltip(move |_, cx| {
Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
})
.on_hover(cx.listener(move |this, is_hovered, _window, cx| {
if *is_hovered {
this.hovered_index = Some(ix);

View File

@@ -1,9 +1,9 @@
use gpui::{Action, IntoElement, ParentElement, RenderOnce, point};
use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
use language_model::{IconOrSvg, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
use ui::{Divider, List, ListBulletItem, prelude::*};
pub struct ApiKeysWithProviders {
configured_providers: Vec<(IconName, SharedString)>,
configured_providers: Vec<(IconOrSvg, SharedString)>,
}
impl ApiKeysWithProviders {
@@ -13,7 +13,8 @@ impl ApiKeysWithProviders {
|this: &mut Self, _registry, event: &language_model::Event, cx| match event {
language_model::Event::ProviderStateChanged(_)
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
| language_model::Event::RemovedProvider(_)
| language_model::Event::ProvidersChanged => {
this.configured_providers = Self::compute_configured_providers(cx)
}
_ => {}
@@ -26,9 +27,9 @@ impl ApiKeysWithProviders {
}
}
fn compute_configured_providers(cx: &App) -> Vec<(IconName, SharedString)> {
fn compute_configured_providers(cx: &App) -> Vec<(IconOrSvg, SharedString)> {
LanguageModelRegistry::read_global(cx)
.providers()
.visible_providers()
.iter()
.filter(|provider| {
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
@@ -47,7 +48,14 @@ impl Render for ApiKeysWithProviders {
.map(|(icon, name)| {
h_flex()
.gap_1p5()
.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
.child(
match icon {
IconOrSvg::Icon(icon_name) => Icon::new(icon_name),
IconOrSvg::Svg(icon_path) => Icon::from_external_svg(icon_path),
}
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(Label::new(name))
});
div()

View File

@@ -11,7 +11,7 @@ use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding};
pub struct AgentPanelOnboarding {
user_store: Entity<UserStore>,
client: Arc<Client>,
configured_providers: Vec<(IconName, SharedString)>,
has_configured_providers: bool,
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
}
@@ -27,8 +27,9 @@ impl AgentPanelOnboarding {
|this: &mut Self, _registry, event: &language_model::Event, cx| match event {
language_model::Event::ProviderStateChanged(_)
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
this.configured_providers = Self::compute_available_providers(cx)
| language_model::Event::RemovedProvider(_)
| language_model::Event::ProvidersChanged => {
this.has_configured_providers = Self::has_configured_providers(cx)
}
_ => {}
},
@@ -38,20 +39,16 @@ impl AgentPanelOnboarding {
Self {
user_store,
client,
configured_providers: Self::compute_available_providers(cx),
has_configured_providers: Self::has_configured_providers(cx),
continue_with_zed_ai: Arc::new(continue_with_zed_ai),
}
}
fn compute_available_providers(cx: &App) -> Vec<(IconName, SharedString)> {
fn has_configured_providers(cx: &App) -> bool {
LanguageModelRegistry::read_global(cx)
.providers()
.visible_providers()
.iter()
.filter(|provider| {
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
})
.map(|provider| (provider.icon(), provider.name().0))
.collect()
.any(|provider| provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID)
}
}
@@ -81,7 +78,7 @@ impl Render for AgentPanelOnboarding {
}),
)
.map(|this| {
if enrolled_in_trial || is_pro_user || !self.configured_providers.is_empty() {
if enrolled_in_trial || is_pro_user || self.has_configured_providers {
this
} else {
this.child(ApiKeysWithoutProviders::new())

View File

@@ -1159,6 +1159,34 @@ impl BufferDiff {
new_index_text
}
pub fn stage_or_unstage_all_hunks(
&mut self,
stage: bool,
buffer: &text::BufferSnapshot,
file_exists: bool,
cx: &mut Context<Self>,
) {
let hunks = self
.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx)
.collect::<Vec<_>>();
let Some(secondary) = self.secondary_diff.as_ref() else {
return;
};
self.inner.stage_or_unstage_hunks_impl(
&secondary.read(cx).inner,
stage,
&hunks,
buffer,
file_exists,
);
if let Some((first, last)) = hunks.first().zip(hunks.last()) {
let changed_range = first.buffer_range.start..last.buffer_range.end;
cx.emit(BufferDiffEvent::DiffChanged {
changed_range: Some(changed_range),
});
}
}
pub fn range_to_hunk_range(
&self,
range: Range<Anchor>,

View File

@@ -0,0 +1,45 @@
[package]
name = "component_preview"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/component_preview.rs"
[features]
default = []
preview = []
test-support = ["db/test-support"]
[dependencies]
anyhow.workspace = true
client.workspace = true
collections.workspace = true
component.workspace = true
db.workspace = true
fs.workspace = true
gpui.workspace = true
language.workspace = true
log.workspace = true
node_runtime.workspace = true
notifications.workspace = true
project.workspace = true
release_channel.workspace = true
reqwest_client.workspace = true
session.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
ui_input.workspace = true
uuid.workspace = true
workspace.workspace = true
[[example]]
name = "component_preview"
path = "examples/component_preview.rs"
required-features = ["preview"]

View File

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

View File

@@ -0,0 +1,18 @@
//! Component Preview Example
//!
//! Run with: `cargo run -p component_preview --example component_preview --features="preview"`
//!
//! To use this in other projects, add the following to your `Cargo.toml`:
//!
//! ```toml
//! [dependencies]
//! component_preview = { path = "../component_preview", features = ["preview"] }
//!
//! [[example]]
//! name = "component_preview"
//! path = "examples/component_preview.rs"
//! ```
fn main() {
component_preview::run_component_preview();
}

View File

@@ -1,7 +1,4 @@
//! # Component Preview
//!
//! A view for exploring Zed components.
mod component_preview_example;
mod persistence;
use client::UserStore;
@@ -11,18 +8,21 @@ use gpui::{
App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
};
use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
use languages::LanguageRegistry;
use language::LanguageRegistry;
use notifications::status_toast::{StatusToast, ToastIcon};
use persistence::COMPONENT_PREVIEW_DB;
use project::Project;
use std::{iter::Iterator, ops::Range, sync::Arc};
use ui::{ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Tooltip, prelude::*};
use ui_input::InputField;
use workspace::AppState;
use workspace::{
AppState, Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items,
item::ItemEvent,
Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items, item::ItemEvent,
};
#[allow(unused_imports)]
pub use component_preview_example::*;
pub fn init(app_state: Arc<AppState>, cx: &mut App) {
workspace::register_serializable_item::<ComponentPreview>(cx);

View File

@@ -0,0 +1,145 @@
/// Run the component preview application.
///
/// This initializes the application with minimal required infrastructure
/// and opens a workspace with the ComponentPreview item.
#[cfg(feature = "preview")]
pub fn run_component_preview() {
use fs::RealFs;
use gpui::{
AppContext as _, Application, Bounds, KeyBinding, WindowBounds, WindowOptions, actions,
size,
};
use client::{Client, UserStore};
use language::LanguageRegistry;
use node_runtime::NodeRuntime;
use project::Project;
use reqwest_client::ReqwestClient;
use session::{AppSession, Session};
use std::sync::Arc;
use ui::{App, px};
use workspace::{AppState, Workspace, WorkspaceStore};
use crate::{ComponentPreview, init};
actions!(zed, [Quit]);
fn quit(_: &Quit, cx: &mut App) {
cx.quit();
}
Application::new().run(|cx| {
component::init();
cx.on_action(quit);
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
let version = release_channel::AppVersion::load(env!("CARGO_PKG_VERSION"), None, None);
release_channel::init(version, cx);
let http_client =
ReqwestClient::user_agent("component_preview").expect("Failed to create HTTP client");
cx.set_http_client(Arc::new(http_client));
let fs = Arc::new(RealFs::new(None, cx.background_executor().clone()));
<dyn fs::Fs>::set_global(fs.clone(), cx);
settings::init(cx);
theme::init(theme::LoadThemes::JustBase, cx);
let languages = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
let client = Client::production(cx);
client::init(&client, cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx));
let session_id = uuid::Uuid::new_v4().to_string();
let session = cx.background_executor().block(Session::new(session_id));
let session = cx.new(|cx| AppSession::new(session, cx));
let node_runtime = NodeRuntime::unavailable();
let app_state = Arc::new(AppState {
languages,
client,
user_store,
workspace_store,
fs,
build_window_options: |_, _| Default::default(),
node_runtime,
session,
});
AppState::set_global(Arc::downgrade(&app_state), cx);
workspace::init(app_state.clone(), cx);
init(app_state.clone(), cx);
let size = size(px(1200.), px(800.));
let bounds = Bounds::centered(None, size, cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
{
move |window, cx| {
let app_state = app_state;
theme::setup_ui_font(window, cx);
let project = Project::local(
app_state.client.clone(),
app_state.node_runtime.clone(),
app_state.user_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
None,
false,
cx,
);
let workspace = cx.new(|cx| {
Workspace::new(
Default::default(),
project.clone(),
app_state.clone(),
window,
cx,
)
});
workspace.update(cx, |workspace, cx| {
let weak_workspace = cx.entity().downgrade();
let language_registry = app_state.languages.clone();
let user_store = app_state.user_store.clone();
let component_preview = cx.new(|cx| {
ComponentPreview::new(
weak_workspace,
project,
language_registry,
user_store,
None,
None,
window,
cx,
)
.expect("Failed to create component preview")
});
workspace.add_item_to_active_pane(
Box::new(component_preview),
None,
true,
window,
cx,
);
});
workspace
}
},
)
.expect("Failed to open component preview window");
cx.activate(true);
});
}

View File

@@ -7,8 +7,6 @@ license = "GPL-3.0-or-later"
[dependencies]
anyhow.workspace = true
command_palette.workspace = true
gpui.workspace = true
# We are specifically pinning this version of mdbook, as later versions introduce issues with double-nested subdirectories.
# Ask @maxdeviant about this before bumping.
mdbook = "= 0.4.40"
@@ -17,7 +15,6 @@ serde.workspace = true
serde_json.workspace = true
settings.workspace = true
util.workspace = true
zed.workspace = true
zlog.workspace = true
task.workspace = true
theme.workspace = true
@@ -27,4 +24,4 @@ workspace = true
[[bin]]
name = "docs_preprocessor"
path = "src/main.rs"
path = "src/main.rs"

View File

@@ -22,16 +22,13 @@ static KEYMAP_WINDOWS: LazyLock<KeymapFile> = LazyLock::new(|| {
load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap")
});
static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(dump_all_gpui_actions);
static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(load_all_actions);
const FRONT_MATTER_COMMENT: &str = "<!-- ZED_META {} -->";
fn main() -> Result<()> {
zlog::init();
zlog::init_output_stderr();
// call a zed:: function so everything in `zed` crate is linked and
// all actions in the actual app are registered
zed::stdout_is_a_pty();
let args = std::env::args().skip(1).collect::<Vec<_>>();
match args.get(0).map(String::as_str) {
@@ -72,8 +69,8 @@ enum PreprocessorError {
impl PreprocessorError {
fn new_for_not_found_action(action_name: String) -> Self {
for action in &*ALL_ACTIONS {
for alias in action.deprecated_aliases {
if alias == &action_name {
for alias in &action.deprecated_aliases {
if alias == action_name.as_str() {
return PreprocessorError::DeprecatedActionUsed {
used: action_name,
should_be: action.name.to_string(),
@@ -214,7 +211,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Prepr
chapter.content = regex
.replace_all(&chapter.content, |caps: &regex::Captures| {
let action = caps[1].trim();
if find_action_by_name(action).is_none() {
if is_missing_action(action) {
errors.insert(PreprocessorError::new_for_not_found_action(
action.to_string(),
));
@@ -244,10 +241,12 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Preproces
.replace_all(&chapter.content, |caps: &regex::Captures| {
let name = caps[1].trim();
let Some(action) = find_action_by_name(name) else {
errors.insert(PreprocessorError::new_for_not_found_action(
name.to_string(),
));
return String::new();
if actions_available() {
errors.insert(PreprocessorError::new_for_not_found_action(
name.to_string(),
));
}
return format!("<code class=\"hljs\">{}</code>", name);
};
format!("<code class=\"hljs\">{}</code>", &action.human_name)
})
@@ -257,11 +256,19 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Preproces
fn find_action_by_name(name: &str) -> Option<&ActionDef> {
ALL_ACTIONS
.binary_search_by(|action| action.name.cmp(name))
.binary_search_by(|action| action.name.as_str().cmp(name))
.ok()
.map(|index| &ALL_ACTIONS[index])
}
fn actions_available() -> bool {
!ALL_ACTIONS.is_empty()
}
fn is_missing_action(name: &str) -> bool {
actions_available() && find_action_by_name(name).is_none()
}
fn find_binding(os: &str, action: &str) -> Option<String> {
let keymap = match os {
"macos" => &KEYMAP_MACOS,
@@ -384,18 +391,13 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
let keymap = settings::KeymapFile::parse(&snippet_json_fixed)
.context("Failed to parse keymap JSON")?;
for section in keymap.sections() {
for (keystrokes, action) in section.bindings() {
keystrokes
.split_whitespace()
.map(|source| gpui::Keystroke::parse(source))
.collect::<std::result::Result<Vec<_>, _>>()
.context("Failed to parse keystroke")?;
for (_keystrokes, action) in section.bindings() {
if let Some((action_name, _)) = settings::KeymapFile::parse_action(action)
.map_err(|err| anyhow::format_err!(err))
.context("Failed to parse action")?
{
anyhow::ensure!(
find_action_by_name(action_name).is_some(),
!is_missing_action(action_name),
"Action not found: {}",
action_name
);
@@ -491,27 +493,35 @@ where
});
}
#[derive(Debug, serde::Serialize)]
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct ActionDef {
name: &'static str,
name: String,
human_name: String,
deprecated_aliases: &'static [&'static str],
docs: Option<&'static str>,
deprecated_aliases: Vec<String>,
#[serde(rename = "documentation")]
docs: Option<String>,
}
fn dump_all_gpui_actions() -> Vec<ActionDef> {
let mut actions = gpui::generate_list_of_all_registered_actions()
.map(|action| ActionDef {
name: action.name,
human_name: command_palette::humanize_action_name(action.name),
deprecated_aliases: action.deprecated_aliases,
docs: action.documentation,
})
.collect::<Vec<ActionDef>>();
actions.sort_by_key(|a| a.name);
actions
fn load_all_actions() -> Vec<ActionDef> {
let asset_path = concat!(env!("CARGO_MANIFEST_DIR"), "/actions.json");
match std::fs::read_to_string(asset_path) {
Ok(content) => {
let mut actions: Vec<ActionDef> =
serde_json::from_str(&content).expect("Failed to parse actions.json");
actions.sort_by(|a, b| a.name.cmp(&b.name));
actions
}
Err(err) => {
if std::env::var("CI").is_ok() {
panic!("actions.json not found at {}: {}", asset_path, err);
}
eprintln!(
"Warning: actions.json not found, action validation will be skipped: {}",
err
);
Vec::new()
}
}
}
fn handle_postprocessing() -> Result<()> {
@@ -647,7 +657,7 @@ fn generate_big_table_of_actions() -> String {
let mut output = String::new();
let mut actions_sorted = actions.iter().collect::<Vec<_>>();
actions_sorted.sort_by_key(|a| a.name);
actions_sorted.sort_by_key(|a| a.name.as_str());
// Start the definition list with custom styling for better spacing
output.push_str("<dl style=\"line-height: 1.8;\">\n");
@@ -664,7 +674,7 @@ fn generate_big_table_of_actions() -> String {
output.push_str("<dd style=\"margin-left: 2em; margin-bottom: 1em;\">\n");
// Add the description, escaping HTML if needed
if let Some(description) = action.docs {
if let Some(description) = action.docs.as_ref() {
output.push_str(
&description
.replace("&", "&amp;")
@@ -674,7 +684,7 @@ fn generate_big_table_of_actions() -> String {
output.push_str("<br>\n");
}
output.push_str("Keymap Name: <code>");
output.push_str(action.name);
output.push_str(&action.name);
output.push_str("</code><br>\n");
if !action.deprecated_aliases.is_empty() {
output.push_str("Deprecated Alias(es): ");

View File

@@ -51,6 +51,8 @@ pub const MENU_ASIDE_MIN_WIDTH: Pixels = px(260.);
pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.);
pub const COMPLETION_MENU_MIN_WIDTH: Pixels = px(280.);
pub const COMPLETION_MENU_MAX_WIDTH: Pixels = px(540.);
pub const CODE_ACTION_MENU_MIN_WIDTH: Pixels = px(220.);
pub const CODE_ACTION_MENU_MAX_WIDTH: Pixels = px(540.);
// Constants for the markdown cache. The purpose of this cache is to reduce flickering due to
// documentation not yet being parsed.
@@ -179,7 +181,7 @@ impl CodeContextMenu {
) -> Option<AnyElement> {
match self {
CodeContextMenu::Completions(menu) => menu.render_aside(max_size, window, cx),
CodeContextMenu::CodeActions(_) => None,
CodeContextMenu::CodeActions(menu) => menu.render_aside(max_size, window, cx),
}
}
@@ -891,7 +893,7 @@ impl CompletionsMenu {
None
} else {
Some(
Label::new(text.clone())
Label::new(text.trim().to_string())
.ml_4()
.size(LabelSize::Small)
.color(Color::Muted),
@@ -1419,26 +1421,6 @@ pub enum CodeActionsItem {
}
impl CodeActionsItem {
fn as_task(&self) -> Option<&ResolvedTask> {
let Self::Task(_, task) = self else {
return None;
};
Some(task)
}
fn as_code_action(&self) -> Option<&CodeAction> {
let Self::CodeAction { action, .. } = self else {
return None;
};
Some(action)
}
fn as_debug_scenario(&self) -> Option<&DebugScenario> {
let Self::DebugScenario(scenario) = self else {
return None;
};
Some(scenario)
}
pub fn label(&self) -> String {
match self {
Self::CodeAction { action, .. } => action.lsp_action.title().to_owned(),
@@ -1446,6 +1428,14 @@ impl CodeActionsItem {
Self::DebugScenario(scenario) => scenario.label.to_string(),
}
}
pub fn menu_label(&self) -> String {
match self {
Self::CodeAction { action, .. } => action.lsp_action.title().replace("\n", ""),
Self::Task(_, task) => task.resolved_label.replace("\n", ""),
Self::DebugScenario(scenario) => format!("debug: {}", scenario.label),
}
}
}
pub struct CodeActionsMenu {
@@ -1555,60 +1545,33 @@ impl CodeActionsMenu {
let item_ix = range.start + ix;
let selected = item_ix == selected_item;
let colors = cx.theme().colors();
div().min_w(px(220.)).max_w(px(540.)).child(
ListItem::new(item_ix)
.inset(true)
.toggle_state(selected)
.when_some(action.as_code_action(), |this, action| {
this.child(
h_flex()
.overflow_hidden()
.when(is_quick_action_bar, |this| this.text_ui(cx))
.child(
// TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
action.lsp_action.title().replace("\n", ""),
)
.when(selected, |this| {
this.text_color(colors.text_accent)
}),
)
})
.when_some(action.as_task(), |this, task| {
this.child(
h_flex()
.overflow_hidden()
.when(is_quick_action_bar, |this| this.text_ui(cx))
.child(task.resolved_label.replace("\n", ""))
.when(selected, |this| {
this.text_color(colors.text_accent)
}),
)
})
.when_some(action.as_debug_scenario(), |this, scenario| {
this.child(
h_flex()
.overflow_hidden()
.when(is_quick_action_bar, |this| this.text_ui(cx))
.child("debug: ")
.child(scenario.label.clone())
.when(selected, |this| {
this.text_color(colors.text_accent)
}),
)
})
.on_click(cx.listener(move |editor, _, window, cx| {
cx.stop_propagation();
if let Some(task) = editor.confirm_code_action(
&ConfirmCodeAction {
item_ix: Some(item_ix),
},
window,
cx,
) {
task.detach_and_log_err(cx)
}
})),
)
ListItem::new(item_ix)
.inset(true)
.toggle_state(selected)
.overflow_x()
.child(
div()
.min_w(CODE_ACTION_MENU_MIN_WIDTH)
.max_w(CODE_ACTION_MENU_MAX_WIDTH)
.overflow_hidden()
.text_ellipsis()
.when(is_quick_action_bar, |this| this.text_ui(cx))
.when(selected, |this| this.text_color(colors.text_accent))
.child(action.menu_label()),
)
.on_click(cx.listener(move |editor, _, window, cx| {
cx.stop_propagation();
if let Some(task) = editor.confirm_code_action(
&ConfirmCodeAction {
item_ix: Some(item_ix),
},
window,
cx,
) {
task.detach_and_log_err(cx)
}
}))
})
.collect()
}),
@@ -1635,4 +1598,46 @@ impl CodeActionsMenu {
Popover::new().child(list).into_any_element()
}
fn render_aside(
&mut self,
max_size: Size<Pixels>,
window: &mut Window,
_cx: &mut Context<Editor>,
) -> Option<AnyElement> {
let Some(action) = self.actions.get(self.selected_item) else {
return None;
};
let label = action.menu_label();
let text_system = window.text_system();
let mut line_wrapper = text_system.line_wrapper(
window.text_style().font(),
window.text_style().font_size.to_pixels(window.rem_size()),
);
let is_truncated = line_wrapper.should_truncate_line(
&label,
CODE_ACTION_MENU_MAX_WIDTH,
"",
gpui::TruncateFrom::End,
);
if is_truncated.is_none() {
return None;
}
Some(
Popover::new()
.child(
div()
.child(label)
.id("code_actions_menu_extended")
.px(MENU_ASIDE_X_PADDING / 2.)
.max_w(max_size.width)
.max_h(max_size.height)
.occlude(),
)
.into_any_element(),
)
}
}

View File

@@ -163,6 +163,7 @@ use project::{
project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter, ProjectSettings},
};
use rand::seq::SliceRandom;
use regex::Regex;
use rpc::{ErrorCode, ErrorExt, proto::PeerId};
use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager};
use selections_collection::{MutableSelectionsCollection, SelectionsCollection};
@@ -4787,82 +4788,146 @@ impl Editor {
let end = selection.end;
let selection_is_empty = start == end;
let language_scope = buffer.language_scope_at(start);
let (comment_delimiter, doc_delimiter, newline_formatting) =
if let Some(language) = &language_scope {
let mut newline_formatting =
NewlineFormatting::new(&buffer, start..end, language);
let (delimiter, newline_config) = if let Some(language) = &language_scope {
let needs_extra_newline = NewlineConfig::insert_extra_newline_brackets(
&buffer,
start..end,
language,
)
|| NewlineConfig::insert_extra_newline_tree_sitter(
&buffer,
start..end,
);
// Comment extension on newline is allowed only for cursor selections
let comment_delimiter = maybe!({
if !selection_is_empty {
return None;
}
if !multi_buffer.language_settings(cx).extend_comment_on_newline
{
return None;
}
return comment_delimiter_for_newline(
&start_point,
&buffer,
language,
);
});
let doc_delimiter = maybe!({
if !selection_is_empty {
return None;
}
if !multi_buffer.language_settings(cx).extend_comment_on_newline
{
return None;
}
return documentation_delimiter_for_newline(
&start_point,
&buffer,
language,
&mut newline_formatting,
);
});
(comment_delimiter, doc_delimiter, newline_formatting)
} else {
(None, None, NewlineFormatting::default())
let mut newline_config = NewlineConfig::Newline {
additional_indent: IndentSize::spaces(0),
extra_line_additional_indent: if needs_extra_newline {
Some(IndentSize::spaces(0))
} else {
None
},
prevent_auto_indent: false,
};
let prevent_auto_indent = doc_delimiter.is_some();
let delimiter = comment_delimiter.or(doc_delimiter);
let comment_delimiter = maybe!({
if !selection_is_empty {
return None;
}
let capacity_for_delimiter =
delimiter.as_deref().map(str::len).unwrap_or_default();
let mut new_text = String::with_capacity(
1 + capacity_for_delimiter
+ existing_indent.len as usize
+ newline_formatting.indent_on_newline.len as usize
+ newline_formatting.indent_on_extra_newline.len as usize,
);
new_text.push('\n');
new_text.extend(existing_indent.chars());
new_text.extend(newline_formatting.indent_on_newline.chars());
if !multi_buffer.language_settings(cx).extend_comment_on_newline {
return None;
}
if let Some(delimiter) = &delimiter {
new_text.push_str(delimiter);
}
return comment_delimiter_for_newline(
&start_point,
&buffer,
language,
);
});
if newline_formatting.insert_extra_newline {
new_text.push('\n');
new_text.extend(existing_indent.chars());
new_text.extend(newline_formatting.indent_on_extra_newline.chars());
}
let doc_delimiter = maybe!({
if !selection_is_empty {
return None;
}
if !multi_buffer.language_settings(cx).extend_comment_on_newline {
return None;
}
return documentation_delimiter_for_newline(
&start_point,
&buffer,
language,
&mut newline_config,
);
});
let list_delimiter = maybe!({
if !selection_is_empty {
return None;
}
if !multi_buffer.language_settings(cx).extend_list_on_newline {
return None;
}
return list_delimiter_for_newline(
&start_point,
&buffer,
language,
&mut newline_config,
);
});
(
comment_delimiter.or(doc_delimiter).or(list_delimiter),
newline_config,
)
} else {
(
None,
NewlineConfig::Newline {
additional_indent: IndentSize::spaces(0),
extra_line_additional_indent: None,
prevent_auto_indent: false,
},
)
};
let (edit_start, new_text, prevent_auto_indent) = match &newline_config {
NewlineConfig::ClearCurrentLine => {
let row_start =
buffer.point_to_offset(Point::new(start_point.row, 0));
(row_start, String::new(), false)
}
NewlineConfig::UnindentCurrentLine { continuation } => {
let row_start =
buffer.point_to_offset(Point::new(start_point.row, 0));
let tab_size = buffer.language_settings_at(start, cx).tab_size;
let tab_size_indent = IndentSize::spaces(tab_size.get());
let reduced_indent =
existing_indent.with_delta(Ordering::Less, tab_size_indent);
let mut new_text = String::new();
new_text.extend(reduced_indent.chars());
new_text.push_str(continuation);
(row_start, new_text, true)
}
NewlineConfig::Newline {
additional_indent,
extra_line_additional_indent,
prevent_auto_indent,
} => {
let capacity_for_delimiter =
delimiter.as_deref().map(str::len).unwrap_or_default();
let extra_line_len = extra_line_additional_indent
.map(|i| 1 + existing_indent.len as usize + i.len as usize)
.unwrap_or(0);
let mut new_text = String::with_capacity(
1 + capacity_for_delimiter
+ existing_indent.len as usize
+ additional_indent.len as usize
+ extra_line_len,
);
new_text.push('\n');
new_text.extend(existing_indent.chars());
new_text.extend(additional_indent.chars());
if let Some(delimiter) = &delimiter {
new_text.push_str(delimiter);
}
if let Some(extra_indent) = extra_line_additional_indent {
new_text.push('\n');
new_text.extend(existing_indent.chars());
new_text.extend(extra_indent.chars());
}
(start, new_text, *prevent_auto_indent)
}
};
let anchor = buffer.anchor_after(end);
let new_selection = selection.map(|_| anchor);
(
((start..end, new_text), prevent_auto_indent),
(newline_formatting.insert_extra_newline, new_selection),
((edit_start..end, new_text), prevent_auto_indent),
(newline_config.has_extra_line(), new_selection),
)
})
.unzip()
@@ -10387,6 +10452,22 @@ impl Editor {
}
prev_edited_row = selection.end.row;
// If cursor is after a list prefix, make selection non-empty to trigger line indent
if selection.is_empty() {
let cursor = selection.head();
let settings = buffer.language_settings_at(cursor, cx);
if settings.indent_list_on_tab {
if let Some(language) = snapshot.language_scope_at(Point::new(cursor.row, 0)) {
if is_list_prefix_row(MultiBufferRow(cursor.row), &snapshot, &language) {
row_delta = Self::indent_selection(
buffer, &snapshot, selection, &mut edits, row_delta, cx,
);
continue;
}
}
}
}
// If the selection is non-empty, then increase the indentation of the selected lines.
if !selection.is_empty() {
row_delta =
@@ -20394,7 +20475,7 @@ impl Editor {
EditorSettings::get_global(cx).gutter.line_numbers
}
pub fn relative_line_numbers(&self, cx: &mut App) -> RelativeLineNumbers {
pub fn relative_line_numbers(&self, cx: &App) -> RelativeLineNumbers {
match (
self.use_relative_line_numbers,
EditorSettings::get_global(cx).relative_line_numbers,
@@ -23355,7 +23436,7 @@ fn documentation_delimiter_for_newline(
start_point: &Point,
buffer: &MultiBufferSnapshot,
language: &LanguageScope,
newline_formatting: &mut NewlineFormatting,
newline_config: &mut NewlineConfig,
) -> Option<Arc<str>> {
let BlockCommentConfig {
start: start_tag,
@@ -23407,6 +23488,9 @@ fn documentation_delimiter_for_newline(
}
};
let mut needs_extra_line = false;
let mut extra_line_additional_indent = IndentSize::spaces(0);
let cursor_is_before_end_tag_if_exists = {
let mut char_position = 0u32;
let mut end_tag_offset = None;
@@ -23424,11 +23508,11 @@ fn documentation_delimiter_for_newline(
let cursor_is_before_end_tag = column <= end_tag_offset;
if cursor_is_after_start_tag {
if cursor_is_before_end_tag {
newline_formatting.insert_extra_newline = true;
needs_extra_line = true;
}
let cursor_is_at_start_of_end_tag = column == end_tag_offset;
if cursor_is_at_start_of_end_tag {
newline_formatting.indent_on_extra_newline.len = *len;
extra_line_additional_indent.len = *len;
}
}
cursor_is_before_end_tag
@@ -23440,39 +23524,240 @@ fn documentation_delimiter_for_newline(
if (cursor_is_after_start_tag || cursor_is_after_delimiter)
&& cursor_is_before_end_tag_if_exists
{
if cursor_is_after_start_tag {
newline_formatting.indent_on_newline.len = *len;
}
let additional_indent = if cursor_is_after_start_tag {
IndentSize::spaces(*len)
} else {
IndentSize::spaces(0)
};
*newline_config = NewlineConfig::Newline {
additional_indent,
extra_line_additional_indent: if needs_extra_line {
Some(extra_line_additional_indent)
} else {
None
},
prevent_auto_indent: true,
};
Some(delimiter.clone())
} else {
None
}
}
#[derive(Debug, Default)]
struct NewlineFormatting {
insert_extra_newline: bool,
indent_on_newline: IndentSize,
indent_on_extra_newline: IndentSize,
const ORDERED_LIST_MAX_MARKER_LEN: usize = 16;
fn list_delimiter_for_newline(
start_point: &Point,
buffer: &MultiBufferSnapshot,
language: &LanguageScope,
newline_config: &mut NewlineConfig,
) -> Option<Arc<str>> {
let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?;
let num_of_whitespaces = snapshot
.chars_for_range(range.clone())
.take_while(|c| c.is_whitespace())
.count();
let task_list_entries: Vec<_> = language
.task_list()
.into_iter()
.flat_map(|config| {
config
.prefixes
.iter()
.map(|prefix| (prefix.as_ref(), config.continuation.as_ref()))
})
.collect();
let unordered_list_entries: Vec<_> = language
.unordered_list()
.iter()
.map(|marker| (marker.as_ref(), marker.as_ref()))
.collect();
let all_entries: Vec<_> = task_list_entries
.into_iter()
.chain(unordered_list_entries)
.collect();
if let Some(max_prefix_len) = all_entries.iter().map(|(p, _)| p.len()).max() {
let candidate: String = snapshot
.chars_for_range(range.clone())
.skip(num_of_whitespaces)
.take(max_prefix_len)
.collect();
if let Some((prefix, continuation)) = all_entries
.iter()
.filter(|(prefix, _)| candidate.starts_with(*prefix))
.max_by_key(|(prefix, _)| prefix.len())
{
let end_of_prefix = num_of_whitespaces + prefix.len();
let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize;
let has_content_after_marker = snapshot
.chars_for_range(range)
.skip(end_of_prefix)
.any(|c| !c.is_whitespace());
if has_content_after_marker && cursor_is_after_prefix {
return Some((*continuation).into());
}
if start_point.column as usize == end_of_prefix {
if num_of_whitespaces == 0 {
*newline_config = NewlineConfig::ClearCurrentLine;
} else {
*newline_config = NewlineConfig::UnindentCurrentLine {
continuation: (*continuation).into(),
};
}
}
return None;
}
}
let candidate: String = snapshot
.chars_for_range(range.clone())
.skip(num_of_whitespaces)
.take(ORDERED_LIST_MAX_MARKER_LEN)
.collect();
for ordered_config in language.ordered_list() {
let regex = match Regex::new(&ordered_config.pattern) {
Ok(r) => r,
Err(_) => continue,
};
if let Some(captures) = regex.captures(&candidate) {
let full_match = captures.get(0)?;
let marker_len = full_match.len();
let end_of_prefix = num_of_whitespaces + marker_len;
let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize;
let has_content_after_marker = snapshot
.chars_for_range(range)
.skip(end_of_prefix)
.any(|c| !c.is_whitespace());
if has_content_after_marker && cursor_is_after_prefix {
let number: u32 = captures.get(1)?.as_str().parse().ok()?;
let continuation = ordered_config
.format
.replace("{1}", &(number + 1).to_string());
return Some(continuation.into());
}
if start_point.column as usize == end_of_prefix {
let continuation = ordered_config.format.replace("{1}", "1");
if num_of_whitespaces == 0 {
*newline_config = NewlineConfig::ClearCurrentLine;
} else {
*newline_config = NewlineConfig::UnindentCurrentLine {
continuation: continuation.into(),
};
}
}
return None;
}
}
None
}
impl NewlineFormatting {
fn new(
buffer: &MultiBufferSnapshot,
range: Range<MultiBufferOffset>,
language: &LanguageScope,
) -> Self {
Self {
insert_extra_newline: Self::insert_extra_newline_brackets(
buffer,
range.clone(),
language,
) || Self::insert_extra_newline_tree_sitter(buffer, range),
indent_on_newline: IndentSize::spaces(0),
indent_on_extra_newline: IndentSize::spaces(0),
fn is_list_prefix_row(
row: MultiBufferRow,
buffer: &MultiBufferSnapshot,
language: &LanguageScope,
) -> bool {
let Some((snapshot, range)) = buffer.buffer_line_for_row(row) else {
return false;
};
let num_of_whitespaces = snapshot
.chars_for_range(range.clone())
.take_while(|c| c.is_whitespace())
.count();
let task_list_prefixes: Vec<_> = language
.task_list()
.into_iter()
.flat_map(|config| {
config
.prefixes
.iter()
.map(|p| p.as_ref())
.collect::<Vec<_>>()
})
.collect();
let unordered_list_markers: Vec<_> = language
.unordered_list()
.iter()
.map(|marker| marker.as_ref())
.collect();
let all_prefixes: Vec<_> = task_list_prefixes
.into_iter()
.chain(unordered_list_markers)
.collect();
if let Some(max_prefix_len) = all_prefixes.iter().map(|p| p.len()).max() {
let candidate: String = snapshot
.chars_for_range(range.clone())
.skip(num_of_whitespaces)
.take(max_prefix_len)
.collect();
if all_prefixes
.iter()
.any(|prefix| candidate.starts_with(*prefix))
{
return true;
}
}
let ordered_list_candidate: String = snapshot
.chars_for_range(range)
.skip(num_of_whitespaces)
.take(ORDERED_LIST_MAX_MARKER_LEN)
.collect();
for ordered_config in language.ordered_list() {
let regex = match Regex::new(&ordered_config.pattern) {
Ok(r) => r,
Err(_) => continue,
};
if let Some(captures) = regex.captures(&ordered_list_candidate) {
return captures.get(0).is_some();
}
}
false
}
#[derive(Debug)]
enum NewlineConfig {
/// Insert newline with optional additional indent and optional extra blank line
Newline {
additional_indent: IndentSize,
extra_line_additional_indent: Option<IndentSize>,
prevent_auto_indent: bool,
},
/// Clear the current line
ClearCurrentLine,
/// Unindent the current line and add continuation
UnindentCurrentLine { continuation: Arc<str> },
}
impl NewlineConfig {
fn has_extra_line(&self) -> bool {
matches!(
self,
Self::Newline {
extra_line_additional_indent: Some(_),
..
}
)
}
fn insert_extra_newline_brackets(
buffer: &MultiBufferSnapshot,
range: Range<MultiBufferOffset>,
@@ -25009,6 +25294,70 @@ impl EditorSnapshot {
let digit_count = self.widest_line_number().ilog10() + 1;
column_pixels(style, digit_count as usize, window)
}
/// Returns the line delta from `base` to `line` in the multibuffer, ignoring wrapped lines.
///
/// This is positive if `base` is before `line`.
fn relative_line_delta(&self, base: DisplayRow, line: DisplayRow) -> i64 {
let point = DisplayPoint::new(line, 0).to_point(self);
self.relative_line_delta_to_point(base, point)
}
/// Returns the line delta from `base` to `point` in the multibuffer, ignoring wrapped lines.
///
/// This is positive if `base` is before `point`.
pub fn relative_line_delta_to_point(&self, base: DisplayRow, point: Point) -> i64 {
let base_point = DisplayPoint::new(base, 0).to_point(self);
point.row as i64 - base_point.row as i64
}
/// Returns the line delta from `base` to `line` in the multibuffer, counting wrapped lines.
///
/// This is positive if `base` is before `line`.
fn relative_wrapped_line_delta(&self, base: DisplayRow, line: DisplayRow) -> i64 {
let point = DisplayPoint::new(line, 0).to_point(self);
self.relative_wrapped_line_delta_to_point(base, point)
}
/// Returns the line delta from `base` to `point` in the multibuffer, counting wrapped lines.
///
/// This is positive if `base` is before `point`.
pub fn relative_wrapped_line_delta_to_point(&self, base: DisplayRow, point: Point) -> i64 {
let base_point = DisplayPoint::new(base, 0).to_point(self);
let wrap_snapshot = self.wrap_snapshot();
let base_wrap_row = wrap_snapshot.make_wrap_point(base_point, Bias::Left).row();
let wrap_row = wrap_snapshot.make_wrap_point(point, Bias::Left).row();
wrap_row.0 as i64 - base_wrap_row.0 as i64
}
/// Returns the unsigned relative line number to display for each row in `rows`.
///
/// Wrapped rows are excluded from the hashmap if `count_relative_lines` is `false`.
pub fn calculate_relative_line_numbers(
&self,
rows: &Range<DisplayRow>,
relative_to: DisplayRow,
count_wrapped_lines: bool,
) -> HashMap<DisplayRow, u32> {
let initial_offset = if count_wrapped_lines {
self.relative_wrapped_line_delta(relative_to, rows.start)
} else {
self.relative_line_delta(relative_to, rows.start)
};
let display_row_infos = self
.row_infos(rows.start)
.take(rows.len())
.enumerate()
.map(|(i, row_info)| (DisplayRow(rows.start.0 + i as u32), row_info));
display_row_infos
.filter(|(_row, row_info)| {
row_info.buffer_row.is_some()
|| (count_wrapped_lines && row_info.wrapped_buffer_row.is_some())
})
.enumerate()
.map(|(i, (row, _row_info))| (row, (initial_offset + i as i64).unsigned_abs() as u32))
.collect()
}
}
pub fn column_pixels(style: &EditorStyle, column: usize, window: &Window) -> Pixels {

View File

@@ -215,7 +215,8 @@ impl Settings for EditorSettings {
},
scrollbar: Scrollbar {
show: scrollbar.show.map(Into::into).unwrap(),
git_diff: scrollbar.git_diff.unwrap(),
git_diff: scrollbar.git_diff.unwrap()
&& content.git.unwrap().enabled.unwrap().is_git_diff_enabled(),
selected_text: scrollbar.selected_text.unwrap(),
selected_symbol: scrollbar.selected_symbol.unwrap(),
search_results: scrollbar.search_results.unwrap(),

View File

@@ -36,7 +36,8 @@ use languages::markdown_lang;
use languages::rust_lang;
use lsp::CompletionParams;
use multi_buffer::{
IndentGuide, MultiBufferFilterMode, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey,
ExcerptRange, IndentGuide, MultiBuffer, MultiBufferFilterMode, MultiBufferOffset,
MultiBufferOffsetUtf16, PathKey,
};
use parking_lot::Mutex;
use pretty_assertions::{assert_eq, assert_ne};
@@ -28021,7 +28022,7 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
"
});
// Case 2: Test adding new line after nested list preserves indent of previous line
// Case 2: Test adding new line after nested list continues the list with unchecked task
cx.set_state(&indoc! {"
- [ ] Item 1
- [ ] Item 1.a
@@ -28038,20 +28039,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
- [x] Item 2
- [x] Item 2.a
- [x] Item 2.b
ˇ"
- [ ] ˇ"
});
// Case 3: Test adding a new nested list item preserves indent
cx.set_state(&indoc! {"
- [ ] Item 1
- [ ] Item 1.a
- [x] Item 2
- [x] Item 2.a
- [x] Item 2.b
ˇ"
});
// Case 3: Test adding content to continued list item
cx.update_editor(|editor, window, cx| {
editor.handle_input("-", window, cx);
editor.handle_input("Item 2.c", window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
@@ -28060,22 +28053,10 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
- [x] Item 2
- [x] Item 2.a
- [x] Item 2.b
"
});
cx.update_editor(|editor, window, cx| {
editor.handle_input(" [x] Item 2.c", window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
- [ ] Item 1
- [ ] Item 1.a
- [x] Item 2
- [x] Item 2.a
- [x] Item 2.b
- [x] Item 2.cˇ"
- [ ] Item 2.cˇ"
});
// Case 4: Test adding new line after nested ordered list preserves indent of previous line
// Case 4: Test adding new line after nested ordered list continues with next number
cx.set_state(indoc! {"
1. Item 1
1. Item 1.a
@@ -28092,44 +28073,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
2. Item 2
1. Item 2.a
2. Item 2.b
ˇ"
3. ˇ"
});
// Case 5: Adding new ordered list item preserves indent
cx.set_state(indoc! {"
1. Item 1
1. Item 1.a
2. Item 2
1. Item 2.a
2. Item 2.b
ˇ"
});
// Case 5: Adding content to continued ordered list item
cx.update_editor(|editor, window, cx| {
editor.handle_input("3", window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
1. Item 1
1. Item 1.a
2. Item 2
1. Item 2.a
2. Item 2.b
"
});
cx.update_editor(|editor, window, cx| {
editor.handle_input(".", window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
1. Item 1
1. Item 1.a
2. Item 2
1. Item 2.a
2. Item 2.b
3.ˇ"
});
cx.update_editor(|editor, window, cx| {
editor.handle_input(" Item 2.c", window, cx);
editor.handle_input("Item 2.c", window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
@@ -28685,6 +28634,130 @@ async fn test_sticky_scroll(cx: &mut TestAppContext) {
assert_eq!(sticky_headers(10.0), vec![]);
}
#[gpui::test]
fn test_relative_line_numbers(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let buffer_1 = cx.new(|cx| Buffer::local("aaaaaaaaaa\nbbb\n", cx));
let buffer_2 = cx.new(|cx| Buffer::local("cccccccccc\nddd\n", cx));
let buffer_3 = cx.new(|cx| Buffer::local("eee\nffffffffff\n", cx));
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(ReadWrite);
multibuffer.push_excerpts(
buffer_1.clone(),
[ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))],
cx,
);
multibuffer.push_excerpts(
buffer_2.clone(),
[ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))],
cx,
);
multibuffer.push_excerpts(
buffer_3.clone(),
[ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))],
cx,
);
multibuffer
});
// wrapped contents of multibuffer:
// aaa
// aaa
// aaa
// a
// bbb
//
// ccc
// ccc
// ccc
// c
// ddd
//
// eee
// fff
// fff
// fff
// f
let (editor, cx) = cx.add_window_view(|window, cx| build_editor(multibuffer, window, cx));
editor.update_in(cx, |editor, window, cx| {
editor.set_wrap_width(Some(30.0.into()), cx); // every 3 characters
// includes trailing newlines.
let expected_line_numbers = [2, 6, 7, 10, 14, 15, 18, 19, 23];
let expected_wrapped_line_numbers = [
2, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23,
];
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([
Point::new(7, 0)..Point::new(7, 1), // second row of `ccc`
]);
});
let snapshot = editor.snapshot(window, cx);
// these are all 0-indexed
let base_display_row = DisplayRow(11);
let base_row = 3;
let wrapped_base_row = 7;
// test not counting wrapped lines
let expected_relative_numbers = expected_line_numbers
.into_iter()
.enumerate()
.map(|(i, row)| (DisplayRow(row), i.abs_diff(base_row) as u32))
.collect_vec();
let actual_relative_numbers = snapshot
.calculate_relative_line_numbers(
&(DisplayRow(0)..DisplayRow(24)),
base_display_row,
false,
)
.into_iter()
.sorted()
.collect_vec();
assert_eq!(expected_relative_numbers, actual_relative_numbers);
// check `calculate_relative_line_numbers()` against `relative_line_delta()` for each line
for (display_row, relative_number) in expected_relative_numbers {
assert_eq!(
relative_number,
snapshot
.relative_line_delta(display_row, base_display_row)
.unsigned_abs() as u32,
);
}
// test counting wrapped lines
let expected_wrapped_relative_numbers = expected_wrapped_line_numbers
.into_iter()
.enumerate()
.map(|(i, row)| (DisplayRow(row), i.abs_diff(wrapped_base_row) as u32))
.collect_vec();
let actual_relative_numbers = snapshot
.calculate_relative_line_numbers(
&(DisplayRow(0)..DisplayRow(24)),
base_display_row,
true,
)
.into_iter()
.sorted()
.collect_vec();
assert_eq!(expected_wrapped_relative_numbers, actual_relative_numbers);
// check `calculate_relative_line_numbers()` against `relative_wrapped_line_delta()` for each line
for (display_row, relative_number) in expected_wrapped_relative_numbers {
assert_eq!(
relative_number,
snapshot
.relative_wrapped_line_delta(display_row, base_display_row)
.unsigned_abs() as u32,
);
}
});
}
#[gpui::test]
async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -29497,6 +29570,524 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) {
cx.assert_editor_state(after);
}
#[gpui::test]
async fn test_newline_task_list_continuation(cx: &mut TestAppContext) {
init_test(cx, |settings| {
settings.defaults.tab_size = Some(2.try_into().unwrap());
});
let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
// Case 1: Adding newline after (whitespace + prefix + any non-whitespace) adds marker
cx.set_state(indoc! {"
- [ ] taskˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- [ ] task
- [ ] ˇ
"});
// Case 2: Works with checked task items too
cx.set_state(indoc! {"
- [x] completed taskˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- [x] completed task
- [ ] ˇ
"});
// Case 3: Cursor position doesn't matter - content after marker is what counts
cx.set_state(indoc! {"
- [ ] taˇsk
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- [ ] ta
- [ ] ˇsk
"});
// Case 4: Adding newline after (whitespace + prefix + some whitespace) does NOT add marker
cx.set_state(indoc! {"
- [ ] ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(
indoc! {"
- [ ]$$
ˇ
"}
.replace("$", " ")
.as_str(),
);
// Case 5: Adding newline with content adds marker preserving indentation
cx.set_state(indoc! {"
- [ ] task
- [ ] indentedˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- [ ] task
- [ ] indented
- [ ] ˇ
"});
// Case 6: Adding newline with cursor right after prefix, unindents
cx.set_state(indoc! {"
- [ ] task
- [ ] sub task
- [ ] ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- [ ] task
- [ ] sub task
- [ ] ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
// Case 7: Adding newline with cursor right after prefix, removes marker
cx.assert_editor_state(indoc! {"
- [ ] task
- [ ] sub task
- [ ] ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- [ ] task
- [ ] sub task
ˇ
"});
// Case 8: Cursor before or inside prefix does not add marker
cx.set_state(indoc! {"
ˇ- [ ] task
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
ˇ- [ ] task
"});
cx.set_state(indoc! {"
- [ˇ ] task
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- [
ˇ
] task
"});
}
#[gpui::test]
async fn test_newline_unordered_list_continuation(cx: &mut TestAppContext) {
init_test(cx, |settings| {
settings.defaults.tab_size = Some(2.try_into().unwrap());
});
let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
// Case 1: Adding newline after (whitespace + marker + any non-whitespace) adds marker
cx.set_state(indoc! {"
- itemˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- item
- ˇ
"});
// Case 2: Works with different markers
cx.set_state(indoc! {"
* starred itemˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
* starred item
* ˇ
"});
cx.set_state(indoc! {"
+ plus itemˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
+ plus item
+ ˇ
"});
// Case 3: Cursor position doesn't matter - content after marker is what counts
cx.set_state(indoc! {"
- itˇem
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- it
- ˇem
"});
// Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker
cx.set_state(indoc! {"
- ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(
indoc! {"
- $
ˇ
"}
.replace("$", " ")
.as_str(),
);
// Case 5: Adding newline with content adds marker preserving indentation
cx.set_state(indoc! {"
- item
- indentedˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- item
- indented
- ˇ
"});
// Case 6: Adding newline with cursor right after marker, unindents
cx.set_state(indoc! {"
- item
- sub item
- ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- item
- sub item
- ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
// Case 7: Adding newline with cursor right after marker, removes marker
cx.assert_editor_state(indoc! {"
- item
- sub item
- ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- item
- sub item
ˇ
"});
// Case 8: Cursor before or inside prefix does not add marker
cx.set_state(indoc! {"
ˇ- item
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
ˇ- item
"});
cx.set_state(indoc! {"
-ˇ item
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
-
ˇitem
"});
}
#[gpui::test]
async fn test_newline_ordered_list_continuation(cx: &mut TestAppContext) {
init_test(cx, |settings| {
settings.defaults.tab_size = Some(2.try_into().unwrap());
});
let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
// Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number
cx.set_state(indoc! {"
1. first itemˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
1. first item
2. ˇ
"});
// Case 2: Works with larger numbers
cx.set_state(indoc! {"
10. tenth itemˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
10. tenth item
11. ˇ
"});
// Case 3: Cursor position doesn't matter - content after marker is what counts
cx.set_state(indoc! {"
1. itˇem
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
1. it
2. ˇem
"});
// Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker
cx.set_state(indoc! {"
1. ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(
indoc! {"
1. $
ˇ
"}
.replace("$", " ")
.as_str(),
);
// Case 5: Adding newline with content adds marker preserving indentation
cx.set_state(indoc! {"
1. item
2. indentedˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
1. item
2. indented
3. ˇ
"});
// Case 6: Adding newline with cursor right after marker, unindents
cx.set_state(indoc! {"
1. item
2. sub item
3. ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
1. item
2. sub item
1. ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
// Case 7: Adding newline with cursor right after marker, removes marker
cx.assert_editor_state(indoc! {"
1. item
2. sub item
1. ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
1. item
2. sub item
ˇ
"});
// Case 8: Cursor before or inside prefix does not add marker
cx.set_state(indoc! {"
ˇ1. item
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
ˇ1. item
"});
cx.set_state(indoc! {"
1ˇ. item
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
1
ˇ. item
"});
}
#[gpui::test]
async fn test_newline_should_not_autoindent_ordered_list(cx: &mut TestAppContext) {
init_test(cx, |settings| {
settings.defaults.tab_size = Some(2.try_into().unwrap());
});
let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
// Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number
cx.set_state(indoc! {"
1. first item
1. sub first item
2. sub second item
3. ˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
1. first item
1. sub first item
2. sub second item
1. ˇ
"});
}
#[gpui::test]
async fn test_tab_list_indent(cx: &mut TestAppContext) {
init_test(cx, |settings| {
settings.defaults.tab_size = Some(2.try_into().unwrap());
});
let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
// Case 1: Unordered list - cursor after prefix, adds indent before prefix
cx.set_state(indoc! {"
- ˇitem
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
let expected = indoc! {"
$$- ˇitem
"};
cx.assert_editor_state(expected.replace("$", " ").as_str());
// Case 2: Task list - cursor after prefix
cx.set_state(indoc! {"
- [ ] ˇtask
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
let expected = indoc! {"
$$- [ ] ˇtask
"};
cx.assert_editor_state(expected.replace("$", " ").as_str());
// Case 3: Ordered list - cursor after prefix
cx.set_state(indoc! {"
1. ˇfirst
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
let expected = indoc! {"
$$1. ˇfirst
"};
cx.assert_editor_state(expected.replace("$", " ").as_str());
// Case 4: With existing indentation - adds more indent
let initial = indoc! {"
$$- ˇitem
"};
cx.set_state(initial.replace("$", " ").as_str());
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
let expected = indoc! {"
$$$$- ˇitem
"};
cx.assert_editor_state(expected.replace("$", " ").as_str());
// Case 5: Empty list item
cx.set_state(indoc! {"
- ˇ
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
let expected = indoc! {"
$$- ˇ
"};
cx.assert_editor_state(expected.replace("$", " ").as_str());
// Case 6: Cursor at end of line with content
cx.set_state(indoc! {"
- itemˇ
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
let expected = indoc! {"
$$- itemˇ
"};
cx.assert_editor_state(expected.replace("$", " ").as_str());
// Case 7: Cursor at start of list item, indents it
cx.set_state(indoc! {"
- item
ˇ - sub item
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
let expected = indoc! {"
- item
ˇ - sub item
"};
cx.assert_editor_state(expected);
// Case 8: Cursor at start of list item, moves the cursor when "indent_list_on_tab" is false
cx.update_editor(|_, _, cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.all_languages.defaults.indent_list_on_tab = Some(false);
});
});
});
cx.set_state(indoc! {"
- item
ˇ - sub item
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
let expected = indoc! {"
- item
ˇ- sub item
"};
cx.assert_editor_state(expected);
}
#[gpui::test]
async fn test_local_worktree_trust(cx: &mut TestAppContext) {
init_test(cx, |_| {});

View File

@@ -46,9 +46,9 @@ use gpui::{
KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent,
MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement,
Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, WeakEntity,
Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px,
quad, relative, size, solid_background, transparent_black,
Size, StatefulInteractiveElement, Style, Styled, TextAlign, TextRun, TextStyleRefinement,
WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline,
point, px, quad, relative, size, solid_background, transparent_black,
};
use itertools::Itertools;
use language::{IndentGuideSettings, language_settings::ShowWhitespaceSetting};
@@ -66,7 +66,7 @@ use project::{
};
use settings::{
GitGutterSetting, GitHunkStyleSetting, IndentGuideBackgroundColoring, IndentGuideColoring,
Settings,
RelativeLineNumbers, Settings,
};
use smallvec::{SmallVec, smallvec};
use std::{
@@ -194,8 +194,6 @@ pub struct EditorElement {
style: EditorStyle,
}
type DisplayRowDelta = u32;
impl EditorElement {
pub(crate) const SCROLLBAR_WIDTH: Pixels = px(15.);
@@ -1697,9 +1695,13 @@ impl EditorElement {
[cursor_position.row().minus(visible_display_row_range.start) as usize];
let cursor_column = cursor_position.column() as usize;
let cursor_character_x = cursor_row_layout.x_for_index(cursor_column);
let mut block_width =
cursor_row_layout.x_for_index(cursor_column + 1) - cursor_character_x;
let cursor_character_x = cursor_row_layout.x_for_index(cursor_column)
+ cursor_row_layout
.alignment_offset(self.style.text.text_align, text_hitbox.size.width);
let cursor_next_x = cursor_row_layout.x_for_index(cursor_column + 1)
+ cursor_row_layout
.alignment_offset(self.style.text.text_align, text_hitbox.size.width);
let mut block_width = cursor_next_x - cursor_character_x;
if block_width == Pixels::ZERO {
block_width = em_advance;
}
@@ -3225,64 +3227,6 @@ impl EditorElement {
.collect()
}
fn calculate_relative_line_numbers(
&self,
snapshot: &EditorSnapshot,
rows: &Range<DisplayRow>,
relative_to: Option<DisplayRow>,
count_wrapped_lines: bool,
) -> HashMap<DisplayRow, DisplayRowDelta> {
let mut relative_rows: HashMap<DisplayRow, DisplayRowDelta> = Default::default();
let Some(relative_to) = relative_to else {
return relative_rows;
};
let start = rows.start.min(relative_to);
let end = rows.end.max(relative_to);
let buffer_rows = snapshot
.row_infos(start)
.take(1 + end.minus(start) as usize)
.collect::<Vec<_>>();
let head_idx = relative_to.minus(start);
let mut delta = 1;
let mut i = head_idx + 1;
let should_count_line = |row_info: &RowInfo| {
if count_wrapped_lines {
row_info.buffer_row.is_some() || row_info.wrapped_buffer_row.is_some()
} else {
row_info.buffer_row.is_some()
}
};
while i < buffer_rows.len() as u32 {
if should_count_line(&buffer_rows[i as usize]) {
if rows.contains(&DisplayRow(i + start.0)) {
relative_rows.insert(DisplayRow(i + start.0), delta);
}
delta += 1;
}
i += 1;
}
delta = 1;
i = head_idx.min(buffer_rows.len().saturating_sub(1) as u32);
while i > 0 && buffer_rows[i as usize].buffer_row.is_none() && !count_wrapped_lines {
i -= 1;
}
while i > 0 {
i -= 1;
if should_count_line(&buffer_rows[i as usize]) {
if rows.contains(&DisplayRow(i + start.0)) {
relative_rows.insert(DisplayRow(i + start.0), delta);
}
delta += 1;
}
}
relative_rows
}
fn layout_line_numbers(
&self,
gutter_hitbox: Option<&Hitbox>,
@@ -3292,7 +3236,7 @@ impl EditorElement {
rows: Range<DisplayRow>,
buffer_rows: &[RowInfo],
active_rows: &BTreeMap<DisplayRow, LineHighlightSpec>,
newest_selection_head: Option<DisplayPoint>,
relative_line_base: Option<DisplayRow>,
snapshot: &EditorSnapshot,
window: &mut Window,
cx: &mut App,
@@ -3304,32 +3248,16 @@ impl EditorElement {
return Arc::default();
}
let (newest_selection_head, relative) = self.editor.update(cx, |editor, cx| {
let newest_selection_head = newest_selection_head.unwrap_or_else(|| {
let newest = editor
.selections
.newest::<Point>(&editor.display_snapshot(cx));
SelectionLayout::new(
newest,
editor.selections.line_mode(),
editor.cursor_offset_on_selection,
editor.cursor_shape,
&snapshot.display_snapshot,
true,
true,
None,
)
.head
});
let relative = editor.relative_line_numbers(cx);
(newest_selection_head, relative)
});
let relative = self.editor.read(cx).relative_line_numbers(cx);
let relative_line_numbers_enabled = relative.enabled();
let relative_to = relative_line_numbers_enabled.then(|| newest_selection_head.row());
let relative_rows = if relative_line_numbers_enabled && let Some(base) = relative_line_base
{
snapshot.calculate_relative_line_numbers(&rows, base, relative.wrapped())
} else {
Default::default()
};
let relative_rows =
self.calculate_relative_line_numbers(snapshot, &rows, relative_to, relative.wrapped());
let mut line_number = String::new();
let segments = buffer_rows.iter().enumerate().flat_map(|(ix, row_info)| {
let display_row = DisplayRow(rows.start.0 + ix as u32);
@@ -4652,6 +4580,8 @@ impl EditorElement {
gutter_hitbox: &Hitbox,
text_hitbox: &Hitbox,
style: &EditorStyle,
relative_line_numbers: RelativeLineNumbers,
relative_to: Option<DisplayRow>,
window: &mut Window,
cx: &mut App,
) -> Option<StickyHeaders> {
@@ -4681,9 +4611,21 @@ impl EditorElement {
);
let line_number = show_line_numbers.then(|| {
let number = (start_point.row + 1).to_string();
let relative_number = relative_to.and_then(|base| match relative_line_numbers {
RelativeLineNumbers::Disabled => None,
RelativeLineNumbers::Enabled => {
Some(snapshot.relative_line_delta_to_point(base, start_point))
}
RelativeLineNumbers::Wrapped => {
Some(snapshot.relative_wrapped_line_delta_to_point(base, start_point))
}
});
let number = relative_number
.filter(|&delta| delta != 0)
.map(|delta| delta.unsigned_abs() as u32)
.unwrap_or(start_point.row + 1);
let color = cx.theme().colors().editor_line_number;
self.shape_line_number(SharedString::from(number), color, window)
self.shape_line_number(SharedString::from(number.to_string()), color, window)
});
lines.push(StickyHeaderLine::new(
@@ -5417,6 +5359,12 @@ impl EditorElement {
.max(MIN_POPOVER_LINE_HEIGHT * line_height), // Apply minimum height of 4 lines
);
// Don't show hover popovers when context menu is open to avoid overlap
let has_context_menu = self.editor.read(cx).mouse_context_menu.is_some();
if has_context_menu {
return;
}
let hover_popovers = self.editor.update(cx, |editor, cx| {
editor.hover_state.render(
snapshot,
@@ -6216,10 +6164,25 @@ impl EditorElement {
let color = cx.theme().colors().editor_hover_line_number;
let line = self.shape_line_number(shaped_line.text.clone(), color, window);
line.paint(hitbox.origin, line_height, window, cx).log_err()
line.paint(
hitbox.origin,
line_height,
TextAlign::Left,
None,
window,
cx,
)
.log_err()
} else {
shaped_line
.paint(hitbox.origin, line_height, window, cx)
.paint(
hitbox.origin,
line_height,
TextAlign::Left,
None,
window,
cx,
)
.log_err()
}) else {
continue;
@@ -7308,23 +7271,27 @@ impl EditorElement {
.map(|row| {
let line_layout =
&layout.position_map.line_layouts[row.minus(start_row) as usize];
let alignment_offset =
line_layout.alignment_offset(layout.text_align, layout.content_width);
HighlightedRangeLine {
start_x: if row == range.start.row() {
layout.content_origin.x
+ Pixels::from(
ScrollPixelOffset::from(
line_layout.x_for_index(range.start.column() as usize),
line_layout.x_for_index(range.start.column() as usize)
+ alignment_offset,
) - layout.position_map.scroll_pixel_position.x,
)
} else {
layout.content_origin.x
layout.content_origin.x + alignment_offset
- Pixels::from(layout.position_map.scroll_pixel_position.x)
},
end_x: if row == range.end.row() {
layout.content_origin.x
+ Pixels::from(
ScrollPixelOffset::from(
line_layout.x_for_index(range.end.column() as usize),
line_layout.x_for_index(range.end.column() as usize)
+ alignment_offset,
) - layout.position_map.scroll_pixel_position.x,
)
} else {
@@ -7332,6 +7299,7 @@ impl EditorElement {
ScrollPixelOffset::from(
layout.content_origin.x
+ line_layout.width
+ alignment_offset
+ line_end_overshoot,
) - layout.position_map.scroll_pixel_position.x,
)
@@ -8572,8 +8540,15 @@ impl LineWithInvisibles {
for fragment in &self.fragments {
match fragment {
LineFragment::Text(line) => {
line.paint(fragment_origin, line_height, window, cx)
.log_err();
line.paint(
fragment_origin,
line_height,
layout.text_align,
Some(layout.content_width),
window,
cx,
)
.log_err();
fragment_origin.x += line.width;
}
LineFragment::Element { size, .. } => {
@@ -8615,8 +8590,15 @@ impl LineWithInvisibles {
for fragment in &self.fragments {
match fragment {
LineFragment::Text(line) => {
line.paint_background(fragment_origin, line_height, window, cx)
.log_err();
line.paint_background(
fragment_origin,
line_height,
layout.text_align,
Some(layout.content_width),
window,
cx,
)
.log_err();
fragment_origin.x += line.width;
}
LineFragment::Element { size, .. } => {
@@ -8665,7 +8647,7 @@ impl LineWithInvisibles {
[token_offset, token_end_offset],
Box::new(move |window: &mut Window, cx: &mut App| {
invisible_symbol
.paint(origin, line_height, window, cx)
.paint(origin, line_height, TextAlign::Left, None, window, cx)
.log_err();
}),
)
@@ -8826,6 +8808,15 @@ impl LineWithInvisibles {
None
}
pub fn alignment_offset(&self, text_align: TextAlign, content_width: Pixels) -> Pixels {
let line_width = self.width;
match text_align {
TextAlign::Left => px(0.0),
TextAlign::Center => (content_width - line_width) / 2.0,
TextAlign::Right => content_width - line_width,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -9430,6 +9421,28 @@ impl Element for EditorElement {
window,
cx,
);
// relative rows are based on newest selection, even outside the visible area
let relative_row_base = self.editor.update(cx, |editor, cx| {
if editor.selections.count()==0 {
return None;
}
let newest = editor
.selections
.newest::<Point>(&editor.display_snapshot(cx));
Some(SelectionLayout::new(
newest,
editor.selections.line_mode(),
editor.cursor_offset_on_selection,
editor.cursor_shape,
&snapshot.display_snapshot,
true,
true,
None,
)
.head.row())
});
let mut breakpoint_rows = self.editor.update(cx, |editor, cx| {
editor.active_breakpoints(start_row..end_row, window, cx)
});
@@ -9447,7 +9460,7 @@ impl Element for EditorElement {
start_row..end_row,
&row_infos,
&active_rows,
newest_selection_head,
relative_row_base,
&snapshot,
window,
cx,
@@ -9767,6 +9780,7 @@ impl Element for EditorElement {
&& is_singleton
&& EditorSettings::get_global(cx).sticky_scroll.enabled
{
let relative = self.editor.read(cx).relative_line_numbers(cx);
self.layout_sticky_headers(
&snapshot,
editor_width,
@@ -9778,6 +9792,8 @@ impl Element for EditorElement {
&gutter_hitbox,
&text_hitbox,
&style,
relative,
relative_row_base,
window,
cx,
)
@@ -10203,6 +10219,8 @@ impl Element for EditorElement {
em_width,
em_advance,
snapshot,
text_align: self.style.text.text_align,
content_width: text_hitbox.size.width,
gutter_hitbox: gutter_hitbox.clone(),
text_hitbox: text_hitbox.clone(),
inline_blame_bounds: inline_blame_layout
@@ -10256,6 +10274,8 @@ impl Element for EditorElement {
sticky_buffer_header,
sticky_headers,
expand_toggles,
text_align: self.style.text.text_align,
content_width: text_hitbox.size.width,
}
})
})
@@ -10436,6 +10456,8 @@ pub struct EditorLayout {
sticky_buffer_header: Option<AnyElement>,
sticky_headers: Option<StickyHeaders>,
document_colors: Option<(DocumentColorsRenderMode, Vec<(Range<DisplayPoint>, Hsla)>)>,
text_align: TextAlign,
content_width: Pixels,
}
struct StickyHeaders {
@@ -10603,7 +10625,9 @@ impl StickyHeaderLine {
gutter_origin.x + gutter_width - gutter_right_padding - line_number.width,
gutter_origin.y,
);
line_number.paint(origin, line_height, window, cx).log_err();
line_number
.paint(origin, line_height, TextAlign::Left, None, window, cx)
.log_err();
}
}
}
@@ -11042,6 +11066,8 @@ pub(crate) struct PositionMap {
pub visible_row_range: Range<DisplayRow>,
pub line_layouts: Vec<LineWithInvisibles>,
pub snapshot: EditorSnapshot,
pub text_align: TextAlign,
pub content_width: Pixels,
pub text_hitbox: Hitbox,
pub gutter_hitbox: Hitbox,
pub inline_blame_bounds: Option<(Bounds<Pixels>, BufferId, BlameEntry)>,
@@ -11107,10 +11133,12 @@ impl PositionMap {
.line_layouts
.get(row as usize - scroll_position.y as usize)
{
if let Some(ix) = line.index_for_x(x) {
let alignment_offset = line.alignment_offset(self.text_align, self.content_width);
let x_relative_to_text = x - alignment_offset;
if let Some(ix) = line.index_for_x(x_relative_to_text) {
(ix as u32, px(0.))
} else {
(line.len as u32, px(0.).max(x - line.width))
(line.len as u32, px(0.).max(x_relative_to_text - line.width))
}
} else {
(0, x)
@@ -11299,7 +11327,14 @@ impl CursorLayout {
if let Some(block_text) = &self.block_text {
block_text
.paint(self.origin + origin, self.line_height, window, cx)
.paint(
self.origin + origin,
self.line_height,
TextAlign::Left,
None,
window,
cx,
)
.log_err();
}
}
@@ -11625,7 +11660,7 @@ mod tests {
}
#[gpui::test]
fn test_shape_line_numbers(cx: &mut TestAppContext) {
fn test_layout_line_numbers(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let window = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx);
@@ -11665,7 +11700,7 @@ mod tests {
})
.collect::<Vec<_>>(),
&BTreeMap::default(),
Some(DisplayPoint::new(DisplayRow(0), 0)),
Some(DisplayRow(0)),
&snapshot,
window,
cx,
@@ -11677,10 +11712,9 @@ mod tests {
let relative_rows = window
.update(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
element.calculate_relative_line_numbers(
&snapshot,
snapshot.calculate_relative_line_numbers(
&(DisplayRow(0)..DisplayRow(6)),
Some(DisplayRow(3)),
DisplayRow(3),
false,
)
})
@@ -11696,10 +11730,9 @@ mod tests {
let relative_rows = window
.update(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
element.calculate_relative_line_numbers(
&snapshot,
snapshot.calculate_relative_line_numbers(
&(DisplayRow(3)..DisplayRow(6)),
Some(DisplayRow(1)),
DisplayRow(1),
false,
)
})
@@ -11713,10 +11746,9 @@ mod tests {
let relative_rows = window
.update(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
element.calculate_relative_line_numbers(
&snapshot,
snapshot.calculate_relative_line_numbers(
&(DisplayRow(0)..DisplayRow(3)),
Some(DisplayRow(6)),
DisplayRow(6),
false,
)
})
@@ -11753,7 +11785,7 @@ mod tests {
})
.collect::<Vec<_>>(),
&BTreeMap::default(),
Some(DisplayPoint::new(DisplayRow(0), 0)),
Some(DisplayRow(0)),
&snapshot,
window,
cx,
@@ -11768,7 +11800,7 @@ mod tests {
}
#[gpui::test]
fn test_shape_line_numbers_wrapping(cx: &mut TestAppContext) {
fn test_layout_line_numbers_wrapping(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let window = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx);
@@ -11813,7 +11845,7 @@ mod tests {
})
.collect::<Vec<_>>(),
&BTreeMap::default(),
Some(DisplayPoint::new(DisplayRow(0), 0)),
Some(DisplayRow(0)),
&snapshot,
window,
cx,
@@ -11825,10 +11857,9 @@ mod tests {
let relative_rows = window
.update(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
element.calculate_relative_line_numbers(
&snapshot,
snapshot.calculate_relative_line_numbers(
&(DisplayRow(0)..DisplayRow(6)),
Some(DisplayRow(3)),
DisplayRow(3),
true,
)
})
@@ -11865,7 +11896,7 @@ mod tests {
})
.collect::<Vec<_>>(),
&BTreeMap::from_iter([(DisplayRow(0), LineHighlightSpec::default())]),
Some(DisplayPoint::new(DisplayRow(0), 0)),
Some(DisplayRow(0)),
&snapshot,
window,
cx,
@@ -11880,10 +11911,9 @@ mod tests {
let relative_rows = window
.update(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
element.calculate_relative_line_numbers(
&snapshot,
snapshot.calculate_relative_line_numbers(
&(DisplayRow(0)..DisplayRow(6)),
Some(DisplayRow(3)),
DisplayRow(3),
true,
)
})

View File

@@ -17,8 +17,8 @@ use gpui::{
ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point,
};
use language::{
Bias, Buffer, BufferRow, CharKind, CharScopeContext, DiskState, LocalFile, Point,
SelectionGoal, proto::serialize_anchor as serialize_text_anchor,
Bias, Buffer, BufferRow, CharKind, CharScopeContext, LocalFile, Point, SelectionGoal,
proto::serialize_anchor as serialize_text_anchor,
};
use lsp::DiagnosticSeverity;
use multi_buffer::MultiBufferOffset;
@@ -722,7 +722,7 @@ impl Item for Editor {
.read(cx)
.as_singleton()
.and_then(|buffer| buffer.read(cx).file())
.is_some_and(|file| file.disk_state() == DiskState::Deleted);
.is_some_and(|file| file.disk_state().is_deleted());
h_flex()
.gap_2()

View File

@@ -5,7 +5,7 @@ use crate::{
};
use gpui::{Bounds, Context, Pixels, Window};
use language::Point;
use multi_buffer::Anchor;
use multi_buffer::{Anchor, ToPoint};
use std::cmp;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
@@ -186,6 +186,19 @@ impl Editor {
}
}
let style = self.style(cx).clone();
let sticky_headers = self.sticky_headers(&style, cx).unwrap_or_default();
let visible_sticky_headers = sticky_headers
.iter()
.filter(|h| {
let buffer_snapshot = display_map.buffer_snapshot();
let buffer_range =
h.range.start.to_point(buffer_snapshot)..h.range.end.to_point(buffer_snapshot);
buffer_range.contains(&Point::new(target_top as u32, 0))
})
.count();
let margin = if matches!(self.mode, EditorMode::AutoHeight { .. }) {
0.
} else {
@@ -218,7 +231,7 @@ impl Editor {
let was_autoscrolled = match strategy {
AutoscrollStrategy::Fit | AutoscrollStrategy::Newest => {
let margin = margin.min(self.scroll_manager.vertical_scroll_margin);
let target_top = (target_top - margin).max(0.0);
let target_top = (target_top - margin - visible_sticky_headers as f64).max(0.0);
let target_bottom = target_bottom + margin;
let start_row = scroll_position.y;
let end_row = start_row + visible_lines;

View File

@@ -205,6 +205,49 @@ impl EditorLspTestContext {
(_ "{" "}" @end) @indent
(_ "(" ")" @end) @indent
"#})),
text_objects: Some(Cow::from(indoc! {r#"
(function_declaration
body: (_
"{"
(_)* @function.inside
"}")) @function.around
(method_definition
body: (_
"{"
(_)* @function.inside
"}")) @function.around
; Arrow function in variable declaration - capture the full declaration
([
(lexical_declaration
(variable_declarator
value: (arrow_function
body: (statement_block
"{"
(_)* @function.inside
"}"))))
(variable_declaration
(variable_declarator
value: (arrow_function
body: (statement_block
"{"
(_)* @function.inside
"}"))))
]) @function.around
([
(lexical_declaration
(variable_declarator
value: (arrow_function)))
(variable_declaration
(variable_declarator
value: (arrow_function)))
]) @function.around
; Catch-all for arrow functions in other contexts (callbacks, etc.)
((arrow_function) @function.around (#not-has-parent? @function.around variable_declarator))
"#})),
..Default::default()
})
.expect("Could not parse queries");
@@ -276,6 +319,49 @@ impl EditorLspTestContext {
(jsx_opening_element) @start
(jsx_closing_element)? @end) @indent
"#})),
text_objects: Some(Cow::from(indoc! {r#"
(function_declaration
body: (_
"{"
(_)* @function.inside
"}")) @function.around
(method_definition
body: (_
"{"
(_)* @function.inside
"}")) @function.around
; Arrow function in variable declaration - capture the full declaration
([
(lexical_declaration
(variable_declarator
value: (arrow_function
body: (statement_block
"{"
(_)* @function.inside
"}"))))
(variable_declaration
(variable_declarator
value: (arrow_function
body: (statement_block
"{"
(_)* @function.inside
"}"))))
]) @function.around
([
(lexical_declaration
(variable_declarator
value: (arrow_function)))
(variable_declaration
(variable_declarator
value: (arrow_function)))
]) @function.around
; Catch-all for arrow functions in other contexts (callbacks, etc.)
((arrow_function) @function.around (#not-has-parent? @function.around variable_declarator))
"#})),
..Default::default()
})
.expect("Could not parse queries");

View File

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

View File

@@ -19,6 +19,9 @@ impl Global for GlobalExtensionHostProxy {}
///
/// This object implements each of the individual proxy types so that their
/// methods can be called directly on it.
/// Registration function for language model providers.
pub type LanguageModelProviderRegistration = Box<dyn FnOnce(&mut App) + Send>;
#[derive(Default)]
pub struct ExtensionHostProxy {
theme_proxy: RwLock<Option<Arc<dyn ExtensionThemeProxy>>>,
@@ -29,6 +32,7 @@ pub struct ExtensionHostProxy {
slash_command_proxy: RwLock<Option<Arc<dyn ExtensionSlashCommandProxy>>>,
context_server_proxy: RwLock<Option<Arc<dyn ExtensionContextServerProxy>>>,
debug_adapter_provider_proxy: RwLock<Option<Arc<dyn ExtensionDebugAdapterProviderProxy>>>,
language_model_provider_proxy: RwLock<Option<Arc<dyn ExtensionLanguageModelProviderProxy>>>,
}
impl ExtensionHostProxy {
@@ -54,6 +58,7 @@ impl ExtensionHostProxy {
slash_command_proxy: RwLock::default(),
context_server_proxy: RwLock::default(),
debug_adapter_provider_proxy: RwLock::default(),
language_model_provider_proxy: RwLock::default(),
}
}
@@ -90,6 +95,15 @@ impl ExtensionHostProxy {
.write()
.replace(Arc::new(proxy));
}
pub fn register_language_model_provider_proxy(
&self,
proxy: impl ExtensionLanguageModelProviderProxy,
) {
self.language_model_provider_proxy
.write()
.replace(Arc::new(proxy));
}
}
pub trait ExtensionThemeProxy: Send + Sync + 'static {
@@ -446,3 +460,37 @@ impl ExtensionDebugAdapterProviderProxy for ExtensionHostProxy {
proxy.unregister_debug_locator(locator_name)
}
}
pub trait ExtensionLanguageModelProviderProxy: Send + Sync + 'static {
fn register_language_model_provider(
&self,
provider_id: Arc<str>,
register_fn: LanguageModelProviderRegistration,
cx: &mut App,
);
fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App);
}
impl ExtensionLanguageModelProviderProxy for ExtensionHostProxy {
fn register_language_model_provider(
&self,
provider_id: Arc<str>,
register_fn: LanguageModelProviderRegistration,
cx: &mut App,
) {
let Some(proxy) = self.language_model_provider_proxy.read().clone() else {
return;
};
proxy.register_language_model_provider(provider_id, register_fn, cx)
}
fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App) {
let Some(proxy) = self.language_model_provider_proxy.read().clone() else {
return;
};
proxy.unregister_language_model_provider(provider_id, cx)
}
}

View File

@@ -93,6 +93,8 @@ pub struct ExtensionManifest {
pub debug_adapters: BTreeMap<Arc<str>, DebugAdapterManifestEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub debug_locators: BTreeMap<Arc<str>, DebugLocatorManifestEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub language_model_providers: BTreeMap<Arc<str>, LanguageModelProviderManifestEntry>,
}
impl ExtensionManifest {
@@ -288,6 +290,16 @@ pub struct DebugAdapterManifestEntry {
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct DebugLocatorManifestEntry {}
/// Manifest entry for a language model provider.
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct LanguageModelProviderManifestEntry {
/// Display name for the provider.
pub name: String,
/// Path to an SVG icon file relative to the extension root (e.g., "icons/provider.svg").
#[serde(default)]
pub icon: Option<String>,
}
impl ExtensionManifest {
pub async fn load(fs: Arc<dyn Fs>, extension_dir: &Path) -> Result<Self> {
let extension_name = extension_dir
@@ -358,6 +370,7 @@ fn manifest_from_old_manifest(
capabilities: Vec::new(),
debug_adapters: Default::default(),
debug_locators: Default::default(),
language_model_providers: Default::default(),
}
}
@@ -391,6 +404,7 @@ mod tests {
capabilities: vec![],
debug_adapters: Default::default(),
debug_locators: Default::default(),
language_model_providers: BTreeMap::default(),
}
}

View File

@@ -148,6 +148,7 @@ fn manifest() -> ExtensionManifest {
)],
debug_adapters: Default::default(),
debug_locators: Default::default(),
language_model_providers: BTreeMap::default(),
}
}

View File

@@ -113,6 +113,7 @@ mod tests {
capabilities: vec![],
debug_adapters: Default::default(),
debug_locators: Default::default(),
language_model_providers: BTreeMap::default(),
}
}

View File

@@ -165,6 +165,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
capabilities: Vec::new(),
debug_adapters: Default::default(),
debug_locators: Default::default(),
language_model_providers: BTreeMap::default(),
}),
dev: false,
},
@@ -196,6 +197,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
capabilities: Vec::new(),
debug_adapters: Default::default(),
debug_locators: Default::default(),
language_model_providers: BTreeMap::default(),
}),
dev: false,
},
@@ -376,6 +378,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
capabilities: Vec::new(),
debug_adapters: Default::default(),
debug_locators: Default::default(),
language_model_providers: BTreeMap::default(),
}),
dev: false,
},

155
crates/git_ui/src/clone.rs Normal file
View File

@@ -0,0 +1,155 @@
use gpui::{App, Context, WeakEntity, Window};
use notifications::status_toast::{StatusToast, ToastIcon};
use std::sync::Arc;
use ui::{Color, IconName, SharedString};
use util::ResultExt;
use workspace::{self, Workspace};
pub fn clone_and_open(
repo_url: SharedString,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
on_success: Arc<
dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send + Sync + 'static,
>,
) {
let destination_prompt = cx.prompt_for_paths(gpui::PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select as Repository Destination".into()),
});
window
.spawn(cx, async move |cx| {
let mut paths = destination_prompt.await.ok()?.ok()??;
let mut destination_dir = paths.pop()?;
let repo_name = repo_url
.split('/')
.next_back()
.map(|name| name.strip_suffix(".git").unwrap_or(name))
.unwrap_or("repository")
.to_owned();
let clone_task = workspace
.update(cx, |workspace, cx| {
let fs = workspace.app_state().fs.clone();
let destination_dir = destination_dir.clone();
let repo_url = repo_url.clone();
cx.spawn(async move |_workspace, _cx| {
fs.git_clone(&repo_url, destination_dir.as_path()).await
})
})
.ok()?;
if let Err(error) = clone_task.await {
workspace
.update(cx, |workspace, cx| {
let toast = StatusToast::new(error.to_string(), cx, |this, _| {
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
.dismiss_button(true)
});
workspace.toggle_status_toast(toast, cx);
})
.log_err();
return None;
}
let has_worktrees = workspace
.read_with(cx, |workspace, cx| {
workspace.project().read(cx).worktrees(cx).next().is_some()
})
.ok()?;
let prompt_answer = if has_worktrees {
cx.update(|window, cx| {
window.prompt(
gpui::PromptLevel::Info,
&format!("Git Clone: {}", repo_name),
None,
&["Add repo to project", "Open repo in new project"],
cx,
)
})
.ok()?
.await
.ok()?
} else {
// Don't ask if project is empty
0
};
destination_dir.push(&repo_name);
match prompt_answer {
0 => {
workspace
.update_in(cx, |workspace, window, cx| {
let create_task = workspace.project().update(cx, |project, cx| {
project.create_worktree(destination_dir.as_path(), true, cx)
});
let workspace_weak = cx.weak_entity();
let on_success = on_success.clone();
cx.spawn_in(window, async move |_window, cx| {
if create_task.await.log_err().is_some() {
workspace_weak
.update_in(cx, |workspace, window, cx| {
(on_success)(workspace, window, cx);
})
.ok();
}
})
.detach();
})
.ok()?;
}
1 => {
workspace
.update(cx, move |workspace, cx| {
let app_state = workspace.app_state().clone();
let destination_path = destination_dir.clone();
let on_success = on_success.clone();
workspace::open_new(
Default::default(),
app_state,
cx,
move |workspace, window, cx| {
cx.activate(true);
let create_task =
workspace.project().update(cx, |project, cx| {
project.create_worktree(
destination_path.as_path(),
true,
cx,
)
});
let workspace_weak = cx.weak_entity();
cx.spawn_in(window, async move |_window, cx| {
if create_task.await.log_err().is_some() {
workspace_weak
.update_in(cx, |workspace, window, cx| {
(on_success)(workspace, window, cx);
})
.ok();
}
})
.detach();
},
)
.detach();
})
.ok();
}
_ => {}
}
Some(())
})
.detach();
}

View File

@@ -69,7 +69,7 @@ struct GitBlob {
path: RepoPath,
worktree_id: WorktreeId,
is_deleted: bool,
display_name: Arc<str>,
display_name: String,
}
const COMMIT_MESSAGE_SORT_PREFIX: u64 = 0;
@@ -243,9 +243,8 @@ impl CommitView {
.path
.file_name()
.map(|name| name.to_string())
.unwrap_or_else(|| file.path.display(PathStyle::Posix).to_string());
let display_name: Arc<str> =
Arc::from(format!("{short_sha} - {file_name}").into_boxed_str());
.unwrap_or_else(|| file.path.display(PathStyle::local()).to_string());
let display_name = format!("{short_sha} - {file_name}");
let file = Arc::new(GitBlob {
path: file.path.clone(),
@@ -661,15 +660,13 @@ impl language::File for GitBlob {
}
fn disk_state(&self) -> DiskState {
if self.is_deleted {
DiskState::Deleted
} else {
DiskState::New
DiskState::Historic {
was_deleted: self.is_deleted,
}
}
fn path_style(&self, _: &App) -> PathStyle {
PathStyle::Posix
PathStyle::local()
}
fn path(&self) -> &Arc<RelPath> {
@@ -697,45 +694,6 @@ impl language::File for GitBlob {
}
}
// No longer needed since metadata buffer is not created
// impl language::File for CommitMetadataFile {
// fn as_local(&self) -> Option<&dyn language::LocalFile> {
// None
// }
//
// fn disk_state(&self) -> DiskState {
// DiskState::New
// }
//
// fn path_style(&self, _: &App) -> PathStyle {
// PathStyle::Posix
// }
//
// fn path(&self) -> &Arc<RelPath> {
// &self.title
// }
//
// fn full_path(&self, _: &App) -> PathBuf {
// self.title.as_std_path().to_path_buf()
// }
//
// fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
// self.title.file_name().unwrap_or("commit")
// }
//
// fn worktree_id(&self, _: &App) -> WorktreeId {
// self.worktree_id
// }
//
// fn to_proto(&self, _cx: &App) -> language::proto::File {
// unimplemented!()
// }
//
// fn is_private(&self) -> bool {
// false
// }
// }
async fn build_buffer(
mut text: String,
blob: Arc<dyn File>,

View File

@@ -58,7 +58,7 @@ use project::{
git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
project_settings::{GitPathStyle, ProjectSettings},
};
use prompt_store::{PromptId, PromptStore, RULES_FILE_NAMES};
use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES};
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore, StatusStyle};
use std::future::Future;
@@ -2579,25 +2579,26 @@ impl GitPanel {
is_using_legacy_zed_pro: bool,
cx: &mut AsyncApp,
) -> String {
const DEFAULT_PROMPT: &str = include_str!("commit_message_prompt.txt");
// Remove this once we stop supporting legacy Zed Pro
// In legacy Zed Pro, Git commit summary generation did not count as a
// prompt. If the user changes the prompt, our classification will fail,
// meaning that users will be charged for generating commit messages.
if is_using_legacy_zed_pro {
return DEFAULT_PROMPT.to_string();
return BuiltInPrompt::CommitMessage.default_content().to_string();
}
let load = async {
let store = cx.update(|cx| PromptStore::global(cx)).ok()?.await.ok()?;
store
.update(cx, |s, cx| s.load(PromptId::CommitMessage, cx))
.update(cx, |s, cx| {
s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx)
})
.ok()?
.await
.ok()
};
load.await.unwrap_or_else(|| DEFAULT_PROMPT.to_string())
load.await
.unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string())
}
/// Generates a commit message using an LLM.
@@ -2848,93 +2849,15 @@ impl GitPanel {
}
pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
let path = cx.prompt_for_paths(gpui::PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select as Repository Destination".into()),
});
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |this, cx| {
let mut paths = path.await.ok()?.ok()??;
let mut path = paths.pop()?;
let repo_name = repo.split("/").last()?.strip_suffix(".git")?.to_owned();
let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?;
let prompt_answer = match fs.git_clone(&repo, path.as_path()).await {
Ok(_) => cx.update(|window, cx| {
window.prompt(
PromptLevel::Info,
&format!("Git Clone: {}", repo_name),
None,
&["Add repo to project", "Open repo in new project"],
cx,
)
}),
Err(e) => {
this.update(cx, |this: &mut GitPanel, cx| {
let toast = StatusToast::new(e.to_string(), cx, |this, _| {
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
.dismiss_button(true)
});
this.workspace
.update(cx, |workspace, cx| {
workspace.toggle_status_toast(toast, cx);
})
.ok();
})
.ok()?;
return None;
}
}
.ok()?;
path.push(repo_name);
match prompt_answer.await.ok()? {
0 => {
workspace
.update(cx, |workspace, cx| {
workspace
.project()
.update(cx, |project, cx| {
project.create_worktree(path.as_path(), true, cx)
})
.detach();
})
.ok();
}
1 => {
workspace
.update(cx, move |workspace, cx| {
workspace::open_new(
Default::default(),
workspace.app_state().clone(),
cx,
move |workspace, _, cx| {
cx.activate(true);
workspace
.project()
.update(cx, |project, cx| {
project.create_worktree(&path, true, cx)
})
.detach();
},
)
.detach();
})
.ok();
}
_ => {}
}
Some(())
})
.detach();
crate::clone::clone_and_open(
repo.into(),
workspace,
window,
cx,
Arc::new(|_workspace: &mut workspace::Workspace, _window, _cx| {}),
);
}
pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -5280,7 +5203,7 @@ impl GitPanel {
this.child(
self.entry_label(path_name, path_color)
.truncate()
.truncate_start()
.when(strikethrough, Label::strikethrough),
)
})

View File

@@ -10,6 +10,7 @@ use ui::{
};
mod blame_ui;
pub mod clone;
use git::{
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},

View File

@@ -546,8 +546,15 @@ impl Element for TextElement {
window.paint_quad(selection)
}
let line = prepaint.line.take().unwrap();
line.paint(bounds.origin, window.line_height(), window, cx)
.unwrap();
line.paint(
bounds.origin,
window.line_height(),
gpui::TextAlign::Left,
None,
window,
cx,
)
.unwrap();
if focus_handle.is_focused(window)
&& let Some(cursor) = prepaint.cursor.take()

View File

@@ -0,0 +1,174 @@
use gpui::{
App, Application, Context, Corner, Div, Hsla, Stateful, Window, WindowOptions, anchored,
deferred, div, prelude::*, px,
};
/// An example show use deferred to create a floating layers.
struct HelloWorld {
open: bool,
secondary_open: bool,
}
fn button(id: &'static str) -> Stateful<Div> {
div()
.id(id)
.bg(gpui::black())
.text_color(gpui::white())
.px_3()
.py_1()
}
fn popover() -> Div {
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.shadow_lg()
.p_3()
.rounded_md()
.bg(gpui::white())
.text_color(gpui::black())
.border_1()
.text_sm()
.border_color(gpui::black().opacity(0.1))
}
fn line(color: Hsla) -> Div {
div().w(px(480.)).h_2().bg(color.opacity(0.25))
}
impl HelloWorld {
fn render_secondary_popover(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
button("secondary-btn")
.mt_2()
.child("Child Popover")
.on_click(cx.listener(|this, _, _, cx| {
this.secondary_open = true;
cx.notify();
}))
.when(self.secondary_open, |this| {
this.child(
// GPUI can't support deferred here yet,
// it was inside another deferred element.
anchored()
.anchor(Corner::TopLeft)
.snap_to_window_with_margin(px(8.))
.child(
popover()
.child("This is second level Popover")
.bg(gpui::white())
.border_color(gpui::blue())
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
this.secondary_open = false;
cx.notify();
})),
),
)
})
}
}
impl Render for HelloWorld {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_3()
.size_full()
.bg(gpui::white())
.text_color(gpui::black())
.justify_center()
.items_center()
.child(
div()
.flex()
.flex_row()
.gap_4()
.child(
button("popover0").child("Opened Popover").child(
deferred(
anchored()
.anchor(Corner::TopLeft)
.snap_to_window_with_margin(px(8.))
.child(popover().w_96().gap_3().child(
"This is a default opened Popover, \
we can use deferred to render it \
in a floating layer.",
)),
)
.priority(0),
),
)
.child(
button("popover1")
.child("Open Popover")
.on_click(cx.listener(|this, _, _, cx| {
this.open = true;
cx.notify();
}))
.when(self.open, |this| {
this.child(
deferred(
anchored()
.anchor(Corner::TopLeft)
.snap_to_window_with_margin(px(8.))
.child(
popover()
.w_96()
.gap_3()
.child(
"This is first level Popover, \
we can use deferred to render it \
in a floating layer.\n\
Click outside to close.",
)
.when(!self.secondary_open, |this| {
this.on_mouse_down_out(cx.listener(
|this, _, _, cx| {
this.open = false;
cx.notify();
},
))
})
// Here we need render popover after the content
// to ensure it will be on top layer.
.child(
self.render_secondary_popover(window, cx),
),
),
)
.priority(1),
)
}),
),
)
.child(
"Here is an example text rendered, \
to ensure the Popover will float above this contents.",
)
.children([
line(gpui::red()),
line(gpui::yellow()),
line(gpui::blue()),
line(gpui::green()),
])
}
}
fn main() {
Application::new().run(|cx: &mut App| {
cx.open_window(WindowOptions::default(), |_, cx| {
cx.new(|_| HelloWorld {
open: false,
secondary_open: false,
})
})
.unwrap();
cx.activate(true);
});
}

View File

@@ -130,6 +130,50 @@ impl Render for Example {
})),
),
)
.child(
div()
.id("group-1")
.tab_index(6)
.tab_group()
.tab_stop(false)
.child(
button("group-1-button-1")
.tab_index(1)
.child("Tab index [6, 1]"),
)
.child(
button("group-1-button-2")
.tab_index(2)
.child("Tab index [6, 2]"),
)
.child(
button("group-1-button-3")
.tab_index(3)
.child("Tab index [6, 3]"),
),
)
.child(
div()
.id("group-2")
.tab_index(7)
.tab_group()
.tab_stop(false)
.child(
button("group-2-button-1")
.tab_index(1)
.child("Tab index [7, 1]"),
)
.child(
button("group-2-button-2")
.tab_index(2)
.child("Tab index [7, 2]"),
)
.child(
button("group-2-button-3")
.tab_index(3)
.child("Tab index [7, 3]"),
),
)
}
}

View File

@@ -28,8 +28,6 @@ pub use entity_map::*;
use http_client::{HttpClient, Url};
use smallvec::SmallVec;
#[cfg(any(test, feature = "test-support"))]
pub use test_app::*;
#[cfg(any(test, feature = "test-support"))]
pub use test_context::*;
use util::{ResultExt, debug_panic};
@@ -53,8 +51,6 @@ mod async_context;
mod context;
mod entity_map;
#[cfg(any(test, feature = "test-support"))]
mod test_app;
#[cfg(any(test, feature = "test-support"))]
mod test_context;
/// The duration for which futures returned from [Context::on_app_quit] can run before the application fully quits.
@@ -320,6 +316,7 @@ impl SystemWindowTabController {
.find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group));
let current_group = current_group?;
// TODO: `.keys()` returns arbitrary order, what does "next" mean?
let mut group_ids: Vec<_> = controller.tab_groups.keys().collect();
let idx = group_ids.iter().position(|g| *g == current_group)?;
let next_idx = (idx + 1) % group_ids.len();
@@ -344,6 +341,7 @@ impl SystemWindowTabController {
.find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group));
let current_group = current_group?;
// TODO: `.keys()` returns arbitrary order, what does "previous" mean?
let mut group_ids: Vec<_> = controller.tab_groups.keys().collect();
let idx = group_ids.iter().position(|g| *g == current_group)?;
let prev_idx = if idx == 0 {
@@ -365,12 +363,9 @@ impl SystemWindowTabController {
/// Get all tabs in the same window.
pub fn tabs(&self, id: WindowId) -> Option<&Vec<SystemWindowTab>> {
let tab_group = self
.tab_groups
.iter()
.find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group))?;
self.tab_groups.get(&tab_group)
self.tab_groups
.values()
.find(|tabs| tabs.iter().any(|tab| tab.id == id))
}
/// Initialize the visibility of the system window tab controller.
@@ -445,7 +440,7 @@ impl SystemWindowTabController {
/// Insert a tab into a tab group.
pub fn add_tab(cx: &mut App, id: WindowId, tabs: Vec<SystemWindowTab>) {
let mut controller = cx.global_mut::<SystemWindowTabController>();
let Some(tab) = tabs.clone().into_iter().find(|tab| tab.id == id) else {
let Some(tab) = tabs.iter().find(|tab| tab.id == id).cloned() else {
return;
};
@@ -508,16 +503,14 @@ impl SystemWindowTabController {
return;
};
let initial_tabs_len = initial_tabs.len();
let mut all_tabs = initial_tabs.clone();
for tabs in controller.tab_groups.values() {
all_tabs.extend(
tabs.iter()
.filter(|tab| !initial_tabs.contains(tab))
.cloned(),
);
for (_, mut tabs) in controller.tab_groups.drain() {
tabs.retain(|tab| !all_tabs[..initial_tabs_len].contains(tab));
all_tabs.extend(tabs);
}
controller.tab_groups.clear();
controller.tab_groups.insert(0, all_tabs);
}
@@ -587,6 +580,7 @@ impl GpuiMode {
/// You need a reference to an `App` to access the state of a [Entity].
pub struct App {
pub(crate) this: Weak<AppCell>,
pub(crate) liveness: std::sync::Arc<()>,
pub(crate) platform: Rc<dyn Platform>,
pub(crate) mode: GpuiMode,
text_system: Arc<TextSystem>,
@@ -665,6 +659,7 @@ impl App {
let app = Rc::new_cyclic(|this| AppCell {
app: RefCell::new(App {
this: this.clone(),
liveness: std::sync::Arc::new(()),
platform: platform.clone(),
text_system,
mode: GpuiMode::Production,
@@ -1084,11 +1079,9 @@ impl App {
self.platform.window_appearance()
}
/// Writes data to the primary selection buffer.
/// Only available on Linux.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
pub fn write_to_primary(&self, item: ClipboardItem) {
self.platform.write_to_primary(item)
/// Reads data from the platform clipboard.
pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
self.platform.read_from_clipboard()
}
/// Writes data to the platform clipboard.
@@ -1103,9 +1096,31 @@ impl App {
self.platform.read_from_primary()
}
/// Reads data from the platform clipboard.
pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
self.platform.read_from_clipboard()
/// Writes data to the primary selection buffer.
/// Only available on Linux.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
pub fn write_to_primary(&self, item: ClipboardItem) {
self.platform.write_to_primary(item)
}
/// Reads data from macOS's "Find" pasteboard.
///
/// Used to share the current search string between apps.
///
/// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find
#[cfg(target_os = "macos")]
pub fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
self.platform.read_from_find_pasteboard()
}
/// Writes data to macOS's "Find" pasteboard.
///
/// Used to share the current search string between apps.
///
/// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find
#[cfg(target_os = "macos")]
pub fn write_to_find_pasteboard(&self, item: ClipboardItem) {
self.platform.write_to_find_pasteboard(item)
}
/// Writes credentials to the platform keychain.
@@ -1463,6 +1478,7 @@ impl App {
pub fn to_async(&self) -> AsyncApp {
AsyncApp {
app: self.this.clone(),
liveness_token: std::sync::Arc::downgrade(&self.liveness),
background_executor: self.background_executor.clone(),
foreground_executor: self.foreground_executor.clone(),
}
@@ -2199,8 +2215,6 @@ impl App {
}
impl AppContext for App {
type Result<T> = T;
/// Builds an entity that is owned by the application.
///
/// The given function will be invoked with a [`Context`] and must return an object representing the entity. An
@@ -2222,7 +2236,7 @@ impl AppContext for App {
})
}
fn reserve_entity<T: 'static>(&mut self) -> Self::Result<Reservation<T>> {
fn reserve_entity<T: 'static>(&mut self) -> Reservation<T> {
Reservation(self.entities.reserve())
}
@@ -2230,7 +2244,7 @@ impl AppContext for App {
&mut self,
reservation: Reservation<T>,
build_entity: impl FnOnce(&mut Context<T>) -> T,
) -> Self::Result<Entity<T>> {
) -> Entity<T> {
self.update(|cx| {
let slot = reservation.0;
let entity = build_entity(&mut Context::new_context(cx, slot.downgrade()));
@@ -2263,11 +2277,7 @@ impl AppContext for App {
GpuiBorrow::new(handle.clone(), self)
}
fn read_entity<T, R>(
&self,
handle: &Entity<T>,
read: impl FnOnce(&T, &App) -> R,
) -> Self::Result<R>
fn read_entity<T, R>(&self, handle: &Entity<T>, read: impl FnOnce(&T, &App) -> R) -> R
where
T: 'static,
{
@@ -2312,7 +2322,7 @@ impl AppContext for App {
self.background_executor.spawn(future)
}
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result<R>
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> R
where
G: Global,
{

View File

@@ -1,9 +1,10 @@
use crate::{
AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, BorrowAppContext,
Entity, EventEmitter, Focusable, ForegroundExecutor, Global, PromptButton, PromptLevel, Render,
Reservation, Result, Subscription, Task, VisualContext, Window, WindowHandle,
Entity, EventEmitter, Focusable, ForegroundExecutor, Global, GpuiBorrow, PromptButton,
PromptLevel, Render, Reservation, Result, Subscription, Task, VisualContext, Window,
WindowHandle,
};
use anyhow::{Context as _, anyhow};
use anyhow::Context as _;
use derive_more::{Deref, DerefMut};
use futures::channel::oneshot;
use std::{future::Future, rc::Weak};
@@ -12,72 +13,73 @@ use super::{Context, WeakEntity};
/// An async-friendly version of [App] with a static lifetime so it can be held across `await` points in async code.
/// You're provided with an instance when calling [App::spawn], and you can also create one with [App::to_async].
/// Internally, this holds a weak reference to an `App`, so its methods are fallible to protect against cases where the [App] is dropped.
///
/// Internally, this holds a weak reference to an `App`. Methods will panic if the app has been dropped,
/// but this should not happen in practice when using foreground tasks spawned via `cx.spawn()`,
/// as the executor checks if the app is alive before running each task.
#[derive(Clone)]
pub struct AsyncApp {
pub(crate) app: Weak<AppCell>,
pub(crate) liveness_token: std::sync::Weak<()>,
pub(crate) background_executor: BackgroundExecutor,
pub(crate) foreground_executor: ForegroundExecutor,
}
impl AppContext for AsyncApp {
type Result<T> = Result<T>;
impl AsyncApp {
fn app(&self) -> std::rc::Rc<AppCell> {
self.app
.upgrade()
.expect("app was released before async operation completed")
}
}
fn new<T: 'static>(
&mut self,
build_entity: impl FnOnce(&mut Context<T>) -> T,
) -> Self::Result<Entity<T>> {
let app = self.app.upgrade().context("app was released")?;
impl AppContext for AsyncApp {
fn new<T: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Entity<T> {
let app = self.app();
let mut app = app.borrow_mut();
Ok(app.new(build_entity))
app.new(build_entity)
}
fn reserve_entity<T: 'static>(&mut self) -> Result<Reservation<T>> {
let app = self.app.upgrade().context("app was released")?;
fn reserve_entity<T: 'static>(&mut self) -> Reservation<T> {
let app = self.app();
let mut app = app.borrow_mut();
Ok(app.reserve_entity())
app.reserve_entity()
}
fn insert_entity<T: 'static>(
&mut self,
reservation: Reservation<T>,
build_entity: impl FnOnce(&mut Context<T>) -> T,
) -> Result<Entity<T>> {
let app = self.app.upgrade().context("app was released")?;
) -> Entity<T> {
let app = self.app();
let mut app = app.borrow_mut();
Ok(app.insert_entity(reservation, build_entity))
app.insert_entity(reservation, build_entity)
}
fn update_entity<T: 'static, R>(
&mut self,
handle: &Entity<T>,
update: impl FnOnce(&mut T, &mut Context<T>) -> R,
) -> Self::Result<R> {
let app = self.app.upgrade().context("app was released")?;
) -> R {
let app = self.app();
let mut app = app.borrow_mut();
Ok(app.update_entity(handle, update))
app.update_entity(handle, update)
}
fn as_mut<'a, T>(&'a mut self, _handle: &Entity<T>) -> Self::Result<super::GpuiBorrow<'a, T>>
fn as_mut<'a, T>(&'a mut self, _handle: &Entity<T>) -> GpuiBorrow<'a, T>
where
T: 'static,
{
Err(anyhow!(
"Cannot as_mut with an async context. Try calling update() first"
))
panic!("Cannot as_mut with an async context. Try calling update() first")
}
fn read_entity<T, R>(
&self,
handle: &Entity<T>,
callback: impl FnOnce(&T, &App) -> R,
) -> Self::Result<R>
fn read_entity<T, R>(&self, handle: &Entity<T>, callback: impl FnOnce(&T, &App) -> R) -> R
where
T: 'static,
{
let app = self.app.upgrade().context("app was released")?;
let app = self.app();
let lock = app.borrow();
Ok(lock.read_entity(handle, callback))
lock.read_entity(handle, callback)
}
fn update_window<T, F>(&mut self, window: AnyWindowHandle, f: F) -> Result<T>
@@ -109,23 +111,22 @@ impl AppContext for AsyncApp {
self.background_executor.spawn(future)
}
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result<R>
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> R
where
G: Global,
{
let app = self.app.upgrade().context("app was released")?;
let app = self.app();
let mut lock = app.borrow_mut();
Ok(lock.update(|this| this.read_global(callback)))
lock.update(|this| this.read_global(callback))
}
}
impl AsyncApp {
/// Schedules all windows in the application to be redrawn.
pub fn refresh(&self) -> Result<()> {
let app = self.app.upgrade().context("app was released")?;
pub fn refresh(&self) {
let app = self.app();
let mut lock = app.borrow_mut();
lock.refresh_windows();
Ok(())
}
/// Get an executor which can be used to spawn futures in the background.
@@ -139,10 +140,10 @@ impl AsyncApp {
}
/// Invoke the given function in the context of the app, then flush any effects produced during its invocation.
pub fn update<R>(&self, f: impl FnOnce(&mut App) -> R) -> Result<R> {
let app = self.app.upgrade().context("app was released")?;
pub fn update<R>(&self, f: impl FnOnce(&mut App) -> R) -> R {
let app = self.app();
let mut lock = app.borrow_mut();
Ok(lock.update(f))
lock.update(f)
}
/// Arrange for the given callback to be invoked whenever the given entity emits an event of a given type.
@@ -150,16 +151,15 @@ impl AsyncApp {
pub fn subscribe<T, Event>(
&mut self,
entity: &Entity<T>,
mut on_event: impl FnMut(Entity<T>, &Event, &mut App) + 'static,
) -> Result<Subscription>
on_event: impl FnMut(Entity<T>, &Event, &mut App) + 'static,
) -> Subscription
where
T: 'static + EventEmitter<Event>,
Event: 'static,
{
let app = self.app.upgrade().context("app was released")?;
let app = self.app();
let mut lock = app.borrow_mut();
let subscription = lock.subscribe(entity, on_event);
Ok(subscription)
lock.subscribe(entity, on_event)
}
/// Open a window with the given options based on the root view returned by the given function.
@@ -171,7 +171,7 @@ impl AsyncApp {
where
V: 'static + Render,
{
let app = self.app.upgrade().context("app was released")?;
let app = self.app();
let mut lock = app.borrow_mut();
lock.open_window(options, build_root_view)
}
@@ -185,65 +185,54 @@ impl AsyncApp {
{
let mut cx = self.clone();
self.foreground_executor
.spawn(async move { f(&mut cx).await })
.spawn_context(self.liveness_token.clone(), async move { f(&mut cx).await })
}
/// Determine whether global state of the specified type has been assigned.
/// Returns an error if the `App` has been dropped.
pub fn has_global<G: Global>(&self) -> Result<bool> {
let app = self.app.upgrade().context("app was released")?;
pub fn has_global<G: Global>(&self) -> bool {
let app = self.app();
let app = app.borrow_mut();
Ok(app.has_global::<G>())
app.has_global::<G>()
}
/// Reads the global state of the specified type, passing it to the given callback.
///
/// Panics if no global state of the specified type has been assigned.
/// Returns an error if the `App` has been dropped.
pub fn read_global<G: Global, R>(&self, read: impl FnOnce(&G, &App) -> R) -> Result<R> {
let app = self.app.upgrade().context("app was released")?;
pub fn read_global<G: Global, R>(&self, read: impl FnOnce(&G, &App) -> R) -> R {
let app = self.app();
let app = app.borrow_mut();
Ok(read(app.global(), &app))
read(app.global(), &app)
}
/// Reads the global state of the specified type, passing it to the given callback.
///
/// Similar to [`AsyncApp::read_global`], but returns an error instead of panicking
/// if no state of the specified type has been assigned.
///
/// Returns an error if no state of the specified type has been assigned the `App` has been dropped.
pub fn try_read_global<G: Global, R>(&self, read: impl FnOnce(&G, &App) -> R) -> Option<R> {
let app = self.app.upgrade()?;
let app = self.app();
let app = app.borrow_mut();
Some(read(app.try_global()?, &app))
}
/// Reads the global state of the specified type, passing it to the given callback.
/// A default value is assigned if a global of this type has not yet been assigned.
///
/// # Errors
/// If the app has ben dropped this returns an error.
pub fn try_read_default_global<G: Global + Default, R>(
pub fn read_default_global<G: Global + Default, R>(
&self,
read: impl FnOnce(&G, &App) -> R,
) -> Result<R> {
let app = self.app.upgrade().context("app was released")?;
) -> R {
let app = self.app();
let mut app = app.borrow_mut();
app.update(|cx| {
cx.default_global::<G>();
});
Ok(read(app.try_global().context("app was released")?, &app))
read(app.global(), &app)
}
/// A convenience method for [`App::update_global`](BorrowAppContext::update_global)
/// for updating the global state of the specified type.
pub fn update_global<G: Global, R>(
&self,
update: impl FnOnce(&mut G, &mut App) -> R,
) -> Result<R> {
let app = self.app.upgrade().context("app was released")?;
pub fn update_global<G: Global, R>(&self, update: impl FnOnce(&mut G, &mut App) -> R) -> R {
let app = self.app();
let mut app = app.borrow_mut();
Ok(app.update(|cx| cx.update_global(update)))
app.update(|cx| cx.update_global(update))
}
/// Run something using this entity and cx, when the returned struct is dropped
@@ -334,7 +323,10 @@ impl AsyncWindowContext {
{
let mut cx = self.clone();
self.foreground_executor
.spawn(async move { f(&mut cx).await })
.spawn_context(
self.app.liveness_token.clone(),
async move { f(&mut cx).await },
)
}
/// Present a platform dialog.
@@ -359,54 +351,41 @@ impl AsyncWindowContext {
}
impl AppContext for AsyncWindowContext {
type Result<T> = Result<T>;
fn new<T>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Result<Entity<T>>
fn new<T>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Entity<T>
where
T: 'static,
{
self.app
.update_window(self.window, |_, _, cx| cx.new(build_entity))
self.app.new(build_entity)
}
fn reserve_entity<T: 'static>(&mut self) -> Result<Reservation<T>> {
self.app
.update_window(self.window, |_, _, cx| cx.reserve_entity())
fn reserve_entity<T: 'static>(&mut self) -> Reservation<T> {
self.app.reserve_entity()
}
fn insert_entity<T: 'static>(
&mut self,
reservation: Reservation<T>,
build_entity: impl FnOnce(&mut Context<T>) -> T,
) -> Self::Result<Entity<T>> {
self.app.update_window(self.window, |_, _, cx| {
cx.insert_entity(reservation, build_entity)
})
) -> Entity<T> {
self.app.insert_entity(reservation, build_entity)
}
fn update_entity<T: 'static, R>(
&mut self,
handle: &Entity<T>,
update: impl FnOnce(&mut T, &mut Context<T>) -> R,
) -> Result<R> {
self.app
.update_window(self.window, |_, _, cx| cx.update_entity(handle, update))
) -> R {
self.app.update_entity(handle, update)
}
fn as_mut<'a, T>(&'a mut self, _: &Entity<T>) -> Self::Result<super::GpuiBorrow<'a, T>>
fn as_mut<'a, T>(&'a mut self, _: &Entity<T>) -> GpuiBorrow<'a, T>
where
T: 'static,
{
Err(anyhow!(
"Cannot use as_mut() from an async context, call `update`"
))
panic!("Cannot use as_mut() from an async context, call `update`")
}
fn read_entity<T, R>(
&self,
handle: &Entity<T>,
read: impl FnOnce(&T, &App) -> R,
) -> Self::Result<R>
fn read_entity<T, R>(&self, handle: &Entity<T>, read: impl FnOnce(&T, &App) -> R) -> R
where
T: 'static,
{
@@ -438,7 +417,7 @@ impl AppContext for AsyncWindowContext {
self.app.background_executor.spawn(future)
}
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> Result<R>
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> R
where
G: Global,
{
@@ -454,7 +433,7 @@ impl VisualContext for AsyncWindowContext {
fn new_window_entity<T: 'static>(
&mut self,
build_entity: impl FnOnce(&mut Window, &mut Context<T>) -> T,
) -> Self::Result<Entity<T>> {
) -> Result<Entity<T>> {
self.app.update_window(self.window, |_, window, cx| {
cx.new(|cx| build_entity(window, cx))
})
@@ -464,7 +443,7 @@ impl VisualContext for AsyncWindowContext {
&mut self,
view: &Entity<T>,
update: impl FnOnce(&mut T, &mut Window, &mut Context<T>) -> R,
) -> Self::Result<R> {
) -> Result<R> {
self.app.update_window(self.window, |_, window, cx| {
view.update(cx, |entity, cx| update(entity, window, cx))
})
@@ -473,7 +452,7 @@ impl VisualContext for AsyncWindowContext {
fn replace_root_view<V>(
&mut self,
build_view: impl FnOnce(&mut Window, &mut Context<V>) -> V,
) -> Self::Result<Entity<V>>
) -> Result<Entity<V>>
where
V: 'static + Render,
{
@@ -482,7 +461,7 @@ impl VisualContext for AsyncWindowContext {
})
}
fn focus<V>(&mut self, view: &Entity<V>) -> Self::Result<()>
fn focus<V>(&mut self, view: &Entity<V>) -> Result<()>
where
V: Focusable,
{

View File

@@ -753,8 +753,6 @@ impl<T> Context<'_, T> {
}
impl<T> AppContext for Context<'_, T> {
type Result<U> = U;
#[inline]
fn new<U: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<U>) -> U) -> Entity<U> {
self.app.new(build_entity)
@@ -770,7 +768,7 @@ impl<T> AppContext for Context<'_, T> {
&mut self,
reservation: Reservation<U>,
build_entity: impl FnOnce(&mut Context<U>) -> U,
) -> Self::Result<Entity<U>> {
) -> Entity<U> {
self.app.insert_entity(reservation, build_entity)
}
@@ -784,7 +782,7 @@ impl<T> AppContext for Context<'_, T> {
}
#[inline]
fn as_mut<'a, E>(&'a mut self, handle: &Entity<E>) -> Self::Result<super::GpuiBorrow<'a, E>>
fn as_mut<'a, E>(&'a mut self, handle: &Entity<E>) -> super::GpuiBorrow<'a, E>
where
E: 'static,
{
@@ -792,11 +790,7 @@ impl<T> AppContext for Context<'_, T> {
}
#[inline]
fn read_entity<U, R>(
&self,
handle: &Entity<U>,
read: impl FnOnce(&U, &App) -> R,
) -> Self::Result<R>
fn read_entity<U, R>(&self, handle: &Entity<U>, read: impl FnOnce(&U, &App) -> R) -> R
where
U: 'static,
{
@@ -832,7 +826,7 @@ impl<T> AppContext for Context<'_, T> {
}
#[inline]
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result<R>
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> R
where
G: Global,
{

View File

@@ -431,11 +431,7 @@ impl<T: 'static> Entity<T> {
/// Read the entity referenced by this handle with the given function.
#[inline]
pub fn read_with<R, C: AppContext>(
&self,
cx: &C,
f: impl FnOnce(&T, &App) -> R,
) -> C::Result<R> {
pub fn read_with<R, C: AppContext>(&self, cx: &C, f: impl FnOnce(&T, &App) -> R) -> R {
cx.read_entity(self, f)
}
@@ -445,18 +441,18 @@ impl<T: 'static> Entity<T> {
&self,
cx: &mut C,
update: impl FnOnce(&mut T, &mut Context<T>) -> R,
) -> C::Result<R> {
) -> R {
cx.update_entity(self, update)
}
/// Updates the entity referenced by this handle with the given function.
#[inline]
pub fn as_mut<'a, C: AppContext>(&self, cx: &'a mut C) -> C::Result<GpuiBorrow<'a, T>> {
pub fn as_mut<'a, C: AppContext>(&self, cx: &'a mut C) -> GpuiBorrow<'a, T> {
cx.as_mut(self)
}
/// Updates the entity referenced by this handle with the given function.
pub fn write<C: AppContext>(&self, cx: &mut C, value: T) -> C::Result<()> {
pub fn write<C: AppContext>(&self, cx: &mut C, value: T) {
self.update(cx, |entity, cx| {
*entity = value;
cx.notify();
@@ -465,13 +461,13 @@ impl<T: 'static> Entity<T> {
/// Updates the entity referenced by this handle with the given function if
/// the referenced entity still exists, within a visual context that has a window.
/// Returns an error if the entity has been released.
/// Returns an error if the window has been closed.
#[inline]
pub fn update_in<R, C: VisualContext>(
&self,
cx: &mut C,
update: impl FnOnce(&mut T, &mut Window, &mut Context<T>) -> R,
) -> C::Result<R> {
) -> Result<R> {
cx.update_window_entity(self, update)
}
}
@@ -749,13 +745,9 @@ impl<T: 'static> WeakEntity<T> {
) -> Result<R>
where
C: AppContext,
Result<C::Result<R>>: crate::Flatten<R>,
{
crate::Flatten::flatten(
self.upgrade()
.context("entity released")
.map(|this| cx.update_entity(&this, update)),
)
let entity = self.upgrade().context("entity released")?;
Ok(cx.update_entity(&entity, update))
}
/// Updates the entity referenced by this handle with the given function if
@@ -768,14 +760,13 @@ impl<T: 'static> WeakEntity<T> {
) -> Result<R>
where
C: VisualContext,
Result<C::Result<R>>: crate::Flatten<R>,
{
let window = cx.window_handle();
let this = self.upgrade().context("entity released")?;
let entity = self.upgrade().context("entity released")?;
crate::Flatten::flatten(window.update(cx, |_, window, cx| {
this.update(cx, |entity, cx| update(entity, window, cx))
}))
window.update(cx, |_, window, cx| {
entity.update(cx, |entity, cx| update(entity, window, cx))
})
}
/// Reads the entity referenced by this handle with the given function if
@@ -784,13 +775,9 @@ impl<T: 'static> WeakEntity<T> {
pub fn read_with<C, R>(&self, cx: &C, read: impl FnOnce(&T, &App) -> R) -> Result<R>
where
C: AppContext,
Result<C::Result<R>>: crate::Flatten<R>,
{
crate::Flatten::flatten(
self.upgrade()
.context("entity released")
.map(|this| cx.read_entity(&this, read)),
)
let entity = self.upgrade().context("entity released")?;
Ok(cx.read_entity(&entity, read))
}
/// Create a new weak entity that can never be upgraded.

View File

@@ -1,605 +0,0 @@
//! A clean testing API for GPUI applications.
//!
//! `TestApp` provides a simpler alternative to `TestAppContext` with:
//! - Automatic effect flushing after updates
//! - Clean window creation and inspection
//! - Input simulation helpers
//!
//! # Example
//! ```ignore
//! #[test]
//! fn test_my_view() {
//! let mut app = TestApp::new();
//!
//! let mut window = app.open_window(|window, cx| {
//! MyView::new(window, cx)
//! });
//!
//! window.update(|view, window, cx| {
//! view.do_something(cx);
//! });
//!
//! // Check rendered state
//! assert_eq!(window.title(), Some("Expected Title"));
//! }
//! ```
use crate::{
AnyWindowHandle, App, AppCell, AppContext, AsyncApp, BackgroundExecutor, BorrowAppContext,
Bounds, ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke,
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, Render,
SceneSnapshot, Size, Task, TestDispatcher, TestPlatform, TextSystem, Window, WindowBounds,
WindowHandle, WindowOptions, app::GpuiMode,
};
use rand::{SeedableRng, rngs::StdRng};
use std::{future::Future, rc::Rc, sync::Arc, time::Duration};
/// A test application context with a clean API.
///
/// Unlike `TestAppContext`, `TestApp` automatically flushes effects after
/// each update and provides simpler window management.
pub struct TestApp {
app: Rc<AppCell>,
platform: Rc<TestPlatform>,
background_executor: BackgroundExecutor,
foreground_executor: ForegroundExecutor,
#[allow(dead_code)]
dispatcher: TestDispatcher,
text_system: Arc<TextSystem>,
}
impl TestApp {
/// Create a new test application.
pub fn new() -> Self {
Self::with_seed(0)
}
/// Create a new test application with a specific random seed.
pub fn with_seed(seed: u64) -> Self {
let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(seed));
let arc_dispatcher = Arc::new(dispatcher.clone());
let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone());
let asset_source = Arc::new(());
let http_client = http_client::FakeHttpClient::with_404_response();
let text_system = Arc::new(TextSystem::new(platform.text_system()));
let mut app = App::new_app(platform.clone(), asset_source, http_client);
app.borrow_mut().mode = GpuiMode::test();
Self {
app,
platform,
background_executor,
foreground_executor,
dispatcher,
text_system,
}
}
/// Run a closure with mutable access to the App context.
/// Automatically runs until parked after the closure completes.
pub fn update<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
let result = {
let mut app = self.app.borrow_mut();
app.update(f)
};
self.run_until_parked();
result
}
/// Run a closure with read-only access to the App context.
pub fn read<R>(&self, f: impl FnOnce(&App) -> R) -> R {
let app = self.app.borrow();
f(&app)
}
/// Create a new entity in the app.
pub fn new_entity<T: 'static>(
&mut self,
build: impl FnOnce(&mut Context<T>) -> T,
) -> Entity<T> {
self.update(|cx| cx.new(build))
}
/// Update an entity.
pub fn update_entity<T: 'static, R>(
&mut self,
entity: &Entity<T>,
f: impl FnOnce(&mut T, &mut Context<T>) -> R,
) -> R {
self.update(|cx| entity.update(cx, f))
}
/// Read an entity.
pub fn read_entity<T: 'static, R>(
&self,
entity: &Entity<T>,
f: impl FnOnce(&T, &App) -> R,
) -> R {
self.read(|cx| f(entity.read(cx), cx))
}
/// Open a test window with the given root view.
pub fn open_window<V: Render + 'static>(
&mut self,
build_view: impl FnOnce(&mut Window, &mut Context<V>) -> V,
) -> TestWindow<V> {
let bounds = self.read(|cx| Bounds::maximized(None, cx));
let handle = self.update(|cx| {
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| cx.new(|cx| build_view(window, cx)),
)
.unwrap()
});
TestWindow {
handle,
app: self.app.clone(),
platform: self.platform.clone(),
background_executor: self.background_executor.clone(),
}
}
/// Open a test window with specific options.
pub fn open_window_with_options<V: Render + 'static>(
&mut self,
options: WindowOptions,
build_view: impl FnOnce(&mut Window, &mut Context<V>) -> V,
) -> TestWindow<V> {
let handle = self.update(|cx| {
cx.open_window(options, |window, cx| cx.new(|cx| build_view(window, cx)))
.unwrap()
});
TestWindow {
handle,
app: self.app.clone(),
platform: self.platform.clone(),
background_executor: self.background_executor.clone(),
}
}
/// Run pending tasks until there's nothing left to do.
pub fn run_until_parked(&self) {
self.background_executor.run_until_parked();
}
/// Advance the simulated clock by the given duration.
pub fn advance_clock(&self, duration: Duration) {
self.background_executor.advance_clock(duration);
}
/// Spawn a future on the foreground executor.
pub fn spawn<Fut, R>(&self, f: impl FnOnce(AsyncApp) -> Fut) -> Task<R>
where
Fut: Future<Output = R> + 'static,
R: 'static,
{
self.foreground_executor.spawn(f(self.to_async()))
}
/// Spawn a future on the background executor.
pub fn background_spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>
where
R: Send + 'static,
{
self.background_executor.spawn(future)
}
/// Get an async handle to the app.
pub fn to_async(&self) -> AsyncApp {
AsyncApp {
app: Rc::downgrade(&self.app),
background_executor: self.background_executor.clone(),
foreground_executor: self.foreground_executor.clone(),
}
}
/// Get the background executor.
pub fn background_executor(&self) -> &BackgroundExecutor {
&self.background_executor
}
/// Get the foreground executor.
pub fn foreground_executor(&self) -> &ForegroundExecutor {
&self.foreground_executor
}
/// Get the text system.
pub fn text_system(&self) -> &Arc<TextSystem> {
&self.text_system
}
/// Check if a global of the given type exists.
pub fn has_global<G: Global>(&self) -> bool {
self.read(|cx| cx.has_global::<G>())
}
/// Set a global value.
pub fn set_global<G: Global>(&mut self, global: G) {
self.update(|cx| cx.set_global(global));
}
/// Read a global value.
pub fn read_global<G: Global, R>(&self, f: impl FnOnce(&G, &App) -> R) -> R {
self.read(|cx| f(cx.global(), cx))
}
/// Update a global value.
pub fn update_global<G: Global, R>(&mut self, f: impl FnOnce(&mut G, &mut App) -> R) -> R {
self.update(|cx| cx.update_global(f))
}
// Platform simulation methods
/// Write text to the simulated clipboard.
pub fn write_to_clipboard(&self, item: ClipboardItem) {
self.platform.write_to_clipboard(item);
}
/// Read from the simulated clipboard.
pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
self.platform.read_from_clipboard()
}
/// Get URLs that have been opened via `cx.open_url()`.
pub fn opened_url(&self) -> Option<String> {
self.platform.opened_url.borrow().clone()
}
/// Check if a file path prompt is pending.
pub fn did_prompt_for_new_path(&self) -> bool {
self.platform.did_prompt_for_new_path()
}
/// Simulate answering a path selection dialog.
pub fn simulate_new_path_selection(
&self,
select: impl FnOnce(&std::path::Path) -> Option<std::path::PathBuf>,
) {
self.platform.simulate_new_path_selection(select);
}
/// Check if a prompt dialog is pending.
pub fn has_pending_prompt(&self) -> bool {
self.platform.has_pending_prompt()
}
/// Simulate answering a prompt dialog.
pub fn simulate_prompt_answer(&self, button: &str) {
self.platform.simulate_prompt_answer(button);
}
/// Get all open windows.
pub fn windows(&self) -> Vec<AnyWindowHandle> {
self.read(|cx| cx.windows())
}
}
impl Default for TestApp {
fn default() -> Self {
Self::new()
}
}
/// A test window with inspection and simulation capabilities.
pub struct TestWindow<V> {
handle: WindowHandle<V>,
app: Rc<AppCell>,
platform: Rc<TestPlatform>,
background_executor: BackgroundExecutor,
}
impl<V: 'static + Render> TestWindow<V> {
/// Get the window handle.
pub fn handle(&self) -> WindowHandle<V> {
self.handle
}
/// Get the root view entity.
pub fn root(&self) -> Entity<V> {
let mut app = self.app.borrow_mut();
let any_handle: AnyWindowHandle = self.handle.into();
app.update_window(any_handle, |root_view, _, _| {
root_view.downcast::<V>().expect("root view type mismatch")
})
.expect("window not found")
}
/// Update the root view.
/// Automatically draws the window after the update to ensure the scene is current.
pub fn update<R>(&mut self, f: impl FnOnce(&mut V, &mut Window, &mut Context<V>) -> R) -> R {
let result = {
let mut app = self.app.borrow_mut();
let any_handle: AnyWindowHandle = self.handle.into();
app.update_window(any_handle, |root_view, window, cx| {
let view = root_view.downcast::<V>().expect("root view type mismatch");
view.update(cx, |view, cx| f(view, window, cx))
})
.expect("window not found")
};
self.background_executor.run_until_parked();
self.draw();
result
}
/// Read the root view.
pub fn read<R>(&self, f: impl FnOnce(&V, &App) -> R) -> R {
let app = self.app.borrow();
let view = self
.app
.borrow()
.windows
.get(self.handle.window_id())
.and_then(|w| w.as_ref())
.and_then(|w| w.root.clone())
.and_then(|r| r.downcast::<V>().ok())
.expect("window or root view not found");
f(view.read(&app), &app)
}
/// Get the window title.
pub fn title(&self) -> Option<String> {
let app = self.app.borrow();
app.read_window(&self.handle, |_, _cx| {
// TODO: expose title through Window API
None
})
.unwrap()
}
/// Simulate a keystroke.
/// Automatically draws the window after the keystroke.
pub fn simulate_keystroke(&mut self, keystroke: &str) {
let keystroke = Keystroke::parse(keystroke).unwrap();
{
let mut app = self.app.borrow_mut();
let any_handle: AnyWindowHandle = self.handle.into();
app.update_window(any_handle, |_, window, cx| {
window.dispatch_keystroke(keystroke, cx);
})
.unwrap();
}
self.background_executor.run_until_parked();
self.draw();
}
/// Simulate multiple keystrokes (space-separated).
pub fn simulate_keystrokes(&mut self, keystrokes: &str) {
for keystroke in keystrokes.split(' ') {
self.simulate_keystroke(keystroke);
}
}
/// Simulate typing text.
pub fn simulate_input(&mut self, input: &str) {
for char in input.chars() {
self.simulate_keystroke(&char.to_string());
}
}
/// Simulate a mouse move.
pub fn simulate_mouse_move(&mut self, position: Point<Pixels>) {
self.simulate_event(MouseMoveEvent {
position,
modifiers: Default::default(),
pressed_button: None,
});
}
/// Simulate a mouse down event.
pub fn simulate_mouse_down(&mut self, position: Point<Pixels>, button: MouseButton) {
self.simulate_event(MouseDownEvent {
position,
button,
modifiers: Default::default(),
click_count: 1,
first_mouse: false,
});
}
/// Simulate a mouse up event.
pub fn simulate_mouse_up(&mut self, position: Point<Pixels>, button: MouseButton) {
self.simulate_event(MouseUpEvent {
position,
button,
modifiers: Default::default(),
click_count: 1,
});
}
/// Simulate a click at the given position.
pub fn simulate_click(&mut self, position: Point<Pixels>, button: MouseButton) {
self.simulate_mouse_down(position, button);
self.simulate_mouse_up(position, button);
}
/// Simulate a scroll event.
pub fn simulate_scroll(&mut self, position: Point<Pixels>, delta: Point<Pixels>) {
self.simulate_event(crate::ScrollWheelEvent {
position,
delta: crate::ScrollDelta::Pixels(delta),
modifiers: Default::default(),
touch_phase: crate::TouchPhase::Moved,
});
}
/// Simulate an input event.
/// Automatically draws the window after the event.
pub fn simulate_event<E: InputEvent>(&mut self, event: E) {
let platform_input = event.to_platform_input();
{
let mut app = self.app.borrow_mut();
let any_handle: AnyWindowHandle = self.handle.into();
app.update_window(any_handle, |_, window, cx| {
window.dispatch_event(platform_input, cx);
})
.unwrap();
}
self.background_executor.run_until_parked();
self.draw();
}
/// Simulate resizing the window.
/// Automatically draws the window after the resize.
pub fn simulate_resize(&mut self, size: Size<Pixels>) {
let window_id = self.handle.window_id();
let mut app = self.app.borrow_mut();
if let Some(Some(window)) = app.windows.get_mut(window_id) {
if let Some(test_window) = window.platform_window.as_test() {
test_window.simulate_resize(size);
}
}
drop(app);
self.background_executor.run_until_parked();
self.draw();
}
/// Force a redraw of the window.
pub fn draw(&mut self) {
let mut app = self.app.borrow_mut();
let any_handle: AnyWindowHandle = self.handle.into();
app.update_window(any_handle, |_, window, cx| {
window.draw(cx).clear();
})
.unwrap();
}
/// Get a snapshot of the rendered scene for inspection.
/// The scene is automatically kept up to date after `update()` and `simulate_*()` calls.
pub fn scene_snapshot(&self) -> SceneSnapshot {
let app = self.app.borrow();
let window = app
.windows
.get(self.handle.window_id())
.and_then(|w| w.as_ref())
.expect("window not found");
window.rendered_frame.scene.snapshot()
}
/// Get the named diagnostic quads recorded during imperative paint, without inspecting the
/// rest of the scene snapshot.
///
/// This is useful for tests that want a stable, semantic view of layout/paint geometry without
/// coupling to the low-level quad/glyph output.
pub fn diagnostic_quads(&self) -> Vec<crate::scene::test_scene::DiagnosticQuad> {
self.scene_snapshot().diagnostic_quads
}
}
impl<V> Clone for TestWindow<V> {
fn clone(&self) -> Self {
Self {
handle: self.handle,
app: self.app.clone(),
platform: self.platform.clone(),
background_executor: self.background_executor.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FocusHandle, Focusable, div, prelude::*};
struct Counter {
count: usize,
focus_handle: FocusHandle,
}
impl Counter {
fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
Self {
count: 0,
focus_handle,
}
}
fn increment(&mut self, _cx: &mut Context<Self>) {
self.count += 1;
}
}
impl Focusable for Counter {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Counter {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(format!("Count: {}", self.count))
}
}
#[test]
fn test_basic_usage() {
let mut app = TestApp::new();
let mut window = app.open_window(Counter::new);
window.update(|counter, _window, cx| {
counter.increment(cx);
});
window.read(|counter, _| {
assert_eq!(counter.count, 1);
});
}
#[test]
fn test_entity_creation() {
let mut app = TestApp::new();
let entity = app.new_entity(|cx| Counter {
count: 42,
focus_handle: cx.focus_handle(),
});
app.read_entity(&entity, |counter, _| {
assert_eq!(counter.count, 42);
});
app.update_entity(&entity, |counter, _cx| {
counter.count += 1;
});
app.read_entity(&entity, |counter, _| {
assert_eq!(counter.count, 43);
});
}
#[test]
fn test_globals() {
let mut app = TestApp::new();
struct MyGlobal(String);
impl Global for MyGlobal {}
assert!(!app.has_global::<MyGlobal>());
app.set_global(MyGlobal("hello".into()));
assert!(app.has_global::<MyGlobal>());
app.read_global::<MyGlobal, _>(|global, _| {
assert_eq!(global.0, "hello");
});
app.update_global::<MyGlobal, _>(|global, _| {
global.0 = "world".into();
});
app.read_global::<MyGlobal, _>(|global, _| {
assert_eq!(global.0, "world");
});
}
}

View File

@@ -3,9 +3,9 @@ use crate::{
BackgroundExecutor, BorrowAppContext, Bounds, Capslock, ClipboardItem, DrawPhase, Drawable,
Element, Empty, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers,
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, TestPlatformWindow,
TestScreenCaptureSource, TextSystem, VisualContext, Window, WindowBounds, WindowHandle,
WindowOptions, app::GpuiMode,
Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform,
TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds,
WindowHandle, WindowOptions, app::GpuiMode,
};
use anyhow::{anyhow, bail};
use futures::{Stream, StreamExt, channel::oneshot};
@@ -33,17 +33,12 @@ pub struct TestAppContext {
}
impl AppContext for TestAppContext {
type Result<T> = T;
fn new<T: 'static>(
&mut self,
build_entity: impl FnOnce(&mut Context<T>) -> T,
) -> Self::Result<Entity<T>> {
fn new<T: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Entity<T> {
let mut app = self.app.borrow_mut();
app.new(build_entity)
}
fn reserve_entity<T: 'static>(&mut self) -> Self::Result<crate::Reservation<T>> {
fn reserve_entity<T: 'static>(&mut self) -> crate::Reservation<T> {
let mut app = self.app.borrow_mut();
app.reserve_entity()
}
@@ -52,7 +47,7 @@ impl AppContext for TestAppContext {
&mut self,
reservation: crate::Reservation<T>,
build_entity: impl FnOnce(&mut Context<T>) -> T,
) -> Self::Result<Entity<T>> {
) -> Entity<T> {
let mut app = self.app.borrow_mut();
app.insert_entity(reservation, build_entity)
}
@@ -61,23 +56,19 @@ impl AppContext for TestAppContext {
&mut self,
handle: &Entity<T>,
update: impl FnOnce(&mut T, &mut Context<T>) -> R,
) -> Self::Result<R> {
) -> R {
let mut app = self.app.borrow_mut();
app.update_entity(handle, update)
}
fn as_mut<'a, T>(&'a mut self, _: &Entity<T>) -> Self::Result<super::GpuiBorrow<'a, T>>
fn as_mut<'a, T>(&'a mut self, _: &Entity<T>) -> super::GpuiBorrow<'a, T>
where
T: 'static,
{
panic!("Cannot use as_mut with a test app context. Try calling update() first")
}
fn read_entity<T, R>(
&self,
handle: &Entity<T>,
read: impl FnOnce(&T, &App) -> R,
) -> Self::Result<R>
fn read_entity<T, R>(&self, handle: &Entity<T>, read: impl FnOnce(&T, &App) -> R) -> R
where
T: 'static,
{
@@ -112,7 +103,7 @@ impl AppContext for TestAppContext {
self.background_executor.spawn(future)
}
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result<R>
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> R
where
G: Global,
{
@@ -220,7 +211,7 @@ impl TestAppContext {
f(&cx)
}
/// Adds a new window. The Window will always be backed by a `TestPlatformWindow` which
/// Adds a new window. The Window will always be backed by a `TestWindow` which
/// can be retrieved with `self.test_window(handle)`
pub fn add_window<F, V>(&mut self, build_window: F) -> WindowHandle<V>
where
@@ -405,6 +396,7 @@ impl TestAppContext {
pub fn to_async(&self) -> AsyncApp {
AsyncApp {
app: Rc::downgrade(&self.app),
liveness_token: std::sync::Arc::downgrade(&self.app.borrow().liveness),
background_executor: self.background_executor.clone(),
foreground_executor: self.foreground_executor.clone(),
}
@@ -465,8 +457,8 @@ impl TestAppContext {
.unwrap();
}
/// Returns the `TestPlatformWindow` backing the given handle.
pub(crate) fn test_window(&self, window: AnyWindowHandle) -> TestPlatformWindow {
/// Returns the `TestWindow` backing the given handle.
pub(crate) fn test_window(&self, window: AnyWindowHandle) -> TestWindow {
self.app
.borrow_mut()
.windows
@@ -916,16 +908,11 @@ impl VisualTestContext {
}
impl AppContext for VisualTestContext {
type Result<T> = <TestAppContext as AppContext>::Result<T>;
fn new<T: 'static>(
&mut self,
build_entity: impl FnOnce(&mut Context<T>) -> T,
) -> Self::Result<Entity<T>> {
fn new<T: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Entity<T> {
self.cx.new(build_entity)
}
fn reserve_entity<T: 'static>(&mut self) -> Self::Result<crate::Reservation<T>> {
fn reserve_entity<T: 'static>(&mut self) -> crate::Reservation<T> {
self.cx.reserve_entity()
}
@@ -933,7 +920,7 @@ impl AppContext for VisualTestContext {
&mut self,
reservation: crate::Reservation<T>,
build_entity: impl FnOnce(&mut Context<T>) -> T,
) -> Self::Result<Entity<T>> {
) -> Entity<T> {
self.cx.insert_entity(reservation, build_entity)
}
@@ -941,25 +928,21 @@ impl AppContext for VisualTestContext {
&mut self,
handle: &Entity<T>,
update: impl FnOnce(&mut T, &mut Context<T>) -> R,
) -> Self::Result<R>
) -> R
where
T: 'static,
{
self.cx.update_entity(handle, update)
}
fn as_mut<'a, T>(&'a mut self, handle: &Entity<T>) -> Self::Result<super::GpuiBorrow<'a, T>>
fn as_mut<'a, T>(&'a mut self, handle: &Entity<T>) -> super::GpuiBorrow<'a, T>
where
T: 'static,
{
self.cx.as_mut(handle)
}
fn read_entity<T, R>(
&self,
handle: &Entity<T>,
read: impl FnOnce(&T, &App) -> R,
) -> Self::Result<R>
fn read_entity<T, R>(&self, handle: &Entity<T>, read: impl FnOnce(&T, &App) -> R) -> R
where
T: 'static,
{
@@ -991,7 +974,7 @@ impl AppContext for VisualTestContext {
self.cx.background_spawn(future)
}
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result<R>
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> R
where
G: Global,
{
@@ -1008,46 +991,38 @@ impl VisualContext for VisualTestContext {
fn new_window_entity<T: 'static>(
&mut self,
build_entity: impl FnOnce(&mut Window, &mut Context<T>) -> T,
) -> Self::Result<Entity<T>> {
self.window
.update(&mut self.cx, |_, window, cx| {
cx.new(|cx| build_entity(window, cx))
})
.unwrap()
) -> Result<Entity<T>> {
self.window.update(&mut self.cx, |_, window, cx| {
cx.new(|cx| build_entity(window, cx))
})
}
fn update_window_entity<V: 'static, R>(
&mut self,
view: &Entity<V>,
update: impl FnOnce(&mut V, &mut Window, &mut Context<V>) -> R,
) -> Self::Result<R> {
self.window
.update(&mut self.cx, |_, window, cx| {
view.update(cx, |v, cx| update(v, window, cx))
})
.unwrap()
) -> Result<R> {
self.window.update(&mut self.cx, |_, window, cx| {
view.update(cx, |v, cx| update(v, window, cx))
})
}
fn replace_root_view<V>(
&mut self,
build_view: impl FnOnce(&mut Window, &mut Context<V>) -> V,
) -> Self::Result<Entity<V>>
) -> Result<Entity<V>>
where
V: 'static + Render,
{
self.window
.update(&mut self.cx, |_, window, cx| {
window.replace_root(cx, build_view)
})
.unwrap()
self.window.update(&mut self.cx, |_, window, cx| {
window.replace_root(cx, build_view)
})
}
fn focus<V: crate::Focusable>(&mut self, view: &Entity<V>) -> Self::Result<()> {
self.window
.update(&mut self.cx, |_, window, cx| {
view.read(cx).focus_handle(cx).focus(window, cx)
})
.unwrap()
fn focus<V: crate::Focusable>(&mut self, view: &Entity<V>) -> Result<()> {
self.window.update(&mut self.cx, |_, window, cx| {
view.read(cx).focus_handle(cx).focus(window, cx)
})
}
}

View File

@@ -808,15 +808,6 @@ impl LinearColorStop {
}
impl Background {
/// Returns the solid color if this is a solid background, None otherwise.
pub fn as_solid(&self) -> Option<Hsla> {
if self.tag == BackgroundTag::Solid {
Some(self.solid)
} else {
None
}
}
/// Use specified color space for color interpolation.
///
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/color-interpolation-method>

View File

@@ -1730,6 +1730,11 @@ impl Interactivity {
let clicked_state = clicked_state.borrow();
self.active = Some(clicked_state.element);
}
if self.hover_style.is_some() || self.group_hover_style.is_some() {
element_state
.hover_state
.get_or_insert_with(Default::default);
}
if let Some(active_tooltip) = element_state.active_tooltip.as_ref() {
if self.tooltip_builder.is_some() {
self.tooltip_id = set_tooltip_on_window(active_tooltip, window);
@@ -2150,14 +2155,46 @@ impl Interactivity {
{
let hitbox = hitbox.clone();
let was_hovered = hitbox.is_hovered(window);
let hover_state = self.hover_style.as_ref().and_then(|_| {
element_state
.as_ref()
.and_then(|state| state.hover_state.as_ref())
.cloned()
});
let current_view = window.current_view();
window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| {
let hovered = hitbox.is_hovered(window);
if phase == DispatchPhase::Capture && hovered != was_hovered {
if let Some(hover_state) = &hover_state {
hover_state.borrow_mut().element = hovered;
}
cx.notify(current_view);
}
});
}
if let Some(group_hover) = self.group_hover_style.as_ref() {
if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) {
let hover_state = element_state
.as_ref()
.and_then(|element| element.hover_state.as_ref())
.cloned();
let was_group_hovered = group_hitbox_id.is_hovered(window);
let current_view = window.current_view();
window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| {
let group_hovered = group_hitbox_id.is_hovered(window);
if phase == DispatchPhase::Capture && group_hovered != was_group_hovered {
if let Some(hover_state) = &hover_state {
hover_state.borrow_mut().group = group_hovered;
}
cx.notify(current_view);
}
});
}
}
let drag_cursor_style = self.base_style.as_ref().mouse_cursor;
let mut drag_listener = mem::take(&mut self.drag_listener);
@@ -2346,8 +2383,8 @@ impl Interactivity {
&& hitbox.is_hovered(window);
let mut was_hovered = was_hovered.borrow_mut();
if is_hovered != *was_hovered {
*was_hovered = is_hovered;
if is_hovered != was_hovered.element {
was_hovered.element = is_hovered;
drop(was_hovered);
hover_listener(&is_hovered, window, cx);
@@ -2580,22 +2617,46 @@ impl Interactivity {
}
}
if let Some(hitbox) = hitbox {
if !cx.has_active_drag() {
if let Some(group_hover) = self.group_hover_style.as_ref()
&& let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx)
&& group_hitbox_id.is_hovered(window)
{
style.refine(&group_hover.style);
}
if !cx.has_active_drag() {
if let Some(group_hover) = self.group_hover_style.as_ref() {
let is_group_hovered =
if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) {
group_hitbox_id.is_hovered(window)
} else if let Some(element_state) = element_state.as_ref() {
element_state
.hover_state
.as_ref()
.map(|state| state.borrow().group)
.unwrap_or(false)
} else {
false
};
if let Some(hover_style) = self.hover_style.as_ref()
&& hitbox.is_hovered(window)
{
style.refine(hover_style);
if is_group_hovered {
style.refine(&group_hover.style);
}
}
if let Some(hover_style) = self.hover_style.as_ref() {
let is_hovered = if let Some(hitbox) = hitbox {
hitbox.is_hovered(window)
} else if let Some(element_state) = element_state.as_ref() {
element_state
.hover_state
.as_ref()
.map(|state| state.borrow().element)
.unwrap_or(false)
} else {
false
};
if is_hovered {
style.refine(hover_style);
}
}
}
if let Some(hitbox) = hitbox {
if let Some(drag) = cx.active_drag.take() {
let mut can_drop = true;
if let Some(can_drop_predicate) = &self.can_drop_predicate {
@@ -2654,7 +2715,7 @@ impl Interactivity {
pub struct InteractiveElementState {
pub(crate) focus_handle: Option<FocusHandle>,
pub(crate) clicked_state: Option<Rc<RefCell<ElementClickedState>>>,
pub(crate) hover_state: Option<Rc<RefCell<bool>>>,
pub(crate) hover_state: Option<Rc<RefCell<ElementHoverState>>>,
pub(crate) pending_mouse_down: Option<Rc<RefCell<Option<MouseDownEvent>>>>,
pub(crate) scroll_offset: Option<Rc<RefCell<Point<Pixels>>>>,
pub(crate) active_tooltip: Option<Rc<RefCell<Option<ActiveTooltip>>>>,
@@ -2676,6 +2737,16 @@ impl ElementClickedState {
}
}
/// Whether or not the element or a group that contains it is hovered.
#[derive(Copy, Clone, Default, Eq, PartialEq)]
pub struct ElementHoverState {
/// True if this element's group is hovered, false otherwise
pub group: bool,
/// True if this element is hovered, false otherwise
pub element: bool,
}
pub(crate) enum ActiveTooltip {
/// Currently delaying before showing the tooltip.
WaitingForShow { _task: Task<()> },

View File

@@ -2,8 +2,8 @@ use crate::{
ActiveTooltip, AnyView, App, Bounds, DispatchPhase, Element, ElementId, GlobalElementId,
HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, SharedString, Size, TextOverflow,
TextRun, TextStyle, TooltipId, WhiteSpace, Window, WrappedLine, WrappedLineLayout,
register_tooltip_mouse_handlers, set_tooltip_on_window,
TextRun, TextStyle, TooltipId, TruncateFrom, WhiteSpace, Window, WrappedLine,
WrappedLineLayout, register_tooltip_mouse_handlers, set_tooltip_on_window,
};
use anyhow::Context as _;
use itertools::Itertools;
@@ -354,7 +354,7 @@ impl TextLayout {
None
};
let (truncate_width, truncation_suffix) =
let (truncate_width, truncation_affix, truncate_from) =
if let Some(text_overflow) = text_style.text_overflow.clone() {
let width = known_dimensions.width.or(match available_space.width {
crate::AvailableSpace::Definite(x) => match text_style.line_clamp {
@@ -365,17 +365,24 @@ impl TextLayout {
});
match text_overflow {
TextOverflow::Truncate(s) => (width, s),
TextOverflow::Truncate(s) => (width, s, TruncateFrom::End),
TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start),
}
} else {
(None, "".into())
(None, "".into(), TruncateFrom::End)
};
// Only use cached layout if:
// 1. We have a cached size
// 2. wrap_width matches (or both are None)
// 3. truncate_width is None (if truncate_width is Some, we need to re-layout
// because the previous layout may have been computed without truncation)
if let Some(text_layout) = element_state.0.borrow().as_ref()
&& text_layout.size.is_some()
&& let Some(size) = text_layout.size
&& (wrap_width.is_none() || wrap_width == text_layout.wrap_width)
&& truncate_width.is_none()
{
return text_layout.size.unwrap();
return size;
}
let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size);
@@ -383,8 +390,9 @@ impl TextLayout {
line_wrapper.truncate_line(
text.clone(),
truncate_width,
&truncation_suffix,
&truncation_affix,
&runs,
truncate_from,
)
} else {
(text.clone(), Cow::Borrowed(&*runs))

View File

@@ -125,6 +125,30 @@ impl<T> Task<T> {
Task(TaskState::Spawned(task)) => task.detach(),
}
}
/// Converts this task into a fallible task that returns `Option<T>`.
///
/// Unlike the standard `Task<T>`, a [`FallibleTask`] will return `None`
/// if the app was dropped while the task is executing.
///
/// # Example
///
/// ```ignore
/// // Background task that gracefully handles app shutdown:
/// cx.background_spawn(async move {
/// let result = foreground_task.fallible().await;
/// if let Some(value) = result {
/// // Process the value
/// }
/// // If None, app was shut down - just exit gracefully
/// }).detach();
/// ```
pub fn fallible(self) -> FallibleTask<T> {
FallibleTask(match self.0 {
TaskState::Ready(val) => FallibleTaskState::Ready(val),
TaskState::Spawned(task) => FallibleTaskState::Spawned(task.fallible()),
})
}
}
impl<E, T> Task<Result<T, E>>
@@ -154,6 +178,55 @@ impl<T> Future for Task<T> {
}
}
/// A task that returns `Option<T>` instead of panicking when cancelled.
#[must_use]
pub struct FallibleTask<T>(FallibleTaskState<T>);
enum FallibleTaskState<T> {
/// A task that is ready to return a value
Ready(Option<T>),
/// A task that is currently running (wraps async_task::FallibleTask).
Spawned(async_task::FallibleTask<T, RunnableMeta>),
}
impl<T> FallibleTask<T> {
/// Creates a new fallible task that will resolve with the value.
pub fn ready(val: T) -> Self {
FallibleTask(FallibleTaskState::Ready(Some(val)))
}
/// Detaching a task runs it to completion in the background.
pub fn detach(self) {
match self.0 {
FallibleTaskState::Ready(_) => {}
FallibleTaskState::Spawned(task) => task.detach(),
}
}
}
impl<T> Future for FallibleTask<T> {
type Output = Option<T>;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
match unsafe { self.get_unchecked_mut() } {
FallibleTask(FallibleTaskState::Ready(val)) => Poll::Ready(val.take()),
FallibleTask(FallibleTaskState::Spawned(task)) => Pin::new(task).poll(cx),
}
}
}
impl<T> std::fmt::Debug for FallibleTask<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.0 {
FallibleTaskState::Ready(_) => f.debug_tuple("FallibleTask::Ready").finish(),
FallibleTaskState::Spawned(task) => {
f.debug_tuple("FallibleTask::Spawned").field(task).finish()
}
}
}
}
/// A task label is an opaque identifier that you can use to
/// refer to a task in tests.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
@@ -252,7 +325,10 @@ impl BackgroundExecutor {
let (runnable, task) = unsafe {
async_task::Builder::new()
.metadata(RunnableMeta { location })
.metadata(RunnableMeta {
location,
app: None,
})
.spawn_unchecked(
move |_| async {
let _notify_guard = NotifyOnDrop(pair);
@@ -330,7 +406,10 @@ impl BackgroundExecutor {
);
async_task::Builder::new()
.metadata(RunnableMeta { location })
.metadata(RunnableMeta {
location,
app: None,
})
.spawn(
move |_| future,
move |runnable| {
@@ -340,7 +419,10 @@ impl BackgroundExecutor {
} else {
let location = core::panic::Location::caller();
async_task::Builder::new()
.metadata(RunnableMeta { location })
.metadata(RunnableMeta {
location,
app: None,
})
.spawn(
move |_| future,
move |runnable| {
@@ -566,7 +648,10 @@ impl BackgroundExecutor {
}
let location = core::panic::Location::caller();
let (runnable, task) = async_task::Builder::new()
.metadata(RunnableMeta { location })
.metadata(RunnableMeta {
location,
app: None,
})
.spawn(move |_| async move {}, {
let dispatcher = self.dispatcher.clone();
move |runnable| dispatcher.dispatch_after(duration, RunnableVariant::Meta(runnable))
@@ -681,7 +766,7 @@ impl ForegroundExecutor {
where
R: 'static,
{
self.spawn_with_priority(Priority::default(), future)
self.inner_spawn(None, Priority::default(), future)
}
/// Enqueues the given Task to run on the main thread at some point in the future.
@@ -691,6 +776,31 @@ impl ForegroundExecutor {
priority: Priority,
future: impl Future<Output = R> + 'static,
) -> Task<R>
where
R: 'static,
{
self.inner_spawn(None, priority, future)
}
#[track_caller]
pub(crate) fn spawn_context<R>(
&self,
app: std::sync::Weak<()>,
future: impl Future<Output = R> + 'static,
) -> Task<R>
where
R: 'static,
{
self.inner_spawn(Some(app), Priority::default(), future)
}
#[track_caller]
pub(crate) fn inner_spawn<R>(
&self,
app: Option<std::sync::Weak<()>>,
priority: Priority,
future: impl Future<Output = R> + 'static,
) -> Task<R>
where
R: 'static,
{
@@ -702,6 +812,7 @@ impl ForegroundExecutor {
dispatcher: Arc<dyn PlatformDispatcher>,
future: AnyLocalFuture<R>,
location: &'static core::panic::Location<'static>,
app: Option<std::sync::Weak<()>>,
priority: Priority,
) -> Task<R> {
let (runnable, task) = spawn_local_with_source_location(
@@ -709,12 +820,12 @@ impl ForegroundExecutor {
move |runnable| {
dispatcher.dispatch_on_main_thread(RunnableVariant::Meta(runnable), priority)
},
RunnableMeta { location },
RunnableMeta { location, app },
);
runnable.schedule();
Task(TaskState::Spawned(task))
}
inner::<R>(dispatcher, Box::pin(future), location, priority)
inner::<R>(dispatcher, Box::pin(future), location, app, priority)
}
}
@@ -847,3 +958,259 @@ impl Drop for Scope<'_> {
self.executor.block(self.rx.next());
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::{App, TestDispatcher, TestPlatform};
use rand::SeedableRng;
use std::cell::RefCell;
#[test]
fn sanity_test_tasks_run() {
let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
let arc_dispatcher = Arc::new(dispatcher.clone());
let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
let platform = TestPlatform::new(background_executor, foreground_executor.clone());
let asset_source = Arc::new(());
let http_client = http_client::FakeHttpClient::with_404_response();
let app = App::new_app(platform, asset_source, http_client);
let liveness_token = std::sync::Arc::downgrade(&app.borrow().liveness);
let task_ran = Rc::new(RefCell::new(false));
foreground_executor
.spawn_context(liveness_token, {
let task_ran = Rc::clone(&task_ran);
async move {
*task_ran.borrow_mut() = true;
}
})
.detach();
// Run dispatcher while app is still alive
dispatcher.run_until_parked();
// Task should have run
assert!(
*task_ran.borrow(),
"Task should run normally when app is alive"
);
}
#[test]
fn test_task_cancelled_when_app_dropped() {
let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
let arc_dispatcher = Arc::new(dispatcher.clone());
let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
let platform = TestPlatform::new(background_executor, foreground_executor.clone());
let asset_source = Arc::new(());
let http_client = http_client::FakeHttpClient::with_404_response();
let app = App::new_app(platform, asset_source, http_client);
let liveness_token = std::sync::Arc::downgrade(&app.borrow().liveness);
let app_weak = Rc::downgrade(&app);
let task_ran = Rc::new(RefCell::new(false));
let task_ran_clone = Rc::clone(&task_ran);
foreground_executor
.spawn_context(liveness_token, async move {
*task_ran_clone.borrow_mut() = true;
})
.detach();
drop(app);
assert!(app_weak.upgrade().is_none(), "App should have been dropped");
dispatcher.run_until_parked();
// The task should have been cancelled, not run
assert!(
!*task_ran.borrow(),
"Task should have been cancelled when app was dropped, but it ran!"
);
}
#[test]
fn test_nested_tasks_both_cancel() {
let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
let arc_dispatcher = Arc::new(dispatcher.clone());
let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
let platform = TestPlatform::new(background_executor, foreground_executor.clone());
let asset_source = Arc::new(());
let http_client = http_client::FakeHttpClient::with_404_response();
let app = App::new_app(platform, asset_source, http_client);
let liveness_token = std::sync::Arc::downgrade(&app.borrow().liveness);
let app_weak = Rc::downgrade(&app);
let outer_completed = Rc::new(RefCell::new(false));
let inner_completed = Rc::new(RefCell::new(false));
let reached_await = Rc::new(RefCell::new(false));
let outer_flag = Rc::clone(&outer_completed);
let inner_flag = Rc::clone(&inner_completed);
let await_flag = Rc::clone(&reached_await);
// Channel to block the inner task until we're ready
let (tx, rx) = futures::channel::oneshot::channel::<()>();
// We need clones of executor and liveness_token for the inner spawn
let inner_executor = foreground_executor.clone();
let inner_liveness_token = liveness_token.clone();
foreground_executor
.spawn_context(liveness_token, async move {
let inner_task = inner_executor.spawn_context(inner_liveness_token, {
let inner_flag = Rc::clone(&inner_flag);
async move {
rx.await.ok();
*inner_flag.borrow_mut() = true;
}
});
*await_flag.borrow_mut() = true;
inner_task.await;
*outer_flag.borrow_mut() = true;
})
.detach();
// Run dispatcher until outer task reaches the await point
// The inner task will be blocked on the channel
dispatcher.run_until_parked();
// Verify we actually reached the await point before dropping the app
assert!(
*reached_await.borrow(),
"Outer task should have reached the await point"
);
// Neither task should have completed yet
assert!(
!*outer_completed.borrow(),
"Outer task should not have completed yet"
);
assert!(
!*inner_completed.borrow(),
"Inner task should not have completed yet"
);
// Drop the channel sender and app while outer is awaiting inner
drop(tx);
drop(app);
assert!(app_weak.upgrade().is_none(), "App should have been dropped");
// Run dispatcher - both tasks should be cancelled
dispatcher.run_until_parked();
// Neither task should have completed (both were cancelled)
assert!(
!*outer_completed.borrow(),
"Outer task should have been cancelled, not completed"
);
assert!(
!*inner_completed.borrow(),
"Inner task should have been cancelled, not completed"
);
}
#[test]
fn test_task_without_app_tracking_still_runs() {
let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
let arc_dispatcher = Arc::new(dispatcher.clone());
let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
let platform = TestPlatform::new(background_executor, foreground_executor.clone());
let asset_source = Arc::new(());
let http_client = http_client::FakeHttpClient::with_404_response();
let app = App::new_app(platform, asset_source, http_client);
let app_weak = Rc::downgrade(&app);
let task_ran = Rc::new(RefCell::new(false));
let task_ran_clone = Rc::clone(&task_ran);
let _task = foreground_executor.spawn(async move {
*task_ran_clone.borrow_mut() = true;
});
drop(app);
assert!(app_weak.upgrade().is_none(), "App should have been dropped");
dispatcher.run_until_parked();
assert!(
*task_ran.borrow(),
"Task without app tracking should still run after app is dropped"
);
}
#[test]
#[should_panic]
fn test_polling_cancelled_task_panics() {
let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
let arc_dispatcher = Arc::new(dispatcher.clone());
let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone());
let asset_source = Arc::new(());
let http_client = http_client::FakeHttpClient::with_404_response();
let app = App::new_app(platform, asset_source, http_client);
let liveness_token = std::sync::Arc::downgrade(&app.borrow().liveness);
let app_weak = Rc::downgrade(&app);
let task = foreground_executor.spawn_context(liveness_token, async move { 42 });
drop(app);
assert!(app_weak.upgrade().is_none(), "App should have been dropped");
dispatcher.run_until_parked();
background_executor.block(task);
}
#[test]
fn test_polling_cancelled_task_returns_none_with_fallible() {
let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
let arc_dispatcher = Arc::new(dispatcher.clone());
let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone());
let asset_source = Arc::new(());
let http_client = http_client::FakeHttpClient::with_404_response();
let app = App::new_app(platform, asset_source, http_client);
let liveness_token = std::sync::Arc::downgrade(&app.borrow().liveness);
let app_weak = Rc::downgrade(&app);
let task = foreground_executor
.spawn_context(liveness_token, async move { 42 })
.fallible();
drop(app);
assert!(app_weak.upgrade().is_none(), "App should have been dropped");
dispatcher.run_until_parked();
let result = background_executor.block(task);
assert_eq!(result, None, "Cancelled task should return None");
}
}

View File

@@ -118,23 +118,16 @@ pub use window::*;
/// The context trait, allows the different contexts in GPUI to be used
/// interchangeably for certain operations.
pub trait AppContext {
/// The result type for this context, used for async contexts that
/// can't hold a direct reference to the application context.
type Result<T>;
/// Create a new entity in the app context.
#[expect(
clippy::wrong_self_convention,
reason = "`App::new` is an ubiquitous function for creating entities"
)]
fn new<T: 'static>(
&mut self,
build_entity: impl FnOnce(&mut Context<T>) -> T,
) -> Self::Result<Entity<T>>;
fn new<T: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Entity<T>;
/// Reserve a slot for a entity to be inserted later.
/// The returned [Reservation] allows you to obtain the [EntityId] for the future entity.
fn reserve_entity<T: 'static>(&mut self) -> Self::Result<Reservation<T>>;
fn reserve_entity<T: 'static>(&mut self) -> Reservation<T>;
/// Insert a new entity in the app context based on a [Reservation] previously obtained from [`reserve_entity`].
///
@@ -143,28 +136,24 @@ pub trait AppContext {
&mut self,
reservation: Reservation<T>,
build_entity: impl FnOnce(&mut Context<T>) -> T,
) -> Self::Result<Entity<T>>;
) -> Entity<T>;
/// Update a entity in the app context.
fn update_entity<T, R>(
&mut self,
handle: &Entity<T>,
update: impl FnOnce(&mut T, &mut Context<T>) -> R,
) -> Self::Result<R>
) -> R
where
T: 'static;
/// Update a entity in the app context.
fn as_mut<'a, T>(&'a mut self, handle: &Entity<T>) -> Self::Result<GpuiBorrow<'a, T>>
fn as_mut<'a, T>(&'a mut self, handle: &Entity<T>) -> GpuiBorrow<'a, T>
where
T: 'static;
/// Read a entity from the app context.
fn read_entity<T, R>(
&self,
handle: &Entity<T>,
read: impl FnOnce(&T, &App) -> R,
) -> Self::Result<R>
fn read_entity<T, R>(&self, handle: &Entity<T>, read: impl FnOnce(&T, &App) -> R) -> R
where
T: 'static;
@@ -188,7 +177,7 @@ pub trait AppContext {
R: Send + 'static;
/// Read a global from this app context
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result<R>
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> R
where
G: Global;
}
@@ -215,24 +204,24 @@ pub trait VisualContext: AppContext {
&mut self,
entity: &Entity<T>,
update: impl FnOnce(&mut T, &mut Window, &mut Context<T>) -> R,
) -> Self::Result<R>;
) -> Result<R>;
/// Create a new entity, with access to `Window`.
fn new_window_entity<T: 'static>(
&mut self,
build_entity: impl FnOnce(&mut Window, &mut Context<T>) -> T,
) -> Self::Result<Entity<T>>;
) -> Result<Entity<T>>;
/// Replace the root view of a window with a new view.
fn replace_root_view<V>(
&mut self,
build_view: impl FnOnce(&mut Window, &mut Context<V>) -> V,
) -> Self::Result<Entity<V>>
) -> Result<Entity<V>>
where
V: 'static + Render;
/// Focus a entity in the window, if it implements the [`Focusable`] trait.
fn focus<V>(&mut self, entity: &Entity<V>) -> Self::Result<()>
fn focus<V>(&mut self, entity: &Entity<V>) -> Result<()>
where
V: Focusable;
}
@@ -284,24 +273,6 @@ where
}
}
/// A flatten equivalent for anyhow `Result`s.
pub trait Flatten<T> {
/// Convert this type into a simple `Result<T>`.
fn flatten(self) -> Result<T>;
}
impl<T> Flatten<T> for Result<Result<T>> {
fn flatten(self) -> Result<T> {
self?
}
}
impl<T> Flatten<T> for Result<T> {
fn flatten(self) -> Result<T> {
self
}
}
/// Information about the GPU GPUI is running on.
#[derive(Default, Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct GpuSpecs {

View File

@@ -262,12 +262,18 @@ pub(crate) trait Platform: 'static {
fn set_cursor_style(&self, style: CursorStyle);
fn should_auto_hide_scrollbars(&self) -> bool;
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn write_to_primary(&self, item: ClipboardItem);
fn read_from_clipboard(&self) -> Option<ClipboardItem>;
fn write_to_clipboard(&self, item: ClipboardItem);
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn read_from_primary(&self) -> Option<ClipboardItem>;
fn read_from_clipboard(&self) -> Option<ClipboardItem>;
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn write_to_primary(&self, item: ClipboardItem);
#[cfg(target_os = "macos")]
fn read_from_find_pasteboard(&self) -> Option<ClipboardItem>;
#[cfg(target_os = "macos")]
fn write_to_find_pasteboard(&self, item: ClipboardItem);
fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>>;
fn read_credentials(&self, url: &str) -> Task<Result<Option<(String, Vec<u8>)>>>;
@@ -561,7 +567,7 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
fn update_ime_position(&self, _bounds: Bounds<Pixels>);
#[cfg(any(test, feature = "test-support"))]
fn as_test(&mut self) -> Option<&mut TestPlatformWindow> {
fn as_test(&mut self) -> Option<&mut TestWindow> {
None
}
}
@@ -569,10 +575,30 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
/// This type is public so that our test macro can generate and use it, but it should not
/// be considered part of our public API.
#[doc(hidden)]
#[derive(Debug)]
pub struct RunnableMeta {
/// Location of the runnable
pub location: &'static core::panic::Location<'static>,
/// Weak reference to check if the app is still alive before running this task
pub app: Option<std::sync::Weak<()>>,
}
impl std::fmt::Debug for RunnableMeta {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RunnableMeta")
.field("location", &self.location)
.field("app_alive", &self.is_app_alive())
.finish()
}
}
impl RunnableMeta {
/// Returns true if the app is still alive (or if no app tracking is configured).
pub fn is_app_alive(&self) -> bool {
match &self.app {
Some(weak) => weak.strong_count() > 0,
None => true,
}
}
}
#[doc(hidden)]

View File

@@ -1,5 +1,4 @@
use crate::{Action, App, Platform, SharedString};
use util::ResultExt;
/// A menu of the application, either a main menu or a submenu
pub struct Menu {
@@ -263,14 +262,18 @@ pub(crate) fn init_app_menus(platform: &dyn Platform, cx: &App) {
platform.on_will_open_app_menu(Box::new({
let cx = cx.to_async();
move || {
cx.update(|cx| cx.clear_pending_keystrokes()).ok();
if let Some(app) = cx.app.upgrade() {
app.borrow_mut().update(|cx| cx.clear_pending_keystrokes());
}
}
}));
platform.on_validate_app_menu_command(Box::new({
let cx = cx.to_async();
move |action| {
cx.update(|cx| cx.is_action_available(action))
cx.app
.upgrade()
.map(|app| app.borrow_mut().update(|cx| cx.is_action_available(action)))
.unwrap_or(false)
}
}));
@@ -278,7 +281,9 @@ pub(crate) fn init_app_menus(platform: &dyn Platform, cx: &App) {
platform.on_app_menu_action(Box::new({
let cx = cx.to_async();
move |action| {
cx.update(|cx| cx.dispatch_action(action)).log_err();
if let Some(app) = cx.app.upgrade() {
app.borrow_mut().update(|cx| cx.dispatch_action(action));
}
}
}));
}

View File

@@ -32,7 +32,11 @@ impl HeadlessClient {
.insert_source(main_receiver, |event, _, _: &mut HeadlessClient| {
if let calloop::channel::Event::Msg(runnable) = event {
match runnable {
crate::RunnableVariant::Meta(runnable) => runnable.run(),
crate::RunnableVariant::Meta(runnable) => {
if runnable.metadata().is_app_alive() {
runnable.run();
}
}
crate::RunnableVariant::Compat(runnable) => runnable.run(),
};
}

View File

@@ -502,7 +502,14 @@ impl WaylandClient {
let start = Instant::now();
let mut timing = match runnable {
RunnableVariant::Meta(runnable) => {
let location = runnable.metadata().location;
let metadata = runnable.metadata();
let location = metadata.location;
if !metadata.is_app_alive() {
drop(runnable);
return;
}
let timing = TaskTiming {
location,
start,

View File

@@ -316,7 +316,14 @@ impl X11Client {
let start = Instant::now();
let mut timing = match runnable {
RunnableVariant::Meta(runnable) => {
let location = runnable.metadata().location;
let metadata = runnable.metadata();
let location = metadata.location;
if !metadata.is_app_alive() {
drop(runnable);
return;
}
let timing = TaskTiming {
location,
start,
@@ -944,6 +951,8 @@ impl X11Client {
let window = self.get_window(event.event)?;
window.set_active(false);
let mut state = self.0.borrow_mut();
// Set last scroll values to `None` so that a large delta isn't created if scrolling is done outside the window (the valuator is global)
reset_all_pointer_device_scroll_positions(&mut state.pointer_device_states);
state.keyboard_focused_window = None;
if let Some(compose_state) = state.compose_state.as_mut() {
compose_state.reset();

View File

@@ -431,6 +431,7 @@ impl X11WindowState {
// https://stackoverflow.com/questions/43218127/x11-xlib-xcb-creating-a-window-requires-border-pixel-if-specifying-colormap-wh
.border_pixel(visual_set.black_pixel)
.colormap(colormap)
.override_redirect((params.kind == WindowKind::PopUp) as u32)
.event_mask(
xproto::EventMask::EXPOSURE
| xproto::EventMask::STRUCTURE_NOTIFY

View File

@@ -5,6 +5,7 @@ mod display;
mod display_link;
mod events;
mod keyboard;
mod pasteboard;
#[cfg(feature = "screen-capture")]
mod screen_capture;
@@ -21,8 +22,6 @@ use metal_renderer as renderer;
#[cfg(feature = "macos-blade")]
use crate::platform::blade as renderer;
mod attributed_string;
#[cfg(feature = "font-kit")]
mod open_type;

View File

@@ -1,129 +0,0 @@
use cocoa::base::id;
use cocoa::foundation::NSRange;
use objc::{class, msg_send, sel, sel_impl};
/// The `cocoa` crate does not define NSAttributedString (and related Cocoa classes),
/// which are needed for copying rich text (that is, text intermingled with images)
/// to the clipboard. This adds access to those APIs.
#[allow(non_snake_case)]
pub trait NSAttributedString: Sized {
unsafe fn alloc(_: Self) -> id {
msg_send![class!(NSAttributedString), alloc]
}
unsafe fn init_attributed_string(self, string: id) -> id;
unsafe fn appendAttributedString_(self, attr_string: id);
unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id;
unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id;
unsafe fn string(self) -> id;
}
impl NSAttributedString for id {
unsafe fn init_attributed_string(self, string: id) -> id {
msg_send![self, initWithString: string]
}
unsafe fn appendAttributedString_(self, attr_string: id) {
let _: () = msg_send![self, appendAttributedString: attr_string];
}
unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id {
msg_send![self, RTFDFromRange: range documentAttributes: attrs]
}
unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id {
msg_send![self, RTFFromRange: range documentAttributes: attrs]
}
unsafe fn string(self) -> id {
msg_send![self, string]
}
}
pub trait NSMutableAttributedString: NSAttributedString {
unsafe fn alloc(_: Self) -> id {
msg_send![class!(NSMutableAttributedString), alloc]
}
}
impl NSMutableAttributedString for id {}
#[cfg(test)]
mod tests {
use crate::platform::mac::ns_string;
use super::*;
use cocoa::appkit::NSImage;
use cocoa::base::nil;
use cocoa::foundation::NSAutoreleasePool;
#[test]
#[ignore] // This was SIGSEGV-ing on CI but not locally; need to investigate https://github.com/zed-industries/zed/actions/runs/10362363230/job/28684225486?pr=15782#step:4:1348
fn test_nsattributed_string() {
// TODO move these to parent module once it's actually ready to be used
#[allow(non_snake_case)]
pub trait NSTextAttachment: Sized {
unsafe fn alloc(_: Self) -> id {
msg_send![class!(NSTextAttachment), alloc]
}
}
impl NSTextAttachment for id {}
unsafe {
let image: id = {
let img: id = msg_send![class!(NSImage), alloc];
let img: id = msg_send![img, initWithContentsOfFile: ns_string("test.jpeg")];
let img: id = msg_send![img, autorelease];
img
};
let _size = image.size();
let string = ns_string("Test String");
let attr_string = NSMutableAttributedString::alloc(nil)
.init_attributed_string(string)
.autorelease();
let hello_string = ns_string("Hello World");
let hello_attr_string = NSAttributedString::alloc(nil)
.init_attributed_string(hello_string)
.autorelease();
attr_string.appendAttributedString_(hello_attr_string);
let attachment: id = msg_send![NSTextAttachment::alloc(nil), autorelease];
let _: () = msg_send![attachment, setImage: image];
let image_attr_string =
msg_send![class!(NSAttributedString), attributedStringWithAttachment: attachment];
attr_string.appendAttributedString_(image_attr_string);
let another_string = ns_string("Another String");
let another_attr_string = NSAttributedString::alloc(nil)
.init_attributed_string(another_string)
.autorelease();
attr_string.appendAttributedString_(another_attr_string);
let _len: cocoa::foundation::NSUInteger = msg_send![attr_string, length];
///////////////////////////////////////////////////
// pasteboard.clearContents();
let rtfd_data = attr_string.RTFDFromRange_documentAttributes_(
NSRange::new(0, msg_send![attr_string, length]),
nil,
);
assert_ne!(rtfd_data, nil);
// if rtfd_data != nil {
// pasteboard.setData_forType(rtfd_data, NSPasteboardTypeRTFD);
// }
// let rtf_data = attributed_string.RTFFromRange_documentAttributes_(
// NSRange::new(0, attributed_string.length()),
// nil,
// );
// if rtf_data != nil {
// pasteboard.setData_forType(rtf_data, NSPasteboardTypeRTF);
// }
// let plain_text = attributed_string.string();
// pasteboard.setString_forType(plain_text, NSPasteboardTypeString);
}
}
}

View File

@@ -251,7 +251,13 @@ extern "C" fn trampoline(runnable: *mut c_void) {
let task =
unsafe { Runnable::<RunnableMeta>::from_raw(NonNull::new_unchecked(runnable as *mut ())) };
let location = task.metadata().location;
let metadata = task.metadata();
let location = metadata.location;
if !metadata.is_app_alive() {
drop(task);
return;
}
let start = Instant::now();
let timing = TaskTiming {

View File

@@ -0,0 +1,344 @@
use core::slice;
use std::ffi::c_void;
use cocoa::{
appkit::{NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF},
base::{id, nil},
foundation::NSData,
};
use objc::{msg_send, runtime::Object, sel, sel_impl};
use strum::IntoEnumIterator as _;
use crate::{
ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, asset_cache::hash,
platform::mac::ns_string,
};
pub struct Pasteboard {
inner: id,
text_hash_type: id,
metadata_type: id,
}
impl Pasteboard {
pub fn general() -> Self {
unsafe { Self::new(NSPasteboard::generalPasteboard(nil)) }
}
pub fn find() -> Self {
unsafe { Self::new(NSPasteboard::pasteboardWithName(nil, NSPasteboardNameFind)) }
}
#[cfg(test)]
pub fn unique() -> Self {
unsafe { Self::new(NSPasteboard::pasteboardWithUniqueName(nil)) }
}
unsafe fn new(inner: id) -> Self {
Self {
inner,
text_hash_type: unsafe { ns_string("zed-text-hash") },
metadata_type: unsafe { ns_string("zed-metadata") },
}
}
pub fn read(&self) -> Option<ClipboardItem> {
// First, see if it's a string.
unsafe {
let pasteboard_types: id = self.inner.types();
let string_type: id = ns_string("public.utf8-plain-text");
if msg_send![pasteboard_types, containsObject: string_type] {
let data = self.inner.dataForType(string_type);
if data == nil {
return None;
} else if data.bytes().is_null() {
// https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
// "If the length of the NSData object is 0, this property returns nil."
return Some(self.read_string(&[]));
} else {
let bytes =
slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
return Some(self.read_string(bytes));
}
}
// If it wasn't a string, try the various supported image types.
for format in ImageFormat::iter() {
if let Some(item) = self.read_image(format) {
return Some(item);
}
}
}
// If it wasn't a string or a supported image type, give up.
None
}
fn read_image(&self, format: ImageFormat) -> Option<ClipboardItem> {
let mut ut_type: UTType = format.into();
unsafe {
let types: id = self.inner.types();
if msg_send![types, containsObject: ut_type.inner()] {
self.data_for_type(ut_type.inner_mut()).map(|bytes| {
let bytes = bytes.to_vec();
let id = hash(&bytes);
ClipboardItem {
entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
}
})
} else {
None
}
}
}
fn read_string(&self, text_bytes: &[u8]) -> ClipboardItem {
unsafe {
let text = String::from_utf8_lossy(text_bytes).to_string();
let metadata = self
.data_for_type(self.text_hash_type)
.and_then(|hash_bytes| {
let hash_bytes = hash_bytes.try_into().ok()?;
let hash = u64::from_be_bytes(hash_bytes);
let metadata = self.data_for_type(self.metadata_type)?;
if hash == ClipboardString::text_hash(&text) {
String::from_utf8(metadata.to_vec()).ok()
} else {
None
}
});
ClipboardItem {
entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
}
}
}
unsafe fn data_for_type(&self, kind: id) -> Option<&[u8]> {
unsafe {
let data = self.inner.dataForType(kind);
if data == nil {
None
} else {
Some(slice::from_raw_parts(
data.bytes() as *mut u8,
data.length() as usize,
))
}
}
}
pub fn write(&self, item: ClipboardItem) {
unsafe {
match item.entries.as_slice() {
[] => {
// Writing an empty list of entries just clears the clipboard.
self.inner.clearContents();
}
[ClipboardEntry::String(string)] => {
self.write_plaintext(string);
}
[ClipboardEntry::Image(image)] => {
self.write_image(image);
}
[ClipboardEntry::ExternalPaths(_)] => {}
_ => {
// Agus NB: We're currently only writing string entries to the clipboard when we have more than one.
//
// This was the existing behavior before I refactored the outer clipboard code:
// https://github.com/zed-industries/zed/blob/65f7412a0265552b06ce122655369d6cc7381dd6/crates/gpui/src/platform/mac/platform.rs#L1060-L1110
//
// Note how `any_images` is always `false`. We should fix that, but that's orthogonal to the refactor.
let mut combined = ClipboardString {
text: String::new(),
metadata: None,
};
for entry in item.entries {
match entry {
ClipboardEntry::String(text) => {
combined.text.push_str(&text.text());
if combined.metadata.is_none() {
combined.metadata = text.metadata;
}
}
_ => {}
}
}
self.write_plaintext(&combined);
}
}
}
}
fn write_plaintext(&self, string: &ClipboardString) {
unsafe {
self.inner.clearContents();
let text_bytes = NSData::dataWithBytes_length_(
nil,
string.text.as_ptr() as *const c_void,
string.text.len() as u64,
);
self.inner
.setData_forType(text_bytes, NSPasteboardTypeString);
if let Some(metadata) = string.metadata.as_ref() {
let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
let hash_bytes = NSData::dataWithBytes_length_(
nil,
hash_bytes.as_ptr() as *const c_void,
hash_bytes.len() as u64,
);
self.inner.setData_forType(hash_bytes, self.text_hash_type);
let metadata_bytes = NSData::dataWithBytes_length_(
nil,
metadata.as_ptr() as *const c_void,
metadata.len() as u64,
);
self.inner
.setData_forType(metadata_bytes, self.metadata_type);
}
}
}
unsafe fn write_image(&self, image: &Image) {
unsafe {
self.inner.clearContents();
let bytes = NSData::dataWithBytes_length_(
nil,
image.bytes.as_ptr() as *const c_void,
image.bytes.len() as u64,
);
self.inner
.setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
}
}
}
#[link(name = "AppKit", kind = "framework")]
unsafe extern "C" {
/// [Apple's documentation](https://developer.apple.com/documentation/appkit/nspasteboardnamefind?language=objc)
pub static NSPasteboardNameFind: id;
}
impl From<ImageFormat> for UTType {
fn from(value: ImageFormat) -> Self {
match value {
ImageFormat::Png => Self::png(),
ImageFormat::Jpeg => Self::jpeg(),
ImageFormat::Tiff => Self::tiff(),
ImageFormat::Webp => Self::webp(),
ImageFormat::Gif => Self::gif(),
ImageFormat::Bmp => Self::bmp(),
ImageFormat::Svg => Self::svg(),
ImageFormat::Ico => Self::ico(),
}
}
}
// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
pub struct UTType(id);
impl UTType {
pub fn png() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
}
pub fn jpeg() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
Self(unsafe { ns_string("public.jpeg") })
}
pub fn gif() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
Self(unsafe { ns_string("com.compuserve.gif") })
}
pub fn webp() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
Self(unsafe { ns_string("org.webmproject.webp") })
}
pub fn bmp() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
Self(unsafe { ns_string("com.microsoft.bmp") })
}
pub fn svg() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
Self(unsafe { ns_string("public.svg-image") })
}
pub fn ico() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
Self(unsafe { ns_string("com.microsoft.ico") })
}
pub fn tiff() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
}
fn inner(&self) -> *const Object {
self.0
}
pub fn inner_mut(&self) -> *mut Object {
self.0 as *mut _
}
}
#[cfg(test)]
mod tests {
use cocoa::{appkit::NSPasteboardTypeString, foundation::NSData};
use crate::{ClipboardEntry, ClipboardItem, ClipboardString};
use super::*;
#[test]
fn test_string() {
let pasteboard = Pasteboard::unique();
assert_eq!(pasteboard.read(), None);
let item = ClipboardItem::new_string("1".to_string());
pasteboard.write(item.clone());
assert_eq!(pasteboard.read(), Some(item));
let item = ClipboardItem {
entries: vec![ClipboardEntry::String(
ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
)],
};
pasteboard.write(item.clone());
assert_eq!(pasteboard.read(), Some(item));
let text_from_other_app = "text from other app";
unsafe {
let bytes = NSData::dataWithBytes_length_(
nil,
text_from_other_app.as_ptr() as *const c_void,
text_from_other_app.len() as u64,
);
pasteboard
.inner
.setData_forType(bytes, NSPasteboardTypeString);
}
assert_eq!(
pasteboard.read(),
Some(ClipboardItem::new_string(text_from_other_app.to_string()))
);
}
}

View File

@@ -1,29 +1,24 @@
use super::{
BoolExt, MacKeyboardLayout, MacKeyboardMapper,
attributed_string::{NSAttributedString, NSMutableAttributedString},
events::key_to_native,
ns_string, renderer,
BoolExt, MacKeyboardLayout, MacKeyboardMapper, events::key_to_native, ns_string, renderer,
};
use crate::{
Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString,
CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher,
MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform,
PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, WindowParams, hash,
Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor,
KeyContext, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu,
PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper,
PlatformTextSystem, PlatformWindow, Result, SystemMenuType, Task, WindowAppearance,
WindowParams, platform::mac::pasteboard::Pasteboard,
};
use anyhow::{Context as _, anyhow};
use block::ConcreteBlock;
use cocoa::{
appkit::{
NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard,
NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeRTFD, NSPasteboardTypeString,
NSPasteboardTypeTIFF, NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow,
NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel,
NSVisualEffectState, NSVisualEffectView, NSWindow,
},
base::{BOOL, NO, YES, id, nil, selector},
foundation::{
NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSRange, NSString,
NSUInteger, NSURL,
NSArray, NSAutoreleasePool, NSBundle, NSInteger, NSProcessInfo, NSString, NSUInteger, NSURL,
},
};
use core_foundation::{
@@ -49,7 +44,6 @@ use ptr::null_mut;
use semver::Version;
use std::{
cell::Cell,
convert::TryInto,
ffi::{CStr, OsStr, c_void},
os::{raw::c_char, unix::ffi::OsStrExt},
path::{Path, PathBuf},
@@ -58,7 +52,6 @@ use std::{
slice, str,
sync::{Arc, OnceLock},
};
use strum::IntoEnumIterator;
use util::{
ResultExt,
command::{new_smol_command, new_std_command},
@@ -164,9 +157,8 @@ pub(crate) struct MacPlatformState {
text_system: Arc<dyn PlatformTextSystem>,
renderer_context: renderer::Context,
headless: bool,
pasteboard: id,
text_hash_pasteboard_type: id,
metadata_pasteboard_type: id,
general_pasteboard: Pasteboard,
find_pasteboard: Pasteboard,
reopen: Option<Box<dyn FnMut()>>,
on_keyboard_layout_change: Option<Box<dyn FnMut()>>,
quit: Option<Box<dyn FnMut()>>,
@@ -206,9 +198,8 @@ impl MacPlatform {
background_executor: BackgroundExecutor::new(dispatcher.clone()),
foreground_executor: ForegroundExecutor::new(dispatcher),
renderer_context: renderer::Context::default(),
pasteboard: unsafe { NSPasteboard::generalPasteboard(nil) },
text_hash_pasteboard_type: unsafe { ns_string("zed-text-hash") },
metadata_pasteboard_type: unsafe { ns_string("zed-metadata") },
general_pasteboard: Pasteboard::general(),
find_pasteboard: Pasteboard::find(),
reopen: None,
quit: None,
menu_command: None,
@@ -224,20 +215,6 @@ impl MacPlatform {
}))
}
unsafe fn read_from_pasteboard(&self, pasteboard: *mut Object, kind: id) -> Option<&[u8]> {
unsafe {
let data = pasteboard.dataForType(kind);
if data == nil {
None
} else {
Some(slice::from_raw_parts(
data.bytes() as *mut u8,
data.length() as usize,
))
}
}
}
unsafe fn create_menu_bar(
&self,
menus: &Vec<Menu>,
@@ -710,7 +687,7 @@ impl Platform for MacPlatform {
}
self.background_executor()
.spawn(async { crate::Flatten::flatten(done_rx.await.map_err(|e| anyhow!(e))) })
.spawn(async { done_rx.await.map_err(|e| anyhow!(e))? })
}
fn on_open_urls(&self, callback: Box<dyn FnMut(Vec<String>)>) {
@@ -1034,119 +1011,24 @@ impl Platform for MacPlatform {
}
}
fn write_to_clipboard(&self, item: ClipboardItem) {
use crate::ClipboardEntry;
unsafe {
// We only want to use NSAttributedString if there are multiple entries to write.
if item.entries.len() <= 1 {
match item.entries.first() {
Some(entry) => match entry {
ClipboardEntry::String(string) => {
self.write_plaintext_to_clipboard(string);
}
ClipboardEntry::Image(image) => {
self.write_image_to_clipboard(image);
}
ClipboardEntry::ExternalPaths(_) => {}
},
None => {
// Writing an empty list of entries just clears the clipboard.
let state = self.0.lock();
state.pasteboard.clearContents();
}
}
} else {
let mut any_images = false;
let attributed_string = {
let mut buf = NSMutableAttributedString::alloc(nil)
// TODO can we skip this? Or at least part of it?
.init_attributed_string(ns_string(""))
.autorelease();
for entry in item.entries {
if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry
{
let to_append = NSAttributedString::alloc(nil)
.init_attributed_string(ns_string(&text))
.autorelease();
buf.appendAttributedString_(to_append);
}
}
buf
};
let state = self.0.lock();
state.pasteboard.clearContents();
// Only set rich text clipboard types if we actually have 1+ images to include.
if any_images {
let rtfd_data = attributed_string.RTFDFromRange_documentAttributes_(
NSRange::new(0, msg_send![attributed_string, length]),
nil,
);
if rtfd_data != nil {
state
.pasteboard
.setData_forType(rtfd_data, NSPasteboardTypeRTFD);
}
let rtf_data = attributed_string.RTFFromRange_documentAttributes_(
NSRange::new(0, attributed_string.length()),
nil,
);
if rtf_data != nil {
state
.pasteboard
.setData_forType(rtf_data, NSPasteboardTypeRTF);
}
}
let plain_text = attributed_string.string();
state
.pasteboard
.setString_forType(plain_text, NSPasteboardTypeString);
}
}
}
fn read_from_clipboard(&self) -> Option<ClipboardItem> {
let state = self.0.lock();
let pasteboard = state.pasteboard;
state.general_pasteboard.read()
}
// First, see if it's a string.
unsafe {
let types: id = pasteboard.types();
let string_type: id = ns_string("public.utf8-plain-text");
fn write_to_clipboard(&self, item: ClipboardItem) {
let state = self.0.lock();
state.general_pasteboard.write(item);
}
if msg_send![types, containsObject: string_type] {
let data = pasteboard.dataForType(string_type);
if data == nil {
return None;
} else if data.bytes().is_null() {
// https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
// "If the length of the NSData object is 0, this property returns nil."
return Some(self.read_string_from_clipboard(&state, &[]));
} else {
let bytes =
slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
let state = self.0.lock();
state.find_pasteboard.read()
}
return Some(self.read_string_from_clipboard(&state, bytes));
}
}
// If it wasn't a string, try the various supported image types.
for format in ImageFormat::iter() {
if let Some(item) = try_clipboard_image(pasteboard, format) {
return Some(item);
}
}
}
// If it wasn't a string or a supported image type, give up.
None
fn write_to_find_pasteboard(&self, item: ClipboardItem) {
let state = self.0.lock();
state.find_pasteboard.write(item);
}
fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>> {
@@ -1255,116 +1137,6 @@ impl Platform for MacPlatform {
}
}
impl MacPlatform {
unsafe fn read_string_from_clipboard(
&self,
state: &MacPlatformState,
text_bytes: &[u8],
) -> ClipboardItem {
unsafe {
let text = String::from_utf8_lossy(text_bytes).to_string();
let metadata = self
.read_from_pasteboard(state.pasteboard, state.text_hash_pasteboard_type)
.and_then(|hash_bytes| {
let hash_bytes = hash_bytes.try_into().ok()?;
let hash = u64::from_be_bytes(hash_bytes);
let metadata = self
.read_from_pasteboard(state.pasteboard, state.metadata_pasteboard_type)?;
if hash == ClipboardString::text_hash(&text) {
String::from_utf8(metadata.to_vec()).ok()
} else {
None
}
});
ClipboardItem {
entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
}
}
}
unsafe fn write_plaintext_to_clipboard(&self, string: &ClipboardString) {
unsafe {
let state = self.0.lock();
state.pasteboard.clearContents();
let text_bytes = NSData::dataWithBytes_length_(
nil,
string.text.as_ptr() as *const c_void,
string.text.len() as u64,
);
state
.pasteboard
.setData_forType(text_bytes, NSPasteboardTypeString);
if let Some(metadata) = string.metadata.as_ref() {
let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
let hash_bytes = NSData::dataWithBytes_length_(
nil,
hash_bytes.as_ptr() as *const c_void,
hash_bytes.len() as u64,
);
state
.pasteboard
.setData_forType(hash_bytes, state.text_hash_pasteboard_type);
let metadata_bytes = NSData::dataWithBytes_length_(
nil,
metadata.as_ptr() as *const c_void,
metadata.len() as u64,
);
state
.pasteboard
.setData_forType(metadata_bytes, state.metadata_pasteboard_type);
}
}
}
unsafe fn write_image_to_clipboard(&self, image: &Image) {
unsafe {
let state = self.0.lock();
state.pasteboard.clearContents();
let bytes = NSData::dataWithBytes_length_(
nil,
image.bytes.as_ptr() as *const c_void,
image.bytes.len() as u64,
);
state
.pasteboard
.setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
}
}
}
fn try_clipboard_image(pasteboard: id, format: ImageFormat) -> Option<ClipboardItem> {
let mut ut_type: UTType = format.into();
unsafe {
let types: id = pasteboard.types();
if msg_send![types, containsObject: ut_type.inner()] {
let data = pasteboard.dataForType(ut_type.inner_mut());
if data == nil {
None
} else {
let bytes = Vec::from(slice::from_raw_parts(
data.bytes() as *mut u8,
data.length() as usize,
));
let id = hash(&bytes);
Some(ClipboardItem {
entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
})
}
} else {
None
}
}
}
unsafe fn path_from_objc(path: id) -> PathBuf {
let len = msg_send![path, lengthOfBytesUsingEncoding: NSUTF8StringEncoding];
let bytes = unsafe { path.UTF8String() as *const u8 };
@@ -1605,120 +1377,3 @@ mod security {
pub const errSecUserCanceled: OSStatus = -128;
pub const errSecItemNotFound: OSStatus = -25300;
}
impl From<ImageFormat> for UTType {
fn from(value: ImageFormat) -> Self {
match value {
ImageFormat::Png => Self::png(),
ImageFormat::Jpeg => Self::jpeg(),
ImageFormat::Tiff => Self::tiff(),
ImageFormat::Webp => Self::webp(),
ImageFormat::Gif => Self::gif(),
ImageFormat::Bmp => Self::bmp(),
ImageFormat::Svg => Self::svg(),
ImageFormat::Ico => Self::ico(),
}
}
}
// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
struct UTType(id);
impl UTType {
pub fn png() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
}
pub fn jpeg() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
Self(unsafe { ns_string("public.jpeg") })
}
pub fn gif() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
Self(unsafe { ns_string("com.compuserve.gif") })
}
pub fn webp() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
Self(unsafe { ns_string("org.webmproject.webp") })
}
pub fn bmp() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
Self(unsafe { ns_string("com.microsoft.bmp") })
}
pub fn svg() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
Self(unsafe { ns_string("public.svg-image") })
}
pub fn ico() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
Self(unsafe { ns_string("com.microsoft.ico") })
}
pub fn tiff() -> Self {
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
}
fn inner(&self) -> *const Object {
self.0
}
fn inner_mut(&self) -> *mut Object {
self.0 as *mut _
}
}
#[cfg(test)]
mod tests {
use crate::ClipboardItem;
use super::*;
#[test]
fn test_clipboard() {
let platform = build_platform();
assert_eq!(platform.read_from_clipboard(), None);
let item = ClipboardItem::new_string("1".to_string());
platform.write_to_clipboard(item.clone());
assert_eq!(platform.read_from_clipboard(), Some(item));
let item = ClipboardItem {
entries: vec![ClipboardEntry::String(
ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
)],
};
platform.write_to_clipboard(item.clone());
assert_eq!(platform.read_from_clipboard(), Some(item));
let text_from_other_app = "text from other app";
unsafe {
let bytes = NSData::dataWithBytes_length_(
nil,
text_from_other_app.as_ptr() as *const c_void,
text_from_other_app.len() as u64,
);
platform
.0
.lock()
.pasteboard
.setData_forType(bytes, NSPasteboardTypeString);
}
assert_eq!(
platform.read_from_clipboard(),
Some(ClipboardItem::new_string(text_from_other_app.to_string()))
);
}
fn build_platform() -> MacPlatform {
let platform = MacPlatform::new(false);
platform.0.lock().pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) };
platform
}
}

View File

@@ -177,7 +177,14 @@ impl TestDispatcher {
// todo(localcc): add timings to tests
match runnable {
RunnableVariant::Meta(runnable) => runnable.run(),
RunnableVariant::Meta(runnable) => {
if !runnable.metadata().is_app_alive() {
drop(runnable);
self.state.lock().is_main_thread = was_main_thread;
return true;
}
runnable.run()
}
RunnableVariant::Compat(runnable) => runnable.run(),
};

View File

@@ -3,7 +3,7 @@ use crate::{
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay,
PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton,
ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, Task,
TestDisplay, TestPlatformWindow, WindowAppearance, WindowParams, size,
TestDisplay, TestWindow, WindowAppearance, WindowParams, size,
};
use anyhow::Result;
use collections::VecDeque;
@@ -26,12 +26,14 @@ pub(crate) struct TestPlatform {
background_executor: BackgroundExecutor,
foreground_executor: ForegroundExecutor,
pub(crate) active_window: RefCell<Option<TestPlatformWindow>>,
pub(crate) active_window: RefCell<Option<TestWindow>>,
active_display: Rc<dyn PlatformDisplay>,
active_cursor: Mutex<CursorStyle>,
current_clipboard_item: Mutex<Option<ClipboardItem>>,
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
current_primary_item: Mutex<Option<ClipboardItem>>,
#[cfg(target_os = "macos")]
current_find_pasteboard_item: Mutex<Option<ClipboardItem>>,
pub(crate) prompts: RefCell<TestPrompts>,
screen_capture_sources: RefCell<Vec<TestScreenCaptureSource>>,
pub opened_url: RefCell<Option<String>>,
@@ -117,6 +119,8 @@ impl TestPlatform {
current_clipboard_item: Mutex::new(None),
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
current_primary_item: Mutex::new(None),
#[cfg(target_os = "macos")]
current_find_pasteboard_item: Mutex::new(None),
weak: weak.clone(),
opened_url: Default::default(),
#[cfg(target_os = "windows")]
@@ -196,7 +200,7 @@ impl TestPlatform {
rx
}
pub(crate) fn set_active_window(&self, window: Option<TestPlatformWindow>) {
pub(crate) fn set_active_window(&self, window: Option<TestWindow>) {
let executor = self.foreground_executor();
let previous_window = self.active_window.borrow_mut().take();
self.active_window.borrow_mut().clone_from(&window);
@@ -314,7 +318,7 @@ impl Platform for TestPlatform {
handle: AnyWindowHandle,
params: WindowParams,
) -> anyhow::Result<Box<dyn crate::PlatformWindow>> {
let window = TestPlatformWindow::new(
let window = TestWindow::new(
handle,
params,
self.weak.clone(),
@@ -398,9 +402,8 @@ impl Platform for TestPlatform {
false
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn write_to_primary(&self, item: ClipboardItem) {
*self.current_primary_item.lock() = Some(item);
fn read_from_clipboard(&self) -> Option<ClipboardItem> {
self.current_clipboard_item.lock().clone()
}
fn write_to_clipboard(&self, item: ClipboardItem) {
@@ -412,8 +415,19 @@ impl Platform for TestPlatform {
self.current_primary_item.lock().clone()
}
fn read_from_clipboard(&self) -> Option<ClipboardItem> {
self.current_clipboard_item.lock().clone()
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn write_to_primary(&self, item: ClipboardItem) {
*self.current_primary_item.lock() = Some(item);
}
#[cfg(target_os = "macos")]
fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
self.current_find_pasteboard_item.lock().clone()
}
#[cfg(target_os = "macos")]
fn write_to_find_pasteboard(&self, item: ClipboardItem) {
*self.current_find_pasteboard_item.lock() = Some(item);
}
fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task<Result<()>> {

View File

@@ -12,7 +12,7 @@ use std::{
sync::{self, Arc},
};
pub(crate) struct TestPlatformWindowState {
pub(crate) struct TestWindowState {
pub(crate) bounds: Bounds<Pixels>,
pub(crate) handle: AnyWindowHandle,
display: Rc<dyn PlatformDisplay>,
@@ -32,9 +32,9 @@ pub(crate) struct TestPlatformWindowState {
}
#[derive(Clone)]
pub(crate) struct TestPlatformWindow(pub(crate) Rc<Mutex<TestPlatformWindowState>>);
pub(crate) struct TestWindow(pub(crate) Rc<Mutex<TestWindowState>>);
impl HasWindowHandle for TestPlatformWindow {
impl HasWindowHandle for TestWindow {
fn window_handle(
&self,
) -> Result<raw_window_handle::WindowHandle<'_>, raw_window_handle::HandleError> {
@@ -42,7 +42,7 @@ impl HasWindowHandle for TestPlatformWindow {
}
}
impl HasDisplayHandle for TestPlatformWindow {
impl HasDisplayHandle for TestWindow {
fn display_handle(
&self,
) -> Result<raw_window_handle::DisplayHandle<'_>, raw_window_handle::HandleError> {
@@ -50,14 +50,14 @@ impl HasDisplayHandle for TestPlatformWindow {
}
}
impl TestPlatformWindow {
impl TestWindow {
pub fn new(
handle: AnyWindowHandle,
params: WindowParams,
platform: Weak<TestPlatform>,
display: Rc<dyn PlatformDisplay>,
) -> Self {
Self(Rc::new(Mutex::new(TestPlatformWindowState {
Self(Rc::new(Mutex::new(TestWindowState {
bounds: params.bounds,
display,
platform,
@@ -111,7 +111,7 @@ impl TestPlatformWindow {
}
}
impl PlatformWindow for TestPlatformWindow {
impl PlatformWindow for TestWindow {
fn bounds(&self) -> Bounds<Pixels> {
self.0.lock().bounds
}
@@ -272,7 +272,7 @@ impl PlatformWindow for TestPlatformWindow {
self.0.lock().sprite_atlas.clone()
}
fn as_test(&mut self) -> Option<&mut TestPlatformWindow> {
fn as_test(&mut self) -> Option<&mut TestWindow> {
Some(self)
}

View File

@@ -73,7 +73,14 @@ impl WindowsDispatcher {
let mut timing = match runnable {
RunnableVariant::Meta(runnable) => {
let location = runnable.metadata().location;
let metadata = runnable.metadata();
let location = metadata.location;
if !metadata.is_app_alive() {
drop(runnable);
return;
}
let timing = TaskTiming {
location,
start,

View File

@@ -1,4 +1,5 @@
use std::{
collections::VecDeque,
fmt,
iter::FusedIterator,
sync::{Arc, atomic::AtomicUsize},
@@ -9,9 +10,9 @@ use rand::{Rng, SeedableRng, rngs::SmallRng};
use crate::Priority;
struct PriorityQueues<T> {
high_priority: Vec<T>,
medium_priority: Vec<T>,
low_priority: Vec<T>,
high_priority: VecDeque<T>,
medium_priority: VecDeque<T>,
low_priority: VecDeque<T>,
}
impl<T> PriorityQueues<T> {
@@ -42,9 +43,9 @@ impl<T> PriorityQueueState<T> {
let mut queues = self.queues.lock();
match priority {
Priority::Realtime(_) => unreachable!(),
Priority::High => queues.high_priority.push(item),
Priority::Medium => queues.medium_priority.push(item),
Priority::Low => queues.low_priority.push(item),
Priority::High => queues.high_priority.push_back(item),
Priority::Medium => queues.medium_priority.push_back(item),
Priority::Low => queues.low_priority.push_back(item),
};
self.condvar.notify_one();
Ok(())
@@ -141,9 +142,9 @@ impl<T> PriorityQueueReceiver<T> {
pub(crate) fn new() -> (PriorityQueueSender<T>, Self) {
let state = PriorityQueueState {
queues: parking_lot::Mutex::new(PriorityQueues {
high_priority: Vec::new(),
medium_priority: Vec::new(),
low_priority: Vec::new(),
high_priority: VecDeque::new(),
medium_priority: VecDeque::new(),
low_priority: VecDeque::new(),
}),
condvar: parking_lot::Condvar::new(),
receiver_count: AtomicUsize::new(1),
@@ -226,7 +227,7 @@ impl<T> PriorityQueueReceiver<T> {
if !queues.high_priority.is_empty() {
let flip = self.rand.random_ratio(P::High.probability(), mass);
if flip {
return Ok(queues.high_priority.pop());
return Ok(queues.high_priority.pop_front());
}
mass -= P::High.probability();
}
@@ -234,7 +235,7 @@ impl<T> PriorityQueueReceiver<T> {
if !queues.medium_priority.is_empty() {
let flip = self.rand.random_ratio(P::Medium.probability(), mass);
if flip {
return Ok(queues.medium_priority.pop());
return Ok(queues.medium_priority.pop_front());
}
mass -= P::Medium.probability();
}
@@ -242,7 +243,7 @@ impl<T> PriorityQueueReceiver<T> {
if !queues.low_priority.is_empty() {
let flip = self.rand.random_ratio(P::Low.probability(), mass);
if flip {
return Ok(queues.low_priority.pop());
return Ok(queues.low_priority.pop_front());
}
}

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