Compare commits

..

59 Commits

Author SHA1 Message Date
Conrad Irwin
824b430767 Add logging 2025-12-17 13:56:12 -07:00
Conrad Irwin
0fe60ec532 Trigger auto-fix auto-matically (#44947)
This updates our CI workflow to try to run the autofix.yml workflow
if any of prettier, cargo fmt, or cargo clippy fail.

Release Notes:

- N/A
2025-12-17 10:41:43 -07:00
Miguel Raz Guzmán Macedo
c56eb46311 Add davidbarsky to community champion labelers (#45132) 2025-12-17 17:32:18 +00:00
Kirill Bulatov
ec6702aa73 Remove global workspace trust concept (#45129)
Follow-up of https://github.com/zed-industries/zed/pull/44887

Trims the worktree trust mechanism to the actual `worktree`s, so now
"global", workspace-level things like `prettier`, `NodeRuntime`,
`copilot` and global MCP servers are considered as "trusted" a priori.

In the future, a separate mechanism for those will be considered and
added.

Release Notes:

- N/A
2025-12-17 16:53:42 +00:00
Xipeng Jin
f084e20c56 Fix stale pending keybinding indicators on focus change (#44678)
Closes #ISSUE

Problem:

- The status bar’s pending keystroke indicator (shown next to --NORMAL--
in Vim mode) didn’t clear when focus moved to another context, e.g.
hitting g in the editor then clicking the Git panel. The keymap state
correctly canceled the prefix, but observers that render the indicator
never received a “pending input changed” notification, so the UI kept
showing stale prefixes until a new keystroke occurred.

Fix:

- The change introduces a `pending_input_changed_queued` flag and a new
helper `notify_pending_input_if_needed` which will flushes the queued
notification as soon as we have an App context. The
`pending_input_changed` now resets the flag after notifying subscribers.

Before:


https://github.com/user-attachments/assets/7bec4c34-acbf-42bd-b0d1-88df5ff099aa

After:



https://github.com/user-attachments/assets/2264dc93-3405-4d63-ad8f-50ada6733ae7



Release Notes:

- Fixed: pending keybinding prefixes on the status bar now clear
immediately when focus moves to another panel or UI context.

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-12-17 16:51:16 +00:00
Katie Geer
ad58f1f68b docs: Add migrate docs for Webstorm / Pycharm / RustRover (#45128)
Release Notes:

- N/A
2025-12-17 08:44:48 -08:00
Ramon
74b4013e67 git: Mark entries as pending when staging a files making the staged highlighting more "optimistic" (#43434)
This at least speeds it up, not sure if this would close the issue

On main (342eba6f22):


https://github.com/user-attachments/assets/55d10187-b4e6-410d-9002-06509e8015c9


This branch:


https://github.com/user-attachments/assets/e9a5c14f-9694-4321-a81c-88d6f62fb342


Closes #26870

Release Notes:

- Added optimistic staged hunk updating
2025-12-17 11:32:50 -05:00
Antonio Scandurra
f6c944f865 Fix focus lost when navigating to settings subpages (#45111)
Fixes #42668

When clicking 'Configure' to enter a settings subpage, focus was being
lost because push_sub_page only called cx.notify() without managing
focus. Similarly, pop_sub_page had the same issue when navigating back.

This fix:
- Adds window parameter to push_sub_page and pop_sub_page
- Focuses the content area when entering/leaving subpages
- Resets scroll position when entering a subpage

Release Notes:

- Fixed a bug that prevented keyboard navigation in the settings window.
2025-12-17 17:28:42 +01:00
Katie Geer
081e820c43 docs: Dev container (#44498)
Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-17 08:28:32 -08:00
Yara 🏳️‍⚧️
1446d84941 Blockmap sync fix (#44743)
Release Notes:

- Improved display map rendering performance with many lines in the the multi-buffer.

---------

Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Co-authored-by: Cole Miller <cole@zed.dev>
2025-12-17 16:14:57 +00:00
Joseph T. Lyons
80aefbe8e1 Unified wording for discarding file changes in git panel (#45124)
In the `...` menu, we use `Discard...`

<img width="390" height="317" alt="SCR-20251217-kbdh"
src="https://github.com/user-attachments/assets/f88271a6-efab-48fb-bac1-2dacf4fad8f0"
/>

But in the context menu of each entry, we use "Restore..."

<img width="366" height="250" alt="SCR-20251217-kbcj"
src="https://github.com/user-attachments/assets/6c10842b-80f4-4868-a655-2703cba6bd5e"
/>

This PR just makes this more consistent, by using "Discard..." in the
second case.

Release Notes:

- Unified wording for discarding file changes in git panel
2025-12-17 16:14:29 +00:00
Danilo Leal
1705a7ce4e ui: Remove InlineCode component (#45123)
We recently added this `InlineCode` component but I'd forgotten that
many months ago I also introduced an `inline_code` method to the Label
component which does the same thing. That means we don't need a
standalone component at all!

Release Notes:

- N/A
2025-12-17 16:00:50 +00:00
Smit Barmase
1cf3422787 editor: Separate delimiters computation from the newline method (#45119)
Some refactoring I ran into while working on automatic Markdown list
continuation on newline.

This PR:
- Moves `comment_delimiter` and `documentation_delimiter` computation
outside of newline method.
- Adds `NewlineFormatting`, which holds info about how newlines affect
indentation and other formatting we need.
- Moves newline-specific methods into the new `NewlineFormatting`
struct.

Release Notes:

- N/A
2025-12-17 21:06:22 +05:30
peter schilling
00ee06137e Allow opening git commit view via URI scheme (#43341)
Add support for `zed://git/commit/<path-to-repo>#<sha>` (**EDIT:** now
changed to `zed://git/commit/<sha>?repo=<path>`) URI scheme to access
the git commit view

implement parsing and handling of git commit URIs to navigate directly
to commit views from external links. the main use case for me is to use
OSC8 hyperlinks to link from a git sha into zed. this allows me e.g. to
easily navigate from a terminal into zed

**questions**

- is this URI scheme appropriate? it was the first one i thought of, but
wondering if `?ref=<some sha>` might make more sense – the git/commit
namespace was also an equally arbitrary choice

<details>
<summary>video demo showing navigation from zed's built in
terminal</summary>


https://github.com/user-attachments/assets/18ad7e64-6b39-44b2-a440-1a9eb71cd212
</details>

<details>
<summary>video demo showing navigation from ghostty to zed's commit
view</summary>


https://github.com/user-attachments/assets/1825e753-523f-4f98-b59c-7188ae2f5f19

</details>



Release Notes:

- Added support for `zed://git/commit/<sha>?repo=<path>` URI scheme to
access the git commit view

---------

Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-12-17 15:32:37 +00:00
Remco Smits
5b8e4e58c5 git_ui: Fix select first entry selects the wrong visual first entry when tree view is enabled (#45108)
This PR fixes a bug where the select first didn't select the first
visual entry when the first entry is a collapsed directory.

Follow-up: https://github.com/zed-industries/zed/pull/45030

**Before**:


https://github.com/user-attachments/assets/5e5865cc-ec0f-471d-a81b-9521fb70df41

**After**:


https://github.com/user-attachments/assets/05562572-e43f-4d1e-9638-80e4dccc0998

Release Notes:

- git_ui: Fix select first entry selects the wrong visual first entry
when tree view is enabled
2025-12-17 15:31:36 +00:00
Danilo Leal
a16f0712c8 agent_ui: Fix double axis scroll in the edited files list (#45116)
Previously, the list of edit files had a double axis scroll issue
because the list itself scrolled vertically and each file row would
scroll horizontally, causing a bad UX. The horizontal scroll intention
was so that you could see the whole path, but I've included it in the
tooltip in case it becomes obscured due to a small panel width.

<img width="500" height="666" alt="Screenshot 2025-12-17 at 11  24@2x"
src="https://github.com/user-attachments/assets/ea87236d-f5c6-475a-bf66-1afae7a6ca05"
/>

Release Notes:

- agent: N/A
2025-12-17 14:36:01 +00:00
Gaauwe Rombouts
c186877ff7 lsp: Open updated imports in multibuffer after file rename (#45110)
Fixes an issue where we would update the imports after a file rename in
TypeScript, but those changes wouldn't surface anywhere until those
buffers were manually opened
(https://github.com/zed-industries/zed/issues/35930#issuecomment-3366852945).
In https://github.com/zed-industries/zed/pull/36681 we already added
support for opening a multibuffer with edits, but vtsls has a different
flow for renames.

Release Notes:

- Files with updated imports now open in a multibuffer when renaming or
moving TypeScript or JavaScript files
2025-12-17 15:29:48 +01:00
Gaauwe Rombouts
0c304c0e1b lsp: Persist vtsls update imports on rename choice (#45105)
Closes #35930

When a TypeScript file is renamed or moved, vtsls can automatically
update the imports in other files. It pops up a message with the option
to always automatically update imports. This choice would previously
only be remembered for the current session and would pop up again after
a restart.

Now we persist that choice to the vtsls LSP settings in Zed, so that it
remembers across editor sessions.

Release Notes:

- When renaming a TypeScript or JavaScript file, the selected option to
automatically update imports will now be remembered across editor
sessions.
2025-12-17 15:19:01 +01:00
André Eriksson
1b24b442c6 docs: Add Tailwind configuration section for JavaScript/TypeScript (#45057)
Addresses some tasks in #43969. Namely adding TailwindCSS documentation
for the following languages: HTML, JavaScript and Typescript.

**Some Notes**
- Maybe the additional information in the HTML section is unnecessary,
unsure open to suggestions.
- I tried utilizing capturing groups with alternatives like
`\\.(add|remove|toggle|contains)` but this didn't seem to work, so I was
forced to use multiple lines.

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-17 14:16:37 +00:00
Bennet Bo Fenner
71e8b5504c nightly: Temporarly delete commit message prompt from rules library (#45106)
Relevant for Nightly Users only, follow up to #45004.

In case you use nightly this will break preview/stable since
deserialisation will fail. Shipping this to Nightly so that staff does
not run into this issue. We can revert this PR in the following days.
I'll make a follow up PR which only stores the prompt in the database in
case you customise it.

Release Notes:

- N/A
2025-12-17 13:25:48 +00:00
Aero
acae823fb1 agent_ui: Add regeneration button to text and agent thread titles (#43859)
<img width="500" height="830" alt="Screenshot 2025-12-17 at 10  10@2x"
src="https://github.com/user-attachments/assets/057fe20b-50b3-44de-96b8-8a6e3d9239df"
/>

Release Notes:

- agent: Added the ability to regenerate the auto-summarized title of
threads to the "Regenerate Thread Title" button available the ellipsis
menu of the agent panel.

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-17 10:22:17 -03:00
Danilo Leal
9b8bc63524 Revert "Remove CopyAsMarkdown" (#45101)
Reverts https://github.com/zed-industries/zed/pull/44933.

It turns out that if you're copying agent responses to paste it anywhere
else that isn't the message editor (e.g., for a follow up prompt),
getting Markdown formatting is helpful. However, with the revert, the
underlying issue in https://github.com/zed-industries/zed/issues/42958
remains, so I'll reopen that issue, unfortunately.

Release Notes:

- N/A
2025-12-17 09:49:19 -03:00
Antonio Scandurra
4af26f0852 Fix tab bar button flickering when opening menus (#45098)
Closes #33018

### Problem

When opening a `PopoverMenu` or `RightClickMenu`, the pane's tab bar
buttons would flicker (disappear for a couple frames then reappear).
This happened because:

1. The menu is created and `window.focus()` was called immediately
2. However, menus are rendered using `deferred()`, so their focus
handles aren't connected in the dispatch tree until after the deferred
draw callback runs
3. When the pane checks `has_focus()`, it calls `contains_focused()`
which walks up the focus hierarchy — but the menu's focus handle isn't
linked yet
4. `has_focus()` returns false → tab bar buttons disappear
5. Next frame, the menu is rendered and linked → `has_focus()` returns
true → buttons reappear

### Solution

Delay the focus transfer by 2 frames using nested `on_next_frame()`
calls before focusing the menu.

**Why 2 frames instead of 1?**

The frame lifecycle in GPUI runs `next_frame_callbacks` BEFORE `draw()`:

```
on_request_frame:
  1. Run next_frame_callbacks
  2. window.draw()  ← menu rendered here via deferred()
  3. Present
```

So:
- **Frame 1**: First `on_next_frame` callback runs, queues second
callback. Then `draw()` renders the menu and connects its focus handle
to the dispatch tree.
- **Frame 2**: Second `on_next_frame` callback runs and focuses the
menu. Now the focus handle is connected (from Frame 1's draw), so
`contains_focused()` returns true.

With only 1 frame, the focus would happen BEFORE `draw()`, when the
menu's focus handle isn't connected yet.

This follows the same pattern established in b709996ec6 which fixed the
identical issue for the editor's `MouseContextMenu`.
2025-12-17 12:42:43 +00:00
Yara 🏳️‍⚧️
b29e8244d5 Fix Yara's GitHub handle (#45095)
Release Notes:

- N/A
2025-12-17 12:06:46 +00:00
Shardul Vaidya
edf21a38c1 bedrock: Add Bedrock API key authentication support (#41393) 2025-12-17 12:54:57 +01:00
tidely
c0b3422941 node_runtime: Use semver::Version to represent package versions (#44342)
Closes #ISSUE

This PR is rather a nice to have change than anything critical, so
review priority should remain low.

Switch to using `semver::Version` for representing node binary and npm
package versions. This is in an effort to root out implicit behavior and
improve type safety when interacting with the `node_runtime` crate by
catching invalid versions where they appear. Currently Zed may
implicitly assume the current version is correct, or always install the
newest version when a invalid version is passed. `semver::Version` also
doesn't require the heap, which is probably more of a fun fact than
anything useful.

`npm_install_packages` still takes versions as a `&str`, because
`latest` can be used to fetch the latest version on npm. This could
likely be made into an enum as well, but would make the PR even larger.

I tested changes with some node based language servers and external
agents, which all worked fine. It would be nice to have some e2e tests
for node. To be safe I'd put it on nightly after a Wednesday release.

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-17 12:27:06 +01:00
Anthony Eid
010b871a8e git: Show pure white space changes in word diffs (#45090)
Closes #44624

Before this change, white space would be trimmed from word diff ranges.
Users found this behavior confusing, so we're changing it to be more
inline with how GitHub treats whitespace in their word diffs.

Release Notes:

- git: Word diffs won't filter out pure whitespace diffs now
2025-12-17 10:52:27 +00:00
Dino
14958a47ed vim: Attempt to fix flaky vim tests on windows (#45089)
Both `test_miniquotes_object` and `test_minibrackets_object` rely on
tree-sitter parsing for `MultiBufferSnapshot.bracket_ranges` to find
quote/bracket pairs. The `VimTestContext.set_state` call eventually
triggers async tree-sitter parsing, but `run_until_parked` doesn't
guarantee parsing completion.

We suspect this is what might be causing the flakiness on Windows, as
the syntax might not yet be parsed when the
`VimTestContext.simulate_keystrokes` call is made, so there's no bracket
pairs returned.

This commit adds an explicit await call on `Bufffer.parsing_idle` after
each `VimTestContext.set_state` call, to ensure tree-sitter parsing
completes before simulating keystrokes.

Release Notes:

- N/A
2025-12-17 10:31:36 +00:00
Lukas Wirth
f5ba029313 remote: Implement client side connection support for windows remotes (#45084)
Obviously this doesn't do too much without having an actual windows
server binary for the remote side, but it does at least improve the
error message as right now we will complain about `uname` not being a
valid powershell command.

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-17 11:31:18 +01:00
Anthony Eid
93246163c6 git: Fix deletion icon button in branch list deleting the wrong branch (#45087)
Closes #45033 

This bug happened because the deletion icon would use the selected entry
index to choose what branch to delete. This works for all cases except
when hovering on an entry, so the fix was passing in the entry index to
the deletion button on_click handler.

I also disabled the deletion button from working if a branch is HEAD,
because it's an illegal operation to delete a branch a user is currently
on.

Finally, I made WeakEntity<Workspace> a non-optional field on
`BranchList` because a workspace should always be present, and it's used
to show toast notifications when a git operation fails. The popover view
wouldn't have a workspace before, so users wouldn't get error messages
when a git operation failed in that view.

Release Notes:

- git: Fix bug where branch list deletion button would delete the wrong
branch
2025-12-17 10:20:43 +00:00
Jeff Brennan
a7bab0b050 language: Fix auto-indentation for Python code blocks in Markdown (#43853)
Closes #43722

Release Notes:

- Fixed an issue where auto-indentation didn’t work correctly for Python
code blocks in Markdown.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-12-17 15:40:39 +05:30
Antonio Scandurra
637ff34254 Fix editor hang when positioned above viewport (#45077)
Fixes the hang introduced in #44995 (which was reverted in #45011) and
re-enables the optimization.

## Background

PR #44995 introduced an optimization to skip rendering lines that are
clipped by parent containers (e.g., when a large AutoHeight editor is
inside a scrollable List). This significantly improved performance for
large diffs in the Agent Panel.
However, #45011 reverted this change because it caused the main thread
to hang for 100+ seconds in certain scenarios, requiring a force quit to
recover.

## Root Cause
The original analysis in #45011 suggested that visible_bounds wasn’t
being intersected properly, but that was incorrect—the intersection via
with_content_mask works correctly. The actual bug: when an editor is
positioned above the visible viewport (e.g., scrolled past in a List),
the clipping calculation produces a start_row that exceeds max_row:

1. Editor’s bounds.origin.y becomes very negative (e.g., -10000px)
2. After intersection, visible_bounds.origin.y is at the viewport top
(e.g., 0)
3. clipped_top_in_lines = (0 - (-10000)) / line_height = huge number
4. start_row = huge number, but end_row is clamped to max_row
5. This creates an invalid range where start_row > end_row

This caused two different failures depending on build mode:
- Debug mode: Panic from subtraction overflow in
Range<DisplayRow>::len()
- Release mode: Integer wraparound causing blocks_in_range to enter an
infinite loop (the 100+ second hang)

## Fix

Simply clamp start_row to max_row, ensuring the row range is always
valid:

```rs
let start_row = cmp::min(
    DisplayRow((scroll_position.y + clipped_top_in_lines).floor() as u32),
    max_row,
);
```

## Testing
Added a regression test that draws an editor at y=-10000 to simulate an
editor that’s been scrolled past in a List. This would panic in debug
mode (and hang in release mode) before the fix.

Release Notes:
- Improved agent panel performance when rendering large diffs.
2025-12-17 09:17:45 +00:00
Lukas Wirth
c5b3b06b94 python: Fetch non pre-release versions of ty (#45080)
0.0.2 is not a pre-release artifact unlike the previous one, so our
version fetch ignored it.

Fixes https://github.com/zed-industries/zed/issues/45061

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-17 09:04:10 +00:00
Mayank Verma
79e2e52012 project: Clear stale settings when switching remote projects (#45021)
Closes #44898

Release Notes:

- Fixed stale settings persisting when switching remote projects
2025-12-17 08:59:29 +00:00
Lukas Wirth
25b89dd8e9 workspace: Don't debug display paths to users in trust popup (#45079)
On windows this will render two backslashes otherwise

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-17 08:43:34 +00:00
Lukas Wirth
edcde6d90c Fix semantic merge conflict (#45078)
Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-17 08:28:59 +00:00
Marco Mihai Condrache
280864e7f2 remote: Support IPv6 when using SSH (#43591)
Closes #33650

Release Notes:

- Added support for remote connections over IPv6

---------

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>
2025-12-17 08:59:27 +01:00
tidely
949cbc2b18 gpui: Remove intermediate allocations when reconstructing text from a TextLayout (#45037)
Closes #ISSUE

Remove some intermediate allocations when reconstructing text or wrapped
text from a `TextLayout`. Currently creates a intermediate `Vec<String>`
which gets joined, when you could join an `impl Iterator<Item = &str>`

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-17 08:56:05 +01:00
Copilot
6f5da5e34e Fix NewWindow flicker by creating buffer synchronously (#44915)
Closes #20613

Release Notes:

- Fixed: New windows no longer flicker between "Open a file or project
to get started" and an empty editor.

---

When opening a new window (`cmd-shift-n`), the window rendered showing
the empty state message before the editor was created, causing a visible
flicker.

**Changes:**

- Modified `Workspace::new_local` to accept an optional `init` callback
that executes inside the window build closure
- The init callback runs within `cx.new` (the `build_root_view`
closure), before `window.draw()` is called for the first render
- Changed the NewWindow action handler to use
`Project::create_local_buffer()` (synchronous) instead of
`Editor::new_file()` (asynchronous)
- Updated `open_new` to pass the editor creation callback to `new_local`
- All other `new_local` call sites pass `None` to maintain existing
behavior

**Key Technical Detail:**

The window creation sequence in `cx.open_window()` is:
1. `build_root_view` closure is called (creates workspace via `cx.new`)
2. `window.draw(cx)` is called (first render)
3. `open_window` returns

The fix uses `Project::create_local_buffer()` which creates a buffer
**synchronously** (returns `Entity<Buffer>` directly), rather than
`Editor::new_file()` which is asynchronous (calls
`project.create_buffer()` which returns a `Task`). The editor is created
from this buffer inside the `cx.new` closure (step 1), ensuring it
exists before step 2 renders the first frame.

**Before:**
```rust
let task = Workspace::new_local(Vec::new(), app_state, None, env, cx);
cx.spawn(async move |cx| {
    let (workspace, _) = task.await?;  // Window already drawn
    workspace.update(cx, |workspace, window, cx| {
        Editor::new_file(workspace, ...)  // Async - editor not present for first render
    })?;
})
```

**After:**
```rust
cx.open_window(options, {
    move |window, cx| {
        cx.new(|cx| {
            let mut workspace = Workspace::new(...);
            // Create buffer synchronously, then create editor
            if let Some(init) = init {
                init(&mut workspace, window, cx);  // Uses create_local_buffer (sync)
            }
            workspace
        })
    }
})?
```

The editor is now part of the workspace before the window's first frame
is rendered, eliminating the flicker.

<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> 
> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>Opening a new window flickers before opening an empty
buffer</issue_title>
> <issue_description>### Check for existing issues
> 
> - [x] Completed
> 
> ### Describe the bug / provide steps to reproduce it
> 
> Opening a new window, with e.g. `cmd-shift-n`, flickers for a fraction
of a second. The new window first shows the startup page, "Open a file
or project to get started.". Then, a frame or two later, a new empty
buffer opens.
> 
> Not sure if I'm sensitive or something but these kinds of flashes can
knock me out of focus/flow pretty easily.
> 
> It'd be great to either have the empty buffer open from the first
frame, or to have an option to simply not open that empty buffer when a
new window is opened.
> 
> ### Zed Version and System Specs
> 
> Zed: v0.170.4 (Zed)
> OS: macOS 14.6.1
> Memory: 36 GiB
> Architecture: aarch64
> 
> ### If applicable, add screenshots or screencasts of the incorrect
state / behavior
> 
>
https://github.com/user-attachments/assets/6d9ba791-8a02-4e13-857c-66a33eb0905b
> 
> ### If applicable, attach your Zed.log file to this issue.
> 
> N/A</issue_description>
> 
> <agent_instructions>We should make sure that the window is created in
the correct state, and not have an intermediate render before the editor
opens.</agent_instructions>
> 
> ## Comments on the Issue (you are @copilot in this section)
> 
> <comments>
> <comment_new><author>@ConradIrwin</author><body>
> Ugh, no. I don't believe I never noticed this before, but now I can't
unsee it :s
> 
> If you'd like to pair on this: https://cal.com/conradirwin/pairing,
otherwise I'll see if I get around to it.</body></comment_new>
> <comment_new><author>@ConradIrwin</author><body>
> Yeah... I wonder if that can be a preview tab or something. It's nice
when you want it, but not so nice when you don't.
> 
> Fixing this will also make zed-industries/zed#33334 feel much
smoother.</body></comment_new>
> <comment_new><author>@zelenenka</author><body>
> @robinplace do you maybe have an opportunity to test it with the
latest stable version, 0.213.3?</body></comment_new>
> </comments>
> 


</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes zed-industries/zed#23742

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ConradIrwin <94272+ConradIrwin@users.noreply.github.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-12-16 23:04:16 -07:00
Conrad Irwin
76665a78d1 More secure auto-fixer (#44952)
Split running `cargo clippy` out of the job that has access to ZIPPY
secrets as
a precaution against accidentally leaking the secrets through build.rs
or
something...

Release Notes:

- N/A
2025-12-17 05:47:44 +00:00
Matthew Chisolm
92b1f1fffb workspace: Persist window values without project (#44937)
Persist and restore window values (size, position, etc.) to the KV Store
when there are no projects open.

Relates to Discussion
https://github.com/zed-industries/zed/discussions/24228#discussioncomment-15224666

Release Notes:

-  Added persistence for window size when no projects are open

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-12-17 04:59:47 +00:00
Max Brunsfeld
1c33dbcb66 Fix slow tree-sitter query execution by limiting the range that queries search (#39416)
Part of https://github.com/zed-industries/zed/issues/39594
Closes https://github.com/zed-industries/zed/issues/4701
Closes https://github.com/zed-industries/zed/issues/42861
Closes https://github.com/zed-industries/zed/issues/44503
~Depends on https://github.com/tree-sitter/tree-sitter/pull/4919~

Release Notes:

- Fixed some performance bottlenecks related to syntax analysis when
editing very large files

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
2025-12-16 17:41:06 -08:00
Piotr Osiewicz
975a76bbf0 Bump Rust version to 1.92 (#44649)
Release Notes:

- N/A

---------

Co-authored-by: Julia Ryan <juliaryan3.14@gmail.com>
2025-12-17 01:42:04 +01:00
Anthony Eid
4fe6dc06ea git: Align checkboxes in git panel (#45048)
Before this fix checkboxes would overflow off the visible view which
isn't ideal. This aligns the checkboxes by allowing the path name to
overflow.

#### Before
<img width="135" height="159" alt="image"
src="https://github.com/user-attachments/assets/1a9e4c64-0d7b-4a8d-870a-bb198cc7377a"
/>

#### After
<img width="148" height="165" alt="image"
src="https://github.com/user-attachments/assets/c7cf7a7c-c765-4e2b-8968-b3affcaa8649"
/>

Release Notes:

- N/A

Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Matt Miller <mattrx@gmail.com>
2025-12-17 00:02:46 +00:00
Kirill Bulatov
af3902a33f Move DB away from the project (#45036)
Follow-up of https://github.com/zed-industries/zed/pull/44887

This fixes remote server builds.

Additionally:

* slightly rewords workspace trust text in the security modal
* eagerly ask for worktree trust on open

Release Notes:

- N/A
2025-12-17 01:59:34 +02:00
AidanV
83de583fb1 nix: Resolve 'hostPlatform' rename warning in dev shell (#45045)
This PR fixes the warning from entering the nix development shell:
```
evaluation warning: 'hostPlatform' has been renamed to/replaced by 'stdenv.hostPlatform'
```

Decided to go with `zed-editor = mkZed pkgs;` instead of `zed-editor =
packages.${pkgs.stdenv.hostPlatform.system}.default;`, because it is
simpler and with my understanding it is logically equivalent (i.e. we
are getting `packages.<system>.default` which we can see in the
definition of packages is equal to `mkZed pkgs;`).

Release Notes:

- N/A
2025-12-16 15:57:26 -08:00
Michael Benfield
bd20339f82 Don't apply StripInvalidSpans for tool using inline assistant (#45040)
It can occasionally mutilate the text when used with the tool format.

Release Notes:

- N/A
2025-12-16 15:30:36 -08:00
Joseph T. Lyons
2886806809 Display all branches and remotes by default in the branch picker (#45041)
This both matches VS Code's branch picker and makes the "Filter Remotes"
button make more sense.

<img width="584" height="496" alt="SCR-20251216-pgkv"
src="https://github.com/user-attachments/assets/e2ae5917-38dc-42e3-a1be-4b3a1f23523e"
/>

<img width="614" height="410" alt="SCR-20251216-pgqp"
src="https://github.com/user-attachments/assets/30b0a17a-1529-4f75-9781-92b08125aa0b"
/>


Release Notes:

- Display all branches and remotes by default in the branch picker
2025-12-16 22:51:33 +00:00
Anthony Eid
3a013d8090 gpui: Add is_action_available_in function (#45029)
This compliments the `window.is_action_available` function that already
exists.

Release Notes:

- N/A
2025-12-16 17:03:30 -05:00
Remco Smits
ab4cd95e9c git_ui: Fix select next/previous entry selects non-visible entry when tree view is enabled (#45030)
Before this commit, we would select a non-visible entry when a directory
is collapsed. Now we correctly select the visible entry that is visually
the previous/next entry in the list.

**Note**: I removed the `cx.notify()` call as it's already part of the
`self.scroll_to_selected_entry(cx)` call. So we don't notify twice :).

Follow-up: https://github.com/zed-industries/zed/pull/45002

**Before**


https://github.com/user-attachments/assets/da0b8084-0081-4d98-ad8a-c11c3b95a1b7

**After**


https://github.com/user-attachments/assets/8a16afb0-fdde-4317-b419-13143d5d608e

Release Notes:

- git_ui: Fix select next/previous entry selects non-visible entry when
tree view is enabled
2025-12-16 21:53:30 +00:00
Danilo Leal
78cd106b64 inline assistant: Add some slight touch ups to the rating UI (#45034)
Just touching up the tooltip casing, colors, and a bit of spacing. Also
added the keybiniding to close the assistant. Maybe it was obvious
already but I don't think it hurts.

Release Notes:

- N/A
2025-12-16 18:47:56 -03:00
Torstein Sørnes
eba811a127 Add support for MCP tools/list_changed notification (#42453)
## Summary

This PR adds support for the MCP (Model Context Protocol)
`notifications/tools/list_changed` notification, enabling dynamic tool
discovery when MCP servers add, remove, or modify their available tools
at runtime.

## Release Notes:

- Improved: MCP tools are now automatically reloaded when a context
server sends a `tools/list_changed` notification, eliminating the need
to restart the server to discover new tools.

## Changes

- Register a notification handler for `notifications/tools/list_changed`
in `ContextServerRegistry`
- Automatically reload tools when the notification is received
- Handler is registered both on initial server startup and when a server
transitions to `Running` status

## Motivation

The MCP specification includes a `notifications/tools/list_changed`
notification to inform clients when the list of available tools has
changed. Previously, Zed's agent would only load tools once when a
context server started. This meant that:

1. If an MCP server dynamically registered new tools after
initialization, they would not be available to the agent
2. The only way to refresh tools was to restart the entire context
server
3. Tools that were removed or modified would remain in the old state
until restart

## Implementation Details

The implementation follows these steps:

1. When a context server transitions to `Running` status, register a
notification handler for `notifications/tools/list_changed`
2. The handler captures a weak reference to the `ContextServerRegistry`
entity
3. When the notification is received, spawn a task that calls
`reload_tools_for_server` with the server ID
4. The existing `reload_tools_for_server` method handles fetching the
updated tool list and notifying observers

This approach is minimal and reuses existing tool-loading
infrastructure.

## Testing

- [x] Code compiles with `./script/clippy -p agent`
- The notification handler infrastructure already exists and is tested
in the codebase
- The `reload_tools_for_server` method is already tested and working

## Benefits

- Improves developer experience by enabling hot-reloading of MCP tools
- Aligns with the MCP specification's capability negotiation system
- No breaking changes to existing functionality
- Enables more flexible and dynamic MCP server implementations

## Related Issues

This implements part of the MCP specification that was already defined
in the type system but not wired up to actually handle the
notifications.

---------

Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-12-16 21:44:39 +00:00
Danilo Leal
301d7fbc61 agent_ui: Add keybinding to cycle through favorited models (#45032)
Similar to how you can use `shift-tab` to cycle through profiles/modes,
you can now use `alt-tab` to cycle through the language models you have
favorited.

<img width="500" height="312" alt="Screenshot 2025-12-16 at 5  23@2x"
src="https://github.com/user-attachments/assets/006d417d-5da1-48f9-82cc-ea06e28adb30"
/>

Release Notes:

- agent: Added the ability to cycle through favorited models using the
`alt-tab` keybinding.
2025-12-16 18:23:30 -03:00
Bennet Bo Fenner
7972baafe9 git: Prevent customizing commit message prompt for legacy Zed Pro users (#45016)
We need to prevent this, since commit message generation did not count
as a prompt in the old billing model.
If users of Legacy Zed Pro customise the prompt, it will count as an
actual prompt since our matching algorithm will fail.
We can remove this once we stop supporting Legacy Zed Pro on 17 January.

Release Notes:

- N/A
2025-12-16 21:07:10 +01:00
Joseph T. Lyons
abcf5a1273 Revert "gpui: Take advantage of unified memory on Apple silicon (#44273)" (#45022)
This reverts commit 2441dc3f66.

Release Notes:

- N/A
2025-12-16 19:41:59 +00:00
Richard Feldman
d16619a654 Improve token count accuracy using Anthropic's API (#44943)
Closes #38533

<img width="807" height="425" alt="Screenshot 2025-12-16 at 2 32 21 PM"
src="https://github.com/user-attachments/assets/6ebb915c-91d3-4158-a2b9-9fe17d301dd6"
/>


Release Notes:

- Use up-to-date token counts from LLM responses when reporting tokens
used per thread

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-16 14:32:41 -05:00
Oleksii (Alexey) Orlenko
0c91f061c3 agent_ui: Implement favorite models selection (#44297)
This PR solves my main pain point with Zed agent: I have a long list of
available models from different providers, and I switch between a few of
them depending on the context and the project. In particular, I use the
same models from different providers depending on whether I'm working on
a personal project or at my day job. Since I only care about a few
models (none of which are in "recommended") that are scattered all over
the list, switching between them is bothersome, even using search.

This change adds a new option in `settings.json`
(`agent.favorite_models`) and the UI to manipulate it directly from the
list of available models. When any models are marked as favorites, they
appear in a dedicated section at the very top of the list. Each model
has a small icon button that appears on hover and allows to toggle
whether it's marked as favorite.

I implemented this on the UI level (i.e. there's no first-party
knowledge about favorite models in the agent itself; in theory it could
return favorite models as a group but it would make it harder to
implement bespoke UI for the favorite models section and it also
wouldn't work for text threads which don't use the ACP infrastructure).

The feature is only enabled for the native agent but disabled for
external agents because we can't easily map their model IDs to settings
and there could be weird collisions between them.


https://github.com/user-attachments/assets/cf23afe4-3883-45cb-9906-f55de3ea2a97

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

Release Notes:

- Added the ability to mark language models as favorites and pin them to
the top of the list. This feature is available in the native Zed agent
(including text threads and the inline assistant), but not in external
agents via ACP.

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-12-16 16:22:30 -03:00
Josh Robson Chase
91a976bf7b nix: Pin cargo-about to 0.8.2 (#44901)
`cargo-about` got pinned to 0.8.2 in
https://github.com/zed-industries/zed/pull/44012, but this isn't exactly
"easy" to accomplish in nix. The version of nixpkgs in the flake inputs
uses the proper version, but if you override the nixpkgs input or use
the provided overlay, you might end up trying to build with a bad
version of `cargo-about`.

Since nixpkgs is versioned as a whole, your options are (in rough order
of desirability):
1. Hope that nixpkgs simply includes multiple versions of the same
package (common for things with stable major versions/breaking changes)
1. Use either `override` or `overrideAttrs` to provide different
version/source attributes
1. Depend on multiple versions of nixpkgs to get the specific versions
of the packages you want
1. Vendor the whole package build from a specific point in its history

Option 1 is out - there's only one version of cargo-about in nixpkgs.

Option 2 doesn't seem to work due to the way that `buildRustPackage`
wraps the base `mkDerivation` which provides the `override` extension
functions. There *might* be a way to make this work, but I haven't dug
into the `buildRustPackage` internals enough to say for sure. Edit: I
apparently can't read and the problems with this option were already
solved for `cargo-bundle`, so this is the final approach!

Option 3 always just feels a bit icky and opaque to me.

Leaving Option 4. I usually find this approach to be "fine" for small
package definitions that aren't actually much bigger than the overridden
attributes would have be with the Option 2 approach. ~~Since the
`cargo-about` definition is nice and small, this is the approach I
chose.~~

~~Since this has the potential to require a build of `cargo-about`, I'm
only actually invoking its build if the provided version is wrong - more
or less the same thing that's happening in the `generate-licenses`
script, but nix-y.~~
Edit: Shouldn't ever cause a rebuild since there's only one 0.8.2 input
source/vendored deps, so anything that was already using it will already
be cached.

I'm also updating nixpkgs to the latest unstable which currently has
`cargo-about 0.8.4` to prove that this works.

Unrelatedly, I also ran `nix fmt` as a drive-by change. `nix/build.nix`
was a bit out of spec.

Release Notes:

- N/A
2025-12-16 11:00:46 -08:00
Bennet Bo Fenner
e4029c13c9 prompt_store: Remove unused PromptId::EditWorkflow (#45018)
Release Notes:

- N/A
2025-12-16 18:55:34 +00:00
225 changed files with 7211 additions and 3569 deletions

View File

@@ -9,26 +9,23 @@ on:
description: pr_number
required: true
type: string
run_clippy:
description: run_clippy
type: boolean
default: 'true'
jobs:
run_autofix:
runs-on: namespace-profile-16x32-ubuntu-2204
steps:
- id: get-app-token
name: autofix_pr::run_autofix::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: steps::checkout_repo_with_token
- name: steps::checkout_repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
clean: false
token: ${{ steps.get-app-token.outputs.token }}
- name: autofix_pr::run_autofix::checkout_pr
run: gh pr checkout ${{ inputs.pr_number }}
shell: bash -euxo pipefail {0}
env:
GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: steps::setup_cargo_config
run: |
mkdir -p ./../.cargo
@@ -58,26 +55,74 @@ jobs:
run: cargo fmt --all
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
shell: bash -euxo pipefail {0}
- name: autofix_pr::run_autofix::commit_and_push
- id: create-patch
name: autofix_pr::run_autofix::create_patch
run: |
if git diff --quiet; then
echo "No changes to commit"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
git add -A
git commit -m "Autofix"
git push
git diff > autofix.patch
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
shell: bash -euxo pipefail {0}
- name: upload artifact autofix-patch
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
name: autofix-patch
path: autofix.patch
if-no-files-found: ignore
retention-days: '1'
- name: steps::cleanup_cargo_config
if: always()
run: |
rm -rf ./../.cargo
shell: bash -euxo pipefail {0}
outputs:
has_changes: ${{ steps.create-patch.outputs.has_changes }}
commit_changes:
needs:
- run_autofix
if: needs.run_autofix.outputs.has_changes == 'true'
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: get-app-token
name: autofix_pr::commit_changes::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: steps::checkout_repo_with_token
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
clean: false
token: ${{ steps.get-app-token.outputs.token }}
- name: autofix_pr::commit_changes::checkout_pr
run: gh pr checkout ${{ inputs.pr_number }}
shell: bash -euxo pipefail {0}
env:
GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
- name: autofix_pr::download_patch_artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53
with:
name: autofix-patch
- name: autofix_pr::commit_changes::apply_patch
run: git apply autofix.patch
shell: bash -euxo pipefail {0}
- name: autofix_pr::commit_changes::commit_and_push
run: |
git commit -am "Autofix"
git push
shell: bash -euxo pipefail {0}
env:
GIT_COMMITTER_NAME: Zed Zippy
GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com
GIT_AUTHOR_NAME: Zed Zippy
GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com
GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
- name: steps::cleanup_cargo_config
if: always()
run: |
rm -rf ./../.cargo
shell: bash -euxo pipefail {0}
concurrency:
group: ${{ github.workflow }}-${{ inputs.pr_number }}
cancel-in-progress: true

View File

@@ -34,6 +34,7 @@ jobs:
CharlesChen0823
chbk
cppcoffee
davidbarsky
davewa
ddoemonn
djsauble

View File

@@ -74,6 +74,12 @@ jobs:
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- name: steps::trigger_autofix
if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=true
shell: bash -euxo pipefail {0}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: steps::cargo_install_nextest
uses: taiki-e/install-action@nextest
- name: steps::clear_target_dir_if_large

View File

@@ -77,6 +77,15 @@ jobs:
- name: ./script/prettier
run: ./script/prettier
shell: bash -euxo pipefail {0}
- name: steps::cargo_fmt
run: cargo fmt --all -- --check
shell: bash -euxo pipefail {0}
- name: steps::trigger_autofix
if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=false
shell: bash -euxo pipefail {0}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: ./script/check-todos
run: ./script/check-todos
shell: bash -euxo pipefail {0}
@@ -87,9 +96,6 @@ jobs:
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06
with:
config: ./typos.toml
- name: steps::cargo_fmt
run: cargo fmt --all -- --check
shell: bash -euxo pipefail {0}
timeout-minutes: 60
run_tests_windows:
needs:
@@ -160,6 +166,12 @@ jobs:
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- name: steps::trigger_autofix
if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=true
shell: bash -euxo pipefail {0}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: steps::cargo_install_nextest
uses: taiki-e/install-action@nextest
- name: steps::clear_target_dir_if_large

View File

@@ -141,6 +141,9 @@ Uladzislau Kaminski <i@uladkaminski.com>
Uladzislau Kaminski <i@uladkaminski.com> <uladzislau_kaminski@epam.com>
Vitaly Slobodin <vitaliy.slobodin@gmail.com>
Vitaly Slobodin <vitaliy.slobodin@gmail.com> <vitaly_slobodin@fastmail.com>
Yara <davidsk@zed.dev>
Yara <git@davidsk.dev>
Yara <git@yara.blue>
Will Bradley <williambbradley@gmail.com>
Will Bradley <williambbradley@gmail.com> <will@zed.dev>
WindSoilder <WindSoilder@outlook.com>

69
Cargo.lock generated
View File

@@ -301,6 +301,7 @@ dependencies = [
name = "agent_settings"
version = "0.1.0"
dependencies = [
"agent-client-protocol",
"anyhow",
"cloud_llm_client",
"collections",
@@ -1440,9 +1441,9 @@ dependencies = [
[[package]]
name = "aws-config"
version = "1.8.8"
version = "1.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37cf2b6af2a95a20e266782b4f76f1a5e12bf412a9db2de9c1e9123b9d8c0ad8"
checksum = "1856b1b48b65f71a4dd940b1c0931f9a7b646d4a924b9828ffefc1454714668a"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1506,9 +1507,9 @@ dependencies = [
[[package]]
name = "aws-runtime"
version = "1.5.12"
version = "1.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa006bb32360ed90ac51203feafb9d02e3d21046e1fd3a450a404b90ea73e5d"
checksum = "9f2402da1a5e16868ba98725e5d73f26b8116eaa892e56f2cd0bf5eec7985f70"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -1531,9 +1532,9 @@ dependencies = [
[[package]]
name = "aws-sdk-bedrockruntime"
version = "1.109.0"
version = "1.112.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbfdfd941dcb253c17bf70baddbf1e5b22f19e29d313d2e049bad4b1dadb2011"
checksum = "c06c037e6823696d752702ec2bad758d3cf95d1b92b712c8ac7e93824b5e2391"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1613,9 +1614,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
version = "1.86.0"
version = "1.88.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0abbfab841446cce6e87af853a3ba2cc1bc9afcd3f3550dd556c43d434c86d"
checksum = "d05b276777560aa9a196dbba2e3aada4d8006d3d7eeb3ba7fe0c317227d933c4"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1635,9 +1636,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ssooidc"
version = "1.88.0"
version = "1.90.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a68d675582afea0e94d38b6ca9c5aaae4ca14f1d36faa6edb19b42e687e70d7"
checksum = "f9be14d6d9cd761fac3fd234a0f47f7ed6c0df62d83c0eeb7012750e4732879b"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1657,9 +1658,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
version = "1.88.0"
version = "1.90.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d30990923f4f675523c51eb1c0dec9b752fb267b36a61e83cbc219c9d86da715"
checksum = "98a862d704c817d865c8740b62d8bbeb5adcb30965e93b471df8a5bcefa20a80"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1680,9 +1681,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
version = "1.3.5"
version = "1.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bffc03068fbb9c8dd5ce1c6fb240678a5cffb86fb2b7b1985c999c4b83c8df68"
checksum = "c35452ec3f001e1f2f6db107b6373f1f48f05ec63ba2c5c9fa91f07dad32af11"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
@@ -1739,9 +1740,9 @@ dependencies = [
[[package]]
name = "aws-smithy-eventstream"
version = "0.60.12"
version = "0.60.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9656b85088f8d9dc7ad40f9a6c7228e1e8447cdf4b046c87e152e0805dea02fa"
checksum = "e29a304f8319781a39808847efb39561351b1bb76e933da7aa90232673638658"
dependencies = [
"aws-smithy-types",
"bytes 1.10.1",
@@ -1750,9 +1751,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
version = "0.62.4"
version = "0.62.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3feafd437c763db26aa04e0cc7591185d0961e64c61885bece0fb9d50ceac671"
checksum = "445d5d720c99eed0b4aa674ed00d835d9b1427dd73e04adaf2f94c6b2d6f9fca"
dependencies = [
"aws-smithy-eventstream",
"aws-smithy-runtime-api",
@@ -1760,6 +1761,7 @@ dependencies = [
"bytes 1.10.1",
"bytes-utils",
"futures-core",
"futures-util",
"http 0.2.12",
"http 1.3.1",
"http-body 0.4.6",
@@ -1771,9 +1773,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
version = "1.1.3"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1053b5e587e6fa40ce5a79ea27957b04ba660baa02b28b7436f64850152234f1"
checksum = "623254723e8dfd535f566ee7b2381645f8981da086b5c4aa26c0c41582bb1d2c"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -1801,9 +1803,9 @@ dependencies = [
[[package]]
name = "aws-smithy-json"
version = "0.61.6"
version = "0.61.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cff418fc8ec5cadf8173b10125f05c2e7e1d46771406187b2c878557d4503390"
checksum = "2db31f727935fc63c6eeae8b37b438847639ec330a9161ece694efba257e0c54"
dependencies = [
"aws-smithy-types",
]
@@ -1829,9 +1831,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.9.3"
version = "1.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ab99739082da5347660c556689256438defae3bcefd66c52b095905730e404"
checksum = "0bbe9d018d646b96c7be063dd07987849862b0e6d07c778aad7d93d1be6c1ef0"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -1853,9 +1855,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.9.1"
version = "1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3683c5b152d2ad753607179ed71988e8cfd52964443b4f74fd8e552d0bbfeb46"
checksum = "ec7204f9fd94749a7c53b26da1b961b4ac36bf070ef1e0b94bb09f79d4f6c193"
dependencies = [
"aws-smithy-async",
"aws-smithy-types",
@@ -1870,9 +1872,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.3.3"
version = "1.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f5b3a7486f6690ba25952cabf1e7d75e34d69eaff5081904a47bc79074d6457"
checksum = "25f535879a207fce0db74b679cfc3e91a3159c8144d717d55f5832aea9eef46e"
dependencies = [
"base64-simd",
"bytes 1.10.1",
@@ -1896,18 +1898,18 @@ dependencies = [
[[package]]
name = "aws-smithy-xml"
version = "0.60.11"
version = "0.60.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9c34127e8c624bc2999f3b657e749c1393bedc9cd97b92a804db8ced4d2e163"
checksum = "eab77cdd036b11056d2a30a7af7b775789fb024bf216acc13884c6c97752ae56"
dependencies = [
"xmlparser",
]
[[package]]
name = "aws-types"
version = "1.3.9"
version = "1.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2fd329bf0e901ff3f60425691410c69094dc2a1f34b331f37bfc4e9ac1565a1"
checksum = "d79fb68e3d7fe5d4833ea34dc87d2e97d26d3086cb3da660bb6b1f76d98680b6"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
@@ -3622,6 +3624,7 @@ dependencies = [
"serde",
"serde_json",
"settings",
"slotmap",
"smol",
"tempfile",
"terminal",
@@ -8812,6 +8815,7 @@ dependencies = [
"regex",
"rpc",
"schemars",
"semver",
"serde",
"serde_json",
"settings",
@@ -9046,6 +9050,7 @@ dependencies = [
"regex",
"rope",
"rust-embed",
"semver",
"serde",
"serde_json",
"serde_json_lenient",

View File

@@ -455,15 +455,15 @@ async-task = "4.7"
async-trait = "0.1"
async-tungstenite = "0.31.0"
async_zip = { version = "0.0.18", features = ["deflate", "deflate64"] }
aws-config = { version = "1.6.1", features = ["behavior-version-latest"] }
aws-credential-types = { version = "1.2.2", features = [
aws-config = { version = "1.8.10", features = ["behavior-version-latest"] }
aws-credential-types = { version = "1.2.8", features = [
"hardcoded-credentials",
] }
aws-sdk-bedrockruntime = { version = "1.80.0", features = [
aws-sdk-bedrockruntime = { version = "1.112.0", features = [
"behavior-version-latest",
] }
aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] }
aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] }
aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] }
aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] }
backtrace = "0.3"
base64 = "0.22"
bincode = "1.2.1"

View File

@@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.2
FROM rust:1.91.1-bookworm as builder
FROM rust:1.92-bookworm as builder
WORKDIR app
COPY . .

View File

@@ -28,7 +28,7 @@ ai
= @rtfeldman
audio
= @dvdsk
= @yara-blue
crashes
= @p1n3appl3
@@ -53,7 +53,7 @@ extension
git
= @cole-miller
= @danilo-leal
= @dvdsk
= @yara-blue
= @kubkon
= @Anthony-Eid
= @cameron1024
@@ -76,7 +76,7 @@ languages
linux
= @cole-miller
= @dvdsk
= @yara-blue
= @p1n3appl3
= @probably-neb
= @smitbarmase
@@ -92,7 +92,7 @@ multi_buffer
= @SomeoneToIgnore
pickers
= @dvdsk
= @yara-blue
= @p1n3appl3
= @SomeoneToIgnore

View File

@@ -252,6 +252,7 @@
"ctrl-y": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
"ctrl-alt-z": "agent::RejectOnce",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -263,9 +264,9 @@
{
"context": "AgentPanel > Markdown",
"bindings": {
"copy": "markdown::Copy",
"ctrl-insert": "markdown::Copy",
"ctrl-c": "markdown::Copy",
"copy": "markdown::CopyAsMarkdown",
"ctrl-insert": "markdown::CopyAsMarkdown",
"ctrl-c": "markdown::CopyAsMarkdown",
},
},
{
@@ -346,6 +347,7 @@
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{

View File

@@ -266,6 +266,7 @@
"cmd-g": "search::SelectNextMatch",
"cmd-shift-g": "search::SelectPreviousMatch",
"cmd-k l": "agent::OpenRulesLibrary",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -292,6 +293,7 @@
"cmd-y": "agent::AllowOnce",
"cmd-alt-y": "agent::AllowAlways",
"cmd-alt-z": "agent::RejectOnce",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -304,7 +306,7 @@
"context": "AgentPanel > Markdown",
"use_key_equivalents": true,
"bindings": {
"cmd-c": "markdown::Copy",
"cmd-c": "markdown::CopyAsMarkdown",
},
},
{
@@ -386,6 +388,7 @@
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -397,6 +400,7 @@
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -880,6 +884,7 @@
"use_key_equivalents": true,
"bindings": {
"cmd-alt-/": "agent::ToggleModelSelector",
"alt-tab": "agent::CycleFavoriteModels",
"ctrl-[": "agent::CyclePreviousInlineAssist",
"ctrl-]": "agent::CycleNextInlineAssist",
"cmd-shift-enter": "inline_assistant::ThumbsUpResult",

View File

@@ -253,6 +253,7 @@
"shift-alt-a": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
"shift-alt-z": "agent::RejectOnce",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -266,7 +267,7 @@
"context": "AgentPanel > Markdown",
"use_key_equivalents": true,
"bindings": {
"ctrl-c": "markdown::Copy",
"ctrl-c": "markdown::CopyAsMarkdown",
},
},
{
@@ -342,6 +343,7 @@
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -353,6 +355,7 @@
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{

View File

@@ -202,6 +202,12 @@ pub trait AgentModelSelector: 'static {
fn should_render_footer(&self) -> bool {
false
}
/// Whether this selector supports the favorites feature.
/// Only the native agent uses the model ID format that maps to settings.
fn supports_favorites(&self) -> bool {
false
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -239,6 +245,10 @@ impl AgentModelList {
AgentModelList::Grouped(groups) => groups.is_empty(),
}
}
pub fn is_flat(&self) -> bool {
matches!(self, AgentModelList::Flat(_))
}
}
#[cfg(feature = "test-support")]

View File

@@ -1164,6 +1164,10 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
fn should_render_footer(&self) -> bool {
true
}
fn supports_favorites(&self) -> bool {
true
}
}
impl acp_thread::AgentConnection for NativeAgentConnection {

View File

@@ -2809,3 +2809,181 @@ fn setup_context_server(
cx.run_until_parked();
mcp_tool_calls_rx
}
#[gpui::test]
async fn test_tokens_before_message(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
// First message
let message_1_id = UserMessageId::new();
thread
.update(cx, |thread, cx| {
thread.send(message_1_id.clone(), ["First message"], cx)
})
.unwrap();
cx.run_until_parked();
// Before any response, tokens_before_message should return None for first message
thread.read_with(cx, |thread, _| {
assert_eq!(
thread.tokens_before_message(&message_1_id),
None,
"First message should have no tokens before it"
);
});
// Complete first message with usage
fake_model.send_last_completion_stream_text_chunk("Response 1");
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
language_model::TokenUsage {
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
));
fake_model.end_last_completion_stream();
cx.run_until_parked();
// First message still has no tokens before it
thread.read_with(cx, |thread, _| {
assert_eq!(
thread.tokens_before_message(&message_1_id),
None,
"First message should still have no tokens before it after response"
);
});
// Second message
let message_2_id = UserMessageId::new();
thread
.update(cx, |thread, cx| {
thread.send(message_2_id.clone(), ["Second message"], cx)
})
.unwrap();
cx.run_until_parked();
// Second message should have first message's input tokens before it
thread.read_with(cx, |thread, _| {
assert_eq!(
thread.tokens_before_message(&message_2_id),
Some(100),
"Second message should have 100 tokens before it (from first request)"
);
});
// Complete second message
fake_model.send_last_completion_stream_text_chunk("Response 2");
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
language_model::TokenUsage {
input_tokens: 250, // Total for this request (includes previous context)
output_tokens: 75,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
));
fake_model.end_last_completion_stream();
cx.run_until_parked();
// Third message
let message_3_id = UserMessageId::new();
thread
.update(cx, |thread, cx| {
thread.send(message_3_id.clone(), ["Third message"], cx)
})
.unwrap();
cx.run_until_parked();
// Third message should have second message's input tokens (250) before it
thread.read_with(cx, |thread, _| {
assert_eq!(
thread.tokens_before_message(&message_3_id),
Some(250),
"Third message should have 250 tokens before it (from second request)"
);
// Second message should still have 100
assert_eq!(
thread.tokens_before_message(&message_2_id),
Some(100),
"Second message should still have 100 tokens before it"
);
// First message still has none
assert_eq!(
thread.tokens_before_message(&message_1_id),
None,
"First message should still have no tokens before it"
);
});
}
#[gpui::test]
async fn test_tokens_before_message_after_truncate(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
// Set up three messages with responses
let message_1_id = UserMessageId::new();
thread
.update(cx, |thread, cx| {
thread.send(message_1_id.clone(), ["Message 1"], cx)
})
.unwrap();
cx.run_until_parked();
fake_model.send_last_completion_stream_text_chunk("Response 1");
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
language_model::TokenUsage {
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
));
fake_model.end_last_completion_stream();
cx.run_until_parked();
let message_2_id = UserMessageId::new();
thread
.update(cx, |thread, cx| {
thread.send(message_2_id.clone(), ["Message 2"], cx)
})
.unwrap();
cx.run_until_parked();
fake_model.send_last_completion_stream_text_chunk("Response 2");
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
language_model::TokenUsage {
input_tokens: 250,
output_tokens: 75,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
));
fake_model.end_last_completion_stream();
cx.run_until_parked();
// Verify initial state
thread.read_with(cx, |thread, _| {
assert_eq!(thread.tokens_before_message(&message_2_id), Some(100));
});
// Truncate at message 2 (removes message 2 and everything after)
thread
.update(cx, |thread, cx| thread.truncate(message_2_id.clone(), cx))
.unwrap();
cx.run_until_parked();
// After truncation, message_2_id no longer exists, so lookup should return None
thread.read_with(cx, |thread, _| {
assert_eq!(
thread.tokens_before_message(&message_2_id),
None,
"After truncation, message 2 no longer exists"
);
// Message 1 still exists but has no tokens before it
assert_eq!(
thread.tokens_before_message(&message_1_id),
None,
"First message still has no tokens before it"
);
});
}

View File

@@ -2,8 +2,8 @@ use crate::{
ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread,
DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool,
RestoreFileFromDiskTool, SaveFileTool, SpawnSubagentTool, SystemPromptTemplate, Template,
Templates, TerminalTool, ThinkingTool, WebSearchTool,
RestoreFileFromDiskTool, SaveFileTool, SystemPromptTemplate, Template, Templates, TerminalTool,
ThinkingTool, WebSearchTool,
};
use acp_thread::{MentionUri, UserMessageId};
use action_log::ActionLog;
@@ -1011,7 +1011,6 @@ impl Thread {
));
self.add_tool(SaveFileTool::new(self.project.clone()));
self.add_tool(RestoreFileFromDiskTool::new(self.project.clone()));
self.add_tool(SpawnSubagentTool::new(None));
self.add_tool(TerminalTool::new(self.project.clone(), environment));
self.add_tool(ThinkingTool);
self.add_tool(WebSearchTool);
@@ -1096,6 +1095,28 @@ impl Thread {
})
}
/// Get the total input token count as of the message before the given message.
///
/// Returns `None` if:
/// - `target_id` is the first message (no previous message)
/// - The previous message hasn't received a response yet (no usage data)
/// - `target_id` is not found in the messages
pub fn tokens_before_message(&self, target_id: &UserMessageId) -> Option<u64> {
let mut previous_user_message_id: Option<&UserMessageId> = None;
for message in &self.messages {
if let Message::User(user_msg) = message {
if &user_msg.id == target_id {
let prev_id = previous_user_message_id?;
let usage = self.request_token_usage.get(prev_id)?;
return Some(usage.input_tokens);
}
previous_user_message_id = Some(&user_msg.id);
}
}
None
}
/// Look up the active profile and resolve its preferred model if one is configured.
fn resolve_profile_model(
profile_id: &AgentProfileId,
@@ -1704,6 +1725,10 @@ impl Thread {
self.pending_summary_generation.is_some()
}
pub fn is_generating_title(&self) -> bool {
self.pending_title_generation.is_some()
}
pub fn summary(&mut self, cx: &mut Context<Self>) -> Shared<Task<Option<SharedString>>> {
if let Some(summary) = self.summary.as_ref() {
return Task::ready(Some(summary.clone())).shared();
@@ -1771,7 +1796,7 @@ impl Thread {
task
}
fn generate_title(&mut self, cx: &mut Context<Self>) {
pub fn generate_title(&mut self, cx: &mut Context<Self>) {
let Some(model) = self.summarization_model.clone() else {
return;
};

View File

@@ -14,7 +14,6 @@ mod open_tool;
mod read_file_tool;
mod restore_file_from_disk_tool;
mod save_file_tool;
mod spawn_subagent_tool;
mod terminal_tool;
mod thinking_tool;
@@ -39,7 +38,6 @@ pub use open_tool::*;
pub use read_file_tool::*;
pub use restore_file_from_disk_tool::*;
pub use save_file_tool::*;
pub use spawn_subagent_tool::*;
pub use terminal_tool::*;
pub use thinking_tool::*;
@@ -98,7 +96,6 @@ tools! {
ReadFileTool,
RestoreFileFromDiskTool,
SaveFileTool,
SpawnSubagentTool,
TerminalTool,
ThinkingTool,
WebSearchTool,

View File

@@ -2,7 +2,7 @@ use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream};
use agent_client_protocol::ToolKind;
use anyhow::{Result, anyhow, bail};
use collections::{BTreeMap, HashMap};
use context_server::ContextServerId;
use context_server::{ContextServerId, client::NotificationSubscription};
use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task};
use project::context_server_store::{ContextServerStatus, ContextServerStore};
use std::sync::Arc;
@@ -31,17 +31,7 @@ struct RegisteredContextServer {
prompts: BTreeMap<SharedString, ContextServerPrompt>,
load_tools: Task<Result<()>>,
load_prompts: Task<Result<()>>,
}
impl RegisteredContextServer {
fn new() -> Self {
Self {
tools: BTreeMap::default(),
prompts: BTreeMap::default(),
load_tools: Task::ready(Ok(())),
load_prompts: Task::ready(Ok(())),
}
}
_tools_updated_subscription: Option<NotificationSubscription>,
}
impl ContextServerRegistry {
@@ -111,10 +101,57 @@ impl ContextServerRegistry {
fn get_or_register_server(
&mut self,
server_id: &ContextServerId,
cx: &mut Context<Self>,
) -> &mut RegisteredContextServer {
self.registered_servers
.entry(server_id.clone())
.or_insert_with(RegisteredContextServer::new)
.or_insert_with(|| Self::init_registered_server(server_id, &self.server_store, cx))
}
fn init_registered_server(
server_id: &ContextServerId,
server_store: &Entity<ContextServerStore>,
cx: &mut Context<Self>,
) -> RegisteredContextServer {
let tools_updated_subscription = server_store
.read(cx)
.get_running_server(server_id)
.and_then(|server| {
let client = server.client()?;
if !client.capable(context_server::protocol::ServerCapability::Tools) {
return None;
}
let server_id = server.id();
let this = cx.entity().downgrade();
Some(client.on_notification(
"notifications/tools/list_changed",
Box::new(move |_params, cx: AsyncApp| {
let server_id = server_id.clone();
let this = this.clone();
cx.spawn(async move |cx| {
this.update(cx, |this, cx| {
log::info!(
"Received tools/list_changed notification for server {}",
server_id
);
this.reload_tools_for_server(server_id, cx);
})
})
.detach();
}),
))
});
RegisteredContextServer {
tools: BTreeMap::default(),
prompts: BTreeMap::default(),
load_tools: Task::ready(Ok(())),
load_prompts: Task::ready(Ok(())),
_tools_updated_subscription: tools_updated_subscription,
}
}
fn reload_tools_for_server(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
@@ -124,11 +161,12 @@ impl ContextServerRegistry {
let Some(client) = server.client() else {
return;
};
if !client.capable(context_server::protocol::ServerCapability::Tools) {
return;
}
let registered_server = self.get_or_register_server(&server_id);
let registered_server = self.get_or_register_server(&server_id, cx);
registered_server.load_tools = cx.spawn(async move |this, cx| {
let response = client
.request::<context_server::types::requests::ListTools>(())
@@ -167,7 +205,7 @@ impl ContextServerRegistry {
return;
}
let registered_server = self.get_or_register_server(&server_id);
let registered_server = self.get_or_register_server(&server_id, cx);
registered_server.load_prompts = cx.spawn(async move |this, cx| {
let response = client

View File

@@ -1,221 +0,0 @@
use crate::{AgentTool, ToolCallEventStream};
use agent_client_protocol as acp;
use anyhow::{Result, anyhow};
use gpui::{App, AsyncApp, SharedString, Task};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
/// Spawn a subagent (child thread) that can be visited while it runs, and returns a value to the parent.
///
/// Note: This file intentionally defines only the tool surface and streaming updates. The actual
/// spawning/navigation plumbing requires a host capability (session manager + UI) that is not yet
/// present in the native agent tool environment. Until that capability is wired in, this tool will
/// fail with a clear error.
///
/// Expected design (to be implemented in the host):
/// - The tool is constructed with a `SubagentHost` implementation that can:
/// - create a child session/thread
/// - stream child progress updates
/// - complete with a final return value
/// - provide a navigable URI for the UI (e.g. `zed://agent/thread/<session_id>`)
///
/// The tool then:
/// - emits a `ResourceLink` pointing at the child thread so users can open it
/// - streams progress into the tool call card as markdown
/// - resolves with the child's final return value (string)
#[derive(JsonSchema, Serialize, Deserialize)]
pub struct SpawnSubagentToolInput {
/// A short label/title for the subagent.
pub title: String,
/// The instructions to run in the subagent.
pub prompt: String,
/// Optional: profile id to use for the subagent.
#[serde(default)]
pub profile_id: Option<String>,
}
/// The final return value from the subagent.
pub type SpawnSubagentToolOutput = String;
/// Host interface required to implement spawning + streaming + returning.
///
/// This is intentionally minimal and object-safe to allow injecting a host backed by `NativeAgent`.
pub trait SubagentHost: Send + Sync + 'static {
/// Start a child subagent session and return a handle containing a navigable URI plus a stream
/// of progress updates and a final result.
///
/// The returned `SubagentRun` must:
/// - yield `Progress` updates in-order
/// - eventually yield exactly one `Final` or `Error`
fn spawn_subagent(
&self,
title: String,
prompt: String,
profile_id: Option<String>,
cx: &mut AsyncApp,
) -> Task<Result<SubagentRun>>;
}
/// A handle for a running subagent.
pub struct SubagentRun {
/// URI that the UI can open to navigate to the child thread.
pub thread_uri: String,
/// A human-friendly label for the link.
pub thread_label: String,
/// Progress stream for tool UI updates.
pub updates: futures::channel::mpsc::UnboundedReceiver<SubagentUpdate>,
}
pub enum SubagentUpdate {
/// A streaming progress chunk (e.g. "thinking…", partial summary, etc).
Progress(String),
/// The final return value for the parent.
Final(String),
/// Terminal error.
Error(anyhow::Error),
}
pub struct SpawnSubagentTool {
host: Option<Arc<dyn SubagentHost>>,
}
impl SpawnSubagentTool {
pub fn new(host: Option<Arc<dyn SubagentHost>>) -> Self {
Self { host }
}
}
impl AgentTool for SpawnSubagentTool {
type Input = SpawnSubagentToolInput;
type Output = SpawnSubagentToolOutput;
fn name() -> &'static str {
"spawn_subagent"
}
fn kind() -> acp::ToolKind {
acp::ToolKind::Other
}
fn description() -> SharedString {
"Spawns a child Zed Agent thread (subagent), streams its progress, and returns its final value to the parent."
.into()
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
if let Ok(input) = input {
format!("Spawn subagent: {}", input.title).into()
} else {
"Spawn subagent".into()
}
}
fn run(
self: Arc<Self>,
input: Self::Input,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output>> {
let Some(host) = self.host.clone() else {
return Task::ready(Err(anyhow!(
"spawn_subagent is not available: native agent host capability is not wired into tools yet"
)));
};
let title = input.title;
let prompt = input.prompt;
let profile_id = input.profile_id;
cx.spawn(async move |cx| {
// Start the child run via host.
let mut run = host
.spawn_subagent(title.clone(), prompt, profile_id, cx)
.await?;
// Emit a link to the child thread so the user can open/visit it.
event_stream.update_fields(
acp::ToolCallUpdateFields::new().content(vec![acp::ToolCallContent::Content(
acp::Content::new(acp::ContentBlock::ResourceLink(
acp::ResourceLink::new(run.thread_label.clone(), run.thread_uri.clone())
.title(run.thread_label.clone()),
)),
)]),
);
// Stream progress as markdown appended below the link.
let mut accumulated_progress = String::new();
while let Some(update) = run.updates.next().await {
match update {
SubagentUpdate::Progress(chunk) => {
if !accumulated_progress.is_empty() {
accumulated_progress.push('\n');
}
accumulated_progress.push_str(&chunk);
event_stream.update_fields(
acp::ToolCallUpdateFields::new().content(vec![
acp::ToolCallContent::Content(acp::Content::new(
acp::ContentBlock::ResourceLink(
acp::ResourceLink::new(
run.thread_label.clone(),
run.thread_uri.clone(),
)
.title(run.thread_label.clone()),
),
)),
acp::ToolCallContent::Content(acp::Content::new(
acp::ContentBlock::Text(acp::TextContent::new(
format!("### Subagent progress\n\n{}", accumulated_progress),
)),
)),
]),
);
}
SubagentUpdate::Final(value) => {
// Final update for UI (optional).
event_stream.update_fields(
acp::ToolCallUpdateFields::new().content(vec![
acp::ToolCallContent::Content(acp::Content::new(
acp::ContentBlock::ResourceLink(
acp::ResourceLink::new(
run.thread_label.clone(),
run.thread_uri.clone(),
)
.title(run.thread_label.clone()),
),
)),
acp::ToolCallContent::Content(acp::Content::new(
acp::ContentBlock::Text(acp::TextContent::new(format!(
"### Subagent returned\n\n{}",
value
))),
)),
]),
);
return Ok(value);
}
SubagentUpdate::Error(error) => {
return Err(error);
}
}
}
Err(anyhow!("subagent stream ended without producing a final value"))
})
}
}
// futures::StreamExt is only needed in the async run implementation; keep it scoped here.
use futures::StreamExt as _;

View File

@@ -12,6 +12,7 @@ workspace = true
path = "src/agent_settings.rs"
[dependencies]
agent-client-protocol.workspace = true
anyhow.workspace = true
cloud_llm_client.workspace = true
collections.workspace = true

View File

@@ -2,7 +2,8 @@ mod agent_profile;
use std::sync::Arc;
use collections::IndexMap;
use agent_client_protocol::ModelId;
use collections::{HashSet, IndexMap};
use gpui::{App, Pixels, px};
use language_model::LanguageModel;
use project::DisableAiSettings;
@@ -33,6 +34,7 @@ pub struct AgentSettings {
pub commit_message_model: Option<LanguageModelSelection>,
pub thread_summary_model: Option<LanguageModelSelection>,
pub inline_alternatives: Vec<LanguageModelSelection>,
pub favorite_models: Vec<LanguageModelSelection>,
pub default_profile: AgentProfileId,
pub default_view: DefaultAgentView,
pub profiles: IndexMap<AgentProfileId, AgentProfileSettings>,
@@ -96,6 +98,13 @@ impl AgentSettings {
pub fn set_message_editor_max_lines(&self) -> usize {
self.message_editor_min_lines * 2
}
pub fn favorite_model_ids(&self) -> HashSet<ModelId> {
self.favorite_models
.iter()
.map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model)))
.collect()
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
@@ -164,6 +173,7 @@ impl Settings for AgentSettings {
commit_message_model: agent.commit_message_model,
thread_summary_model: agent.thread_summary_model,
inline_alternatives: agent.inline_alternatives.unwrap_or_default(),
favorite_models: agent.favorite_models,
default_profile: AgentProfileId(agent.default_profile.unwrap()),
default_view: agent.default_view.unwrap(),
profiles: agent

View File

@@ -1365,7 +1365,7 @@ mod tests {
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window);
message_editor.read(cx).focus_handle(cx).focus(window, cx);
message_editor.read(cx).editor().clone()
});
@@ -1587,7 +1587,7 @@ mod tests {
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window);
message_editor.read(cx).focus_handle(cx).focus(window, cx);
let editor = message_editor.read(cx).editor().clone();
(message_editor, editor)
});
@@ -2315,7 +2315,7 @@ mod tests {
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window);
message_editor.read(cx).focus_handle(cx).focus(window, cx);
let editor = message_editor.read(cx).editor().clone();
(message_editor, editor)
});

View File

@@ -1,18 +1,22 @@
use std::{cmp::Reverse, rc::Rc, sync::Arc};
use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector};
use agent_client_protocol::ModelId;
use agent_servers::AgentServer;
use agent_settings::AgentSettings;
use anyhow::Result;
use collections::IndexMap;
use collections::{HashSet, IndexMap};
use fs::Fs;
use futures::FutureExt;
use fuzzy::{StringMatchCandidate, match_strings};
use gpui::{
Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Task, WeakEntity,
};
use itertools::Itertools;
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, prelude::*};
use settings::Settings;
use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, prelude::*};
use util::ResultExt;
use zed_actions::agent::OpenSettings;
@@ -38,7 +42,7 @@ pub fn acp_model_selector(
enum AcpModelPickerEntry {
Separator(SharedString),
Model(AgentModelInfo),
Model(AgentModelInfo, bool),
}
pub struct AcpModelPickerDelegate {
@@ -115,6 +119,67 @@ impl AcpModelPickerDelegate {
pub fn active_model(&self) -> Option<&AgentModelInfo> {
self.selected_model.as_ref()
}
pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if !self.selector.supports_favorites() {
return;
}
let favorites = AgentSettings::get_global(cx).favorite_model_ids();
if favorites.is_empty() {
return;
}
let Some(models) = self.models.clone() else {
return;
};
let all_models: Vec<AgentModelInfo> = match models {
AgentModelList::Flat(list) => list,
AgentModelList::Grouped(index_map) => index_map
.into_values()
.flatten()
.collect::<Vec<AgentModelInfo>>(),
};
let favorite_models = all_models
.iter()
.filter(|model| favorites.contains(&model.id))
.unique_by(|model| &model.id)
.cloned()
.collect::<Vec<_>>();
let current_id = self.selected_model.as_ref().map(|m| m.id.clone());
let current_index_in_favorites = current_id
.as_ref()
.and_then(|id| favorite_models.iter().position(|m| &m.id == id))
.unwrap_or(usize::MAX);
let next_index = if current_index_in_favorites == usize::MAX {
0
} else {
(current_index_in_favorites + 1) % favorite_models.len()
};
let next_model = favorite_models[next_index].clone();
self.selector
.select_model(next_model.id.clone(), cx)
.detach_and_log_err(cx);
self.selected_model = Some(next_model);
// Keep the picker selection aligned with the newly-selected model
if let Some(new_index) = self.filtered_entries.iter().position(|entry| {
matches!(entry, AcpModelPickerEntry::Model(model_info, _) if self.selected_model.as_ref().is_some_and(|selected| model_info.id == selected.id))
}) {
self.set_selected_index(new_index, window, cx);
} else {
cx.notify();
}
}
}
impl PickerDelegate for AcpModelPickerDelegate {
@@ -140,7 +205,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
_cx: &mut Context<Picker<Self>>,
) -> bool {
match self.filtered_entries.get(ix) {
Some(AcpModelPickerEntry::Model(_)) => true,
Some(AcpModelPickerEntry::Model(_, _)) => true,
Some(AcpModelPickerEntry::Separator(_)) | None => false,
}
}
@@ -155,6 +220,12 @@ impl PickerDelegate for AcpModelPickerDelegate {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let favorites = if self.selector.supports_favorites() {
Arc::new(AgentSettings::get_global(cx).favorite_model_ids())
} else {
Default::default()
};
cx.spawn_in(window, async move |this, cx| {
let filtered_models = match this
.read_with(cx, |this, cx| {
@@ -171,7 +242,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
this.update_in(cx, |this, window, cx| {
this.delegate.filtered_entries =
info_list_to_picker_entries(filtered_models).collect();
info_list_to_picker_entries(filtered_models, favorites);
// Finds the currently selected model in the list
let new_index = this
.delegate
@@ -179,7 +250,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
.as_ref()
.and_then(|selected| {
this.delegate.filtered_entries.iter().position(|entry| {
if let AcpModelPickerEntry::Model(model_info) = entry {
if let AcpModelPickerEntry::Model(model_info, _) = entry {
model_info.id == selected.id
} else {
false
@@ -195,7 +266,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if let Some(AcpModelPickerEntry::Model(model_info)) =
if let Some(AcpModelPickerEntry::Model(model_info, _)) =
self.filtered_entries.get(self.selected_index)
{
if window.modifiers().secondary() {
@@ -233,7 +304,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
fn render_match(
&self,
ix: usize,
is_focused: bool,
selected: bool,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
@@ -241,32 +312,53 @@ impl PickerDelegate for AcpModelPickerDelegate {
AcpModelPickerEntry::Separator(title) => {
Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
}
AcpModelPickerEntry::Model(model_info) => {
AcpModelPickerEntry::Model(model_info, is_favorite) => {
let is_selected = Some(model_info) == self.selected_model.as_ref();
let default_model = self.agent_server.default_model(cx);
let is_default = default_model.as_ref() == Some(&model_info.id);
let supports_favorites = self.selector.supports_favorites();
let is_favorite = *is_favorite;
let handle_action_click = {
let model_id = model_info.id.clone();
let fs = self.fs.clone();
move |cx: &App| {
crate::favorite_models::toggle_model_id_in_settings(
model_id.clone(),
!is_favorite,
fs.clone(),
cx,
);
}
};
Some(
div()
.id(("model-picker-menu-child", ix))
.when_some(model_info.description.clone(), |this, description| {
this
.on_hover(cx.listener(move |menu, hovered, _, cx| {
if *hovered {
menu.delegate.selected_description = Some((ix, description.clone(), is_default));
} else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) {
menu.delegate.selected_description = None;
}
cx.notify();
}))
this.on_hover(cx.listener(move |menu, hovered, _, cx| {
if *hovered {
menu.delegate.selected_description =
Some((ix, description.clone(), is_default));
} else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) {
menu.delegate.selected_description = None;
}
cx.notify();
}))
})
.child(
ModelSelectorListItem::new(ix, model_info.name.clone())
.is_focused(is_focused)
.when_some(model_info.icon, |this, icon| this.icon(icon))
.is_selected(is_selected)
.when_some(model_info.icon, |this, icon| this.icon(icon)),
.is_focused(selected)
.when(supports_favorites, |this| {
this.is_favorite(is_favorite)
.on_toggle_favorite(handle_action_click)
}),
)
.into_any_element()
.into_any_element(),
)
}
}
@@ -314,18 +406,51 @@ impl PickerDelegate for AcpModelPickerDelegate {
fn info_list_to_picker_entries(
model_list: AgentModelList,
) -> impl Iterator<Item = AcpModelPickerEntry> {
match model_list {
AgentModelList::Flat(list) => {
itertools::Either::Left(list.into_iter().map(AcpModelPickerEntry::Model))
}
AgentModelList::Grouped(index_map) => {
itertools::Either::Right(index_map.into_iter().flat_map(|(group_name, models)| {
std::iter::once(AcpModelPickerEntry::Separator(group_name.0))
.chain(models.into_iter().map(AcpModelPickerEntry::Model))
}))
favorites: Arc<HashSet<ModelId>>,
) -> Vec<AcpModelPickerEntry> {
let mut entries = Vec::new();
let all_models: Vec<_> = match &model_list {
AgentModelList::Flat(list) => list.iter().collect(),
AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(),
};
let favorite_models: Vec<_> = all_models
.iter()
.filter(|m| favorites.contains(&m.id))
.unique_by(|m| &m.id)
.collect();
let has_favorites = !favorite_models.is_empty();
if has_favorites {
entries.push(AcpModelPickerEntry::Separator("Favorite".into()));
for model in favorite_models {
entries.push(AcpModelPickerEntry::Model((*model).clone(), true));
}
}
match model_list {
AgentModelList::Flat(list) => {
if has_favorites {
entries.push(AcpModelPickerEntry::Separator("All".into()));
}
for model in list {
let is_favorite = favorites.contains(&model.id);
entries.push(AcpModelPickerEntry::Model(model, is_favorite));
}
}
AgentModelList::Grouped(index_map) => {
for (group_name, models) in index_map {
entries.push(AcpModelPickerEntry::Separator(group_name.0));
for model in models {
let is_favorite = favorites.contains(&model.id);
entries.push(AcpModelPickerEntry::Model(model, is_favorite));
}
}
}
}
entries
}
async fn fuzzy_search(
@@ -447,6 +572,170 @@ mod tests {
}
}
fn create_favorites(models: Vec<&str>) -> Arc<HashSet<ModelId>> {
Arc::new(
models
.into_iter()
.map(|m| ModelId::new(m.to_string()))
.collect(),
)
}
fn get_entry_model_ids(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
entries
.iter()
.filter_map(|entry| match entry {
AcpModelPickerEntry::Model(info, _) => Some(info.id.0.as_ref()),
_ => None,
})
.collect()
}
fn get_entry_labels(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
entries
.iter()
.map(|entry| match entry {
AcpModelPickerEntry::Model(info, _) => info.id.0.as_ref(),
AcpModelPickerEntry::Separator(s) => &s,
})
.collect()
}
#[gpui::test]
fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
let models = create_model_list(vec![
("zed", vec!["zed/claude", "zed/gemini"]),
("openai", vec!["openai/gpt-5"]),
]);
let favorites = create_favorites(vec!["zed/gemini"]);
let entries = info_list_to_picker_entries(models, favorites);
assert!(matches!(
entries.first(),
Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite"
));
let model_ids = get_entry_model_ids(&entries);
assert_eq!(model_ids[0], "zed/gemini");
}
#[gpui::test]
fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) {
let models = create_model_list(vec![("zed", vec!["zed/claude", "zed/gemini"])]);
let favorites = create_favorites(vec![]);
let entries = info_list_to_picker_entries(models, favorites);
assert!(matches!(
entries.first(),
Some(AcpModelPickerEntry::Separator(s)) if s == "zed"
));
}
#[gpui::test]
fn test_models_have_correct_actions(_cx: &mut TestAppContext) {
let models = create_model_list(vec![
("zed", vec!["zed/claude", "zed/gemini"]),
("openai", vec!["openai/gpt-5"]),
]);
let favorites = create_favorites(vec!["zed/claude"]);
let entries = info_list_to_picker_entries(models, favorites);
for entry in &entries {
if let AcpModelPickerEntry::Model(info, is_favorite) = entry {
if info.id.0.as_ref() == "zed/claude" {
assert!(is_favorite, "zed/claude should be a favorite");
} else {
assert!(!is_favorite, "{} should not be a favorite", info.id.0);
}
}
}
}
#[gpui::test]
fn test_favorites_appear_in_both_sections(_cx: &mut TestAppContext) {
let models = create_model_list(vec![
("zed", vec!["zed/claude", "zed/gemini"]),
("openai", vec!["openai/gpt-5", "openai/gpt-4"]),
]);
let favorites = create_favorites(vec!["zed/gemini", "openai/gpt-5"]);
let entries = info_list_to_picker_entries(models, favorites);
let model_ids = get_entry_model_ids(&entries);
assert_eq!(model_ids[0], "zed/gemini");
assert_eq!(model_ids[1], "openai/gpt-5");
assert!(model_ids[2..].contains(&"zed/gemini"));
assert!(model_ids[2..].contains(&"openai/gpt-5"));
}
#[gpui::test]
fn test_favorites_are_not_duplicated_when_repeated_in_other_sections(_cx: &mut TestAppContext) {
let models = create_model_list(vec![
("Recommended", vec!["zed/claude", "anthropic/claude"]),
("Zed", vec!["zed/claude", "zed/gpt-5"]),
("Antropic", vec!["anthropic/claude"]),
("OpenAI", vec!["openai/gpt-5"]),
]);
let favorites = create_favorites(vec!["zed/claude"]);
let entries = info_list_to_picker_entries(models, favorites);
let labels = get_entry_labels(&entries);
assert_eq!(
labels,
vec![
"Favorite",
"zed/claude",
"Recommended",
"zed/claude",
"anthropic/claude",
"Zed",
"zed/claude",
"zed/gpt-5",
"Antropic",
"anthropic/claude",
"OpenAI",
"openai/gpt-5"
]
);
}
#[gpui::test]
fn test_flat_model_list_with_favorites(_cx: &mut TestAppContext) {
let models = AgentModelList::Flat(vec![
acp_thread::AgentModelInfo {
id: acp::ModelId::new("zed/claude".to_string()),
name: "Claude".into(),
description: None,
icon: None,
},
acp_thread::AgentModelInfo {
id: acp::ModelId::new("zed/gemini".to_string()),
name: "Gemini".into(),
description: None,
icon: None,
},
]);
let favorites = create_favorites(vec!["zed/gemini"]);
let entries = info_list_to_picker_entries(models, favorites);
assert!(matches!(
entries.first(),
Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite"
));
assert!(entries.iter().any(|e| matches!(
e,
AcpModelPickerEntry::Separator(s) if s == "All"
)));
}
#[gpui::test]
async fn test_fuzzy_match(cx: &mut TestAppContext) {
let models = create_model_list(vec![

View File

@@ -3,15 +3,15 @@ use std::sync::Arc;
use acp_thread::{AgentModelInfo, AgentModelSelector};
use agent_servers::AgentServer;
use agent_settings::AgentSettings;
use fs::Fs;
use gpui::{Entity, FocusHandle};
use picker::popover_menu::PickerPopoverMenu;
use ui::{
ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, TintColor, Tooltip, Window,
prelude::*,
};
use settings::Settings as _;
use ui::{ButtonLike, KeyBinding, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
use zed_actions::agent::ToggleModelSelector;
use crate::CycleFavoriteModels;
use crate::acp::{AcpModelSelector, model_selector::acp_model_selector};
pub struct AcpModelSelectorPopover {
@@ -54,6 +54,12 @@ impl AcpModelSelectorPopover {
pub fn active_model<'a>(&self, cx: &'a App) -> Option<&'a AgentModelInfo> {
self.selector.read(cx).delegate.active_model()
}
pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context<Self>) {
self.selector.update(cx, |selector, cx| {
selector.delegate.cycle_favorite_models(window, cx);
});
}
}
impl Render for AcpModelSelectorPopover {
@@ -74,6 +80,46 @@ impl Render for AcpModelSelectorPopover {
(Color::Muted, IconName::ChevronDown)
};
let tooltip = Tooltip::element({
move |_, cx| {
let focus_handle = focus_handle.clone();
let should_show_cycle_row = !AgentSettings::get_global(cx)
.favorite_model_ids()
.is_empty();
v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new("Change Model"))
.child(KeyBinding::for_action_in(
&ToggleModelSelector,
&focus_handle,
cx,
)),
)
.when(should_show_cycle_row, |this| {
this.child(
h_flex()
.pt_1()
.gap_2()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.justify_between()
.child(Label::new("Cycle Favorited Models"))
.child(KeyBinding::for_action_in(
&CycleFavoriteModels,
&focus_handle,
cx,
)),
)
})
.into_any()
}
});
PickerPopoverMenu::new(
self.selector.clone(),
ButtonLike::new("active-model")
@@ -88,9 +134,7 @@ impl Render for AcpModelSelectorPopover {
.ml_0p5(),
)
.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)),
move |_window, cx| {
Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
},
tooltip,
gpui::Corner::BottomRight,
cx,
)

View File

@@ -66,8 +66,8 @@ use crate::profile_selector::{ProfileProvider, ProfileSelector};
use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip, UsageCallout};
use crate::{
AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode,
CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAgentDiff, OpenHistory,
RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector,
CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread,
OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector,
};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
@@ -253,7 +253,7 @@ impl ThreadFeedbackState {
editor
});
editor.read(cx).focus_handle(cx).focus(window);
editor.read(cx).focus_handle(cx).focus(window, cx);
editor
}
}
@@ -682,7 +682,7 @@ impl AcpThreadView {
})
});
this.message_editor.focus_handle(cx).focus(window);
this.message_editor.focus_handle(cx).focus(window, cx);
cx.notify();
}
@@ -784,7 +784,7 @@ impl AcpThreadView {
_subscription: subscription,
};
if this.message_editor.focus_handle(cx).is_focused(window) {
this.focus_handle.focus(window)
this.focus_handle.focus(window, cx)
}
cx.notify();
})
@@ -804,7 +804,7 @@ impl AcpThreadView {
ThreadState::LoadError(LoadError::Other(format!("{:#}", err).into()))
}
if self.message_editor.focus_handle(cx).is_focused(window) {
self.focus_handle.focus(window)
self.focus_handle.focus(window, cx)
}
cx.notify();
}
@@ -1270,7 +1270,7 @@ impl AcpThreadView {
}
})
};
self.focus_handle(cx).focus(window);
self.focus_handle(cx).focus(window, cx);
cx.notify();
}
@@ -1322,7 +1322,7 @@ impl AcpThreadView {
.await?;
this.update_in(cx, |this, window, cx| {
this.send_impl(message_editor, window, cx);
this.focus_handle(cx).focus(window);
this.focus_handle(cx).focus(window, cx);
})?;
anyhow::Ok(())
})
@@ -1465,7 +1465,7 @@ impl AcpThreadView {
self.thread_retry_status.take();
self.thread_state = ThreadState::LoadError(error.clone());
if self.message_editor.focus_handle(cx).is_focused(window) {
self.focus_handle.focus(window)
self.focus_handle.focus(window, cx)
}
}
AcpThreadEvent::TitleUpdated => {
@@ -1898,6 +1898,17 @@ impl AcpThreadView {
})
}
pub fn has_user_submitted_prompt(&self, cx: &App) -> bool {
self.thread().is_some_and(|thread| {
thread.read(cx).entries().iter().any(|entry| {
matches!(
entry,
AgentThreadEntry::UserMessage(user_message) if user_message.id.is_some()
)
})
})
}
fn authorize_tool_call(
&mut self,
tool_call_id: acp::ToolCallId,
@@ -4110,6 +4121,8 @@ impl AcpThreadView {
.ml_1p5()
});
let full_path = path.display(path_style).to_string();
let file_icon = FileIcons::get_icon(path.as_std_path(), cx)
.map(Icon::from_path)
.map(|icon| icon.color(Color::Muted).size(IconSize::Small))
@@ -4143,7 +4156,6 @@ impl AcpThreadView {
.relative()
.pr_8()
.w_full()
.overflow_x_scroll()
.child(
h_flex()
.id(("file-name-path", index))
@@ -4155,7 +4167,14 @@ impl AcpThreadView {
.child(file_icon)
.children(file_name)
.children(file_path)
.tooltip(Tooltip::text("Go to File"))
.tooltip(move |_, cx| {
Tooltip::with_meta(
"Go to File",
None,
full_path.clone(),
cx,
)
})
.on_click({
let buffer = buffer.clone();
cx.listener(move |this, _, window, cx| {
@@ -4293,6 +4312,13 @@ impl AcpThreadView {
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
}
}))
.on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
if let Some(model_selector) = this.model_selector.as_ref() {
model_selector.update(cx, |model_selector, cx| {
model_selector.cycle_favorite_models(window, cx);
});
}
}))
.p_2()
.gap_2()
.border_t_1()

View File

@@ -446,17 +446,17 @@ impl AddLlmProviderModal {
})
}
fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context<Self>) {
window.focus_next();
fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
window.focus_next(cx);
}
fn on_tab_prev(
&mut self,
_: &menu::SelectPrevious,
window: &mut Window,
_: &mut Context<Self>,
cx: &mut Context<Self>,
) {
window.focus_prev();
window.focus_prev(cx);
}
}
@@ -493,7 +493,7 @@ impl Render for AddLlmProviderModal {
.on_action(cx.listener(Self::on_tab))
.on_action(cx.listener(Self::on_tab_prev))
.capture_any_mouse_down(cx.listener(|this, _, window, cx| {
this.focus_handle(cx).focus(window);
this.focus_handle(cx).focus(window, cx);
}))
.child(
Modal::new("configure-context-server", None)

View File

@@ -831,7 +831,7 @@ impl Render for ConfigureContextServerModal {
}),
)
.capture_any_mouse_down(cx.listener(|this, _, window, cx| {
this.focus_handle(cx).focus(window);
this.focus_handle(cx).focus(window, cx);
}))
.child(
Modal::new("configure-context-server", None)

View File

@@ -156,7 +156,7 @@ impl ManageProfilesModal {
cx.observe_global_in::<SettingsStore>(window, |this, window, cx| {
if matches!(this.mode, Mode::ChooseProfile(_)) {
this.mode = Mode::choose_profile(window, cx);
this.focus_handle(cx).focus(window);
this.focus_handle(cx).focus(window, cx);
cx.notify();
}
});
@@ -173,7 +173,7 @@ impl ManageProfilesModal {
fn choose_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.mode = Mode::choose_profile(window, cx);
self.focus_handle(cx).focus(window);
self.focus_handle(cx).focus(window, cx);
}
fn new_profile(
@@ -191,7 +191,7 @@ impl ManageProfilesModal {
name_editor,
base_profile_id,
});
self.focus_handle(cx).focus(window);
self.focus_handle(cx).focus(window, cx);
}
pub fn view_profile(
@@ -209,7 +209,7 @@ impl ManageProfilesModal {
delete_profile: NavigableEntry::focusable(cx),
cancel_item: NavigableEntry::focusable(cx),
});
self.focus_handle(cx).focus(window);
self.focus_handle(cx).focus(window, cx);
}
fn configure_default_model(
@@ -222,7 +222,6 @@ impl ManageProfilesModal {
let profile_id_for_closure = profile_id.clone();
let model_picker = cx.new(|cx| {
let fs = fs.clone();
let profile_id = profile_id_for_closure.clone();
language_model_selector(
@@ -250,22 +249,36 @@ impl ManageProfilesModal {
})
}
},
move |model, cx| {
let provider = model.provider_id().0.to_string();
let model_id = model.id().0.to_string();
let profile_id = profile_id.clone();
{
let fs = fs.clone();
move |model, cx| {
let provider = model.provider_id().0.to_string();
let model_id = model.id().0.to_string();
let profile_id = profile_id.clone();
update_settings_file(fs.clone(), cx, move |settings, _cx| {
let agent_settings = settings.agent.get_or_insert_default();
if let Some(profiles) = agent_settings.profiles.as_mut() {
if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) {
profile.default_model = Some(LanguageModelSelection {
provider: LanguageModelProviderSetting(provider.clone()),
model: model_id.clone(),
});
update_settings_file(fs.clone(), cx, move |settings, _cx| {
let agent_settings = settings.agent.get_or_insert_default();
if let Some(profiles) = agent_settings.profiles.as_mut() {
if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) {
profile.default_model = Some(LanguageModelSelection {
provider: LanguageModelProviderSetting(provider.clone()),
model: model_id.clone(),
});
}
}
}
});
});
}
},
{
let fs = fs.clone();
move |model, should_be_favorite, cx| {
crate::favorite_models::toggle_in_settings(
model,
should_be_favorite,
fs.clone(),
cx,
);
}
},
false, // Do not use popover styles for the model picker
self.focus_handle.clone(),
@@ -287,7 +300,7 @@ impl ManageProfilesModal {
model_picker,
_subscription: dismiss_subscription,
};
self.focus_handle(cx).focus(window);
self.focus_handle(cx).focus(window, cx);
}
fn configure_mcp_tools(
@@ -323,7 +336,7 @@ impl ManageProfilesModal {
tool_picker,
_subscription: dismiss_subscription,
};
self.focus_handle(cx).focus(window);
self.focus_handle(cx).focus(window, cx);
}
fn configure_builtin_tools(
@@ -364,7 +377,7 @@ impl ManageProfilesModal {
tool_picker,
_subscription: dismiss_subscription,
};
self.focus_handle(cx).focus(window);
self.focus_handle(cx).focus(window, cx);
}
fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -938,7 +951,7 @@ impl Render for ManageProfilesModal {
.on_action(cx.listener(|this, _: &menu::Cancel, window, cx| this.cancel(window, cx)))
.on_action(cx.listener(|this, _: &menu::Confirm, window, cx| this.confirm(window, cx)))
.capture_any_mouse_down(cx.listener(|this, _, window, cx| {
this.focus_handle(cx).focus(window);
this.focus_handle(cx).focus(window, cx);
}))
.on_mouse_down_out(cx.listener(|_this, _, _, cx| cx.emit(DismissEvent)))
.child(match &self.mode {

View File

@@ -212,10 +212,10 @@ impl AgentDiffPane {
.focus_handle(cx)
.contains_focused(window, cx)
{
self.focus_handle.focus(window);
self.focus_handle.focus(window, cx);
} else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
self.editor.update(cx, |editor, cx| {
editor.focus_handle(cx).focus(window);
editor.focus_handle(cx).focus(window, cx);
});
}
}
@@ -874,12 +874,12 @@ impl AgentDiffToolbar {
match active_item {
AgentDiffToolbarItem::Pane(agent_diff) => {
if let Some(agent_diff) = agent_diff.upgrade() {
agent_diff.focus_handle(cx).focus(window);
agent_diff.focus_handle(cx).focus(window, cx);
}
}
AgentDiffToolbarItem::Editor { editor, .. } => {
if let Some(editor) = editor.upgrade() {
editor.read(cx).focus_handle(cx).focus(window);
editor.read(cx).focus_handle(cx).focus(window, cx);
}
}
}

View File

@@ -29,26 +29,39 @@ impl AgentModelSelector {
Self {
selector: cx.new(move |cx| {
let fs = fs.clone();
language_model_selector(
{
let model_context = model_usage_context.clone();
move |cx| model_context.configured_model(cx)
},
move |model, cx| {
let provider = model.provider_id().0.to_string();
let model_id = model.id().0.to_string();
match &model_usage_context {
ModelUsageContext::InlineAssistant => {
update_settings_file(fs.clone(), cx, move |settings, _cx| {
settings
.agent
.get_or_insert_default()
.set_inline_assistant_model(provider.clone(), model_id);
});
{
let fs = fs.clone();
move |model, cx| {
let provider = model.provider_id().0.to_string();
let model_id = model.id().0.to_string();
match &model_usage_context {
ModelUsageContext::InlineAssistant => {
update_settings_file(fs.clone(), cx, move |settings, _cx| {
settings
.agent
.get_or_insert_default()
.set_inline_assistant_model(provider.clone(), model_id);
});
}
}
}
},
{
let fs = fs.clone();
move |model, should_be_favorite, cx| {
crate::favorite_models::toggle_in_settings(
model,
should_be_favorite,
fs.clone(),
cx,
);
}
},
true, // Use popover styles for picker
focus_handle_clone,
window,

View File

@@ -7,7 +7,6 @@ use db::kvp::{Dismissable, KEY_VALUE_STORE};
use project::{
ExternalAgentServerName,
agent_server_store::{CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME},
trusted_worktrees::{RemoteHostLocation, TrustedWorktrees, wait_for_workspace_trust},
};
use serde::{Deserialize, Serialize};
use settings::{
@@ -264,17 +263,6 @@ impl AgentType {
Self::Custom { .. } => Some(IconName::Sparkle),
}
}
fn is_mcp(&self) -> bool {
match self {
Self::NativeAgent => false,
Self::TextThread => false,
Self::Custom { .. } => false,
Self::Gemini => true,
Self::ClaudeCode => true,
Self::Codex => true,
}
}
}
impl From<ExternalAgent> for AgentType {
@@ -455,9 +443,7 @@ pub struct AgentPanel {
pending_serialization: Option<Task<Result<()>>>,
onboarding: Entity<AgentPanelOnboarding>,
selected_agent: AgentType,
new_agent_thread_task: Task<()>,
show_trust_workspace_message: bool,
_worktree_trust_subscription: Option<Subscription>,
}
impl AgentPanel {
@@ -681,48 +667,6 @@ impl AgentPanel {
None
};
let mut show_trust_workspace_message = false;
let worktree_trust_subscription =
TrustedWorktrees::try_get_global(cx).and_then(|trusted_worktrees| {
let has_global_trust = trusted_worktrees.update(cx, |trusted_worktrees, cx| {
trusted_worktrees.can_trust_workspace(
project
.read(cx)
.remote_connection_options(cx)
.map(RemoteHostLocation::from),
cx,
)
});
if has_global_trust {
None
} else {
show_trust_workspace_message = true;
let project = project.clone();
Some(cx.subscribe(
&trusted_worktrees,
move |agent_panel, trusted_worktrees, _, cx| {
let new_show_trust_workspace_message =
!trusted_worktrees.update(cx, |trusted_worktrees, cx| {
trusted_worktrees.can_trust_workspace(
project
.read(cx)
.remote_connection_options(cx)
.map(RemoteHostLocation::from),
cx,
)
});
if new_show_trust_workspace_message
!= agent_panel.show_trust_workspace_message
{
agent_panel.show_trust_workspace_message =
new_show_trust_workspace_message;
cx.notify();
};
},
))
}
});
let mut panel = Self {
active_view,
workspace,
@@ -745,14 +689,12 @@ impl AgentPanel {
height: None,
zoomed: false,
pending_serialization: None,
new_agent_thread_task: Task::ready(()),
onboarding,
acp_history,
history_store,
selected_agent: AgentType::default(),
loading: false,
show_trust_workspace_message,
_worktree_trust_subscription: worktree_trust_subscription,
show_trust_workspace_message: false,
};
// Initial sync of agent servers from extensions
@@ -880,7 +822,7 @@ impl AgentPanel {
window,
cx,
);
text_thread_editor.focus_handle(cx).focus(window);
text_thread_editor.focus_handle(cx).focus(window, cx);
}
fn external_thread(
@@ -945,47 +887,6 @@ impl AgentPanel {
}
};
if ext_agent.is_mcp() {
let wait_task = this.update(cx, |agent_panel, cx| {
agent_panel.project.update(cx, |project, cx| {
wait_for_workspace_trust(
project.remote_connection_options(cx),
"context servers",
cx,
)
})
})?;
if let Some(wait_task) = wait_task {
this.update_in(cx, |agent_panel, window, cx| {
agent_panel.show_trust_workspace_message = true;
cx.notify();
agent_panel.new_agent_thread_task =
cx.spawn_in(window, async move |agent_panel, cx| {
wait_task.await;
let server = ext_agent.server(fs, history);
agent_panel
.update_in(cx, |agent_panel, window, cx| {
agent_panel.show_trust_workspace_message = false;
cx.notify();
agent_panel._external_thread(
server,
resume_thread,
summarize_thread,
workspace,
project,
loading,
ext_agent,
window,
cx,
);
})
.ok();
});
})?;
return Ok(());
}
}
let server = ext_agent.server(fs, history);
this.update_in(cx, |agent_panel, window, cx| {
agent_panel._external_thread(
@@ -1034,7 +935,7 @@ impl AgentPanel {
if let Some(thread_view) = self.active_thread_view() {
thread_view.update(cx, |view, cx| {
view.expand_message_editor(&ExpandMessageEditor, window, cx);
view.focus_handle(cx).focus(window);
view.focus_handle(cx).focus(window, cx);
});
}
}
@@ -1115,12 +1016,12 @@ impl AgentPanel {
match &self.active_view {
ActiveView::ExternalAgentThread { thread_view } => {
thread_view.focus_handle(cx).focus(window);
thread_view.focus_handle(cx).focus(window, cx);
}
ActiveView::TextThread {
text_thread_editor, ..
} => {
text_thread_editor.focus_handle(cx).focus(window);
text_thread_editor.focus_handle(cx).focus(window, cx);
}
ActiveView::History | ActiveView::Configuration => {}
}
@@ -1268,7 +1169,7 @@ impl AgentPanel {
Self::handle_agent_configuration_event,
));
configuration.focus_handle(cx).focus(window);
configuration.focus_handle(cx).focus(window, cx);
}
}
@@ -1404,7 +1305,7 @@ impl AgentPanel {
}
if focus {
self.focus_handle(cx).focus(window);
self.focus_handle(cx).focus(window, cx);
}
}
@@ -1510,36 +1411,6 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
let wait_task = if agent.is_mcp() {
self.project.update(cx, |project, cx| {
wait_for_workspace_trust(
project.remote_connection_options(cx),
"context servers",
cx,
)
})
} else {
None
};
if let Some(wait_task) = wait_task {
self.show_trust_workspace_message = true;
cx.notify();
self.new_agent_thread_task = cx.spawn_in(window, async move |agent_panel, cx| {
wait_task.await;
agent_panel
.update_in(cx, |agent_panel, window, cx| {
agent_panel.show_trust_workspace_message = false;
cx.notify();
agent_panel._new_agent_thread(agent, window, cx);
})
.ok();
});
} else {
self._new_agent_thread(agent, window, cx);
}
}
fn _new_agent_thread(&mut self, agent: AgentType, window: &mut Window, cx: &mut Context<Self>) {
match agent {
AgentType::TextThread => {
window.dispatch_action(NewTextThread.boxed_clone(), cx);
@@ -1749,14 +1620,19 @@ impl AgentPanel {
let content = match &self.active_view {
ActiveView::ExternalAgentThread { thread_view } => {
let is_generating_title = thread_view
.read(cx)
.as_native_thread(cx)
.map_or(false, |t| t.read(cx).is_generating_title());
if let Some(title_editor) = thread_view.read(cx).title_editor() {
div()
let container = div()
.w_full()
.on_action({
let thread_view = thread_view.downgrade();
move |_: &menu::Confirm, window, cx| {
if let Some(thread_view) = thread_view.upgrade() {
thread_view.focus_handle(cx).focus(window);
thread_view.focus_handle(cx).focus(window, cx);
}
}
})
@@ -1764,12 +1640,25 @@ impl AgentPanel {
let thread_view = thread_view.downgrade();
move |_: &editor::actions::Cancel, window, cx| {
if let Some(thread_view) = thread_view.upgrade() {
thread_view.focus_handle(cx).focus(window);
thread_view.focus_handle(cx).focus(window, cx);
}
}
})
.child(title_editor)
.into_any_element()
.child(title_editor);
if is_generating_title {
container
.with_animation(
"generating_title",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
|div, delta| div.opacity(delta),
)
.into_any_element()
} else {
container.into_any_element()
}
} else {
Label::new(thread_view.read(cx).title(cx))
.color(Color::Muted)
@@ -1799,6 +1688,13 @@ impl AgentPanel {
Label::new(LOADING_SUMMARY_PLACEHOLDER)
.truncate()
.color(Color::Muted)
.with_animation(
"generating_title",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
|label, delta| label.alpha(delta),
)
.into_any_element()
}
}
@@ -1842,6 +1738,25 @@ impl AgentPanel {
.into_any()
}
fn handle_regenerate_thread_title(thread_view: Entity<AcpThreadView>, cx: &mut App) {
thread_view.update(cx, |thread_view, cx| {
if let Some(thread) = thread_view.as_native_thread(cx) {
thread.update(cx, |thread, cx| {
thread.generate_title(cx);
});
}
});
}
fn handle_regenerate_text_thread_title(
text_thread_editor: Entity<TextThreadEditor>,
cx: &mut App,
) {
text_thread_editor.update(cx, |text_thread_editor, cx| {
text_thread_editor.regenerate_summary(cx);
});
}
fn render_panel_options_menu(
&self,
window: &mut Window,
@@ -1861,6 +1776,35 @@ impl AgentPanel {
let selected_agent = self.selected_agent.clone();
let text_thread_view = match &self.active_view {
ActiveView::TextThread {
text_thread_editor, ..
} => Some(text_thread_editor.clone()),
_ => None,
};
let text_thread_with_messages = match &self.active_view {
ActiveView::TextThread {
text_thread_editor, ..
} => text_thread_editor
.read(cx)
.text_thread()
.read(cx)
.messages(cx)
.any(|message| message.role == language_model::Role::Assistant),
_ => false,
};
let thread_view = match &self.active_view {
ActiveView::ExternalAgentThread { thread_view } => Some(thread_view.clone()),
_ => None,
};
let thread_with_messages = match &self.active_view {
ActiveView::ExternalAgentThread { thread_view } => {
thread_view.read(cx).has_user_submitted_prompt(cx)
}
_ => false,
};
PopoverMenu::new("agent-options-menu")
.trigger_with_tooltip(
IconButton::new("agent-options-menu", IconName::Ellipsis)
@@ -1883,6 +1827,7 @@ impl AgentPanel {
move |window, cx| {
Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
menu = menu.context(focus_handle.clone());
if let Some(usage) = usage {
menu = menu
.header_with_link("Prompt Usage", "Manage", account_url.clone())
@@ -1920,6 +1865,38 @@ impl AgentPanel {
.separator()
}
if thread_with_messages | text_thread_with_messages {
menu = menu.header("Current Thread");
if let Some(text_thread_view) = text_thread_view.as_ref() {
menu = menu
.entry("Regenerate Thread Title", None, {
let text_thread_view = text_thread_view.clone();
move |_, cx| {
Self::handle_regenerate_text_thread_title(
text_thread_view.clone(),
cx,
);
}
})
.separator();
}
if let Some(thread_view) = thread_view.as_ref() {
menu = menu
.entry("Regenerate Thread Title", None, {
let thread_view = thread_view.clone();
move |_, cx| {
Self::handle_regenerate_thread_title(
thread_view.clone(),
cx,
);
}
})
.separator();
}
}
menu = menu
.header("MCP Servers")
.action(

View File

@@ -7,6 +7,7 @@ mod buffer_codegen;
mod completion_provider;
mod context;
mod context_server_configuration;
mod favorite_models;
mod inline_assistant;
mod inline_prompt_editor;
mod language_model_selector;
@@ -67,6 +68,8 @@ actions!(
ToggleProfileSelector,
/// Cycles through available session modes.
CycleModeSelector,
/// Cycles through favorited models in the ACP model selector.
CycleFavoriteModels,
/// Expands the message editor to full size.
ExpandMessageEditor,
/// Removes all thread history.
@@ -171,16 +174,6 @@ impl ExternalAgent {
Self::Custom { name } => Rc::new(agent_servers::CustomAgentServer::new(name.clone())),
}
}
pub fn is_mcp(&self) -> bool {
match self {
Self::Gemini => true,
Self::ClaudeCode => true,
Self::Codex => true,
Self::NativeAgent => false,
Self::Custom { .. } => false,
}
}
}
/// Opens the profile management interface for configuring agent tools and settings.
@@ -467,6 +460,7 @@ mod tests {
commit_message_model: None,
thread_summary_model: None,
inline_alternatives: vec![],
favorite_models: vec![],
default_profile: AgentProfileId::default(),
default_view: DefaultAgentView::Thread,
profiles: Default::default(),

View File

@@ -441,7 +441,8 @@ impl CodegenAlternative {
})
.boxed_local()
};
self.generation = self.handle_stream(model, stream, cx);
self.generation =
self.handle_stream(model, /* strip_invalid_spans: */ true, stream, cx);
}
Ok(())
@@ -629,6 +630,7 @@ impl CodegenAlternative {
pub fn handle_stream(
&mut self,
model: Arc<dyn LanguageModel>,
strip_invalid_spans: bool,
stream: impl 'static + Future<Output = Result<LanguageModelTextStream>>,
cx: &mut Context<Self>,
) -> Task<()> {
@@ -713,10 +715,16 @@ impl CodegenAlternative {
let mut response_latency = None;
let request_start = Instant::now();
let diff = async {
let chunks = StripInvalidSpans::new(
stream?.stream.map_err(|error| error.into()),
);
futures::pin_mut!(chunks);
let raw_stream = stream?.stream.map_err(|error| error.into());
let stripped;
let mut chunks: Pin<Box<dyn Stream<Item = Result<String>> + Send>> =
if strip_invalid_spans {
stripped = StripInvalidSpans::new(raw_stream);
Box::pin(stripped)
} else {
Box::pin(raw_stream)
};
let mut diff = StreamingDiff::new(selected_text.to_string());
let mut line_diff = LineDiff::default();
@@ -1307,7 +1315,12 @@ impl CodegenAlternative {
let Some(task) = codegen
.update(cx, move |codegen, cx| {
codegen.handle_stream(model, async { Ok(language_model_text_stream) }, cx)
codegen.handle_stream(
model,
/* strip_invalid_spans: */ false,
async { Ok(language_model_text_stream) },
cx,
)
})
.ok()
else {
@@ -1846,6 +1859,7 @@ mod tests {
codegen.update(cx, |codegen, cx| {
codegen.generation = codegen.handle_stream(
model,
/* strip_invalid_spans: */ false,
future::ready(Ok(LanguageModelTextStream {
message_id: None,
stream: chunks_rx.map(Ok).boxed(),

View File

@@ -0,0 +1,57 @@
use std::sync::Arc;
use agent_client_protocol::ModelId;
use fs::Fs;
use language_model::LanguageModel;
use settings::{LanguageModelSelection, update_settings_file};
use ui::App;
fn language_model_to_selection(model: &Arc<dyn LanguageModel>) -> LanguageModelSelection {
LanguageModelSelection {
provider: model.provider_id().to_string().into(),
model: model.id().0.to_string(),
}
}
fn model_id_to_selection(model_id: &ModelId) -> LanguageModelSelection {
let id = model_id.0.as_ref();
let (provider, model) = id.split_once('/').unwrap_or(("", id));
LanguageModelSelection {
provider: provider.to_owned().into(),
model: model.to_owned(),
}
}
pub fn toggle_in_settings(
model: Arc<dyn LanguageModel>,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
let selection = language_model_to_selection(&model);
update_settings_file(fs, cx, move |settings, _| {
let agent = settings.agent.get_or_insert_default();
if should_be_favorite {
agent.add_favorite_model(selection.clone());
} else {
agent.remove_favorite_model(&selection);
}
});
}
pub fn toggle_model_id_in_settings(
model_id: ModelId,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
let selection = model_id_to_selection(&model_id);
update_settings_file(fs, cx, move |settings, _| {
let agent = settings.agent.get_or_insert_default();
if should_be_favorite {
agent.add_favorite_model(selection.clone());
} else {
agent.remove_favorite_model(&selection);
}
});
}

View File

@@ -1197,7 +1197,7 @@ impl InlineAssistant {
assist
.editor
.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
.ok();
}
@@ -1209,7 +1209,7 @@ impl InlineAssistant {
if let Some(decorations) = assist.decorations.as_ref() {
decorations.prompt_editor.update(cx, |prompt_editor, cx| {
prompt_editor.editor.update(cx, |editor, cx| {
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
editor.select_all(&SelectAll, window, cx);
})
});

View File

@@ -357,7 +357,7 @@ impl<T: 'static> PromptEditor<T> {
creases = insert_message_creases(&mut editor, &existing_creases, window, cx);
if focus {
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
}
editor
});
@@ -844,26 +844,59 @@ impl<T: 'static> PromptEditor<T> {
if show_rating_buttons {
buttons.push(
IconButton::new("thumbs-down", IconName::ThumbsDown)
.icon_color(if rated { Color::Muted } else { Color::Default })
.shape(IconButtonShape::Square)
.disabled(rated)
.tooltip(Tooltip::text("Bad result"))
.on_click(cx.listener(|this, _, window, cx| {
this.thumbs_down(&ThumbsDownResult, window, cx);
}))
.into_any_element(),
);
buttons.push(
IconButton::new("thumbs-up", IconName::ThumbsUp)
.icon_color(if rated { Color::Muted } else { Color::Default })
.shape(IconButtonShape::Square)
.disabled(rated)
.tooltip(Tooltip::text("Good result"))
.on_click(cx.listener(|this, _, window, cx| {
this.thumbs_up(&ThumbsUpResult, window, cx);
}))
h_flex()
.pl_1()
.gap_1()
.border_l_1()
.border_color(cx.theme().colors().border_variant)
.child(
IconButton::new("thumbs-up", IconName::ThumbsUp)
.shape(IconButtonShape::Square)
.map(|this| {
if rated {
this.disabled(true)
.icon_color(Color::Ignored)
.tooltip(move |_, cx| {
Tooltip::with_meta(
"Good Result",
None,
"You already rated this result",
cx,
)
})
} else {
this.icon_color(Color::Muted)
.tooltip(Tooltip::text("Good Result"))
}
})
.on_click(cx.listener(|this, _, window, cx| {
this.thumbs_up(&ThumbsUpResult, window, cx);
})),
)
.child(
IconButton::new("thumbs-down", IconName::ThumbsDown)
.shape(IconButtonShape::Square)
.map(|this| {
if rated {
this.disabled(true)
.icon_color(Color::Ignored)
.tooltip(move |_, cx| {
Tooltip::with_meta(
"Bad Result",
None,
"You already rated this result",
cx,
)
})
} else {
this.icon_color(Color::Muted)
.tooltip(Tooltip::text("Bad Result"))
}
})
.on_click(cx.listener(|this, _, window, cx| {
this.thumbs_down(&ThumbsDownResult, window, cx);
})),
)
.into_any_element(),
);
}
@@ -927,10 +960,21 @@ impl<T: 'static> PromptEditor<T> {
}
fn render_close_button(&self, cx: &mut Context<Self>) -> AnyElement {
let focus_handle = self.editor.focus_handle(cx);
IconButton::new("cancel", IconName::Close)
.icon_color(Color::Muted)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::text("Close Assistant"))
.tooltip({
move |_window, cx| {
Tooltip::for_action_in(
"Close Assistant",
&editor::actions::Cancel,
&focus_handle,
cx,
)
}
})
.on_click(cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::CancelRequested)))
.into_any_element()
}

View File

@@ -1,16 +1,18 @@
use std::{cmp::Reverse, sync::Arc};
use collections::IndexMap;
use agent_settings::AgentSettings;
use collections::{HashMap, HashSet, IndexMap};
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{
Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
};
use language_model::{
AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
LanguageModelRegistry,
AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProvider,
LanguageModelProviderId, LanguageModelRegistry,
};
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
use settings::Settings;
use ui::prelude::*;
use zed_actions::agent::OpenSettings;
@@ -18,12 +20,14 @@ use crate::ui::{ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem}
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
type OnToggleFavorite = Arc<dyn Fn(Arc<dyn LanguageModel>, bool, &App) + 'static>;
pub type LanguageModelSelector = Picker<LanguageModelPickerDelegate>;
pub fn language_model_selector(
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &App) + 'static,
popover_styles: bool,
focus_handle: FocusHandle,
window: &mut Window,
@@ -32,6 +36,7 @@ pub fn language_model_selector(
let delegate = LanguageModelPickerDelegate::new(
get_active_model,
on_model_changed,
on_toggle_favorite,
popover_styles,
focus_handle,
window,
@@ -49,7 +54,17 @@ pub fn language_model_selector(
}
fn all_models(cx: &App) -> GroupedModels {
let providers = LanguageModelRegistry::global(cx).read(cx).providers();
let lm_registry = LanguageModelRegistry::global(cx).read(cx);
let providers = lm_registry.providers();
let mut favorites_index = FavoritesIndex::default();
for sel in &AgentSettings::get_global(cx).favorite_models {
favorites_index
.entry(sel.provider.0.clone().into())
.or_default()
.insert(sel.model.clone().into());
}
let recommended = providers
.iter()
@@ -57,10 +72,7 @@ fn all_models(cx: &App) -> GroupedModels {
provider
.recommended_models(cx)
.into_iter()
.map(|model| ModelInfo {
model,
icon: provider.icon(),
})
.map(|model| ModelInfo::new(&**provider, model, &favorites_index))
})
.collect();
@@ -70,25 +82,44 @@ fn all_models(cx: &App) -> GroupedModels {
provider
.provided_models(cx)
.into_iter()
.map(|model| ModelInfo {
model,
icon: provider.icon(),
})
.map(|model| ModelInfo::new(&**provider, model, &favorites_index))
})
.collect();
GroupedModels::new(all, recommended)
}
type FavoritesIndex = HashMap<LanguageModelProviderId, HashSet<LanguageModelId>>;
#[derive(Clone)]
struct ModelInfo {
model: Arc<dyn LanguageModel>,
icon: IconName,
is_favorite: bool,
}
impl ModelInfo {
fn new(
provider: &dyn LanguageModelProvider,
model: Arc<dyn LanguageModel>,
favorites_index: &FavoritesIndex,
) -> Self {
let is_favorite = favorites_index
.get(&provider.id())
.map_or(false, |set| set.contains(&model.id()));
Self {
model,
icon: provider.icon(),
is_favorite,
}
}
}
pub struct LanguageModelPickerDelegate {
on_model_changed: OnModelChanged,
get_active_model: GetActiveModel,
on_toggle_favorite: OnToggleFavorite,
all_models: Arc<GroupedModels>,
filtered_entries: Vec<LanguageModelPickerEntry>,
selected_index: usize,
@@ -102,6 +133,7 @@ impl LanguageModelPickerDelegate {
fn new(
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &App) + 'static,
popover_styles: bool,
focus_handle: FocusHandle,
window: &mut Window,
@@ -117,6 +149,7 @@ impl LanguageModelPickerDelegate {
selected_index: Self::get_active_model_index(&entries, get_active_model(cx)),
filtered_entries: entries,
get_active_model: Arc::new(get_active_model),
on_toggle_favorite: Arc::new(on_toggle_favorite),
_authenticate_all_providers_task: Self::authenticate_all_providers(cx),
_subscriptions: vec![cx.subscribe_in(
&LanguageModelRegistry::global(cx),
@@ -216,15 +249,57 @@ impl LanguageModelPickerDelegate {
pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
(self.get_active_model)(cx)
}
pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if self.all_models.favorites.is_empty() {
return;
}
let active_model = (self.get_active_model)(cx);
let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
let active_model_id = active_model.as_ref().map(|m| m.model.id());
let current_index = self
.all_models
.favorites
.iter()
.position(|info| {
Some(info.model.provider_id()) == active_provider_id
&& Some(info.model.id()) == active_model_id
})
.unwrap_or(usize::MAX);
let next_index = if current_index == usize::MAX {
0
} else {
(current_index + 1) % self.all_models.favorites.len()
};
let next_model = self.all_models.favorites[next_index].model.clone();
(self.on_model_changed)(next_model, cx);
// Align the picker selection with the newly-active model
let new_index =
Self::get_active_model_index(&self.filtered_entries, (self.get_active_model)(cx));
self.set_selected_index(new_index, window, cx);
}
}
struct GroupedModels {
favorites: Vec<ModelInfo>,
recommended: Vec<ModelInfo>,
all: IndexMap<LanguageModelProviderId, Vec<ModelInfo>>,
}
impl GroupedModels {
pub fn new(all: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
let favorites = all
.iter()
.filter(|info| info.is_favorite)
.cloned()
.collect();
let mut all_by_provider: IndexMap<_, Vec<ModelInfo>> = IndexMap::default();
for model in all {
let provider = model.model.provider_id();
@@ -236,6 +311,7 @@ impl GroupedModels {
}
Self {
favorites,
recommended,
all: all_by_provider,
}
@@ -244,13 +320,18 @@ impl GroupedModels {
fn entries(&self) -> Vec<LanguageModelPickerEntry> {
let mut entries = Vec::new();
if !self.favorites.is_empty() {
entries.push(LanguageModelPickerEntry::Separator("Favorite".into()));
for info in &self.favorites {
entries.push(LanguageModelPickerEntry::Model(info.clone()));
}
}
if !self.recommended.is_empty() {
entries.push(LanguageModelPickerEntry::Separator("Recommended".into()));
entries.extend(
self.recommended
.iter()
.map(|info| LanguageModelPickerEntry::Model(info.clone())),
);
for info in &self.recommended {
entries.push(LanguageModelPickerEntry::Model(info.clone()));
}
}
for models in self.all.values() {
@@ -260,12 +341,11 @@ impl GroupedModels {
entries.push(LanguageModelPickerEntry::Separator(
models[0].model.provider_name().0,
));
entries.extend(
models
.iter()
.map(|info| LanguageModelPickerEntry::Model(info.clone())),
);
for info in models {
entries.push(LanguageModelPickerEntry::Model(info.clone()));
}
}
entries
}
}
@@ -461,7 +541,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
fn render_match(
&self,
ix: usize,
is_focused: bool,
selected: bool,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
@@ -477,11 +557,20 @@ impl PickerDelegate for LanguageModelPickerDelegate {
let is_selected = Some(model_info.model.provider_id()) == active_provider_id
&& Some(model_info.model.id()) == active_model_id;
let is_favorite = model_info.is_favorite;
let handle_action_click = {
let model = model_info.model.clone();
let on_toggle_favorite = self.on_toggle_favorite.clone();
move |cx: &App| on_toggle_favorite(model.clone(), !is_favorite, cx)
};
Some(
ModelSelectorListItem::new(ix, model_info.model.name().0)
.is_focused(is_focused)
.is_selected(is_selected)
.icon(model_info.icon)
.is_selected(is_selected)
.is_focused(selected)
.is_favorite(is_favorite)
.on_toggle_favorite(handle_action_click)
.into_any_element(),
)
}
@@ -493,12 +582,12 @@ impl PickerDelegate for LanguageModelPickerDelegate {
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Option<gpui::AnyElement> {
let focus_handle = self.focus_handle.clone();
if !self.popover_styles {
return None;
}
let focus_handle = self.focus_handle.clone();
Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
}
}
@@ -598,11 +687,24 @@ mod tests {
}
fn create_models(model_specs: Vec<(&str, &str)>) -> Vec<ModelInfo> {
create_models_with_favorites(model_specs, vec![])
}
fn create_models_with_favorites(
model_specs: Vec<(&str, &str)>,
favorites: Vec<(&str, &str)>,
) -> Vec<ModelInfo> {
model_specs
.into_iter()
.map(|(provider, name)| ModelInfo {
model: Arc::new(TestLanguageModel::new(name, provider)),
icon: IconName::Ai,
.map(|(provider, name)| {
let is_favorite = favorites
.iter()
.any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name);
ModelInfo {
model: Arc::new(TestLanguageModel::new(name, provider)),
icon: IconName::Ai,
is_favorite,
}
})
.collect()
}
@@ -740,4 +842,93 @@ mod tests {
vec!["zed/claude", "zed/gemini", "copilot/claude"],
);
}
#[gpui::test]
fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
let recommended_models = create_models(vec![("zed", "claude")]);
let all_models = create_models_with_favorites(
vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")],
vec![("zed", "gemini")],
);
let grouped_models = GroupedModels::new(all_models, recommended_models);
let entries = grouped_models.entries();
assert!(matches!(
entries.first(),
Some(LanguageModelPickerEntry::Separator(s)) if s == "Favorite"
));
assert_models_eq(grouped_models.favorites, vec!["zed/gemini"]);
}
#[gpui::test]
fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) {
let recommended_models = create_models(vec![("zed", "claude")]);
let all_models = create_models(vec![("zed", "claude"), ("zed", "gemini")]);
let grouped_models = GroupedModels::new(all_models, recommended_models);
let entries = grouped_models.entries();
assert!(matches!(
entries.first(),
Some(LanguageModelPickerEntry::Separator(s)) if s == "Recommended"
));
assert!(grouped_models.favorites.is_empty());
}
#[gpui::test]
fn test_models_have_correct_actions(_cx: &mut TestAppContext) {
let recommended_models =
create_models_with_favorites(vec![("zed", "claude")], vec![("zed", "claude")]);
let all_models = create_models_with_favorites(
vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")],
vec![("zed", "claude")],
);
let grouped_models = GroupedModels::new(all_models, recommended_models);
let entries = grouped_models.entries();
for entry in &entries {
if let LanguageModelPickerEntry::Model(info) = entry {
if info.model.telemetry_id() == "zed/claude" {
assert!(info.is_favorite, "zed/claude should be a favorite");
} else {
assert!(
!info.is_favorite,
"{} should not be a favorite",
info.model.telemetry_id()
);
}
}
}
}
#[gpui::test]
fn test_favorites_appear_in_other_sections(_cx: &mut TestAppContext) {
let favorites = vec![("zed", "gemini"), ("openai", "gpt-4")];
let recommended_models =
create_models_with_favorites(vec![("zed", "claude")], favorites.clone());
let all_models = create_models_with_favorites(
vec![
("zed", "claude"),
("zed", "gemini"),
("openai", "gpt-4"),
("openai", "gpt-3.5"),
],
favorites,
);
let grouped_models = GroupedModels::new(all_models, recommended_models);
assert_models_eq(grouped_models.favorites, vec!["zed/gemini", "openai/gpt-4"]);
assert_models_eq(grouped_models.recommended, vec!["zed/claude"]);
assert_models_eq(
grouped_models.all.values().flatten().cloned().collect(),
vec!["zed/claude", "zed/gemini", "openai/gpt-4", "openai/gpt-3.5"],
);
}
}

View File

@@ -127,7 +127,7 @@ impl TerminalInlineAssistant {
if let Some(prompt_editor) = assist.prompt_editor.as_ref() {
prompt_editor.update(cx, |this, cx| {
this.editor.update(cx, |editor, cx| {
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
editor.select_all(&SelectAll, window, cx);
});
});
@@ -292,7 +292,7 @@ impl TerminalInlineAssistant {
.terminal
.update(cx, |this, cx| {
this.clear_block_below_cursor(cx);
this.focus_handle(cx).focus(window);
this.focus_handle(cx).focus(window, cx);
})
.log_err();
@@ -369,7 +369,7 @@ impl TerminalInlineAssistant {
.terminal
.update(cx, |this, cx| {
this.clear_block_below_cursor(cx);
this.focus_handle(cx).focus(window);
this.focus_handle(cx).focus(window, cx);
})
.is_ok()
}

View File

@@ -2,7 +2,7 @@ use crate::{
language_model_selector::{LanguageModelSelector, language_model_selector},
ui::BurnModeTooltip,
};
use agent_settings::CompletionMode;
use agent_settings::{AgentSettings, CompletionMode};
use anyhow::Result;
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection, SlashCommandWorkingSet};
use assistant_slash_commands::{DefaultSlashCommand, FileSlashCommand, selections_creases};
@@ -73,6 +73,8 @@ use workspace::{
};
use zed_actions::agent::{AddSelectionToThread, ToggleModelSelector};
use crate::CycleFavoriteModels;
use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker};
use assistant_text_thread::{
CacheStatus, Content, InvokedSlashCommandId, InvokedSlashCommandStatus, Message, MessageId,
@@ -304,17 +306,31 @@ impl TextThreadEditor {
language_model_selector: cx.new(|cx| {
language_model_selector(
|cx| LanguageModelRegistry::read_global(cx).default_model(),
move |model, cx| {
update_settings_file(fs.clone(), cx, move |settings, _| {
let provider = model.provider_id().0.to_string();
let model = model.id().0.to_string();
settings.agent.get_or_insert_default().set_model(
LanguageModelSelection {
provider: LanguageModelProviderSetting(provider),
model,
},
)
});
{
let fs = fs.clone();
move |model, cx| {
update_settings_file(fs.clone(), cx, move |settings, _| {
let provider = model.provider_id().0.to_string();
let model = model.id().0.to_string();
settings.agent.get_or_insert_default().set_model(
LanguageModelSelection {
provider: LanguageModelProviderSetting(provider),
model,
},
)
});
}
},
{
let fs = fs.clone();
move |model, should_be_favorite, cx| {
crate::favorite_models::toggle_in_settings(
model,
should_be_favorite,
fs.clone(),
cx,
);
}
},
true, // Use popover styles for picker
focus_handle,
@@ -1325,7 +1341,7 @@ impl TextThreadEditor {
if let Some((text, _)) = Self::get_selection_or_code_block(&context_editor_view, cx) {
active_editor_view.update(cx, |editor, cx| {
editor.insert(&text, window, cx);
editor.focus_handle(cx).focus(window);
editor.focus_handle(cx).focus(window, cx);
})
}
}
@@ -2195,12 +2211,53 @@ impl TextThreadEditor {
};
let focus_handle = self.editor().focus_handle(cx);
let (color, icon) = if self.language_model_selector_menu_handle.is_deployed() {
(Color::Accent, IconName::ChevronUp)
} else {
(Color::Muted, IconName::ChevronDown)
};
let tooltip = Tooltip::element({
move |_, cx| {
let focus_handle = focus_handle.clone();
let should_show_cycle_row = !AgentSettings::get_global(cx)
.favorite_model_ids()
.is_empty();
v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new("Change Model"))
.child(KeyBinding::for_action_in(
&ToggleModelSelector,
&focus_handle,
cx,
)),
)
.when(should_show_cycle_row, |this| {
this.child(
h_flex()
.pt_1()
.gap_2()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.justify_between()
.child(Label::new("Cycle Favorited Models"))
.child(KeyBinding::for_action_in(
&CycleFavoriteModels,
&focus_handle,
cx,
)),
)
})
.into_any()
}
});
PickerPopoverMenu::new(
self.language_model_selector.clone(),
ButtonLike::new("active-model")
@@ -2217,9 +2274,7 @@ impl TextThreadEditor {
)
.child(Icon::new(icon).color(color).size(IconSize::XSmall)),
),
move |_window, cx| {
Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
},
tooltip,
gpui::Corner::BottomRight,
cx,
)
@@ -2579,6 +2634,11 @@ impl Render for TextThreadEditor {
.on_action(move |_: &ToggleModelSelector, window, cx| {
language_model_selector.toggle(window, cx);
})
.on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
this.language_model_selector.update(cx, |selector, cx| {
selector.delegate.cycle_favorite_models(window, cx);
});
}))
.size_full()
.child(
div()

View File

@@ -222,8 +222,8 @@ impl Render for AcpOnboardingModal {
acp_onboarding_event!("Canceled", trigger = "Action");
cx.emit(DismissEvent);
}))
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
this.focus_handle.focus(window);
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
this.focus_handle.focus(window, cx);
}))
.child(illustration)
.child(

View File

@@ -230,8 +230,8 @@ impl Render for ClaudeCodeOnboardingModal {
claude_code_onboarding_event!("Canceled", trigger = "Action");
cx.emit(DismissEvent);
}))
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
this.focus_handle.focus(window);
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
this.focus_handle.focus(window, cx);
}))
.child(illustration)
.child(

View File

@@ -1,5 +1,5 @@
use gpui::{Action, FocusHandle, prelude::*};
use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*};
use ui::{ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
#[derive(IntoElement)]
pub struct ModelSelectorHeader {
@@ -42,6 +42,8 @@ pub struct ModelSelectorListItem {
icon: Option<IconName>,
is_selected: bool,
is_focused: bool,
is_favorite: bool,
on_toggle_favorite: Option<Box<dyn Fn(&App) + 'static>>,
}
impl ModelSelectorListItem {
@@ -52,6 +54,8 @@ impl ModelSelectorListItem {
icon: None,
is_selected: false,
is_focused: false,
is_favorite: false,
on_toggle_favorite: None,
}
}
@@ -69,6 +73,16 @@ impl ModelSelectorListItem {
self.is_focused = is_focused;
self
}
pub fn is_favorite(mut self, is_favorite: bool) -> Self {
self.is_favorite = is_favorite;
self
}
pub fn on_toggle_favorite(mut self, handler: impl Fn(&App) + 'static) -> Self {
self.on_toggle_favorite = Some(Box::new(handler));
self
}
}
impl RenderOnce for ModelSelectorListItem {
@@ -79,6 +93,8 @@ impl RenderOnce for ModelSelectorListItem {
Color::Muted
};
let is_favorite = self.is_favorite;
ListItem::new(self.index)
.inset(true)
.spacing(ListItemSpacing::Sparse)
@@ -97,11 +113,24 @@ impl RenderOnce for ModelSelectorListItem {
.child(Label::new(self.title).truncate()),
)
.end_slot(div().pr_2().when(self.is_selected, |this| {
this.child(
Icon::new(IconName::Check)
.color(Color::Accent)
.size(IconSize::Small),
)
this.child(Icon::new(IconName::Check).color(Color::Accent))
}))
.end_hover_slot(div().pr_1p5().when_some(self.on_toggle_favorite, {
|this, handle_click| {
let (icon, color, tooltip) = if is_favorite {
(IconName::StarFilled, Color::Accent, "Unfavorite Model")
} else {
(IconName::Star, Color::Default, "Favorite Model")
};
this.child(
IconButton::new(("toggle-favorite", self.index), icon)
.layer(ElevationIndex::ElevatedSurface)
.icon_color(color)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text(tooltip))
.on_click(move |_, _, cx| (handle_click)(cx)),
)
}
}))
}
}

View File

@@ -83,8 +83,8 @@ impl Render for AgentOnboardingModal {
agent_onboarding_event!("Canceled", trigger = "Action");
cx.emit(DismissEvent);
}))
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
this.focus_handle.focus(window);
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
this.focus_handle.focus(window, cx);
}))
.child(
div()

View File

@@ -1052,6 +1052,71 @@ pub fn parse_prompt_too_long(message: &str) -> Option<u64> {
.ok()
}
/// Request body for the token counting API.
/// Similar to `Request` but without `max_tokens` since it's not needed for counting.
#[derive(Debug, Serialize)]
pub struct CountTokensRequest {
pub model: String,
pub messages: Vec<Message>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system: Option<StringOrContents>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<Tool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking: Option<Thinking>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
}
/// Response from the token counting API.
#[derive(Debug, Deserialize)]
pub struct CountTokensResponse {
pub input_tokens: u64,
}
/// Count the number of tokens in a message without creating it.
pub async fn count_tokens(
client: &dyn HttpClient,
api_url: &str,
api_key: &str,
request: CountTokensRequest,
) -> Result<CountTokensResponse, AnthropicError> {
let uri = format!("{api_url}/v1/messages/count_tokens");
let request_builder = HttpRequest::builder()
.method(Method::POST)
.uri(uri)
.header("Anthropic-Version", "2023-06-01")
.header("X-Api-Key", api_key.trim())
.header("Content-Type", "application/json");
let serialized_request =
serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?;
let http_request = request_builder
.body(AsyncBody::from(serialized_request))
.map_err(AnthropicError::BuildRequestBody)?;
let mut response = client
.send(http_request)
.await
.map_err(AnthropicError::HttpSend)?;
let rate_limits = RateLimitInfo::from_headers(response.headers());
if response.status().is_success() {
let mut body = String::new();
response
.body_mut()
.read_to_string(&mut body)
.await
.map_err(AnthropicError::ReadResponse)?;
serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse)
} else {
Err(handle_error_response(response, rate_limits).await)
}
}
#[test]
fn test_match_window_exceeded() {
let error = ApiError {

View File

@@ -87,7 +87,7 @@ pub async fn stream_completion(
Ok(None) => None,
Err(err) => Some((
Err(BedrockError::ClientError(anyhow!(
"{:?}",
"{}",
aws_sdk_bedrockruntime::error::DisplayErrorContext(err)
))),
stream,

View File

@@ -2155,7 +2155,7 @@ mod tests {
let range = diff_1.inner.compare(&empty_diff.inner, &buffer).unwrap();
assert_eq!(range.to_point(&buffer), Point::new(0, 0)..Point::new(8, 0));
// Edit does not affect the diff.
// Edit does affects the diff because it recalculates word diffs.
buffer.edit_via_marked_text(
&"
one
@@ -2170,7 +2170,14 @@ mod tests {
.unindent(),
);
let diff_2 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx);
assert_eq!(None, diff_2.inner.compare(&diff_1.inner, &buffer));
assert_eq!(
Point::new(4, 0)..Point::new(5, 0),
diff_2
.inner
.compare(&diff_1.inner, &buffer)
.unwrap()
.to_point(&buffer)
);
// Edit turns a deletion hunk into a modification.
buffer.edit_via_marked_text(

View File

@@ -859,9 +859,11 @@ async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &m
cx_a.update(|cx| {
release_channel::init(semver::Version::new(0, 0, 0), cx);
project::trusted_worktrees::init(HashMap::default(), None, None, cx);
});
server_cx.update(|cx| {
release_channel::init(semver::Version::new(0, 0, 0), cx);
project::trusted_worktrees::init(HashMap::default(), None, None, cx);
});
let mut server = TestServer::start(cx_a.executor().clone()).await;

View File

@@ -1252,7 +1252,7 @@ impl CollabPanel {
context_menu
});
window.focus(&context_menu.focus_handle(cx));
window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe_in(
&context_menu,
window,
@@ -1424,7 +1424,7 @@ impl CollabPanel {
context_menu
});
window.focus(&context_menu.focus_handle(cx));
window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe_in(
&context_menu,
window,
@@ -1487,7 +1487,7 @@ impl CollabPanel {
})
});
window.focus(&context_menu.focus_handle(cx));
window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe_in(
&context_menu,
window,
@@ -1521,9 +1521,9 @@ impl CollabPanel {
if cx.stop_active_drag(window) {
return;
} else if self.take_editing_state(window, cx) {
window.focus(&self.filter_editor.focus_handle(cx));
window.focus(&self.filter_editor.focus_handle(cx), cx);
} else if !self.reset_filter_editor_text(window, cx) {
self.focus_handle.focus(window);
self.focus_handle.focus(window, cx);
}
if self.context_menu.is_some() {
@@ -1826,7 +1826,7 @@ impl CollabPanel {
});
self.update_entries(false, cx);
self.select_channel_editor();
window.focus(&self.channel_name_editor.focus_handle(cx));
window.focus(&self.channel_name_editor.focus_handle(cx), cx);
cx.notify();
}
@@ -1851,7 +1851,7 @@ impl CollabPanel {
});
self.update_entries(false, cx);
self.select_channel_editor();
window.focus(&self.channel_name_editor.focus_handle(cx));
window.focus(&self.channel_name_editor.focus_handle(cx), cx);
cx.notify();
}
@@ -1900,7 +1900,7 @@ impl CollabPanel {
editor.set_text(channel.name.clone(), window, cx);
editor.select_all(&Default::default(), window, cx);
});
window.focus(&self.channel_name_editor.focus_handle(cx));
window.focus(&self.channel_name_editor.focus_handle(cx), cx);
self.update_entries(false, cx);
self.select_channel_editor();
}

View File

@@ -642,7 +642,7 @@ impl ChannelModalDelegate {
});
menu
});
window.focus(&context_menu.focus_handle(cx));
window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe_in(
&context_menu,
window,

View File

@@ -588,7 +588,7 @@ impl PickerDelegate for CommandPaletteDelegate {
})
.detach_and_log_err(cx);
let action = command.action;
window.focus(&self.previous_focus_handle);
window.focus(&self.previous_focus_handle, cx);
self.dismissed(window, cx);
window.dispatch_action(action, cx);
}
@@ -784,7 +784,7 @@ mod tests {
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
});
cx.simulate_keystrokes("cmd-shift-p");
@@ -855,7 +855,7 @@ mod tests {
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
});
// Test normalize (trimming whitespace and double colons)

View File

@@ -29,6 +29,7 @@ schemars.workspace = true
serde_json.workspace = true
serde.workspace = true
settings.workspace = true
slotmap.workspace = true
smol.workspace = true
tempfile.workspace = true
url = { workspace = true, features = ["serde"] }

View File

@@ -6,6 +6,7 @@ use parking_lot::Mutex;
use postage::barrier;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::{Value, value::RawValue};
use slotmap::SlotMap;
use smol::channel;
use std::{
fmt,
@@ -50,7 +51,7 @@ pub(crate) struct Client {
next_id: AtomicI32,
outbound_tx: channel::Sender<String>,
name: Arc<str>,
notification_handlers: Arc<Mutex<HashMap<&'static str, NotificationHandler>>>,
subscription_set: Arc<Mutex<NotificationSubscriptionSet>>,
response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
#[allow(clippy::type_complexity)]
#[allow(dead_code)]
@@ -191,21 +192,20 @@ impl Client {
let (outbound_tx, outbound_rx) = channel::unbounded::<String>();
let (output_done_tx, output_done_rx) = barrier::channel();
let notification_handlers =
Arc::new(Mutex::new(HashMap::<_, NotificationHandler>::default()));
let subscription_set = Arc::new(Mutex::new(NotificationSubscriptionSet::default()));
let response_handlers =
Arc::new(Mutex::new(Some(HashMap::<_, ResponseHandler>::default())));
let request_handlers = Arc::new(Mutex::new(HashMap::<_, RequestHandler>::default()));
let receive_input_task = cx.spawn({
let notification_handlers = notification_handlers.clone();
let subscription_set = subscription_set.clone();
let response_handlers = response_handlers.clone();
let request_handlers = request_handlers.clone();
let transport = transport.clone();
async move |cx| {
Self::handle_input(
transport,
notification_handlers,
subscription_set,
request_handlers,
response_handlers,
cx,
@@ -236,7 +236,7 @@ impl Client {
Ok(Self {
server_id,
notification_handlers,
subscription_set,
response_handlers,
name: server_name,
next_id: Default::default(),
@@ -257,7 +257,7 @@ impl Client {
/// to pending requests) and notifications (which trigger registered handlers).
async fn handle_input(
transport: Arc<dyn Transport>,
notification_handlers: Arc<Mutex<HashMap<&'static str, NotificationHandler>>>,
subscription_set: Arc<Mutex<NotificationSubscriptionSet>>,
request_handlers: Arc<Mutex<HashMap<&'static str, RequestHandler>>>,
response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
cx: &mut AsyncApp,
@@ -282,10 +282,11 @@ impl Client {
handler(Ok(message.to_string()));
}
} else if let Ok(notification) = serde_json::from_str::<AnyNotification>(&message) {
let mut notification_handlers = notification_handlers.lock();
if let Some(handler) = notification_handlers.get_mut(notification.method.as_str()) {
handler(notification.params.unwrap_or(Value::Null), cx.clone());
}
subscription_set.lock().notify(
&notification.method,
notification.params.unwrap_or(Value::Null),
cx,
)
} else {
log::error!("Unhandled JSON from context_server: {}", message);
}
@@ -451,12 +452,18 @@ impl Client {
Ok(())
}
#[must_use]
pub fn on_notification(
&self,
method: &'static str,
f: Box<dyn 'static + Send + FnMut(Value, AsyncApp)>,
) {
self.notification_handlers.lock().insert(method, f);
) -> NotificationSubscription {
let mut notification_subscriptions = self.subscription_set.lock();
NotificationSubscription {
id: notification_subscriptions.add_handler(method, f),
set: self.subscription_set.clone(),
}
}
}
@@ -485,3 +492,73 @@ impl fmt::Debug for Client {
.finish_non_exhaustive()
}
}
slotmap::new_key_type! {
struct NotificationSubscriptionId;
}
#[derive(Default)]
pub struct NotificationSubscriptionSet {
// we have very few subscriptions at the moment
methods: Vec<(&'static str, Vec<NotificationSubscriptionId>)>,
handlers: SlotMap<NotificationSubscriptionId, NotificationHandler>,
}
impl NotificationSubscriptionSet {
#[must_use]
fn add_handler(
&mut self,
method: &'static str,
handler: NotificationHandler,
) -> NotificationSubscriptionId {
let id = self.handlers.insert(handler);
if let Some((_, handler_ids)) = self
.methods
.iter_mut()
.find(|(probe_method, _)| method == *probe_method)
{
debug_assert!(
handler_ids.len() < 20,
"Too many MCP handlers for {}. Consider using a different data structure.",
method
);
handler_ids.push(id);
} else {
self.methods.push((method, vec![id]));
};
id
}
fn notify(&mut self, method: &str, payload: Value, cx: &mut AsyncApp) {
let Some((_, handler_ids)) = self
.methods
.iter_mut()
.find(|(probe_method, _)| method == *probe_method)
else {
return;
};
for handler_id in handler_ids {
if let Some(handler) = self.handlers.get_mut(*handler_id) {
handler(payload.clone(), cx.clone());
}
}
}
}
pub struct NotificationSubscription {
id: NotificationSubscriptionId,
set: Arc<Mutex<NotificationSubscriptionSet>>,
}
impl Drop for NotificationSubscription {
fn drop(&mut self) {
let mut set = self.set.lock();
set.handlers.remove(self.id);
set.methods.retain_mut(|(_, handler_ids)| {
handler_ids.retain(|id| *id != self.id);
!handler_ids.is_empty()
});
}
}

View File

@@ -96,22 +96,6 @@ impl ContextServer {
self.initialize(self.new_client(cx)?).await
}
/// Starts the context server, making sure handlers are registered before initialization happens
pub async fn start_with_handlers(
&self,
notification_handlers: Vec<(
&'static str,
Box<dyn 'static + Send + FnMut(serde_json::Value, AsyncApp)>,
)>,
cx: &AsyncApp,
) -> Result<()> {
let client = self.new_client(cx)?;
for (method, handler) in notification_handlers {
client.on_notification(method, handler);
}
self.initialize(client).await
}
fn new_client(&self, cx: &AsyncApp) -> Result<Client> {
Ok(match &self.configuration {
ContextServerTransport::Stdio(command, working_directory) => Client::stdio(

View File

@@ -12,7 +12,7 @@ use futures::channel::oneshot;
use gpui::AsyncApp;
use serde_json::Value;
use crate::client::Client;
use crate::client::{Client, NotificationSubscription};
use crate::types::{self, Notification, Request};
pub struct ModelContextProtocol {
@@ -119,7 +119,7 @@ impl InitializedContextServerProtocol {
&self,
method: &'static str,
f: Box<dyn 'static + Send + FnMut(Value, AsyncApp)>,
) {
self.inner.on_notification(method, f);
) -> NotificationSubscription {
self.inner.on_notification(method, f)
}
}

View File

@@ -1246,7 +1246,10 @@ async fn get_copilot_lsp(fs: Arc<dyn Fs>, node_runtime: NodeRuntime) -> anyhow::
.await;
if should_install {
node_runtime
.npm_install_packages(paths::copilot_dir(), &[(PACKAGE_NAME, &latest_version)])
.npm_install_packages(
paths::copilot_dir(),
&[(PACKAGE_NAME, &latest_version.to_string())],
)
.await?;
}

View File

@@ -753,7 +753,7 @@ mod tests {
editor
.update(cx, |editor, window, cx| {
use gpui::Focusable;
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
})
.unwrap();
let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot));
@@ -1000,7 +1000,7 @@ mod tests {
editor
.update(cx, |editor, window, cx| {
use gpui::Focusable;
window.focus(&editor.focus_handle(cx))
window.focus(&editor.focus_handle(cx), cx)
})
.unwrap();
let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot));

View File

@@ -435,8 +435,8 @@ impl Render for CopilotCodeVerification {
.on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
cx.emit(DismissEvent);
}))
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _| {
window.focus(&this.focus_handle);
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
window.focus(&this.focus_handle, cx);
}))
.child(
Vector::new(VectorName::ZedXCopilot, rems(8.), rems(4.))

View File

@@ -577,7 +577,7 @@ impl DebugPanel {
menu
});
window.focus(&context_menu.focus_handle(cx));
window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
this.context_menu.take();
cx.notify();
@@ -1052,7 +1052,7 @@ impl DebugPanel {
cx: &mut Context<Self>,
) {
debug_assert!(self.sessions_with_children.contains_key(&session_item));
session_item.focus_handle(cx).focus(window);
session_item.focus_handle(cx).focus(window, cx);
session_item.update(cx, |this, cx| {
this.running_state().update(cx, |this, cx| {
this.go_to_selected_stack_frame(window, cx);

View File

@@ -574,7 +574,7 @@ impl Render for NewProcessModal {
NewProcessMode::Launch => NewProcessMode::Task,
};
this.mode_focus_handle(cx).focus(window);
this.mode_focus_handle(cx).focus(window, cx);
}))
.on_action(
cx.listener(|this, _: &pane::ActivatePreviousItem, window, cx| {
@@ -585,7 +585,7 @@ impl Render for NewProcessModal {
NewProcessMode::Launch => NewProcessMode::Attach,
};
this.mode_focus_handle(cx).focus(window);
this.mode_focus_handle(cx).focus(window, cx);
}),
)
.child(
@@ -602,7 +602,7 @@ impl Render for NewProcessModal {
NewProcessMode::Task.to_string(),
cx.listener(|this, _, window, cx| {
this.mode = NewProcessMode::Task;
this.mode_focus_handle(cx).focus(window);
this.mode_focus_handle(cx).focus(window, cx);
cx.notify();
}),
)
@@ -611,7 +611,7 @@ impl Render for NewProcessModal {
NewProcessMode::Debug.to_string(),
cx.listener(|this, _, window, cx| {
this.mode = NewProcessMode::Debug;
this.mode_focus_handle(cx).focus(window);
this.mode_focus_handle(cx).focus(window, cx);
cx.notify();
}),
)
@@ -629,7 +629,7 @@ impl Render for NewProcessModal {
cx,
);
}
this.mode_focus_handle(cx).focus(window);
this.mode_focus_handle(cx).focus(window, cx);
cx.notify();
}),
)
@@ -638,7 +638,7 @@ impl Render for NewProcessModal {
NewProcessMode::Launch.to_string(),
cx.listener(|this, _, window, cx| {
this.mode = NewProcessMode::Launch;
this.mode_focus_handle(cx).focus(window);
this.mode_focus_handle(cx).focus(window, cx);
cx.notify();
}),
)
@@ -840,17 +840,17 @@ impl ConfigureMode {
}
}
fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context<Self>) {
window.focus_next();
fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
window.focus_next(cx);
}
fn on_tab_prev(
&mut self,
_: &menu::SelectPrevious,
window: &mut Window,
_: &mut Context<Self>,
cx: &mut Context<Self>,
) {
window.focus_prev();
window.focus_prev(cx);
}
fn render(
@@ -923,7 +923,7 @@ impl AttachMode {
window,
cx,
);
window.focus(&modal.focus_handle(cx));
window.focus(&modal.focus_handle(cx), cx);
modal
});

View File

@@ -83,8 +83,8 @@ impl Render for DebuggerOnboardingModal {
debugger_onboarding_event!("Canceled", trigger = "Action");
cx.emit(DismissEvent);
}))
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
this.focus_handle.focus(window);
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
this.focus_handle.focus(window, cx);
}))
.child(
div()

View File

@@ -604,7 +604,7 @@ impl DebugTerminal {
let focus_handle = cx.focus_handle();
let focus_subscription = cx.on_focus(&focus_handle, window, |this, window, cx| {
if let Some(terminal) = this.terminal.as_ref() {
terminal.focus_handle(cx).focus(window);
terminal.focus_handle(cx).focus(window, cx);
}
});

View File

@@ -310,7 +310,7 @@ impl BreakpointList {
fn dismiss(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
if self.input.focus_handle(cx).contains_focused(window, cx) {
self.focus_handle.focus(window);
self.focus_handle.focus(window, cx);
} else if self.strip_mode.is_some() {
self.strip_mode.take();
cx.notify();
@@ -364,9 +364,9 @@ impl BreakpointList {
}
}
}
self.focus_handle.focus(window);
self.focus_handle.focus(window, cx);
} else {
handle.focus(window);
handle.focus(window, cx);
}
return;
@@ -627,7 +627,7 @@ impl BreakpointList {
.on_click({
let focus_handle = focus_handle.clone();
move |_, window, cx| {
focus_handle.focus(window);
focus_handle.focus(window, cx);
window.dispatch_action(ToggleEnableBreakpoint.boxed_clone(), cx)
}
}),
@@ -654,7 +654,7 @@ impl BreakpointList {
)
.on_click({
move |_, window, cx| {
focus_handle.focus(window);
focus_handle.focus(window, cx);
window.dispatch_action(UnsetBreakpoint.boxed_clone(), cx)
}
}),

View File

@@ -105,7 +105,7 @@ impl Console {
cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events),
cx.on_focus(&focus_handle, window, |console, window, cx| {
if console.is_running(cx) {
console.query_bar.focus_handle(cx).focus(window);
console.query_bar.focus_handle(cx).focus(window, cx);
}
}),
];

View File

@@ -403,7 +403,7 @@ impl MemoryView {
this.set_placeholder_text("Write to Selected Memory Range", window, cx);
});
self.is_writing_memory = true;
self.query_editor.focus_handle(cx).focus(window);
self.query_editor.focus_handle(cx).focus(window, cx);
} else {
self.query_editor.update(cx, |this, cx| {
this.clear(window, cx);

View File

@@ -529,7 +529,7 @@ impl VariableList {
fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
self.edited_path.take();
self.focus_handle.focus(window);
self.focus_handle.focus(window, cx);
cx.notify();
}
@@ -1067,7 +1067,7 @@ impl VariableList {
editor.select_all(&editor::actions::SelectAll, window, cx);
editor
});
editor.focus_handle(cx).focus(window);
editor.focus_handle(cx).focus(window, cx);
editor
}

View File

@@ -175,7 +175,7 @@ impl BufferDiagnosticsEditor {
// `BufferDiagnosticsEditor` instance.
EditorEvent::Focused => {
if buffer_diagnostics_editor.multibuffer.read(cx).is_empty() {
window.focus(&buffer_diagnostics_editor.focus_handle);
window.focus(&buffer_diagnostics_editor.focus_handle, cx);
}
}
EditorEvent::Blurred => {
@@ -517,7 +517,7 @@ impl BufferDiagnosticsEditor {
.editor
.read(cx)
.focus_handle(cx)
.focus(window);
.focus(window, cx);
}
}
}
@@ -617,7 +617,7 @@ impl BufferDiagnosticsEditor {
// not empty, focus on the editor instead, which will allow the user to
// start interacting and editing the buffer's contents.
if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
self.editor.focus_handle(cx).focus(window)
self.editor.focus_handle(cx).focus(window, cx)
}
}

View File

@@ -315,6 +315,6 @@ impl DiagnosticBlock {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges([range.start..range.start]);
});
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
}
}

View File

@@ -243,7 +243,7 @@ impl ProjectDiagnosticsEditor {
match event {
EditorEvent::Focused => {
if this.multibuffer.read(cx).is_empty() {
window.focus(&this.focus_handle);
window.focus(&this.focus_handle, cx);
}
}
EditorEvent::Blurred => this.close_diagnosticless_buffers(cx, false),
@@ -434,7 +434,7 @@ impl ProjectDiagnosticsEditor {
fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
self.editor.focus_handle(cx).focus(window)
self.editor.focus_handle(cx).focus(window, cx)
}
}
@@ -650,7 +650,7 @@ impl ProjectDiagnosticsEditor {
})
});
if this.focus_handle.is_focused(window) {
this.editor.read(cx).focus_handle(cx).focus(window);
this.editor.read(cx).focus_handle(cx).focus(window, cx);
}
}

View File

@@ -131,8 +131,8 @@ impl Render for ZedPredictModal {
onboarding_event!("Cancelled", trigger = "Action");
cx.emit(DismissEvent);
}))
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
this.focus_handle.focus(window);
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
this.focus_handle.focus(window, cx);
}))
.child(
div()

View File

@@ -114,7 +114,7 @@ pub fn init(cx: &mut App) -> EpAppState {
tx.send(Some(options)).log_err();
})
.detach();
let node_runtime = NodeRuntime::new(client.http_client(), None, rx, None);
let node_runtime = NodeRuntime::new(client.http_client(), None, rx);
let extension_host_proxy = ExtensionHostProxy::global(cx);

View File

@@ -305,7 +305,7 @@ impl RatePredictionsModal {
&& prediction.id == prev_prediction.prediction.id
{
if focus {
window.focus(&prev_prediction.feedback_editor.focus_handle(cx));
window.focus(&prev_prediction.feedback_editor.focus_handle(cx), cx);
}
return;
}

View File

@@ -29,7 +29,7 @@ fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &TestAppContext
);
editor
});
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
editor
});
@@ -72,7 +72,7 @@ fn open_editor_with_one_long_line(bencher: &mut Bencher<'_>, args: &(String, Tes
editor.set_style(editor::EditorStyle::default(), window, cx);
editor
});
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
editor
});
});
@@ -100,7 +100,7 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) {
editor.set_style(editor::EditorStyle::default(), window, cx);
editor
});
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
editor
});

View File

@@ -14,8 +14,57 @@
//! - [`DisplayMap`] that adds background highlights to the regions of text.
//! Each one of those builds on top of preceding map.
//!
//! ## Structure of the display map layers
//!
//! Each layer in the map (and the multibuffer itself to some extent) has a few
//! structures that are used to implement the public API available to the layer
//! above:
//! - a `Transform` type - this represents a region of text that the layer in
//! question is "managing", that it transforms into a more "processed" text
//! for the layer above. For example, the inlay map has an `enum Transform`
//! that has two variants:
//! - `Isomorphic`, representing a region of text that has no inlay hints (i.e.
//! is passed through the map transparently)
//! - `Inlay`, representing a location where an inlay hint is to be inserted.
//! - a `TransformSummary` type, which is usually a struct with two fields:
//! [`input: TextSummary`][`TextSummary`] and [`output: TextSummary`][`TextSummary`]. Here,
//! `input` corresponds to "text in the layer below", and `output` corresponds to the text
//! exposed to the layer above. So in the inlay map case, a `Transform::Isomorphic`'s summary is
//! just `input = output = summary`, where `summary` is the [`TextSummary`] stored in that
//! variant. Conversely, a `Transform::Inlay` always has an empty `input` summary, because it's
//! not "replacing" any text that exists on disk. The `output` is the summary of the inlay text
//! to be injected. - Various newtype wrappers for co-ordinate spaces (e.g. [`WrapRow`]
//! represents a row index, after soft-wrapping (and all lower layers)).
//! - A `Snapshot` type (e.g. [`InlaySnapshot`]) that captures the state of a layer at a specific
//! point in time.
//! - various APIs which drill through the layers below to work with the underlying text. Notably:
//! - `fn text_summary_for_offset()` returns a [`TextSummary`] for the range in the co-ordinate
//! space that the map in question is responsible for.
//! - `fn <A>_point_to_<B>_point()` converts a point in co-ordinate space `A` into co-ordinate
//! space `B`.
//! - A [`RowInfo`] iterator (e.g. [`InlayBufferRows`]) and a [`Chunk`] iterator
//! (e.g. [`InlayChunks`])
//! - A `sync` function (e.g. [`InlayMap::sync`]) that takes a snapshot and list of [`Edit<T>`]s,
//! and returns a new snapshot and a list of transformed [`Edit<S>`]s. Note that the generic
//! parameter on `Edit` changes, since these methods take in edits in the co-ordinate space of
//! the lower layer, and return edits in their own co-ordinate space. The term "edit" is
//! slightly misleading, since an [`Edit<T>`] doesn't tell you what changed - rather it can be
//! thought of as a "region to invalidate". In theory, it would be correct to always use a
//! single edit that covers the entire range. However, this would lead to lots of unnecessary
//! recalculation.
//!
//! See the docs for the [`inlay_map`] module for a more in-depth explanation of how a single layer
//! works.
//!
//! [Editor]: crate::Editor
//! [EditorElement]: crate::element::EditorElement
//! [`TextSummary`]: multi_buffer::MBTextSummary
//! [`WrapRow`]: wrap_map::WrapRow
//! [`InlayBufferRows`]: inlay_map::InlayBufferRows
//! [`InlayChunks`]: inlay_map::InlayChunks
//! [`Edit<T>`]: text::Edit
//! [`Edit<S>`]: text::Edit
//! [`Chunk`]: language::Chunk
#[macro_use]
mod dimensions;

View File

@@ -545,7 +545,7 @@ impl BlockMap {
{
let max_point = wrap_snapshot.max_point();
let edit_start = wrap_snapshot.prev_row_boundary(max_point);
let edit_end = max_point.row() + WrapRow(1);
let edit_end = max_point.row() + WrapRow(1); // this is end of file
edits = edits.compose([WrapEdit {
old: edit_start..edit_end,
new: edit_start..edit_end,
@@ -715,6 +715,7 @@ impl BlockMap {
let placement = block.placement.to_wrap_row(wrap_snapshot)?;
if let BlockPlacement::Above(row) = placement
&& row < new_start
// this will be true more often now
{
return None;
}

View File

@@ -1,3 +1,10 @@
//! The inlay map. See the [`display_map`][super] docs for an overview of how the inlay map fits
//! into the rest of the [`DisplayMap`][super::DisplayMap]. Much of the documentation for this
//! module generalizes to other layers.
//!
//! The core of this module is the [`InlayMap`] struct, which maintains a vec of [`Inlay`]s, and
//! [`InlaySnapshot`], which holds a sum tree of [`Transform`]s.
use crate::{
ChunkRenderer, HighlightStyles,
inlays::{Inlay, InlayContent},
@@ -69,7 +76,9 @@ impl sum_tree::Item for Transform {
#[derive(Clone, Debug, Default)]
struct TransformSummary {
/// Summary of the text before inlays have been applied.
input: MBTextSummary,
/// Summary of the text after inlays have been applied.
output: MBTextSummary,
}

View File

@@ -840,35 +840,62 @@ impl WrapSnapshot {
self.tab_point_to_wrap_point(self.tab_snapshot.clip_point(self.to_tab_point(point), bias))
}
#[ztracing::instrument(skip_all, fields(point=?point, ret))]
pub fn prev_row_boundary(&self, mut point: WrapPoint) -> WrapRow {
/// Try to find a TabRow start that is also a WrapRow start
/// Every TabRow start is a WrapRow start
#[ztracing::instrument(skip_all, fields(point=?point))]
pub fn prev_row_boundary(&self, point: WrapPoint) -> WrapRow {
if self.transforms.is_empty() {
return WrapRow(0);
}
*point.column_mut() = 0;
let point = WrapPoint::new(point.row(), 0);
let mut cursor = self
.transforms
.cursor::<Dimensions<WrapPoint, TabPoint>>(());
// start
cursor.seek(&point, Bias::Right);
// end
if cursor.item().is_none() {
cursor.prev();
}
// start
while let Some(transform) = cursor.item() {
if transform.is_isomorphic() && cursor.start().1.column() == 0 {
return cmp::min(cursor.end().0.row(), point.row());
} else {
cursor.prev();
}
}
// end
// real newline fake fake
// text: helloworldasldlfjasd\njdlasfalsk\naskdjfasdkfj\n
// dimensions v v v v v
// transforms |-------|-----NW----|-----W------|-----W------|
// cursor ^ ^^^^^^^^^^^^^ ^
// (^) ^^^^^^^^^^^^^^
// point: ^
// point(col_zero): (^)
unreachable!()
while let Some(transform) = cursor.item() {
if transform.is_isomorphic() {
// this transform only has real linefeeds
let tab_summary = &transform.summary.input;
// is the wrap just before the end of the transform a tab row?
// thats only if this transform has at least one newline
//
// "this wrap row is a tab row" <=> self.to_tab_point(WrapPoint::new(wrap_row, 0)).column() == 0
// Note on comparison:
// We have code that relies on this to be row > 1
// It should work with row >= 1 but it does not :(
//
// That means that if every line is wrapped we walk back all the
// way to the start. Which invalidates the entire state triggering
// a full re-render.
if tab_summary.lines.row > 1 {
let wrap_point_at_end = cursor.end().0.row();
return cmp::min(wrap_point_at_end - RowDelta(1), point.row());
} else if cursor.start().1.column() == 0 {
return cmp::min(cursor.end().0.row(), point.row());
}
}
cursor.prev();
}
WrapRow(0)
}
#[ztracing::instrument(skip_all)]
@@ -891,13 +918,11 @@ impl WrapSnapshot {
}
#[cfg(test)]
#[ztracing::instrument(skip_all)]
pub fn text(&self) -> String {
self.text_chunks(WrapRow(0)).collect()
}
#[cfg(test)]
#[ztracing::instrument(skip_all)]
pub fn text_chunks(&self, wrap_row: WrapRow) -> impl Iterator<Item = &str> {
self.chunks(
wrap_row..self.max_point().row() + WrapRow(1),
@@ -1298,6 +1323,71 @@ mod tests {
use text::Rope;
use theme::LoadThemes;
#[gpui::test]
async fn test_prev_row_boundary(cx: &mut gpui::TestAppContext) {
init_test(cx);
fn test_wrap_snapshot(
text: &str,
soft_wrap_every: usize, // font size multiple
cx: &mut gpui::TestAppContext,
) -> WrapSnapshot {
let text_system = cx.read(|cx| cx.text_system().clone());
let tab_size = 4.try_into().unwrap();
let font = test_font();
let _font_id = text_system.resolve_font(&font);
let font_size = px(14.0);
// this is very much an estimate to try and get the wrapping to
// occur at `soft_wrap_every` we check that it pans out for every test case
let soft_wrapping = Some(font_size * soft_wrap_every * 0.6);
let buffer = cx.new(|cx| language::Buffer::local(text, cx));
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx));
let (_inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot);
let (_fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot);
let (mut tab_map, _) = TabMap::new(fold_snapshot, tab_size);
let tabs_snapshot = tab_map.set_max_expansion_column(32);
let (_wrap_map, wrap_snapshot) =
cx.update(|cx| WrapMap::new(tabs_snapshot, font, font_size, soft_wrapping, cx));
wrap_snapshot
}
// These two should pass but dont, see the comparison note in
// prev_row_boundary about why.
//
// // 0123 4567 wrap_rows
// let wrap_snapshot = test_wrap_snapshot("1234\n5678", 1, cx);
// assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8");
// let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
// assert_eq!(row.0, 3);
// // 012 345 678 wrap_rows
// let wrap_snapshot = test_wrap_snapshot("123\n456\n789", 1, cx);
// assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8\n9");
// let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
// assert_eq!(row.0, 5);
// 012345678 wrap_rows
let wrap_snapshot = test_wrap_snapshot("123456789", 1, cx);
assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8\n9");
let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
assert_eq!(row.0, 0);
// 111 2222 44 wrap_rows
let wrap_snapshot = test_wrap_snapshot("123\n4567\n\n89", 4, cx);
assert_eq!(wrap_snapshot.text(), "123\n4567\n\n89");
let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
assert_eq!(row.0, 2);
// 11 2223 wrap_rows
let wrap_snapshot = test_wrap_snapshot("12\n3456\n\n", 3, cx);
assert_eq!(wrap_snapshot.text(), "12\n345\n6\n\n");
let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
assert_eq!(row.0, 3);
}
#[gpui::test(iterations = 100)]
async fn test_random_wraps(cx: &mut gpui::TestAppContext, mut rng: StdRng) {
// todo this test is flaky

View File

@@ -124,8 +124,9 @@ use language::{
AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow,
BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape,
DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind,
IndentSize, Language, LanguageName, LanguageRegistry, OffsetRangeExt, OutlineItem, Point,
Runnable, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery,
IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, OffsetRangeExt,
OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId,
TreeSitterOptions, WordsQuery,
language_settings::{
self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode,
all_language_settings, language_settings,
@@ -2063,46 +2064,34 @@ impl Editor {
})
});
});
let edited_buffers_already_open = {
let other_editors: Vec<Entity<Editor>> = workspace
.read(cx)
.panes()
.iter()
.flat_map(|pane| pane.read(cx).items_of_type::<Editor>())
.filter(|editor| editor.entity_id() != cx.entity_id())
.collect();
transaction.0.keys().all(|buffer| {
other_editors.iter().any(|editor| {
let multi_buffer = editor.read(cx).buffer();
multi_buffer.read(cx).is_singleton()
&& multi_buffer.read(cx).as_singleton().map_or(
false,
|singleton| {
singleton.entity_id() == buffer.entity_id()
},
)
})
})
};
if !edited_buffers_already_open {
let workspace = workspace.downgrade();
let transaction = transaction.clone();
cx.defer_in(window, move |_, window, cx| {
cx.spawn_in(window, async move |editor, cx| {
Self::open_project_transaction(
&editor,
workspace,
transaction,
"Rename".to_string(),
cx,
)
.await
.ok()
})
.detach();
});
}
Self::open_transaction_for_hidden_buffers(
workspace,
transaction.clone(),
"Rename".to_string(),
window,
cx,
);
}
}
project::Event::WorkspaceEditApplied(transaction) => {
let Some(workspace) = editor.workspace() else {
return;
};
let Some(active_editor) = workspace.read(cx).active_item_as::<Self>(cx)
else {
return;
};
if active_editor.entity_id() == cx.entity_id() {
Self::open_transaction_for_hidden_buffers(
workspace,
transaction.clone(),
"LSP Edit".to_string(),
window,
cx,
);
}
}
@@ -3827,7 +3816,7 @@ impl Editor {
) {
if !self.focus_handle.is_focused(window) {
self.last_focused_descendant = None;
window.focus(&self.focus_handle);
window.focus(&self.focus_handle, cx);
}
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
@@ -3932,7 +3921,7 @@ impl Editor {
) {
if !self.focus_handle.is_focused(window) {
self.last_focused_descendant = None;
window.focus(&self.focus_handle);
window.focus(&self.focus_handle, cx);
}
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
@@ -4802,205 +4791,51 @@ 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,
insert_extra_newline,
indent_on_newline,
indent_on_extra_newline,
) = if let Some(language) = &language_scope {
let mut insert_extra_newline =
insert_extra_newline_brackets(&buffer, start..end, language)
|| insert_extra_newline_tree_sitter(&buffer, start..end);
let (comment_delimiter, doc_delimiter, newline_formatting) =
if let Some(language) = &language_scope {
let mut newline_formatting =
NewlineFormatting::new(&buffer, start..end, language);
// 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;
}
let delimiters = language.line_comment_prefixes();
let max_len_of_delimiter =
delimiters.iter().map(|delimiter| delimiter.len()).max()?;
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 comment_candidate = snapshot
.chars_for_range(range.clone())
.skip(num_of_whitespaces)
.take(max_len_of_delimiter)
.collect::<String>();
let (delimiter, trimmed_len) = delimiters
.iter()
.filter_map(|delimiter| {
let prefix = delimiter.trim_end();
if comment_candidate.starts_with(prefix) {
Some((delimiter, prefix.len()))
} else {
None
}
})
.max_by_key(|(_, len)| *len)?;
if let Some(BlockCommentConfig {
start: block_start, ..
}) = language.block_comment()
{
let block_start_trimmed = block_start.trim_end();
if block_start_trimmed.starts_with(delimiter.trim_end()) {
let line_content = snapshot
.chars_for_range(range)
.skip(num_of_whitespaces)
.take(block_start_trimmed.len())
.collect::<String>();
if line_content.starts_with(block_start_trimmed) {
return None;
}
}
}
let cursor_is_placed_after_comment_marker =
num_of_whitespaces + trimmed_len <= start_point.column as usize;
if cursor_is_placed_after_comment_marker {
Some(delimiter.clone())
} else {
None
}
});
let mut indent_on_newline = IndentSize::spaces(0);
let mut indent_on_extra_newline = IndentSize::spaces(0);
let doc_delimiter = maybe!({
if !selection_is_empty {
return None;
}
if !multi_buffer.language_settings(cx).extend_comment_on_newline {
return None;
}
let BlockCommentConfig {
start: start_tag,
end: end_tag,
prefix: delimiter,
tab_size: len,
} = language.documentation_comment()?;
let is_within_block_comment = buffer
.language_scope_at(start_point)
.is_some_and(|scope| scope.override_name() == Some("comment"));
if !is_within_block_comment {
return None;
}
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();
// It is safe to use a column from MultiBufferPoint in context of a single buffer ranges, because we're only ever looking at a single line at a time.
let column = start_point.column;
let cursor_is_after_start_tag = {
let start_tag_len = start_tag.len();
let start_tag_line = snapshot
.chars_for_range(range.clone())
.skip(num_of_whitespaces)
.take(start_tag_len)
.collect::<String>();
if start_tag_line.starts_with(start_tag.as_ref()) {
num_of_whitespaces + start_tag_len <= column as usize
} else {
false
}
};
let cursor_is_after_delimiter = {
let delimiter_trim = delimiter.trim_end();
let delimiter_line = snapshot
.chars_for_range(range.clone())
.skip(num_of_whitespaces)
.take(delimiter_trim.len())
.collect::<String>();
if delimiter_line.starts_with(delimiter_trim) {
num_of_whitespaces + delimiter_trim.len() <= column as usize
} else {
false
}
};
let cursor_is_before_end_tag_if_exists = {
let mut char_position = 0u32;
let mut end_tag_offset = None;
'outer: for chunk in snapshot.text_for_range(range) {
if let Some(byte_pos) = chunk.find(&**end_tag) {
let chars_before_match =
chunk[..byte_pos].chars().count() as u32;
end_tag_offset =
Some(char_position + chars_before_match);
break 'outer;
}
char_position += chunk.chars().count() as u32;
// Comment extension on newline is allowed only for cursor selections
let comment_delimiter = maybe!({
if !selection_is_empty {
return None;
}
if let Some(end_tag_offset) = end_tag_offset {
let cursor_is_before_end_tag = column <= end_tag_offset;
if cursor_is_after_start_tag {
if cursor_is_before_end_tag {
insert_extra_newline = true;
}
let cursor_is_at_start_of_end_tag =
column == end_tag_offset;
if cursor_is_at_start_of_end_tag {
indent_on_extra_newline.len = *len;
}
}
cursor_is_before_end_tag
} else {
true
if !multi_buffer.language_settings(cx).extend_comment_on_newline
{
return None;
}
};
if (cursor_is_after_start_tag || cursor_is_after_delimiter)
&& cursor_is_before_end_tag_if_exists
{
if cursor_is_after_start_tag {
indent_on_newline.len = *len;
return comment_delimiter_for_newline(
&start_point,
&buffer,
language,
);
});
let doc_delimiter = maybe!({
if !selection_is_empty {
return None;
}
Some(delimiter.clone())
} else {
None
}
});
(
comment_delimiter,
doc_delimiter,
insert_extra_newline,
indent_on_newline,
indent_on_extra_newline,
)
} else {
(
None,
None,
false,
IndentSize::default(),
IndentSize::default(),
)
};
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 prevent_auto_indent = doc_delimiter.is_some();
let delimiter = comment_delimiter.or(doc_delimiter);
@@ -5010,28 +4845,28 @@ impl Editor {
let mut new_text = String::with_capacity(
1 + capacity_for_delimiter
+ existing_indent.len as usize
+ indent_on_newline.len as usize
+ indent_on_extra_newline.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(indent_on_newline.chars());
new_text.extend(newline_formatting.indent_on_newline.chars());
if let Some(delimiter) = &delimiter {
new_text.push_str(delimiter);
}
if insert_extra_newline {
if newline_formatting.insert_extra_newline {
new_text.push('\n');
new_text.extend(existing_indent.chars());
new_text.extend(indent_on_extra_newline.chars());
new_text.extend(newline_formatting.indent_on_extra_newline.chars());
}
let anchor = buffer.anchor_after(end);
let new_selection = selection.map(|_| anchor);
(
((start..end, new_text), prevent_auto_indent),
(insert_extra_newline, new_selection),
(newline_formatting.insert_extra_newline, new_selection),
)
})
.unzip()
@@ -6672,6 +6507,52 @@ impl Editor {
}
}
fn open_transaction_for_hidden_buffers(
workspace: Entity<Workspace>,
transaction: ProjectTransaction,
title: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
if transaction.0.is_empty() {
return;
}
let edited_buffers_already_open = {
let other_editors: Vec<Entity<Editor>> = workspace
.read(cx)
.panes()
.iter()
.flat_map(|pane| pane.read(cx).items_of_type::<Editor>())
.filter(|editor| editor.entity_id() != cx.entity_id())
.collect();
transaction.0.keys().all(|buffer| {
other_editors.iter().any(|editor| {
let multi_buffer = editor.read(cx).buffer();
multi_buffer.read(cx).is_singleton()
&& multi_buffer
.read(cx)
.as_singleton()
.map_or(false, |singleton| {
singleton.entity_id() == buffer.entity_id()
})
})
})
};
if !edited_buffers_already_open {
let workspace = workspace.downgrade();
cx.defer_in(window, move |_, window, cx| {
cx.spawn_in(window, async move |editor, cx| {
Self::open_project_transaction(&editor, workspace, transaction, title, cx)
.await
.ok()
})
.detach();
});
}
}
pub async fn open_project_transaction(
editor: &WeakEntity<Editor>,
workspace: WeakEntity<Workspace>,
@@ -6831,7 +6712,7 @@ impl Editor {
})
})
.on_click(cx.listener(move |editor, _: &ClickEvent, window, cx| {
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
editor.toggle_code_actions(
&crate::actions::ToggleCodeActions {
deployed_from: Some(crate::actions::CodeActionSource::Indicator(
@@ -8724,7 +8605,7 @@ impl Editor {
BreakpointEditAction::Toggle
};
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
editor.edit_breakpoint_at_anchor(
position,
breakpoint.as_ref().clone(),
@@ -8916,7 +8797,7 @@ impl Editor {
ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
};
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
editor.toggle_code_actions(
&ToggleCodeActions {
deployed_from: Some(CodeActionSource::RunMenu(row)),
@@ -11331,7 +11212,7 @@ impl Editor {
}];
let focus_handle = bp_prompt.focus_handle(cx);
window.focus(&focus_handle);
window.focus(&focus_handle, cx);
let block_ids = self.insert_blocks(blocks, None, cx);
bp_prompt.update(cx, |prompt, _| {
@@ -15534,10 +15415,9 @@ impl Editor {
I: IntoIterator<Item = P>,
P: AsRef<[u8]>,
{
let case_sensitive = self.select_next_is_case_sensitive.map_or_else(
|| EditorSettings::get_global(cx).search.case_sensitive,
|value| value,
);
let case_sensitive = self
.select_next_is_case_sensitive
.unwrap_or_else(|| EditorSettings::get_global(cx).search.case_sensitive);
let mut builder = AhoCorasickBuilder::new();
builder.ascii_case_insensitive(!case_sensitive);
@@ -18159,7 +18039,7 @@ impl Editor {
cx,
);
let rename_focus_handle = rename_editor.focus_handle(cx);
window.focus(&rename_focus_handle);
window.focus(&rename_focus_handle, cx);
let block_id = this.insert_blocks(
[BlockProperties {
style: BlockStyle::Flex,
@@ -18273,7 +18153,7 @@ impl Editor {
) -> Option<RenameState> {
let rename = self.pending_rename.take()?;
if rename.editor.focus_handle(cx).is_focused(window) {
window.focus(&self.focus_handle);
window.focus(&self.focus_handle, cx);
}
self.remove_blocks(
@@ -22843,7 +22723,7 @@ impl Editor {
.take()
.and_then(|descendant| descendant.upgrade())
{
window.focus(&descendant);
window.focus(&descendant, cx);
} else {
if let Some(blame) = self.blame.as_ref() {
blame.update(cx, GitBlame::focus)
@@ -23474,76 +23354,256 @@ struct CompletionEdit {
snippet: Option<Snippet>,
}
fn insert_extra_newline_brackets(
fn comment_delimiter_for_newline(
start_point: &Point,
buffer: &MultiBufferSnapshot,
range: Range<MultiBufferOffset>,
language: &language::LanguageScope,
) -> bool {
let leading_whitespace_len = buffer
.reversed_chars_at(range.start)
.take_while(|c| c.is_whitespace() && *c != '\n')
.map(|c| c.len_utf8())
.sum::<usize>();
let trailing_whitespace_len = buffer
.chars_at(range.end)
.take_while(|c| c.is_whitespace() && *c != '\n')
.map(|c| c.len_utf8())
.sum::<usize>();
let range = range.start - leading_whitespace_len..range.end + trailing_whitespace_len;
language: &LanguageScope,
) -> Option<Arc<str>> {
let delimiters = language.line_comment_prefixes();
let max_len_of_delimiter = delimiters.iter().map(|delimiter| delimiter.len()).max()?;
let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?;
language.brackets().any(|(pair, enabled)| {
let pair_start = pair.start.trim_end();
let pair_end = pair.end.trim_start();
let num_of_whitespaces = snapshot
.chars_for_range(range.clone())
.take_while(|c| c.is_whitespace())
.count();
let comment_candidate = snapshot
.chars_for_range(range.clone())
.skip(num_of_whitespaces)
.take(max_len_of_delimiter)
.collect::<String>();
let (delimiter, trimmed_len) = delimiters
.iter()
.filter_map(|delimiter| {
let prefix = delimiter.trim_end();
if comment_candidate.starts_with(prefix) {
Some((delimiter, prefix.len()))
} else {
None
}
})
.max_by_key(|(_, len)| *len)?;
enabled
&& pair.newline
&& buffer.contains_str_at(range.end, pair_end)
&& buffer.contains_str_at(
range.start.saturating_sub_usize(pair_start.len()),
pair_start,
)
})
if let Some(BlockCommentConfig {
start: block_start, ..
}) = language.block_comment()
{
let block_start_trimmed = block_start.trim_end();
if block_start_trimmed.starts_with(delimiter.trim_end()) {
let line_content = snapshot
.chars_for_range(range)
.skip(num_of_whitespaces)
.take(block_start_trimmed.len())
.collect::<String>();
if line_content.starts_with(block_start_trimmed) {
return None;
}
}
}
let cursor_is_placed_after_comment_marker =
num_of_whitespaces + trimmed_len <= start_point.column as usize;
if cursor_is_placed_after_comment_marker {
Some(delimiter.clone())
} else {
None
}
}
fn insert_extra_newline_tree_sitter(
fn documentation_delimiter_for_newline(
start_point: &Point,
buffer: &MultiBufferSnapshot,
range: Range<MultiBufferOffset>,
) -> bool {
let (buffer, range) = match buffer.range_to_buffer_ranges(range).as_slice() {
[(buffer, range, _)] => (*buffer, range.clone()),
_ => return false,
language: &LanguageScope,
newline_formatting: &mut NewlineFormatting,
) -> Option<Arc<str>> {
let BlockCommentConfig {
start: start_tag,
end: end_tag,
prefix: delimiter,
tab_size: len,
} = language.documentation_comment()?;
let is_within_block_comment = buffer
.language_scope_at(*start_point)
.is_some_and(|scope| scope.override_name() == Some("comment"));
if !is_within_block_comment {
return None;
}
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();
// It is safe to use a column from MultiBufferPoint in context of a single buffer ranges, because we're only ever looking at a single line at a time.
let column = start_point.column;
let cursor_is_after_start_tag = {
let start_tag_len = start_tag.len();
let start_tag_line = snapshot
.chars_for_range(range.clone())
.skip(num_of_whitespaces)
.take(start_tag_len)
.collect::<String>();
if start_tag_line.starts_with(start_tag.as_ref()) {
num_of_whitespaces + start_tag_len <= column as usize
} else {
false
}
};
let pair = {
let mut result: Option<BracketMatch<usize>> = None;
for pair in buffer
.all_bracket_ranges(range.start.0..range.end.0)
.filter(move |pair| {
pair.open_range.start <= range.start.0 && pair.close_range.end >= range.end.0
})
{
let len = pair.close_range.end - pair.open_range.start;
let cursor_is_after_delimiter = {
let delimiter_trim = delimiter.trim_end();
let delimiter_line = snapshot
.chars_for_range(range.clone())
.skip(num_of_whitespaces)
.take(delimiter_trim.len())
.collect::<String>();
if delimiter_line.starts_with(delimiter_trim) {
num_of_whitespaces + delimiter_trim.len() <= column as usize
} else {
false
}
};
if let Some(existing) = &result {
let existing_len = existing.close_range.end - existing.open_range.start;
if len > existing_len {
continue;
}
let cursor_is_before_end_tag_if_exists = {
let mut char_position = 0u32;
let mut end_tag_offset = None;
'outer: for chunk in snapshot.text_for_range(range) {
if let Some(byte_pos) = chunk.find(&**end_tag) {
let chars_before_match = chunk[..byte_pos].chars().count() as u32;
end_tag_offset = Some(char_position + chars_before_match);
break 'outer;
}
result = Some(pair);
char_position += chunk.chars().count() as u32;
}
result
if let Some(end_tag_offset) = end_tag_offset {
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;
}
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;
}
}
cursor_is_before_end_tag
} else {
true
}
};
let Some(pair) = pair else {
return false;
};
pair.newline_only
&& buffer
.chars_for_range(pair.open_range.end..range.start.0)
.chain(buffer.chars_for_range(range.end.0..pair.close_range.start))
.all(|c| c.is_whitespace() && c != '\n')
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;
}
Some(delimiter.clone())
} else {
None
}
}
#[derive(Debug, Default)]
struct NewlineFormatting {
insert_extra_newline: bool,
indent_on_newline: IndentSize,
indent_on_extra_newline: IndentSize,
}
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 insert_extra_newline_brackets(
buffer: &MultiBufferSnapshot,
range: Range<MultiBufferOffset>,
language: &language::LanguageScope,
) -> bool {
let leading_whitespace_len = buffer
.reversed_chars_at(range.start)
.take_while(|c| c.is_whitespace() && *c != '\n')
.map(|c| c.len_utf8())
.sum::<usize>();
let trailing_whitespace_len = buffer
.chars_at(range.end)
.take_while(|c| c.is_whitespace() && *c != '\n')
.map(|c| c.len_utf8())
.sum::<usize>();
let range = range.start - leading_whitespace_len..range.end + trailing_whitespace_len;
language.brackets().any(|(pair, enabled)| {
let pair_start = pair.start.trim_end();
let pair_end = pair.end.trim_start();
enabled
&& pair.newline
&& buffer.contains_str_at(range.end, pair_end)
&& buffer.contains_str_at(
range.start.saturating_sub_usize(pair_start.len()),
pair_start,
)
})
}
fn insert_extra_newline_tree_sitter(
buffer: &MultiBufferSnapshot,
range: Range<MultiBufferOffset>,
) -> bool {
let (buffer, range) = match buffer.range_to_buffer_ranges(range).as_slice() {
[(buffer, range, _)] => (*buffer, range.clone()),
_ => return false,
};
let pair = {
let mut result: Option<BracketMatch<usize>> = None;
for pair in buffer
.all_bracket_ranges(range.start.0..range.end.0)
.filter(move |pair| {
pair.open_range.start <= range.start.0 && pair.close_range.end >= range.end.0
})
{
let len = pair.close_range.end - pair.open_range.start;
if let Some(existing) = &result {
let existing_len = existing.close_range.end - existing.open_range.start;
if len > existing_len {
continue;
}
}
result = Some(pair);
}
result
};
let Some(pair) = pair else {
return false;
};
pair.newline_only
&& buffer
.chars_for_range(pair.open_range.end..range.start.0)
.chain(buffer.chars_for_range(range.end.0..pair.close_range.start))
.all(|c| c.is_whitespace() && c != '\n')
}
}
fn update_uncommitted_diff_for_buffer(
@@ -25909,7 +25969,7 @@ impl BreakpointPromptEditor {
self.editor
.update(cx, |editor, cx| {
editor.remove_blocks(self.block_ids.clone(), None, cx);
window.focus(&editor.focus_handle);
window.focus(&editor.focus_handle, cx);
})
.log_err();
}

View File

@@ -18201,7 +18201,7 @@ async fn test_on_type_formatting_not_triggered(cx: &mut TestAppContext) {
);
editor_handle.update_in(cx, |editor, window, cx| {
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(0, 21)..Point::new(0, 20)])
});
@@ -25972,6 +25972,48 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) {
"});
}
#[gpui::test]
async fn test_python_indent_in_markdown(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor()));
let python_lang = languages::language("python", tree_sitter_python::LANGUAGE.into());
language_registry.add(markdown_lang());
language_registry.add(python_lang);
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| {
buffer.set_language_registry(language_registry);
buffer.set_language(Some(markdown_lang()), cx);
});
// Test that `else:` correctly outdents to match `if:` inside the Python code block
cx.set_state(indoc! {"
# Heading
```python
def main():
if condition:
pass
ˇ
```
"});
cx.update_editor(|editor, window, cx| {
editor.handle_input("else:", window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
# Heading
```python
def main():
if condition:
pass
else:ˇ
```
"});
}
#[gpui::test]
async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -29370,6 +29412,7 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) {
#[gpui::test]
async fn test_local_worktree_trust(cx: &mut TestAppContext) {
init_test(cx, |_| {});
cx.update(|cx| project::trusted_worktrees::init(HashMap::default(), None, None, cx));
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
@@ -29529,3 +29572,38 @@ async fn test_local_worktree_trust(cx: &mut TestAppContext) {
trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
assert!(can_trust_after, "worktree should be trusted after trust()");
}
#[gpui::test]
fn test_editor_rendering_when_positioned_above_viewport(cx: &mut TestAppContext) {
// This test reproduces a bug where drawing an editor at a position above the viewport
// (simulating what happens when an AutoHeight editor inside a List is scrolled past)
// causes an infinite loop in blocks_in_range.
//
// The issue: when the editor's bounds.origin.y is very negative (above the viewport),
// the content mask intersection produces visible_bounds with origin at the viewport top.
// This makes clipped_top_in_lines very large, causing start_row to exceed max_row.
// When blocks_in_range is called with start_row > max_row, the cursor seeks to the end
// but the while loop after seek never terminates because cursor.next() is a no-op at end.
init_test(cx, |_| {});
let window = cx.add_window(|_, _| gpui::Empty);
let mut cx = VisualTestContext::from_window(*window, cx);
let buffer = cx.update(|_, cx| MultiBuffer::build_simple("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n", cx));
let editor = cx.new_window_entity(|window, cx| build_editor(buffer, window, cx));
// Simulate a small viewport (500x500 pixels at origin 0,0)
cx.simulate_resize(gpui::size(px(500.), px(500.)));
// Draw the editor at a very negative Y position, simulating an editor that's been
// scrolled way above the visible viewport (like in a List that has scrolled past it).
// The editor is 3000px tall but positioned at y=-10000, so it's entirely above the viewport.
// This should NOT hang - it should just render nothing.
cx.draw(
gpui::point(px(0.), px(-10000.)),
gpui::size(px(500.), px(3000.)),
|_, _| editor.clone(),
);
// If we get here without hanging, the test passes
}

View File

@@ -9164,6 +9164,15 @@ impl Element for EditorElement {
let height_in_lines = f64::from(bounds.size.height / line_height);
let max_row = snapshot.max_point().row().as_f64();
// Calculate how much of the editor is clipped by parent containers (e.g., List).
// This allows us to only render lines that are actually visible, which is
// critical for performance when large AutoHeight editors are inside Lists.
let visible_bounds = window.content_mask().bounds;
let clipped_top = (visible_bounds.origin.y - bounds.origin.y).max(px(0.));
let clipped_top_in_lines = f64::from(clipped_top / line_height);
let visible_height_in_lines =
f64::from(visible_bounds.size.height / line_height);
// The max scroll position for the top of the window
let max_scroll_top = if matches!(
snapshot.mode,
@@ -9220,10 +9229,16 @@ impl Element for EditorElement {
let mut scroll_position = snapshot.scroll_position();
// The scroll position is a fractional point, the whole number of which represents
// the top of the window in terms of display rows.
let start_row = DisplayRow(scroll_position.y as u32);
// We add clipped_top_in_lines to skip rows that are clipped by parent containers,
// but we don't modify scroll_position itself since the parent handles positioning.
let max_row = snapshot.max_point().row();
let start_row = cmp::min(
DisplayRow((scroll_position.y + clipped_top_in_lines).floor() as u32),
max_row,
);
let end_row = cmp::min(
(scroll_position.y + height_in_lines).ceil() as u32,
(scroll_position.y + clipped_top_in_lines + visible_height_in_lines).ceil()
as u32,
max_row.next_row().0,
);
let end_row = DisplayRow(end_row);

View File

@@ -218,7 +218,7 @@ impl Editor {
self.hide_hovered_link(cx);
if !hovered_link_state.links.is_empty() {
if !self.focus_handle.is_focused(window) {
window.focus(&self.focus_handle);
window.focus(&self.focus_handle, cx);
}
// exclude links pointing back to the current anchor

View File

@@ -90,8 +90,8 @@ impl MouseContextMenu {
// `true` when the `ContextMenu` is focused.
let focus_handle = context_menu_focus.clone();
cx.on_next_frame(window, move |_, window, cx| {
cx.on_next_frame(window, move |_, window, _cx| {
window.focus(&focus_handle);
cx.on_next_frame(window, move |_, window, cx| {
window.focus(&focus_handle, cx);
});
});
@@ -100,7 +100,7 @@ impl MouseContextMenu {
move |editor, _, _event: &DismissEvent, window, cx| {
editor.mouse_context_menu.take();
if context_menu_focus.contains_focused(window, cx) {
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
}
}
});
@@ -127,7 +127,7 @@ impl MouseContextMenu {
}
editor.mouse_context_menu.take();
if context_menu_focus.contains_focused(window, cx) {
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
}
},
);
@@ -161,7 +161,7 @@ pub fn deploy_context_menu(
cx: &mut Context<Editor>,
) {
if !editor.is_focused(window) {
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
}
// Don't show context menu for inline editors

View File

@@ -176,11 +176,9 @@ pub fn block_content_for_tests(
}
pub fn editor_content_with_blocks(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> String {
cx.draw(
gpui::Point::default(),
size(px(3000.0), px(3000.0)),
|_, _| editor.clone(),
);
let draw_size = size(px(3000.0), px(3000.0));
cx.simulate_resize(draw_size);
cx.draw(gpui::Point::default(), draw_size, |_, _| editor.clone());
let (snapshot, mut lines, blocks) = editor.update_in(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let text = editor.display_text(cx);

View File

@@ -126,7 +126,7 @@ impl EditorLspTestContext {
.read(cx)
.nav_history_for_item(&cx.entity());
editor.set_nav_history(Some(nav_history));
window.focus(&editor.focus_handle(cx))
window.focus(&editor.focus_handle(cx), cx)
});
let lsp = fake_servers.next().await.unwrap();

View File

@@ -78,7 +78,7 @@ impl EditorTestContext {
cx,
);
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
editor
});
let editor_view = editor.root(cx).unwrap();
@@ -139,7 +139,7 @@ impl EditorTestContext {
let editor = cx.add_window(|window, cx| {
let editor = build_editor(buffer, window, cx);
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
editor
});

View File

@@ -422,7 +422,7 @@ pub fn init(cx: &mut App) -> Arc<AgentAppState> {
tx.send(Some(options)).log_err();
})
.detach();
let node_runtime = NodeRuntime::new(client.http_client(), None, rx, None);
let node_runtime = NodeRuntime::new(client.http_client(), None, rx);
let extension_host_proxy = ExtensionHostProxy::global(cx);
debug_adapter_extension::init(extension_host_proxy.clone(), cx);

View File

@@ -736,6 +736,7 @@ impl nodejs::Host for WasmState {
.node_runtime
.npm_package_latest_version(&package_name)
.await
.map(|v| v.to_string())
.to_wasmtime_result()
}
@@ -747,6 +748,7 @@ impl nodejs::Host for WasmState {
.node_runtime
.npm_package_installed_version(&self.work_dir(), &package_name)
.await
.map(|option| option.map(|version| version.to_string()))
.to_wasmtime_result()
}

View File

@@ -1713,7 +1713,7 @@ impl PickerDelegate for FileFinderDelegate {
ui::IconPosition::End,
Some(ToggleIncludeIgnored.boxed_clone()),
move |window, cx| {
window.focus(&focus_handle);
window.focus(&focus_handle, cx);
window.dispatch_action(
ToggleIncludeIgnored.boxed_clone(),
cx,

View File

@@ -72,32 +72,26 @@ pub fn open(
let repository = workspace.project().read(cx).active_repository(cx);
let style = BranchListStyle::Modal;
workspace.toggle_modal(window, cx, |window, cx| {
BranchList::new(
Some(workspace_handle),
repository,
style,
rems(34.),
window,
cx,
)
BranchList::new(workspace_handle, repository, style, rems(34.), window, cx)
})
}
pub fn popover(
workspace: WeakEntity<Workspace>,
repository: Option<Entity<Repository>>,
window: &mut Window,
cx: &mut App,
) -> Entity<BranchList> {
cx.new(|cx| {
let list = BranchList::new(
None,
workspace,
repository,
BranchListStyle::Popover,
rems(20.),
window,
cx,
);
list.focus_handle(cx).focus(window);
list.focus_handle(cx).focus(window, cx);
list
})
}
@@ -117,7 +111,7 @@ pub struct BranchList {
impl BranchList {
fn new(
workspace: Option<WeakEntity<Workspace>>,
workspace: WeakEntity<Workspace>,
repository: Option<Entity<Repository>>,
style: BranchListStyle,
width: Rems,
@@ -316,23 +310,23 @@ impl Entry {
#[derive(Clone, Copy, PartialEq)]
enum BranchFilter {
/// Only show local branches
Local,
/// Only show remote branches
/// Show both local and remote branches.
All,
/// Only show remote branches.
Remote,
}
impl BranchFilter {
fn invert(&self) -> Self {
match self {
BranchFilter::Local => BranchFilter::Remote,
BranchFilter::Remote => BranchFilter::Local,
BranchFilter::All => BranchFilter::Remote,
BranchFilter::Remote => BranchFilter::All,
}
}
}
pub struct BranchListDelegate {
workspace: Option<WeakEntity<Workspace>>,
workspace: WeakEntity<Workspace>,
matches: Vec<Entry>,
all_branches: Option<Vec<Branch>>,
default_branch: Option<SharedString>,
@@ -360,7 +354,7 @@ enum PickerState {
impl BranchListDelegate {
fn new(
workspace: Option<WeakEntity<Workspace>>,
workspace: WeakEntity<Workspace>,
repo: Option<Entity<Repository>>,
style: BranchListStyle,
cx: &mut Context<BranchList>,
@@ -375,7 +369,7 @@ impl BranchListDelegate {
selected_index: 0,
last_query: Default::default(),
modifiers: Default::default(),
branch_filter: BranchFilter::Local,
branch_filter: BranchFilter::All,
state: PickerState::List,
focus_handle: cx.focus_handle(),
}
@@ -464,7 +458,7 @@ impl BranchListDelegate {
log::error!("Failed to delete branch: {}", e);
}
if let Some(workspace) = workspace.and_then(|w| w.upgrade()) {
if let Some(workspace) = workspace.upgrade() {
cx.update(|_window, cx| {
if is_remote {
show_error_toast(
@@ -518,7 +512,7 @@ impl PickerDelegate for BranchListDelegate {
match self.state {
PickerState::List | PickerState::NewRemote | PickerState::NewBranch => {
match self.branch_filter {
BranchFilter::Local => "Select branch…",
BranchFilter::All => "Select branch or remote",
BranchFilter::Remote => "Select remote…",
}
}
@@ -560,8 +554,8 @@ impl PickerDelegate for BranchListDelegate {
self.editor_position() == PickerEditorPosition::End,
|this| {
let tooltip_label = match self.branch_filter {
BranchFilter::Local => "Turn Off Remote Filter",
BranchFilter::Remote => "Filter Remote Branches",
BranchFilter::All => "Filter Remote Branches",
BranchFilter::Remote => "Show All Branches",
};
this.gap_1().justify_between().child({
@@ -625,40 +619,38 @@ impl PickerDelegate for BranchListDelegate {
return Task::ready(());
};
let display_remotes = self.branch_filter;
let branch_filter = self.branch_filter;
cx.spawn_in(window, async move |picker, cx| {
let branch_matches_filter = |branch: &Branch| match branch_filter {
BranchFilter::All => true,
BranchFilter::Remote => branch.is_remote(),
};
let mut matches: Vec<Entry> = if query.is_empty() {
all_branches
let mut matches: Vec<Entry> = all_branches
.into_iter()
.filter(|branch| {
if display_remotes == BranchFilter::Remote {
branch.is_remote()
} else {
!branch.is_remote()
}
})
.filter(|branch| branch_matches_filter(branch))
.map(|branch| Entry::Branch {
branch,
positions: Vec::new(),
})
.collect()
.collect();
// Keep the existing recency sort within each group, but show local branches first.
matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote()));
matches
} else {
let branches = all_branches
.iter()
.filter(|branch| {
if display_remotes == BranchFilter::Remote {
branch.is_remote()
} else {
!branch.is_remote()
}
})
.filter(|branch| branch_matches_filter(branch))
.collect::<Vec<_>>();
let candidates = branches
.iter()
.enumerate()
.map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
.collect::<Vec<StringMatchCandidate>>();
fuzzy::match_strings(
let mut matches: Vec<Entry> = fuzzy::match_strings(
&candidates,
&query,
true,
@@ -673,7 +665,12 @@ impl PickerDelegate for BranchListDelegate {
branch: branches[candidate.candidate_id].clone(),
positions: candidate.positions,
})
.collect()
.collect();
// Keep fuzzy-relevance ordering within local/remote groups, but show locals first.
matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote()));
matches
};
picker
.update(cx, |picker, _| {
@@ -841,10 +838,13 @@ impl PickerDelegate for BranchListDelegate {
Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => {
Icon::new(IconName::Plus).color(Color::Muted)
}
Entry::Branch { .. } => match self.branch_filter {
BranchFilter::Local => Icon::new(IconName::GitBranchAlt).color(Color::Muted),
BranchFilter::Remote => Icon::new(IconName::Screen).color(Color::Muted),
},
Entry::Branch { branch, .. } => {
if branch.is_remote() {
Icon::new(IconName::Screen).color(Color::Muted)
} else {
Icon::new(IconName::GitBranchAlt).color(Color::Muted)
}
}
};
let entry_title = match entry {
@@ -874,19 +874,21 @@ impl PickerDelegate for BranchListDelegate {
Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. }
);
let delete_branch_button = IconButton::new("delete", IconName::Trash)
.tooltip(move |_, cx| {
Tooltip::for_action_in(
"Delete Branch",
&branch_picker::DeleteBranch,
&focus_handle,
cx,
)
})
.on_click(cx.listener(|this, _, window, cx| {
let selected_idx = this.delegate.selected_index();
this.delegate.delete_at(selected_idx, window, cx);
}));
let deleted_branch_icon = |entry_ix: usize, is_head_branch: bool| {
IconButton::new(("delete", entry_ix), IconName::Trash)
.tooltip(move |_, cx| {
Tooltip::for_action_in(
"Delete Branch",
&branch_picker::DeleteBranch,
&focus_handle,
cx,
)
})
.disabled(is_head_branch)
.on_click(cx.listener(move |this, _, window, cx| {
this.delegate.delete_at(entry_ix, window, cx);
}))
};
let create_from_default_button = self.default_branch.as_ref().map(|default_branch| {
let tooltip_label: SharedString = format!("Create New From: {default_branch}").into();
@@ -963,12 +965,12 @@ impl PickerDelegate for BranchListDelegate {
"No commits found".into(),
|subject| {
if show_author_name
&& author_name.is_some()
&& let Some(author) =
author_name
{
format!(
"{}{}",
author_name.unwrap(),
subject
author, subject
)
} else {
subject.to_string()
@@ -1002,10 +1004,12 @@ impl PickerDelegate for BranchListDelegate {
self.editor_position() == PickerEditorPosition::End && !is_new_items,
|this| {
this.map(|this| {
let is_head_branch =
entry.as_branch().is_some_and(|branch| branch.is_head);
if self.selected_index() == ix {
this.end_slot(delete_branch_button)
this.end_slot(deleted_branch_icon(ix, is_head_branch))
} else {
this.end_hover_slot(delete_branch_button)
this.end_hover_slot(deleted_branch_icon(ix, is_head_branch))
}
})
},
@@ -1036,8 +1040,8 @@ impl PickerDelegate for BranchListDelegate {
) -> Option<AnyElement> {
matches!(self.state, PickerState::List).then(|| {
let label = match self.branch_filter {
BranchFilter::Local => "Local",
BranchFilter::Remote => "Remote",
BranchFilter::All => "Branches",
BranchFilter::Remote => "Remotes",
};
ListHeader::new(label).inset(true).into_any_element()
@@ -1230,7 +1234,7 @@ mod tests {
use super::*;
use git::repository::{CommitSummary, Remote};
use gpui::{TestAppContext, VisualTestContext};
use gpui::{AppContext, TestAppContext, VisualTestContext};
use project::{FakeFs, Project};
use rand::{Rng, rngs::StdRng};
use serde_json::json;
@@ -1279,35 +1283,47 @@ mod tests {
]
}
fn init_branch_list_test(
async fn init_branch_list_test(
repository: Option<Entity<Repository>>,
branches: Vec<Branch>,
cx: &mut TestAppContext,
) -> (Entity<BranchList>, VisualTestContext) {
let window = cx.add_window(|window, cx| {
let mut delegate =
BranchListDelegate::new(None, repository, BranchListStyle::Modal, cx);
delegate.all_branches = Some(branches);
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
let picker_focus_handle = picker.focus_handle(cx);
picker.update(cx, |picker, _| {
picker.delegate.focus_handle = picker_focus_handle.clone();
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
cx.emit(DismissEvent);
});
let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
BranchList {
picker,
picker_focus_handle,
width: rems(34.),
_subscription,
}
});
let branch_list = workspace
.update(cx, |workspace, window, cx| {
cx.new(|cx| {
let mut delegate = BranchListDelegate::new(
workspace.weak_handle(),
repository,
BranchListStyle::Modal,
cx,
);
delegate.all_branches = Some(branches);
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
let picker_focus_handle = picker.focus_handle(cx);
picker.update(cx, |picker, _| {
picker.delegate.focus_handle = picker_focus_handle.clone();
});
let branch_list = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(*window, cx);
let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
cx.emit(DismissEvent);
});
BranchList {
picker,
picker_focus_handle,
width: rems(34.),
_subscription,
}
})
})
.unwrap();
let cx = VisualTestContext::from_window(*workspace, cx);
(branch_list, cx)
}
@@ -1343,7 +1359,7 @@ mod tests {
init_test(cx);
let branches = create_test_branches();
let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx);
let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
let cx = &mut ctx;
branch_list
@@ -1419,7 +1435,7 @@ mod tests {
.await;
cx.run_until_parked();
let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx);
let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
let cx = &mut ctx;
update_branch_list_matches_with_empty_query(&branch_list, cx).await;
@@ -1484,7 +1500,7 @@ mod tests {
.await;
cx.run_until_parked();
let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx);
let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
let cx = &mut ctx;
// Enable remote filter
branch_list.update(cx, |branch_list, cx| {
@@ -1532,7 +1548,7 @@ mod tests {
}
#[gpui::test]
async fn test_update_remote_matches_with_query(cx: &mut TestAppContext) {
async fn test_branch_filter_shows_all_then_remotes_and_applies_query(cx: &mut TestAppContext) {
init_test(cx);
let branches = vec![
@@ -1542,39 +1558,54 @@ mod tests {
create_test_branch("develop", false, None, Some(700)),
];
let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx);
let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
let cx = &mut ctx;
update_branch_list_matches_with_empty_query(&branch_list, cx).await;
// Check matches, it should match all existing branches and no option to create new branch
branch_list
.update_in(cx, |branch_list, window, cx| {
branch_list.picker.update(cx, |picker, cx| {
assert_eq!(picker.delegate.matches.len(), 2);
let branches = picker
.delegate
.matches
.iter()
.map(|be| be.name())
.collect::<HashSet<_>>();
assert_eq!(
branches,
["feature-ui", "develop"]
.into_iter()
.collect::<HashSet<_>>()
);
branch_list.update(cx, |branch_list, cx| {
branch_list.picker.update(cx, |picker, _cx| {
assert_eq!(picker.delegate.matches.len(), 4);
// Verify the last entry is NOT the "create new branch" option
let last_match = picker.delegate.matches.last().unwrap();
assert!(!last_match.is_new_branch());
assert!(!last_match.is_new_url());
picker.delegate.branch_filter = BranchFilter::Remote;
picker.delegate.update_matches(String::new(), window, cx)
})
let branches = picker
.delegate
.matches
.iter()
.map(|be| be.name())
.collect::<HashSet<_>>();
assert_eq!(
branches,
["origin/main", "fork/feature-auth", "feature-ui", "develop"]
.into_iter()
.collect::<HashSet<_>>()
);
// Locals should be listed before remotes.
let ordered = picker
.delegate
.matches
.iter()
.map(|be| be.name())
.collect::<Vec<_>>();
assert_eq!(
ordered,
vec!["feature-ui", "develop", "origin/main", "fork/feature-auth"]
);
// Verify the last entry is NOT the "create new branch" option
let last_match = picker.delegate.matches.last().unwrap();
assert!(!last_match.is_new_branch());
assert!(!last_match.is_new_url());
})
.await;
cx.run_until_parked();
});
branch_list.update(cx, |branch_list, cx| {
branch_list.picker.update(cx, |picker, _cx| {
picker.delegate.branch_filter = BranchFilter::Remote;
})
});
update_branch_list_matches_with_empty_query(&branch_list, cx).await;
branch_list
.update_in(cx, |branch_list, window, cx| {
@@ -1637,7 +1668,8 @@ mod tests {
create_test_branch(FEATURE_BRANCH, false, None, Some(900)),
];
let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, test_cx);
let (branch_list, mut ctx) =
init_branch_list_test(repository.into(), branches, test_cx).await;
let cx = &mut ctx;
branch_list
@@ -1696,7 +1728,7 @@ mod tests {
let repository = init_fake_repository(cx).await;
let branches = vec![create_test_branch("main", true, None, Some(1000))];
let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx);
let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
let cx = &mut ctx;
branch_list
@@ -1774,7 +1806,7 @@ mod tests {
init_test(cx);
let branches = vec![create_test_branch("main_branch", true, None, Some(1000))];
let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx);
let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
let cx = &mut ctx;
branch_list
@@ -1837,7 +1869,7 @@ mod tests {
init_test(cx);
let branches = vec![create_test_branch("main", true, None, Some(1000))];
let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx);
let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
let cx = &mut ctx;
let subscription = cx.update(|_, cx| {
@@ -1848,7 +1880,12 @@ mod tests {
branch_list
.update_in(cx, |branch_list, window, cx| {
window.focus(&branch_list.picker_focus_handle);
window.focus(&branch_list.picker_focus_handle, cx);
assert!(
branch_list.picker_focus_handle.is_focused(window),
"Branch picker should be focused when selecting an entry"
);
branch_list.picker.update(cx, |picker, cx| {
picker
.delegate
@@ -1860,6 +1897,9 @@ mod tests {
cx.run_until_parked();
branch_list.update_in(cx, |branch_list, window, cx| {
// Re-focus the picker since workspace initialization during run_until_parked
window.focus(&branch_list.picker_focus_handle, cx);
branch_list.picker.update(cx, |picker, cx| {
let last_match = picker.delegate.matches.last().unwrap();
assert!(last_match.is_new_url());
@@ -1893,7 +1933,7 @@ mod tests {
.map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100)))
.collect();
let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx);
let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
let cx = &mut ctx;
update_branch_list_matches_with_empty_query(&branch_list, cx).await;

View File

@@ -337,6 +337,7 @@ impl CommitModal {
active_repo,
is_amend_pending,
is_signoff_enabled,
workspace,
) = self.git_panel.update(cx, |git_panel, cx| {
let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
let title = git_panel.commit_button_title();
@@ -354,6 +355,7 @@ impl CommitModal {
active_repo,
is_amend_pending,
is_signoff_enabled,
git_panel.workspace.clone(),
)
});
@@ -375,7 +377,14 @@ impl CommitModal {
.style(ButtonStyle::Transparent);
let branch_picker = PopoverMenu::new("popover-button")
.menu(move |window, cx| Some(branch_picker::popover(active_repo.clone(), window, cx)))
.menu(move |window, cx| {
Some(branch_picker::popover(
workspace.clone(),
active_repo.clone(),
window,
cx,
))
})
.with_handle(self.branch_list_handle.clone())
.trigger_with_tooltip(
branch_picker_button,
@@ -512,7 +521,7 @@ impl CommitModal {
fn toggle_branch_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.branch_list_handle.is_focused(window, cx) {
self.focus_handle(cx).focus(window)
self.focus_handle(cx).focus(window, cx)
} else {
self.branch_list_handle.toggle(window, cx);
}
@@ -578,8 +587,8 @@ impl Render for CommitModal {
.bg(cx.theme().colors().editor_background)
.border_1()
.border_color(cx.theme().colors().border_variant)
.on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
window.focus(&editor_focus_handle);
.on_click(cx.listener(move |_, _: &ClickEvent, window, cx| {
window.focus(&editor_focus_handle, cx);
}))
.child(
div()

View File

@@ -633,9 +633,9 @@ impl Item for FileHistoryView {
&mut self,
_workspace: &mut Workspace,
window: &mut Window,
_cx: &mut Context<Self>,
cx: &mut Context<Self>,
) {
window.focus(&self.focus_handle);
window.focus(&self.focus_handle, cx);
}
fn show_toolbar(&self) -> bool {

View File

@@ -43,7 +43,8 @@ use gpui::{
use itertools::Itertools;
use language::{Buffer, File};
use language_model::{
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
Role, ZED_CLOUD_PROVIDER_ID,
};
use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use multi_buffer::ExcerptInfo;
@@ -278,6 +279,13 @@ impl GitListEntry {
_ => None,
}
}
fn directory_entry(&self) -> Option<&GitTreeDirEntry> {
match self {
GitListEntry::Directory(entry) => Some(entry),
_ => None,
}
}
}
enum GitPanelViewMode {
@@ -593,7 +601,7 @@ pub struct GitPanel {
tracked_staged_count: usize,
update_visible_entries_task: Task<()>,
width: Option<Pixels>,
workspace: WeakEntity<Workspace>,
pub(crate) workspace: WeakEntity<Workspace>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
modal_open: bool,
show_placeholders: bool,
@@ -894,12 +902,6 @@ impl GitPanel {
cx.notify();
}
fn first_status_entry_index(&self) -> Option<usize> {
self.entries
.iter()
.position(|entry| entry.status_entry().is_some())
}
fn expand_selected_entry(
&mut self,
_: &ExpandSelectedEntry,
@@ -943,7 +945,21 @@ impl GitPanel {
}
fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
if let Some(first_entry) = self.first_status_entry_index() {
let first_entry = match &self.view_mode {
GitPanelViewMode::Flat => self
.entries
.iter()
.position(|entry| entry.status_entry().is_some()),
GitPanelViewMode::Tree(state) => {
let index = self.entries.iter().position(|entry| {
entry.status_entry().is_some() || entry.directory_entry().is_some()
});
index.map(|index| state.logical_indices[index])
}
};
if let Some(first_entry) = first_entry {
self.selected_entry = Some(first_entry);
self.scroll_to_selected_entry(cx);
}
@@ -960,28 +976,44 @@ impl GitPanel {
return;
}
if let Some(selected_entry) = self.selected_entry {
let new_selected_entry = if selected_entry > 0 {
selected_entry - 1
} else {
selected_entry
};
let Some(selected_entry) = self.selected_entry else {
return;
};
if matches!(
self.entries.get(new_selected_entry),
Some(GitListEntry::Header(..))
) {
if new_selected_entry > 0 {
self.selected_entry = Some(new_selected_entry - 1)
}
} else {
self.selected_entry = Some(new_selected_entry);
let new_index = match &self.view_mode {
GitPanelViewMode::Flat => selected_entry.saturating_sub(1),
GitPanelViewMode::Tree(state) => {
let Some(current_logical_index) = state
.logical_indices
.iter()
.position(|&i| i == selected_entry)
else {
return;
};
state.logical_indices[current_logical_index.saturating_sub(1)]
}
};
self.scroll_to_selected_entry(cx);
if selected_entry == 0 && new_index == 0 {
return;
}
cx.notify();
if matches!(
self.entries.get(new_index.saturating_sub(1)),
Some(GitListEntry::Header(..))
) && new_index == 0
{
return;
}
if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
self.selected_entry = Some(new_index.saturating_sub(1));
} else {
self.selected_entry = Some(new_index);
}
self.scroll_to_selected_entry(cx);
}
fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
@@ -990,25 +1022,36 @@ impl GitPanel {
return;
}
if let Some(selected_entry) = self.selected_entry {
let new_selected_entry = if selected_entry < item_count - 1 {
selected_entry + 1
} else {
selected_entry
};
if matches!(
self.entries.get(new_selected_entry),
Some(GitListEntry::Header(..))
) {
self.selected_entry = Some(new_selected_entry + 1);
} else {
self.selected_entry = Some(new_selected_entry);
}
let Some(selected_entry) = self.selected_entry else {
return;
};
self.scroll_to_selected_entry(cx);
if selected_entry == item_count - 1 {
return;
}
cx.notify();
let new_index = match &self.view_mode {
GitPanelViewMode::Flat => selected_entry.saturating_add(1),
GitPanelViewMode::Tree(state) => {
let Some(current_logical_index) = state
.logical_indices
.iter()
.position(|&i| i == selected_entry)
else {
return;
};
state.logical_indices[current_logical_index.saturating_add(1)]
}
};
if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
self.selected_entry = Some(new_index.saturating_add(1));
} else {
self.selected_entry = Some(new_index);
}
self.scroll_to_selected_entry(cx);
}
fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
@@ -1020,20 +1063,18 @@ impl GitPanel {
fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
self.commit_editor.update(cx, |editor, cx| {
window.focus(&editor.focus_handle(cx));
window.focus(&editor.focus_handle(cx), cx);
});
cx.notify();
}
fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
fn select_first_entry_if_none(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let have_entries = self
.active_repository
.as_ref()
.is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
if have_entries && self.selected_entry.is_none() {
self.selected_entry = self.first_status_entry_index();
self.scroll_to_selected_entry(cx);
cx.notify();
self.select_first(&SelectFirst, window, cx);
}
}
@@ -1043,10 +1084,8 @@ impl GitPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.select_first_entry_if_none(cx);
self.focus_handle.focus(window);
cx.notify();
self.focus_handle.focus(window, cx);
self.select_first_entry_if_none(window, cx);
}
fn get_selected_entry(&self) -> Option<&GitListEntry> {
@@ -1067,7 +1106,7 @@ impl GitPanel {
.project_path_to_repo_path(&project_path, cx)
.as_ref()
{
project_diff.focus_handle(cx).focus(window);
project_diff.focus_handle(cx).focus(window, cx);
project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
return None;
};
@@ -1077,7 +1116,7 @@ impl GitPanel {
ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
})
.ok();
self.focus_handle.focus(window);
self.focus_handle.focus(window, cx);
Some(())
});
@@ -1180,14 +1219,14 @@ impl GitPanel {
let prompt = window.prompt(
PromptLevel::Warning,
&format!(
"Are you sure you want to restore {}?",
"Are you sure you want to discard changes to {}?",
entry
.repo_path
.file_name()
.unwrap_or(entry.repo_path.display(path_style).as_ref()),
),
None,
&["Restore", "Cancel"],
&["Discard Changes", "Cancel"],
cx,
);
cx.background_spawn(prompt)
@@ -2088,7 +2127,10 @@ impl GitPanel {
let commit_message = self.custom_or_suggested_commit_message(window, cx);
let Some(mut message) = commit_message else {
self.commit_editor.read(cx).focus_handle(cx).focus(window);
self.commit_editor
.read(cx)
.focus_handle(cx)
.focus(window, cx);
return;
};
@@ -2422,9 +2464,20 @@ impl GitPanel {
}
}
async fn load_commit_message_prompt(cx: &mut AsyncApp) -> String {
async fn load_commit_message_prompt(
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();
}
let load = async {
let store = cx.update(|cx| PromptStore::global(cx)).ok()?.await.ok()?;
store
@@ -2466,6 +2519,13 @@ impl GitPanel {
let project = self.project.clone();
let repo_work_dir = repo.read(cx).work_directory_abs_path.clone();
// Remove this once we stop supporting legacy Zed Pro
let is_using_legacy_zed_pro = provider.id() == ZED_CLOUD_PROVIDER_ID
&& self.workspace.upgrade().map_or(false, |workspace| {
workspace.read(cx).user_store().read(cx).plan()
== Some(cloud_llm_client::Plan::V1(cloud_llm_client::PlanV1::ZedPro))
});
self.generate_commit_message_task = Some(cx.spawn(async move |this, mut cx| {
async move {
let _defer = cx.on_drop(&this, |this, _cx| {
@@ -2501,7 +2561,7 @@ impl GitPanel {
let rules_content = Self::load_project_rules(&project, &repo_work_dir, &mut cx).await;
let prompt = Self::load_commit_message_prompt(&mut cx).await;
let prompt = Self::load_commit_message_prompt(is_using_legacy_zed_pro, &mut cx).await;
let subject = this.update(cx, |this, cx| {
this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default()
@@ -3503,7 +3563,7 @@ impl GitPanel {
self.bulk_staging = bulk_staging;
}
self.select_first_entry_if_none(cx);
self.select_first_entry_if_none(window, cx);
let suggested_commit_message = self.suggest_commit_message(cx);
let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into());
@@ -4088,7 +4148,7 @@ impl GitPanel {
.border_color(cx.theme().colors().border)
.cursor_text()
.on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
window.focus(&this.commit_editor.focus_handle(cx));
window.focus(&this.commit_editor.focus_handle(cx), cx);
}))
.child(
h_flex()
@@ -4652,7 +4712,7 @@ impl GitPanel {
let restore_title = if entry.status.is_created() {
"Trash File"
} else {
"Restore File"
"Discard Changes"
};
let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
let is_created = entry.status.is_created();
@@ -4882,7 +4942,7 @@ impl GitPanel {
this.open_file(&Default::default(), window, cx)
} else {
this.open_diff(&Default::default(), window, cx);
this.focus_handle.focus(window);
this.focus_handle.focus(window, cx);
}
})
})
@@ -4903,7 +4963,7 @@ impl GitPanel {
cx.stop_propagation();
},
)
.child(name_row)
.child(name_row.overflow_x_hidden())
.child(
div()
.id(checkbox_wrapper_id)
@@ -5057,7 +5117,7 @@ impl GitPanel {
this.toggle_directory(&key, window, cx);
})
})
.child(name_row)
.child(name_row.overflow_x_hidden())
.child(
div()
.id(checkbox_wrapper_id)
@@ -5571,10 +5631,14 @@ impl RenderOnce for PanelRepoFooter {
.as_ref()
.map(|panel| panel.read(cx).project.clone());
let repo = self
let (workspace, repo) = self
.git_panel
.as_ref()
.and_then(|panel| panel.read(cx).active_repository.clone());
.map(|panel| {
let panel = panel.read(cx);
(panel.workspace.clone(), panel.active_repository.clone())
})
.unzip();
let single_repo = project
.as_ref()
@@ -5662,7 +5726,11 @@ impl RenderOnce for PanelRepoFooter {
});
let branch_selector = PopoverMenu::new("popover-button")
.menu(move |window, cx| Some(branch_picker::popover(repo.clone(), window, cx)))
.menu(move |window, cx| {
let workspace = workspace.clone()?;
let repo = repo.clone().flatten();
Some(branch_picker::popover(workspace, repo, window, cx))
})
.trigger_with_tooltip(
branch_selector_button,
Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch),

View File

@@ -817,7 +817,7 @@ impl GitCloneModal {
});
let focus_handle = repo_input.focus_handle(cx);
window.focus(&focus_handle);
window.focus(&focus_handle, cx);
Self {
panel,

View File

@@ -85,8 +85,8 @@ impl Render for GitOnboardingModal {
git_onboarding_event!("Cancelled", trigger = "Action");
cx.emit(DismissEvent);
}))
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
this.focus_handle.focus(window);
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
this.focus_handle.focus(window, cx);
}))
.child(
div().p_1p5().absolute().inset_0().h(px(160.)).child(

View File

@@ -492,7 +492,7 @@ impl ProjectDiff {
if editor.focus_handle(cx).contains_focused(window, cx)
&& self.multibuffer.read(cx).is_empty()
{
self.focus_handle.focus(window)
self.focus_handle.focus(window, cx)
}
}
@@ -597,10 +597,10 @@ impl ProjectDiff {
.focus_handle(cx)
.contains_focused(window, cx)
{
self.focus_handle.focus(window);
self.focus_handle.focus(window, cx);
} else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
self.editor.update(cx, |editor, cx| {
editor.focus_handle(cx).focus(window);
editor.focus_handle(cx).focus(window, cx);
});
}
if self.pending_scroll.as_ref() == Some(&path_key) {
@@ -983,7 +983,7 @@ impl Render for ProjectDiff {
cx,
))
.on_click(move |_, window, cx| {
window.focus(&keybinding_focus_handle);
window.focus(&keybinding_focus_handle, cx);
window.dispatch_action(
Box::new(CloseActiveItem::default()),
cx,
@@ -1153,7 +1153,7 @@ impl ProjectDiffToolbar {
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
if let Some(project_diff) = self.project_diff(cx) {
project_diff.focus_handle(cx).focus(window);
project_diff.focus_handle(cx).focus(window, cx);
}
let action = action.boxed_clone();
cx.defer(move |cx| {

View File

@@ -268,7 +268,7 @@ impl GoToLine {
cx,
|s| s.select_anchor_ranges([start..start]),
);
editor.focus_handle(cx).focus(window);
editor.focus_handle(cx).focus(window, cx);
cx.notify()
});
self.prev_scroll_position.take();

View File

@@ -29,7 +29,7 @@ impl Example {
];
let focus_handle = cx.focus_handle();
window.focus(&focus_handle);
window.focus(&focus_handle, cx);
Self {
focus_handle,
@@ -40,13 +40,13 @@ impl Example {
}
}
fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context<Self>) {
window.focus_next();
fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
window.focus_next(cx);
self.message = SharedString::from("Pressed Tab - focus-visible border should appear!");
}
fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context<Self>) {
window.focus_prev();
fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context<Self>) {
window.focus_prev(cx);
self.message =
SharedString::from("Pressed Shift-Tab - focus-visible border should appear!");
}

View File

@@ -736,7 +736,7 @@ fn main() {
window
.update(cx, |view, window, cx| {
window.focus(&view.text_input.focus_handle(cx));
window.focus(&view.text_input.focus_handle(cx), cx);
cx.activate(true);
})
.unwrap();

View File

@@ -55,7 +55,7 @@ fn main() {
cx.activate(false);
cx.new(|cx| {
let focus_handle = cx.focus_handle();
focus_handle.focus(window);
focus_handle.focus(window, cx);
ExampleWindow { focus_handle }
})
},
@@ -72,7 +72,7 @@ fn main() {
|window, cx| {
cx.new(|cx| {
let focus_handle = cx.focus_handle();
focus_handle.focus(window);
focus_handle.focus(window, cx);
ExampleWindow { focus_handle }
})
},

View File

@@ -22,7 +22,7 @@ impl Example {
];
let focus_handle = cx.focus_handle();
window.focus(&focus_handle);
window.focus(&focus_handle, cx);
Self {
focus_handle,
@@ -31,13 +31,13 @@ impl Example {
}
}
fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context<Self>) {
window.focus_next();
fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
window.focus_next(cx);
self.message = SharedString::from("You have pressed `Tab`.");
}
fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context<Self>) {
window.focus_prev();
fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context<Self>) {
window.focus_prev(cx);
self.message = SharedString::from("You have pressed `Shift-Tab`.");
}
}

View File

@@ -1900,8 +1900,11 @@ impl App {
pub(crate) fn clear_pending_keystrokes(&mut self) {
for window in self.windows() {
window
.update(self, |_, window, _| {
window.clear_pending_keystrokes();
.update(self, |_, window, cx| {
if window.pending_input_keystrokes().is_some() {
window.clear_pending_keystrokes();
window.pending_input_changed(cx);
}
})
.ok();
}

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