Compare commits

...

30 Commits

Author SHA1 Message Date
Danilo Leal
003e2c064f More UI iteration 2025-12-18 14:00:13 -03:00
Danilo Leal
3ad2c1d120 Iterate on the UI 2025-12-18 13:55:34 -03:00
Danilo Leal
23cf50f097 Iterate on the UI 2025-12-18 13:53:15 -03:00
Danilo Leal
cd9aca69f6 Iterate on the UI 2025-12-18 13:51:33 -03:00
Danilo Leal
93ecfa7555 Kick off building out the component 2025-12-18 13:47:24 -03: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
Katie Geer
7098952a1c docs: Migrate from Intellij (#44928)
Adding migration guide for Intellij as well as a doc of rules for agents
to help write future docs

Release Notes:

- N/A...
2025-12-16 10:47:24 -08:00
Kirill Bulatov
bd5569b338 Bump tree-sitter to the latest (#44963)
Release Notes:

- N/A

Co-authored-by: Lukas Wirth <me@lukaswirth.dev>
2025-12-16 20:41:38 +02:00
Nathan Sobo
be1f824a35 Fix agent notification getting stuck when thread view is dropped (#44939)
Closes #32951

## Summary

When an agent notification was shown and the `AcpThreadView` was dropped
(e.g., by closing the project window or navigating to a new thread), the
notification would become orphaned and undismissable because the
subscriptions handling dismiss events were dropped along with the thread
view.

## Fix

Added an `on_release` callback that closes all notification windows when
the thread view is dropped. This ensures notifications are always
cleaned up properly.

## Testing

Added `test_notification_closed_when_thread_view_dropped` to verify
notifications are closed when the thread view is dropped.

Release Notes:

- Fixed agent notification getting stuck and becoming undismissable when
the project window is closed or when navigating to a new thread
2025-12-16 11:38:46 -07:00
Kirill Bulatov
f21cec7cb1 Introduce worktree trust mechanism (#44887)
Closes https://github.com/zed-industries/zed/issues/12589 

Forces Zed to require user permissions before running any basic
potentially dangerous actions: parsing and synchronizing
`.zed/settings.json`, downloading and spawning any language and MCP
servers (includes `prettier` and `copilot` instances) and all
`NodeRuntime` interactions.
There are more we can add later, among the ideas: DAP downloads on
debugger start, Python virtual environment, etc.

By default, Zed starts in restricted mode and shows a `! Restricted
Mode` in the title bar, no aforementioned actions are executed.
Clicking it or calling `workspace::ToggleWorktreeSecurity` command will
bring a modal to trust worktrees or dismiss the modal:

<img width="1341" height="475" alt="1"
src="https://github.com/user-attachments/assets/4fabe63a-6494-42c7-b0ea-606abb1c0c20"
/>

Agent Panel shows a message too:

<img width="644" height="106" alt="2"
src="https://github.com/user-attachments/assets/0a4554bc-1f1e-455b-b97d-244d7d6a3259"
/>

This works on local, SSH and WSL remote projects, trusted worktrees are
persisted between Zed restarts.
There's a way to clear all persisted trust with
`workspace::ClearTrustedWorktrees`, this will restart Zed.

This mechanism can be turned off with settings:
```jsonc
"session": {
  "trust_all_worktrees": true
}
```
in this mode, all worktrees will be trusted by default, allowing all
actions, but no auto trust will be persisted: hence, when the setting is
changed back, auto trusted worktrees will require another trust
confirmation.

This settings switch was added to the onboarding view also.

Release Notes:

- Introduced worktree trust mechanism, can be turned off with
`"session": { "trust_all_worktrees": true }`

---------

Co-authored-by: Matt Miller <mattrx@gmail.com>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: John D. Swanson <swanson.john.d@gmail.com>
2025-12-16 20:34:00 +02:00
Mayank Verma
93d79f3862 git: Add support for repository excludes file (#42082)
Closes #4824

Release Notes:

- Added support for Git repository excludes file `.git/info/exclude`

---------

Co-authored-by: Cole Miller <m@cole-miller.net>
Co-authored-by: Cole Miller <cole@zed.dev>
2025-12-16 13:09:09 -05:00
max
4896f477e2 Add MCP prompt support to agent threads (#43523)
Fixes #43165

## Problem
MCP prompts were only available in text threads, not agent threads.
Users with MCP servers that expose prompts couldn't use them in the main
agent panel.

## Solution
Added MCP prompt support to agent threads by:
- Creating `ContextServerPromptRegistry` to track MCP prompts from
context servers
- Subscribing to context server events to reload prompts when MCP
servers start/stop
- Converting MCP prompts to available commands that appear in the slash
command menu
- Integrating prompt expansion into the agent message flow

## Testing
Tested with a custom MCP server exposing `explain-code` and
`write-tests` prompts. Prompts now appear in the `/` slash command menu
in agent threads.

Release Notes:

- Added MCP prompt support to agent threads. Prompts from MCP servers
now appear in the slash command menu when typing `/` in agent threads.

---------

Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-12-16 18:03:34 +00:00
Bennet Bo Fenner
d07818b20f git: Allow customising commit message prompt from rules library (#45004)
Closes #26823 

Release Notes:

- Added support for customising the prompt used for generating commit
message in the rules library

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-16 18:02:13 +00:00
Nathan Sobo
c1317baebe Revert "Optimize editor rendering when clipped by parent containers" (#45011)
This reverts commit 914b0117fb (#44995).

The optimization introduced a regression that causes the main thread to
hang for **100+ seconds** in certain scenarios, requiring a force quit
to recover.

## Analysis from spindump

When a large `AutoHeight` editor is displayed inside a `List` (e.g.,
Agent Panel thread view), the clipping calculation can produce invalid
row ranges:

1. `visible_bounds` from `window.content_mask().bounds` represents the
window's content mask, not the intersection with the editor
2. When the editor is partially scrolled out of view,
`clipped_top_in_lines` becomes extremely large
3. This causes `start_row` to be computed as an astronomically high
value
4. `blocks_in_range(start_row..end_row)` then spends excessive time in
`Cursor::search_forward` iterating through the block tree

The spindump showed **~46% of samples** (459/1001 over 10+ seconds)
stuck in `BlockSnapshot::blocks_in_range()`, specifically in cursor
iteration.

### Heaviest stack trace
```
EditorElement::prepaint
  └─ blocks_in_range + 236
       └─ Cursor::search_forward (459 samples)
```

## Symptoms

- Main thread unresponsive for 33-113 seconds before sampling even began
- UI completely frozen
- High CPU usage on main thread (10+ seconds of CPU time in the sample)
- Force quit required to recover

## Path forward

The original optimization goal (reducing line layout work for clipped
editors) is valid, but the implementation needs to:
1. Correctly calculate the **intersection** of editor bounds with the
visible viewport
2. Ensure row calculations stay within valid ranges (clamped to
`max_row`)
3. Handle edge cases where the editor is completely outside the visible
bounds

Release Notes:

- Fixed a hang that could occur when viewing large diffs in the Agent
Panel
2025-12-16 12:58:10 -05:00
Remco Smits
3f11cbd62c git_ui: Add support for collapsing/expanding entries with your keyboard (#45002)
This PR adds support for collapsing/expanding Git entries with your
keyboard like you can inside the project panel and variable list.

I noticed there is a bug that selecting the next entry when you are on
the directory level will select a non-visible entry. Will fix that in
another PR, as it is not related to this feature implementation.

**Result**:


https://github.com/user-attachments/assets/912cc146-1e1c-485f-9b60-5ddc0a124696

Release Notes:

- Git panel: Add support for collapsing/expanding entries with your
keyboard.
2025-12-16 17:51:58 +00:00
Joseph T. Lyons
bcebe76e53 Bump Zed to v0.219 (#45009)
Release Notes:

- N/A
2025-12-16 17:14:57 +00:00
Jakub Konka
0466db66cd helix: Map Zed's specific diff and git-related to goto mode (#45006)
Until now, Helix-mode users would have to rely on Vim's `d *` behaviour
which cannot be reliably replicated with Helix's default delete
behaviour and so I believe that remapping this functionality to Helix's
goto mode is a better fit.

Release Notes:

- Added custom mappings for Zed specific diff and git-related actions to
Helix's goto mode:
  * `g o` - toggle selected diff hunks
  * `g O` - toggle staged
  * `g R` - restore change
  * `g u` - stage and goto next diff hunk
  * `g U` - unstage and goto next diff hunk
2025-12-16 16:41:27 +00:00
Nathan Sobo
420254cff1 Re-add save_file and restore_file_from_disk agent tools (#45005)
This re-introduces the `save_file` and `restore_file_from_disk` agent
tools that were reverted in #44949.

I pushed that original PR without trying it just to get the build off my
machine, but I had missed a step: the tools weren't added to the default
profile settings in `default.json`, so they were never enabled even
though the code was present.

## Changes

- Add `save_file` and `restore_file_from_disk` to the "write" profile in
`default.json`
- Add `Thread::has_tool()` method to check tool availability at runtime
- Make `edit_file_tool`'s dirty buffer error message conditional on
whether `save_file`/`restore_file_from_disk` tools are available (so the
agent gets appropriate guidance based on what tools it actually has)
- Update test to match new conditional error message behavior

Release Notes:

- Added `save_file` and `restore_file_from_disk` agent tools to handle
dirty buffers when editing files
2025-12-16 09:18:51 -07:00
Lena
8b9fa1581c Update contribution ideas and guidelines (#45001)
Release Notes:

- N/A
2025-12-16 16:01:28 +00:00
Antonio Scandurra
914b0117fb Optimize editor rendering when clipped by parent containers (#44995)
Fixes #44997

## Summary

Optimizes editor rendering when an editor is partially clipped by a
parent container (e.g., a `List`). The editor now only lays out and
renders lines that are actually visible within the viewport, rather than
all lines in the document.

## Problem

When an `AutoHeight` editor with thousands of lines is placed inside a
scrollable `List` (such as in the Agent Panel thread view), the editor
would lay out **all** lines during prepaint, even though only a small
portion was visible. Profiling showed that ~50% of frame time was spent
in `EditorElement::prepaint` → `LineWithInvisibles::from_chunks`,
processing thousands of invisible lines.

## Solution

Calculate the intersection of the editor's bounds with the current
content mask (which represents the visible viewport after all parent
clipping). Use this to determine:
1. `clipped_top_in_lines` - how many lines are clipped above the
viewport
2. `visible_height_in_lines` - how many lines are actually visible

Then adjust `start_row` and `end_row` to only include visible lines. The
parent container handles positioning, so `scroll_position` remains
unchanged for paint calculations.

## Example

For a 3000-line editor where only 50 lines are visible:
- **Before**: Lay out and render 3000 lines
- **After**: Lay out and render ~50 lines

## Testing

Verified the following scenarios work correctly:
- Editor fully visible (no clipping)
- Editor clipped from top
- Editor clipped from bottom
- Editor completely outside viewport (renders nothing)
- Fractional line clipping at boundaries
- Scrollable editors with internal scroll state inside a clipped
container

Release Notes:

- Improved agent panel performance when rendering large diffs.
2025-12-16 16:59:26 +01:00
106 changed files with 9205 additions and 1022 deletions

View File

@@ -15,15 +15,17 @@ with the community to improve the product in ways we haven't thought of (or had
In particular we love PRs that are:
- Fixes to existing bugs and issues.
- Small enhancements to existing features, particularly to make them work for more people.
- Fixing or extending the docs.
- Fixing bugs.
- Small enhancements to existing features to make them work for more people (making things work on more platforms/modes/whatever).
- Small extra features, like keybindings or actions you miss from other editors or extensions.
- Work towards shipping larger features on our roadmap.
- Part of a Community Program like [Let's Git Together](https://github.com/zed-industries/zed/issues/41541).
If you're looking for concrete ideas:
- Our [top-ranking issues](https://github.com/zed-industries/zed/issues/5393) based on votes by the community.
- Our [public roadmap](https://zed.dev/roadmap) contains a rough outline of our near-term priorities for Zed.
- [Curated board of issues](https://github.com/orgs/zed-industries/projects/69) suitable for everyone from first-time contributors to seasoned community champions.
- [Triaged bugs with confirmed steps to reproduce](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3Astate%3Areproducible).
- [Area labels](https://github.com/zed-industries/zed/labels?q=area%3A*) to browse bugs in a specific part of the product you care about (after clicking on an area label, add type:Bug to the search).
## Sending changes
@@ -37,9 +39,17 @@ like, sorry).
Although we will take a look, we tend to only merge about half the PRs that are
submitted. If you'd like your PR to have the best chance of being merged:
- Include a clear description of what you're solving, and why it's important to you.
- Include tests.
- If it changes the UI, attach screenshots or screen recordings.
- Make sure the change is **desired**: we're always happy to accept bugfixes,
but features should be confirmed with us first if you aim to avoid wasted
effort. If there isn't already a GitHub issue for your feature with staff
confirmation that we want it, start with a GitHub discussion rather than a PR.
- Include a clear description of **what you're solving**, and why it's important.
- Include **tests**.
- If it changes the UI, attach **screenshots** or screen recordings.
- Make the PR about **one thing only**, e.g. if it's a bugfix, don't add two
features and a refactoring on top of that.
- Keep AI assistance under your judgement and responsibility: it's unlikely
we'll merge a vibe-coded PR that the author doesn't understand.
The internal advice for reviewers is as follows:
@@ -50,10 +60,9 @@ The internal advice for reviewers is as follows:
If you need more feedback from us: the best way is to be responsive to
Github comments, or to offer up time to pair with us.
If you are making a larger change, or need advice on how to finish the change
you're making, please open the PR early. We would love to help you get
things right, and it's often easier to see how to solve a problem before the
diff gets too big.
If you need help deciding how to fix a bug, or finish implementing a feature
that we've agreed we want, please open a PR early so we can discuss how to make
the change with code in hand.
## Things we will (probably) not merge
@@ -61,11 +70,11 @@ Although there are few hard and fast rules, typically we don't merge:
- Anything that can be provided by an extension. For example a new language, or theme. For adding themes or support for a new language to Zed, check out our [docs on developing extensions](https://zed.dev/docs/extensions/developing-extensions).
- New file icons. Zed's default icon theme consists of icons that are hand-designed to fit together in a cohesive manner, please don't submit PRs with off-the-shelf SVGs.
- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit.
- Giant refactorings.
- Non-trivial changes with no tests.
- Stylistic code changes that do not alter any app logic. Reducing allocations, removing `.unwrap()`s, fixing typos is great; making code "more readable" — maybe not so much.
- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit.
- Anything that seems completely AI generated.
- Anything that seems AI-generated without understanding the output.
## Bird's-eye view of Zed

329
Cargo.lock generated
View File

@@ -111,6 +111,15 @@ dependencies = [
"workspace",
]
[[package]]
name = "addr2line"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
dependencies = [
"gimli 0.31.1",
]
[[package]]
name = "addr2line"
version = "0.25.1"
@@ -292,6 +301,7 @@ dependencies = [
name = "agent_settings"
version = "0.1.0"
dependencies = [
"agent-client-protocol",
"anyhow",
"cloud_llm_client",
"collections",
@@ -1997,7 +2007,7 @@ version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"addr2line 0.25.1",
"cfg-if",
"libc",
"miniz_oxide",
@@ -2656,9 +2666,9 @@ dependencies = [
[[package]]
name = "cap-fs-ext"
version = "3.4.4"
version = "3.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e41cc18551193fe8fa6f15c1e3c799bc5ec9e2cfbfaa8ed46f37013e3e6c173c"
checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654"
dependencies = [
"cap-primitives",
"cap-std",
@@ -2668,9 +2678,9 @@ dependencies = [
[[package]]
name = "cap-net-ext"
version = "3.4.4"
version = "3.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f83833816c66c986e913b22ac887cec216ea09301802054316fc5301809702c"
checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7"
dependencies = [
"cap-primitives",
"cap-std",
@@ -2680,9 +2690,9 @@ dependencies = [
[[package]]
name = "cap-primitives"
version = "3.4.4"
version = "3.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a1e394ed14f39f8bc26f59d4c0c010dbe7f0a1b9bafff451b1f98b67c8af62a"
checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a"
dependencies = [
"ambient-authority",
"fs-set-times",
@@ -2698,9 +2708,9 @@ dependencies = [
[[package]]
name = "cap-rand"
version = "3.4.4"
version = "3.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0acb89ccf798a28683f00089d0630dfaceec087234eae0d308c05ddeaa941b40"
checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d"
dependencies = [
"ambient-authority",
"rand 0.8.5",
@@ -2708,9 +2718,9 @@ dependencies = [
[[package]]
name = "cap-std"
version = "3.4.4"
version = "3.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07c0355ca583dd58f176c3c12489d684163861ede3c9efa6fd8bba314c984189"
checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a"
dependencies = [
"cap-primitives",
"io-extras",
@@ -2720,9 +2730,9 @@ dependencies = [
[[package]]
name = "cap-time-ext"
version = "3.4.4"
version = "3.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "491af520b8770085daa0466978c75db90368c71896523f2464214e38359b1a5b"
checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80"
dependencies = [
"ambient-authority",
"cap-primitives",
@@ -3613,6 +3623,7 @@ dependencies = [
"serde",
"serde_json",
"settings",
"slotmap",
"smol",
"tempfile",
"terminal",
@@ -3925,19 +3936,37 @@ dependencies = [
]
[[package]]
name = "cranelift-bforest"
version = "0.116.1"
name = "cranelift-assembler-x64"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4"
checksum = "a5023e06632d8f351c2891793ccccfe4aef957954904392434038745fb6f1f68"
dependencies = [
"cranelift-assembler-x64-meta",
]
[[package]]
name = "cranelift-assembler-x64-meta"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c4012b4c8c1f6eb05c0a0a540e3e1ee992631af51aa2bbb3e712903ce4fd65"
dependencies = [
"cranelift-srcgen",
]
[[package]]
name = "cranelift-bforest"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d6d883b4942ef3a7104096b8bc6f2d1a41393f159ac8de12aed27b25d67f895"
dependencies = [
"cranelift-entity",
]
[[package]]
name = "cranelift-bitset"
version = "0.116.1"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c6e3969a7ce267259ce244b7867c5d3bc9e65b0a87e81039588dfdeaede9f34"
checksum = "db7b2ee9eec6ca8a716d900d5264d678fb2c290c58c46c8da7f94ee268175d17"
dependencies = [
"serde",
"serde_derive",
@@ -3945,11 +3974,12 @@ dependencies = [
[[package]]
name = "cranelift-codegen"
version = "0.116.1"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c22032c4cb42558371cf516bb47f26cdad1819d3475c133e93c49f50ebf304e"
checksum = "aeda0892577afdce1ac2e9a983a55f8c5b87a59334e1f79d8f735a2d7ba4f4b4"
dependencies = [
"bumpalo",
"cranelift-assembler-x64",
"cranelift-bforest",
"cranelift-bitset",
"cranelift-codegen-meta",
@@ -3958,9 +3988,10 @@ dependencies = [
"cranelift-entity",
"cranelift-isle",
"gimli 0.31.1",
"hashbrown 0.14.5",
"hashbrown 0.15.5",
"log",
"postcard",
"pulley-interpreter",
"regalloc2",
"rustc-hash 2.1.1",
"serde",
@@ -3972,33 +4003,36 @@ dependencies = [
[[package]]
name = "cranelift-codegen-meta"
version = "0.116.1"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c904bc71c61b27fc57827f4a1379f29de64fe95653b620a3db77d59655eee0b8"
checksum = "e461480d87f920c2787422463313326f67664e68108c14788ba1676f5edfcd15"
dependencies = [
"cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
"cranelift-srcgen",
"pulley-interpreter",
]
[[package]]
name = "cranelift-codegen-shared"
version = "0.116.1"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40180f5497572f644ce88c255480981ae2ec1d7bb4d8e0c0136a13b87a2f2ceb"
checksum = "976584d09f200c6c84c4b9ff7af64fc9ad0cb64dffa5780991edd3fe143a30a1"
[[package]]
name = "cranelift-control"
version = "0.116.1"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d132c6d0bd8a489563472afc171759da0707804a65ece7ceb15a8c6d7dd5ef"
checksum = "46d43d70f4e17c545aa88dbf4c84d4200755d27c6e3272ebe4de65802fa6a955"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
version = "0.116.1"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b2d0d9618275474fbf679dd018ac6e009acbd6ae6850f6a67be33fb3b00b323"
checksum = "d75418674520cb400c8772bfd6e11a62736c78fc1b6e418195696841d1bf91f1"
dependencies = [
"cranelift-bitset",
"serde",
@@ -4007,9 +4041,9 @@ dependencies = [
[[package]]
name = "cranelift-frontend"
version = "0.116.1"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fac41e16729107393174b0c9e3730fb072866100e1e64e80a1a963b2e484d57"
checksum = "3c8b1a91c86687a344f3c52dd6dfb6e50db0dfa7f2e9c7711b060b3623e1fdeb"
dependencies = [
"cranelift-codegen",
"log",
@@ -4019,21 +4053,27 @@ dependencies = [
[[package]]
name = "cranelift-isle"
version = "0.116.1"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ca20d576e5070044d0a72a9effc2deacf4d6aa650403189d8ea50126483944d"
checksum = "711baa4e3432d4129295b39ec2b4040cc1b558874ba0a37d08e832e857db7285"
[[package]]
name = "cranelift-native"
version = "0.116.1"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7"
checksum = "41c83e8666e3bcc5ffeaf6f01f356f0e1f9dcd69ce5511a1efd7ca5722001a3f"
dependencies = [
"cranelift-codegen",
"libc",
"target-lexicon 0.13.3",
]
[[package]]
name = "cranelift-srcgen"
version = "0.120.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02e3f4d783a55c64266d17dc67d2708852235732a100fc40dd9f1051adc64d7b"
[[package]]
name = "crash-context"
version = "0.6.3"
@@ -12421,6 +12461,7 @@ dependencies = [
"context_server",
"dap",
"dap_adapters",
"db",
"extension",
"fancy-regex",
"fs",
@@ -12794,13 +12835,12 @@ checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3"
[[package]]
name = "pulley-interpreter"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62d95f8575df49a2708398182f49a888cf9dc30210fb1fd2df87c889edcee75d"
checksum = "986beaef947a51d17b42b0ea18ceaa88450d35b6994737065ed505c39172db71"
dependencies = [
"cranelift-bitset",
"log",
"sptr",
"wasmtime-math",
]
@@ -13299,9 +13339,9 @@ dependencies = [
[[package]]
name = "regalloc2"
version = "0.11.2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a"
checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734"
dependencies = [
"allocator-api2",
"bumpalo",
@@ -17310,9 +17350,9 @@ dependencies = [
[[package]]
name = "tree-sitter"
version = "0.25.10"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
checksum = "974d205cc395652cfa8b37daa053fe56eebd429acf8dc055503fee648dae981e"
dependencies = [
"cc",
"regex",
@@ -18399,6 +18439,16 @@ dependencies = [
"wasmparser 0.227.1",
]
[[package]]
name = "wasm-encoder"
version = "0.229.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38ba1d491ecacb085a2552025c10a675a6fddcbd03b1fc9b36c536010ce265d2"
dependencies = [
"leb128fmt",
"wasmparser 0.229.0",
]
[[package]]
name = "wasm-metadata"
version = "0.201.0"
@@ -18484,22 +18534,36 @@ dependencies = [
]
[[package]]
name = "wasmprinter"
version = "0.221.3"
name = "wasmparser"
version = "0.229.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283"
checksum = "0cc3b1f053f5d41aa55640a1fa9b6d1b8a9e4418d118ce308d20e24ff3575a8c"
dependencies = [
"bitflags 2.9.4",
"hashbrown 0.15.5",
"indexmap",
"semver",
"serde",
]
[[package]]
name = "wasmprinter"
version = "0.229.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25dac01892684a99b8fbfaf670eb6b56edea8a096438c75392daeb83156ae2e"
dependencies = [
"anyhow",
"termcolor",
"wasmparser 0.221.3",
"wasmparser 0.229.0",
]
[[package]]
name = "wasmtime"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11976a250672556d1c4c04c6d5d7656ac9192ac9edc42a4587d6c21460010e69"
checksum = "57373e1d8699662fb791270ac5dfac9da5c14f618ecf940cdb29dc3ad9472a3c"
dependencies = [
"addr2line 0.24.2",
"anyhow",
"async-trait",
"bitflags 2.9.4",
@@ -18507,7 +18571,7 @@ dependencies = [
"cc",
"cfg-if",
"encoding_rs",
"hashbrown 0.14.5",
"hashbrown 0.15.5",
"indexmap",
"libc",
"log",
@@ -18515,12 +18579,11 @@ dependencies = [
"memfd",
"object 0.36.7",
"once_cell",
"paste",
"postcard",
"psm",
"pulley-interpreter",
"rayon",
"rustix 0.38.44",
"rustix 1.1.2",
"semver",
"serde",
"serde_derive",
@@ -18528,7 +18591,7 @@ dependencies = [
"sptr",
"target-lexicon 0.13.3",
"trait-variant",
"wasmparser 0.221.3",
"wasmparser 0.229.0",
"wasmtime-asm-macros",
"wasmtime-component-macro",
"wasmtime-component-util",
@@ -18545,18 +18608,18 @@ dependencies = [
[[package]]
name = "wasmtime-asm-macros"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f178b0d125201fbe9f75beaf849bd3e511891f9e45ba216a5b620802ccf64f2"
checksum = "bd0fc91372865167a695dc98d0d6771799a388a7541d3f34e939d0539d6583de"
dependencies = [
"cfg-if",
]
[[package]]
name = "wasmtime-c-api-impl"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea30cef3608f2de5797c7bbb94c1ba4f3676d9a7f81ae86ced1b512e2766ed0c"
checksum = "46db556f1dccdd88e0672bd407162ab0036b72e5eccb0f4398d8251cba32dba1"
dependencies = [
"anyhow",
"log",
@@ -18567,9 +18630,9 @@ dependencies = [
[[package]]
name = "wasmtime-c-api-macros"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "022a79ebe1124d5d384d82463d7e61c6b4dd857d81f15cb8078974eeb86db65b"
checksum = "315cc6bc8cdc66f296accb26d7625ae64c1c7b6da6f189e8a72ce6594bf7bd36"
dependencies = [
"proc-macro2",
"quote",
@@ -18577,9 +18640,9 @@ dependencies = [
[[package]]
name = "wasmtime-component-macro"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d74de6592ed945d0a602f71243982a304d5d02f1e501b638addf57f42d57dfaf"
checksum = "25c9c7526675ff9a9794b115023c4af5128e3eb21389bfc3dc1fd344d549258f"
dependencies = [
"anyhow",
"proc-macro2",
@@ -18587,20 +18650,20 @@ dependencies = [
"syn 2.0.106",
"wasmtime-component-util",
"wasmtime-wit-bindgen",
"wit-parser 0.221.3",
"wit-parser 0.229.0",
]
[[package]]
name = "wasmtime-component-util"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707dc7b3c112ab5a366b30cfe2fb5b2f8e6a0f682f16df96a5ec582bfe6f056e"
checksum = "cc42ec8b078875804908d797cb4950fec781d9add9684c9026487fd8eb3f6291"
[[package]]
name = "wasmtime-cranelift"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366be722674d4bf153290fbcbc4d7d16895cc82fb3e869f8d550ff768f9e9e87"
checksum = "b2bd72f0a6a0ffcc6a184ec86ac35c174e48ea0e97bbae277c8f15f8bf77a566"
dependencies = [
"anyhow",
"cfg-if",
@@ -18610,22 +18673,23 @@ dependencies = [
"cranelift-frontend",
"cranelift-native",
"gimli 0.31.1",
"itertools 0.12.1",
"itertools 0.14.0",
"log",
"object 0.36.7",
"pulley-interpreter",
"smallvec",
"target-lexicon 0.13.3",
"thiserror 1.0.69",
"wasmparser 0.221.3",
"thiserror 2.0.17",
"wasmparser 0.229.0",
"wasmtime-environ",
"wasmtime-versioned-export-macros",
]
[[package]]
name = "wasmtime-environ"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdadc1af7097347aa276a4f008929810f726b5b46946971c660b6d421e9994ad"
checksum = "e6187bb108a23eb25d2a92aa65d6c89fb5ed53433a319038a2558567f3011ff2"
dependencies = [
"anyhow",
"cpp_demangle",
@@ -18642,22 +18706,22 @@ dependencies = [
"serde_derive",
"smallvec",
"target-lexicon 0.13.3",
"wasm-encoder 0.221.3",
"wasmparser 0.221.3",
"wasm-encoder 0.229.0",
"wasmparser 0.229.0",
"wasmprinter",
"wasmtime-component-util",
]
[[package]]
name = "wasmtime-fiber"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccba90d4119f081bca91190485650730a617be1fff5228f8c4757ce133d21117"
checksum = "dc8965d2128c012329f390e24b8b2758dd93d01bf67e1a1a0dd3d8fd72f56873"
dependencies = [
"anyhow",
"cc",
"cfg-if",
"rustix 0.38.44",
"rustix 1.1.2",
"wasmtime-asm-macros",
"wasmtime-versioned-export-macros",
"windows-sys 0.59.0",
@@ -18665,9 +18729,9 @@ dependencies = [
[[package]]
name = "wasmtime-jit-icache-coherence"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec5e8552e01692e6c2e5293171704fed8abdec79d1a6995a0870ab190e5747d1"
checksum = "7af0e940cb062a45c0b3f01a926f77da5947149e99beb4e3dd9846d5b8f11619"
dependencies = [
"anyhow",
"cfg-if",
@@ -18677,24 +18741,24 @@ dependencies = [
[[package]]
name = "wasmtime-math"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29210ec2aa25e00f4d54605cedaf080f39ec01a872c5bd520ad04c67af1dde17"
checksum = "acfca360e719dda9a27e26944f2754ff2fd5bad88e21919c42c5a5f38ddd93cb"
dependencies = [
"libm",
]
[[package]]
name = "wasmtime-slab"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcb5821a96fa04ac14bc7b158bb3d5cd7729a053db5a74dad396cd513a5e5ccf"
checksum = "48e240559cada55c4b24af979d5f6c95e0029f5772f32027ec3c62b258aaff65"
[[package]]
name = "wasmtime-versioned-export-macros"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86ff86db216dc0240462de40c8290887a613dddf9685508eb39479037ba97b5b"
checksum = "d0963c1438357a3d8c0efe152b4ef5259846c1cf8b864340270744fe5b3bae5e"
dependencies = [
"proc-macro2",
"quote",
@@ -18703,9 +18767,9 @@ dependencies = [
[[package]]
name = "wasmtime-wasi"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d1be69bfcab1bdac74daa7a1f9695ab992b9c8e21b9b061e7d66434097e0ca4"
checksum = "4ae951b72c7c6749a1c15dcdfb6d940a2614c932b4a54f474636e78e2c744b4c"
dependencies = [
"anyhow",
"async-trait",
@@ -18720,30 +18784,43 @@ dependencies = [
"futures 0.3.31",
"io-extras",
"io-lifetimes",
"rustix 0.38.44",
"rustix 1.1.2",
"system-interface",
"thiserror 1.0.69",
"thiserror 2.0.17",
"tokio",
"tracing",
"trait-variant",
"url",
"wasmtime",
"wasmtime-wasi-io",
"wiggle",
"windows-sys 0.59.0",
]
[[package]]
name = "wasmtime-winch"
version = "29.0.1"
name = "wasmtime-wasi-io"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdbabfb8f20502d5e1d81092b9ead3682ae59988487aafcd7567387b7a43cf8f"
checksum = "a835790dcecc3d7051ec67da52ba9e04af25e1bc204275b9391e3f0042b10797"
dependencies = [
"anyhow",
"async-trait",
"bytes 1.10.1",
"futures 0.3.31",
"wasmtime",
]
[[package]]
name = "wasmtime-winch"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc3b117d03d6eeabfa005a880c5c22c06503bb8820f3aa2e30f0e8d87b6752f"
dependencies = [
"anyhow",
"cranelift-codegen",
"gimli 0.31.1",
"object 0.36.7",
"target-lexicon 0.13.3",
"wasmparser 0.221.3",
"wasmparser 0.229.0",
"wasmtime-cranelift",
"wasmtime-environ",
"winch-codegen",
@@ -18751,14 +18828,14 @@ dependencies = [
[[package]]
name = "wasmtime-wit-bindgen"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8358319c2dd1e4db79e3c1c5d3a5af84956615343f9f89f4e4996a36816e06e6"
checksum = "1382f4f09390eab0d75d4994d0c3b0f6279f86a571807ec67a8253c87cf6a145"
dependencies = [
"anyhow",
"heck 0.5.0",
"indexmap",
"wit-parser 0.221.3",
"wit-parser 0.229.0",
]
[[package]]
@@ -19052,14 +19129,14 @@ dependencies = [
[[package]]
name = "wiggle"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9af35bc9629c52c261465320a9a07959164928b4241980ba1cf923b9e6751d"
checksum = "649c1aca13ef9e9dccf2d5efbbebf12025bc5521c3fb7754355ef60f5eb810be"
dependencies = [
"anyhow",
"async-trait",
"bitflags 2.9.4",
"thiserror 1.0.69",
"thiserror 2.0.17",
"tracing",
"wasmtime",
"wiggle-macro",
@@ -19067,24 +19144,23 @@ dependencies = [
[[package]]
name = "wiggle-generate"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cf267dd05673912c8138f4b54acabe6bd53407d9d1536f0fadb6520dd16e101"
checksum = "164870fc34214ee42bd81b8ce9e7c179800fa1a7d4046d17a84e7f7bf422c8ad"
dependencies = [
"anyhow",
"heck 0.5.0",
"proc-macro2",
"quote",
"shellexpand 2.1.2",
"syn 2.0.106",
"witx",
]
[[package]]
name = "wiggle-macro"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c5c473d4198e6c2d377f3809f713ff0c110cab88a0805ae099a82119ee250c"
checksum = "d873bb5b59ca703b5e41562e96a4796d1af61bf4cf80bf8a7abda755a380ec1c"
dependencies = [
"proc-macro2",
"quote",
@@ -19125,18 +19201,19 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
version = "29.0.1"
version = "33.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f849ef2c5f46cb0a20af4b4487aaa239846e52e2c03f13fa3c784684552859c"
checksum = "7914c296fbcef59d1b89a15e82384d34dc9669bc09763f2ef068a28dd3a64ebf"
dependencies = [
"anyhow",
"cranelift-assembler-x64",
"cranelift-codegen",
"gimli 0.31.1",
"regalloc2",
"smallvec",
"target-lexicon 0.13.3",
"thiserror 1.0.69",
"wasmparser 0.221.3",
"thiserror 2.0.17",
"wasmparser 0.229.0",
"wasmtime-cranelift",
"wasmtime-environ",
]
@@ -20032,24 +20109,6 @@ dependencies = [
"wasmparser 0.201.0",
]
[[package]]
name = "wit-parser"
version = "0.221.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser 0.221.3",
]
[[package]]
name = "wit-parser"
version = "0.227.1"
@@ -20068,6 +20127,24 @@ dependencies = [
"wasmparser 0.227.1",
]
[[package]]
name = "wit-parser"
version = "0.229.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459c6ba62bf511d6b5f2a845a2a736822e38059c1cfa0b644b467bbbfae4efa6"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser 0.229.0",
]
[[package]]
name = "witx"
version = "0.9.1"
@@ -20507,7 +20584,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.218.0"
version = "0.219.0"
dependencies = [
"acp_tools",
"activity_indicator",

View File

@@ -663,7 +663,7 @@ tokio-socks = { version = "0.5.2", default-features = false, features = ["future
toml = "0.8"
toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] }
tower-http = "0.4.4"
tree-sitter = { version = "0.25.10", features = ["wasm"] }
tree-sitter = { version = "0.26", features = ["wasm"] }
tree-sitter-bash = "0.25.1"
tree-sitter-c = "0.23"
tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" }
@@ -697,7 +697,7 @@ uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] }
walkdir = "2.5"
wasm-encoder = "0.221"
wasmparser = "0.221"
wasmtime = { version = "29", default-features = false, features = [
wasmtime = { version = "33", default-features = false, features = [
"async",
"demangle",
"runtime",
@@ -706,7 +706,7 @@ wasmtime = { version = "29", default-features = false, features = [
"incremental-cache",
"parallel-compilation",
] }
wasmtime-wasi = "29"
wasmtime-wasi = "33"
wax = "0.6"
which = "6.0.0"
windows-core = "0.61"

View File

@@ -45,6 +45,7 @@
"ctrl-alt-z": "edit_prediction::RatePredictions",
"ctrl-alt-shift-i": "edit_prediction::ToggleMenu",
"ctrl-alt-l": "lsp_tool::ToggleMenu",
"ctrl-alt-shift-s": "workspace::ToggleWorktreeSecurity",
},
},
{
@@ -251,6 +252,7 @@
"ctrl-y": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
"ctrl-alt-z": "agent::RejectOnce",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -345,6 +347,7 @@
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -900,6 +903,8 @@
{
"context": "GitPanel && ChangesList",
"bindings": {
"left": "git_panel::CollapseSelectedEntry",
"right": "git_panel::ExpandSelectedEntry",
"up": "menu::SelectPrevious",
"down": "menu::SelectNext",
"enter": "menu::Confirm",

View File

@@ -51,6 +51,7 @@
"ctrl-cmd-i": "edit_prediction::ToggleMenu",
"ctrl-cmd-l": "lsp_tool::ToggleMenu",
"ctrl-cmd-c": "editor::DisplayCursorNames",
"ctrl-cmd-s": "workspace::ToggleWorktreeSecurity",
},
},
{
@@ -265,6 +266,7 @@
"cmd-g": "search::SelectNextMatch",
"cmd-shift-g": "search::SelectPreviousMatch",
"cmd-k l": "agent::OpenRulesLibrary",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -291,6 +293,7 @@
"cmd-y": "agent::AllowOnce",
"cmd-alt-y": "agent::AllowAlways",
"cmd-alt-z": "agent::RejectOnce",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -385,6 +388,7 @@
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -396,6 +400,7 @@
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -879,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",
@@ -975,6 +981,8 @@
"context": "GitPanel && ChangesList",
"use_key_equivalents": true,
"bindings": {
"left": "git_panel::CollapseSelectedEntry",
"right": "git_panel::ExpandSelectedEntry",
"up": "menu::SelectPrevious",
"down": "menu::SelectNext",
"cmd-up": "menu::SelectFirst",

View File

@@ -43,6 +43,7 @@
"ctrl-shift-i": "edit_prediction::ToggleMenu",
"shift-alt-l": "lsp_tool::ToggleMenu",
"ctrl-shift-alt-c": "editor::DisplayCursorNames",
"ctrl-shift-alt-s": "workspace::ToggleWorktreeSecurity",
},
},
{
@@ -252,6 +253,7 @@
"shift-alt-a": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
"shift-alt-z": "agent::RejectOnce",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -341,6 +343,7 @@
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -352,6 +355,7 @@
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -904,6 +908,8 @@
"context": "GitPanel && ChangesList",
"use_key_equivalents": true,
"bindings": {
"left": "git_panel::CollapseSelectedEntry",
"right": "git_panel::ExpandSelectedEntry",
"up": "menu::SelectPrevious",
"down": "menu::SelectNext",
"enter": "menu::Confirm",

View File

@@ -502,6 +502,11 @@
"g p": "pane::ActivatePreviousItem",
"shift-h": "pane::ActivatePreviousItem", // not a helix default
"g .": "vim::HelixGotoLastModification",
"g o": "editor::ToggleSelectedDiffHunks", // Zed specific
"g shift-o": "git::ToggleStaged", // Zed specific
"g shift-r": "git::Restore", // Zed specific
"g u": "git::StageAndNext", // Zed specific
"g shift-u": "git::UnstageAndNext", // Zed specific
// Window mode
"space w v": "pane::SplitDown",

View File

@@ -972,6 +972,8 @@
"now": true,
"find_path": true,
"read_file": true,
"restore_file_from_disk": true,
"save_file": true,
"open": true,
"grep": true,
"terminal": true,
@@ -2060,6 +2062,12 @@
//
// Default: true
"restore_unsaved_buffers": true,
// Whether or not to skip worktree trust checks.
// When trusted, project settings are synchronized automatically,
// language and MCP servers are downloaded and started automatically.
//
// Default: false
"trust_all_worktrees": false,
},
// Zed's Prettier integration settings.
// Allows to enable/disable formatting with Prettier

View File

@@ -43,6 +43,7 @@ pub struct UserMessage {
pub content: ContentBlock,
pub chunks: Vec<acp::ContentBlock>,
pub checkpoint: Option<Checkpoint>,
pub indented: bool,
}
#[derive(Debug)]
@@ -73,6 +74,7 @@ impl UserMessage {
#[derive(Debug, PartialEq)]
pub struct AssistantMessage {
pub chunks: Vec<AssistantMessageChunk>,
pub indented: bool,
}
impl AssistantMessage {
@@ -123,6 +125,14 @@ pub enum AgentThreadEntry {
}
impl AgentThreadEntry {
pub fn is_indented(&self) -> bool {
match self {
Self::UserMessage(message) => message.indented,
Self::AssistantMessage(message) => message.indented,
Self::ToolCall(_) => false,
}
}
pub fn to_markdown(&self, cx: &App) -> String {
match self {
Self::UserMessage(message) => message.to_markdown(cx),
@@ -1184,6 +1194,16 @@ impl AcpThread {
message_id: Option<UserMessageId>,
chunk: acp::ContentBlock,
cx: &mut Context<Self>,
) {
self.push_user_content_block_with_indent(message_id, chunk, false, cx)
}
pub fn push_user_content_block_with_indent(
&mut self,
message_id: Option<UserMessageId>,
chunk: acp::ContentBlock,
indented: bool,
cx: &mut Context<Self>,
) {
let language_registry = self.project.read(cx).languages().clone();
let path_style = self.project.read(cx).path_style(cx);
@@ -1194,8 +1214,10 @@ impl AcpThread {
id,
content,
chunks,
indented: existing_indented,
..
}) = last_entry
&& *existing_indented == indented
{
*id = message_id.or(id.take());
content.append(chunk.clone(), &language_registry, path_style, cx);
@@ -1210,6 +1232,7 @@ impl AcpThread {
content,
chunks: vec![chunk],
checkpoint: None,
indented,
}),
cx,
);
@@ -1221,12 +1244,26 @@ impl AcpThread {
chunk: acp::ContentBlock,
is_thought: bool,
cx: &mut Context<Self>,
) {
self.push_assistant_content_block_with_indent(chunk, is_thought, false, cx)
}
pub fn push_assistant_content_block_with_indent(
&mut self,
chunk: acp::ContentBlock,
is_thought: bool,
indented: bool,
cx: &mut Context<Self>,
) {
let language_registry = self.project.read(cx).languages().clone();
let path_style = self.project.read(cx).path_style(cx);
let entries_len = self.entries.len();
if let Some(last_entry) = self.entries.last_mut()
&& let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry
&& let AgentThreadEntry::AssistantMessage(AssistantMessage {
chunks,
indented: existing_indented,
}) = last_entry
&& *existing_indented == indented
{
let idx = entries_len - 1;
cx.emit(AcpThreadEvent::EntryUpdated(idx));
@@ -1255,6 +1292,7 @@ impl AcpThread {
self.push_entry(
AgentThreadEntry::AssistantMessage(AssistantMessage {
chunks: vec![chunk],
indented,
}),
cx,
);
@@ -1704,6 +1742,7 @@ impl AcpThread {
content: block,
chunks: message,
checkpoint: None,
indented: false,
}),
cx,
);

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

@@ -5,12 +5,12 @@ mod legacy_thread;
mod native_agent_server;
pub mod outline;
mod templates;
#[cfg(test)]
mod tests;
mod thread;
mod tools;
#[cfg(test)]
mod tests;
use context_server::ContextServerId;
pub use db::*;
pub use history_store::*;
pub use native_agent_server::NativeAgentServer;
@@ -18,11 +18,11 @@ pub use templates::*;
pub use thread::*;
pub use tools::*;
use acp_thread::{AcpThread, AgentModelSelector};
use acp_thread::{AcpThread, AgentModelSelector, UserMessageId};
use agent_client_protocol as acp;
use anyhow::{Context as _, Result, anyhow};
use chrono::{DateTime, Utc};
use collections::{HashSet, IndexMap};
use collections::{HashMap, HashSet, IndexMap};
use fs::Fs;
use futures::channel::{mpsc, oneshot};
use futures::future::Shared;
@@ -39,7 +39,6 @@ use prompt_store::{
use serde::{Deserialize, Serialize};
use settings::{LanguageModelSelection, update_settings_file};
use std::any::Any;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
@@ -252,12 +251,24 @@ impl NativeAgent {
.await;
cx.new(|cx| {
let context_server_store = project.read(cx).context_server_store();
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
let mut subscriptions = vec![
cx.subscribe(&project, Self::handle_project_event),
cx.subscribe(
&LanguageModelRegistry::global(cx),
Self::handle_models_updated_event,
),
cx.subscribe(
&context_server_store,
Self::handle_context_server_store_updated,
),
cx.subscribe(
&context_server_registry,
Self::handle_context_server_registry_event,
),
];
if let Some(prompt_store) = prompt_store.as_ref() {
subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event))
@@ -266,16 +277,14 @@ impl NativeAgent {
let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
watch::channel(());
Self {
sessions: HashMap::new(),
sessions: HashMap::default(),
history,
project_context: cx.new(|_| project_context),
project_context_needs_refresh: project_context_needs_refresh_tx,
_maintain_project_context: cx.spawn(async move |this, cx| {
Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await
}),
context_server_registry: cx.new(|cx| {
ContextServerRegistry::new(project.read(cx).context_server_store(), cx)
}),
context_server_registry,
templates,
models: LanguageModels::new(cx),
project,
@@ -344,6 +353,9 @@ impl NativeAgent {
pending_save: Task::ready(()),
},
);
self.update_available_commands(cx);
acp_thread
}
@@ -414,10 +426,7 @@ impl NativeAgent {
.into_iter()
.flat_map(|(contents, prompt_metadata)| match contents {
Ok(contents) => Some(UserRulesContext {
uuid: match prompt_metadata.id {
prompt_store::PromptId::User { uuid } => uuid,
prompt_store::PromptId::EditWorkflow => return None,
},
uuid: prompt_metadata.id.user_id()?,
title: prompt_metadata.title.map(|title| title.to_string()),
contents,
}),
@@ -611,6 +620,99 @@ impl NativeAgent {
}
}
fn handle_context_server_store_updated(
&mut self,
_store: Entity<project::context_server_store::ContextServerStore>,
_event: &project::context_server_store::Event,
cx: &mut Context<Self>,
) {
self.update_available_commands(cx);
}
fn handle_context_server_registry_event(
&mut self,
_registry: Entity<ContextServerRegistry>,
event: &ContextServerRegistryEvent,
cx: &mut Context<Self>,
) {
match event {
ContextServerRegistryEvent::ToolsChanged => {}
ContextServerRegistryEvent::PromptsChanged => {
self.update_available_commands(cx);
}
}
}
fn update_available_commands(&self, cx: &mut Context<Self>) {
let available_commands = self.build_available_commands(cx);
for session in self.sessions.values() {
if let Some(acp_thread) = session.acp_thread.upgrade() {
acp_thread.update(cx, |thread, cx| {
thread
.handle_session_update(
acp::SessionUpdate::AvailableCommandsUpdate(
acp::AvailableCommandsUpdate::new(available_commands.clone()),
),
cx,
)
.log_err();
});
}
}
}
fn build_available_commands(&self, cx: &App) -> Vec<acp::AvailableCommand> {
let registry = self.context_server_registry.read(cx);
let mut prompt_name_counts: HashMap<&str, usize> = HashMap::default();
for context_server_prompt in registry.prompts() {
*prompt_name_counts
.entry(context_server_prompt.prompt.name.as_str())
.or_insert(0) += 1;
}
registry
.prompts()
.flat_map(|context_server_prompt| {
let prompt = &context_server_prompt.prompt;
let should_prefix = prompt_name_counts
.get(prompt.name.as_str())
.copied()
.unwrap_or(0)
> 1;
let name = if should_prefix {
format!("{}.{}", context_server_prompt.server_id, prompt.name)
} else {
prompt.name.clone()
};
let mut command = acp::AvailableCommand::new(
name,
prompt.description.clone().unwrap_or_default(),
);
match prompt.arguments.as_deref() {
Some([arg]) => {
let hint = format!("<{}>", arg.name);
command = command.input(acp::AvailableCommandInput::Unstructured(
acp::UnstructuredCommandInput::new(hint),
));
}
Some([]) | None => {}
Some(_) => {
// skip >1 argument commands since we don't support them yet
return None;
}
}
Some(command)
})
.collect()
}
pub fn load_thread(
&mut self,
id: acp::SessionId,
@@ -709,6 +811,102 @@ impl NativeAgent {
history.update(cx, |history, cx| history.reload(cx)).ok();
});
}
fn send_mcp_prompt(
&self,
message_id: UserMessageId,
session_id: agent_client_protocol::SessionId,
prompt_name: String,
server_id: ContextServerId,
arguments: HashMap<String, String>,
original_content: Vec<acp::ContentBlock>,
cx: &mut Context<Self>,
) -> Task<Result<acp::PromptResponse>> {
let server_store = self.context_server_registry.read(cx).server_store().clone();
let path_style = self.project.read(cx).path_style(cx);
cx.spawn(async move |this, cx| {
let prompt =
crate::get_prompt(&server_store, &server_id, &prompt_name, arguments, cx).await?;
let (acp_thread, thread) = this.update(cx, |this, _cx| {
let session = this
.sessions
.get(&session_id)
.context("Failed to get session")?;
anyhow::Ok((session.acp_thread.clone(), session.thread.clone()))
})??;
let mut last_is_user = true;
thread.update(cx, |thread, cx| {
thread.push_acp_user_block(
message_id,
original_content.into_iter().skip(1),
path_style,
cx,
);
})?;
for message in prompt.messages {
let context_server::types::PromptMessage { role, content } = message;
let block = mcp_message_content_to_acp_content_block(content);
match role {
context_server::types::Role::User => {
let id = acp_thread::UserMessageId::new();
acp_thread.update(cx, |acp_thread, cx| {
acp_thread.push_user_content_block_with_indent(
Some(id.clone()),
block.clone(),
true,
cx,
);
anyhow::Ok(())
})??;
thread.update(cx, |thread, cx| {
thread.push_acp_user_block(id, [block], path_style, cx);
anyhow::Ok(())
})??;
}
context_server::types::Role::Assistant => {
acp_thread.update(cx, |acp_thread, cx| {
acp_thread.push_assistant_content_block_with_indent(
block.clone(),
false,
true,
cx,
);
anyhow::Ok(())
})??;
thread.update(cx, |thread, cx| {
thread.push_acp_agent_block(block, cx);
anyhow::Ok(())
})??;
}
}
last_is_user = role == context_server::types::Role::User;
}
let response_stream = thread.update(cx, |thread, cx| {
if last_is_user {
thread.send_existing(cx)
} else {
// Resume if MCP prompt did not end with a user message
thread.resume(cx)
}
})??;
cx.update(|cx| {
NativeAgentConnection::handle_thread_events(response_stream, acp_thread, cx)
})?
.await
})
}
}
/// Wrapper struct that implements the AgentConnection trait
@@ -843,6 +1041,39 @@ impl NativeAgentConnection {
}
}
struct Command<'a> {
prompt_name: &'a str,
arg_value: &'a str,
explicit_server_id: Option<&'a str>,
}
impl<'a> Command<'a> {
fn parse(prompt: &'a [acp::ContentBlock]) -> Option<Self> {
let acp::ContentBlock::Text(text_content) = prompt.first()? else {
return None;
};
let text = text_content.text.trim();
let command = text.strip_prefix('/')?;
let (command, arg_value) = command
.split_once(char::is_whitespace)
.unwrap_or((command, ""));
if let Some((server_id, prompt_name)) = command.split_once('.') {
Some(Self {
prompt_name,
arg_value,
explicit_server_id: Some(server_id),
})
} else {
Some(Self {
prompt_name: command,
arg_value,
explicit_server_id: None,
})
}
}
}
struct NativeAgentModelSelector {
session_id: acp::SessionId,
connection: NativeAgentConnection,
@@ -933,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 {
@@ -1008,6 +1243,47 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
let session_id = params.session_id.clone();
log::info!("Received prompt request for session: {}", session_id);
log::debug!("Prompt blocks count: {}", params.prompt.len());
if let Some(parsed_command) = Command::parse(&params.prompt) {
let registry = self.0.read(cx).context_server_registry.read(cx);
let explicit_server_id = parsed_command
.explicit_server_id
.map(|server_id| ContextServerId(server_id.into()));
if let Some(prompt) =
registry.find_prompt(explicit_server_id.as_ref(), parsed_command.prompt_name)
{
let arguments = if !parsed_command.arg_value.is_empty()
&& let Some(arg_name) = prompt
.prompt
.arguments
.as_ref()
.and_then(|args| args.first())
.map(|arg| arg.name.clone())
{
HashMap::from_iter([(arg_name, parsed_command.arg_value.to_string())])
} else {
Default::default()
};
let prompt_name = prompt.prompt.name.clone();
let server_id = prompt.server_id.clone();
return self.0.update(cx, |agent, cx| {
agent.send_mcp_prompt(
id,
session_id.clone(),
prompt_name,
server_id,
arguments,
params.prompt,
cx,
)
});
};
};
let path_style = self.0.read(cx).project.read(cx).path_style(cx);
self.run_turn(session_id, cx, move |thread, cx| {
@@ -1604,3 +1880,35 @@ mod internal_tests {
});
}
}
fn mcp_message_content_to_acp_content_block(
content: context_server::types::MessageContent,
) -> acp::ContentBlock {
match content {
context_server::types::MessageContent::Text {
text,
annotations: _,
} => text.into(),
context_server::types::MessageContent::Image {
data,
mime_type,
annotations: _,
} => acp::ContentBlock::Image(acp::ImageContent::new(data, mime_type)),
context_server::types::MessageContent::Audio {
data,
mime_type,
annotations: _,
} => acp::ContentBlock::Audio(acp::AudioContent::new(data, mime_type)),
context_server::types::MessageContent::Resource {
resource,
annotations: _,
} => {
let mut link =
acp::ResourceLink::new(resource.uri.to_string(), resource.uri.to_string());
if let Some(mime_type) = resource.mime_type {
link = link.mime_type(mime_type);
}
acp::ContentBlock::ResourceLink(link)
}
}
}

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,7 +2,8 @@ use crate::{
ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread,
DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool,
SystemPromptTemplate, Template, Templates, TerminalTool, ThinkingTool, WebSearchTool,
RestoreFileFromDiskTool, SaveFileTool, SystemPromptTemplate, Template, Templates, TerminalTool,
ThinkingTool, WebSearchTool,
};
use acp_thread::{MentionUri, UserMessageId};
use action_log::ActionLog;
@@ -107,7 +108,13 @@ impl Message {
pub fn to_request(&self) -> Vec<LanguageModelRequestMessage> {
match self {
Message::User(message) => vec![message.to_request()],
Message::User(message) => {
if message.content.is_empty() {
vec![]
} else {
vec![message.to_request()]
}
}
Message::Agent(message) => message.to_request(),
Message::Resume => vec![LanguageModelRequestMessage {
role: Role::User,
@@ -1002,6 +1009,8 @@ impl Thread {
self.project.clone(),
self.action_log.clone(),
));
self.add_tool(SaveFileTool::new(self.project.clone()));
self.add_tool(RestoreFileFromDiskTool::new(self.project.clone()));
self.add_tool(TerminalTool::new(self.project.clone(), environment));
self.add_tool(ThinkingTool);
self.add_tool(WebSearchTool);
@@ -1086,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,
@@ -1138,11 +1169,6 @@ impl Thread {
where
T: Into<UserMessageContent>,
{
let model = self.model().context("No language model configured")?;
log::info!("Thread::send called with model: {}", model.name().0);
self.advance_prompt_id();
let content = content.into_iter().map(Into::into).collect::<Vec<_>>();
log::debug!("Thread::send content: {:?}", content);
@@ -1150,10 +1176,59 @@ impl Thread {
.push(Message::User(UserMessage { id, content }));
cx.notify();
self.send_existing(cx)
}
pub fn send_existing(
&mut self,
cx: &mut Context<Self>,
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
let model = self.model().context("No language model configured")?;
log::info!("Thread::send called with model: {}", model.name().0);
self.advance_prompt_id();
log::debug!("Total messages in thread: {}", self.messages.len());
self.run_turn(cx)
}
pub fn push_acp_user_block(
&mut self,
id: UserMessageId,
blocks: impl IntoIterator<Item = acp::ContentBlock>,
path_style: PathStyle,
cx: &mut Context<Self>,
) {
let content = blocks
.into_iter()
.map(|block| UserMessageContent::from_content_block(block, path_style))
.collect::<Vec<_>>();
self.messages
.push(Message::User(UserMessage { id, content }));
cx.notify();
}
pub fn push_acp_agent_block(&mut self, block: acp::ContentBlock, cx: &mut Context<Self>) {
let text = match block {
acp::ContentBlock::Text(text_content) => text_content.text,
acp::ContentBlock::Image(_) => "[image]".to_string(),
acp::ContentBlock::Audio(_) => "[audio]".to_string(),
acp::ContentBlock::ResourceLink(resource_link) => resource_link.uri,
acp::ContentBlock::Resource(resource) => match resource.resource {
acp::EmbeddedResourceResource::TextResourceContents(resource) => resource.uri,
acp::EmbeddedResourceResource::BlobResourceContents(resource) => resource.uri,
_ => "[resource]".to_string(),
},
_ => "[unknown]".to_string(),
};
self.messages.push(Message::Agent(AgentMessage {
content: vec![AgentMessageContent::Text(text)],
..Default::default()
}));
cx.notify();
}
#[cfg(feature = "eval")]
pub fn proceed(
&mut self,
@@ -1966,6 +2041,12 @@ impl Thread {
self.running_turn.as_ref()?.tools.get(name).cloned()
}
pub fn has_tool(&self, name: &str) -> bool {
self.running_turn
.as_ref()
.is_some_and(|turn| turn.tools.contains_key(name))
}
fn build_request_messages(
&self,
available_tools: Vec<SharedString>,

View File

@@ -4,7 +4,6 @@ mod create_directory_tool;
mod delete_path_tool;
mod diagnostics_tool;
mod edit_file_tool;
mod fetch_tool;
mod find_path_tool;
mod grep_tool;
@@ -13,6 +12,8 @@ mod move_path_tool;
mod now_tool;
mod open_tool;
mod read_file_tool;
mod restore_file_from_disk_tool;
mod save_file_tool;
mod terminal_tool;
mod thinking_tool;
@@ -27,7 +28,6 @@ pub use create_directory_tool::*;
pub use delete_path_tool::*;
pub use diagnostics_tool::*;
pub use edit_file_tool::*;
pub use fetch_tool::*;
pub use find_path_tool::*;
pub use grep_tool::*;
@@ -36,6 +36,8 @@ pub use move_path_tool::*;
pub use now_tool::*;
pub use open_tool::*;
pub use read_file_tool::*;
pub use restore_file_from_disk_tool::*;
pub use save_file_tool::*;
pub use terminal_tool::*;
pub use thinking_tool::*;
@@ -92,6 +94,8 @@ tools! {
NowTool,
OpenTool,
ReadFileTool,
RestoreFileFromDiskTool,
SaveFileTool,
TerminalTool,
ThinkingTool,
WebSearchTool,

View File

@@ -2,12 +2,24 @@ use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream};
use agent_client_protocol::ToolKind;
use anyhow::{Result, anyhow, bail};
use collections::{BTreeMap, HashMap};
use context_server::ContextServerId;
use gpui::{App, Context, Entity, SharedString, Task};
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;
use util::ResultExt;
pub struct ContextServerPrompt {
pub server_id: ContextServerId,
pub prompt: context_server::types::Prompt,
}
pub enum ContextServerRegistryEvent {
ToolsChanged,
PromptsChanged,
}
impl EventEmitter<ContextServerRegistryEvent> for ContextServerRegistry {}
pub struct ContextServerRegistry {
server_store: Entity<ContextServerStore>,
registered_servers: HashMap<ContextServerId, RegisteredContextServer>,
@@ -16,7 +28,10 @@ pub struct ContextServerRegistry {
struct RegisteredContextServer {
tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
prompts: BTreeMap<SharedString, ContextServerPrompt>,
load_tools: Task<Result<()>>,
load_prompts: Task<Result<()>>,
_tools_updated_subscription: Option<NotificationSubscription>,
}
impl ContextServerRegistry {
@@ -28,6 +43,7 @@ impl ContextServerRegistry {
};
for server in server_store.read(cx).running_servers() {
this.reload_tools_for_server(server.id(), cx);
this.reload_prompts_for_server(server.id(), cx);
}
this
}
@@ -56,6 +72,88 @@ impl ContextServerRegistry {
.map(|(id, server)| (id, &server.tools))
}
pub fn prompts(&self) -> impl Iterator<Item = &ContextServerPrompt> {
self.registered_servers
.values()
.flat_map(|server| server.prompts.values())
}
pub fn find_prompt(
&self,
server_id: Option<&ContextServerId>,
name: &str,
) -> Option<&ContextServerPrompt> {
if let Some(server_id) = server_id {
self.registered_servers
.get(server_id)
.and_then(|server| server.prompts.get(name))
} else {
self.registered_servers
.values()
.find_map(|server| server.prompts.get(name))
}
}
pub fn server_store(&self) -> &Entity<ContextServerStore> {
&self.server_store
}
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(|| 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>) {
let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else {
return;
@@ -63,17 +161,12 @@ impl ContextServerRegistry {
let Some(client) = server.client() else {
return;
};
if !client.capable(context_server::protocol::ServerCapability::Tools) {
return;
}
let registered_server =
self.registered_servers
.entry(server_id.clone())
.or_insert(RegisteredContextServer {
tools: BTreeMap::default(),
load_tools: Task::ready(Ok(())),
});
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>(())
@@ -94,6 +187,49 @@ impl ContextServerRegistry {
));
registered_server.tools.insert(tool.name(), tool);
}
cx.emit(ContextServerRegistryEvent::ToolsChanged);
cx.notify();
}
})
});
}
fn reload_prompts_for_server(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else {
return;
};
let Some(client) = server.client() else {
return;
};
if !client.capable(context_server::protocol::ServerCapability::Prompts) {
return;
}
let registered_server = self.get_or_register_server(&server_id, cx);
registered_server.load_prompts = cx.spawn(async move |this, cx| {
let response = client
.request::<context_server::types::requests::PromptsList>(())
.await;
this.update(cx, |this, cx| {
let Some(registered_server) = this.registered_servers.get_mut(&server_id) else {
return;
};
registered_server.prompts.clear();
if let Some(response) = response.log_err() {
for prompt in response.prompts {
let name: SharedString = prompt.name.clone().into();
registered_server.prompts.insert(
name,
ContextServerPrompt {
server_id: server_id.clone(),
prompt,
},
);
}
cx.emit(ContextServerRegistryEvent::PromptsChanged);
cx.notify();
}
})
@@ -112,9 +248,17 @@ impl ContextServerRegistry {
ContextServerStatus::Starting => {}
ContextServerStatus::Running => {
self.reload_tools_for_server(server_id.clone(), cx);
self.reload_prompts_for_server(server_id.clone(), cx);
}
ContextServerStatus::Stopped | ContextServerStatus::Error(_) => {
self.registered_servers.remove(server_id);
if let Some(registered_server) = self.registered_servers.remove(server_id) {
if !registered_server.tools.is_empty() {
cx.emit(ContextServerRegistryEvent::ToolsChanged);
}
if !registered_server.prompts.is_empty() {
cx.emit(ContextServerRegistryEvent::PromptsChanged);
}
}
cx.notify();
}
}
@@ -251,3 +395,39 @@ impl AnyAgentTool for ContextServerTool {
Ok(())
}
}
pub fn get_prompt(
server_store: &Entity<ContextServerStore>,
server_id: &ContextServerId,
prompt_name: &str,
arguments: HashMap<String, String>,
cx: &mut AsyncApp,
) -> Task<Result<context_server::types::PromptsGetResponse>> {
let server = match cx.update(|cx| server_store.read(cx).get_running_server(server_id)) {
Ok(server) => server,
Err(error) => return Task::ready(Err(error)),
};
let Some(server) = server else {
return Task::ready(Err(anyhow::anyhow!("Context server not found")));
};
let Some(protocol) = server.client() else {
return Task::ready(Err(anyhow::anyhow!("Context server not initialized")));
};
let prompt_name = prompt_name.to_string();
cx.background_spawn(async move {
let response = protocol
.request::<context_server::types::requests::PromptsGet>(
context_server::types::PromptsGetParams {
name: prompt_name,
arguments: (!arguments.is_empty()).then(|| arguments),
meta: None,
},
)
.await?;
Ok(response)
})
}

View File

@@ -306,20 +306,39 @@ impl AgentTool for EditFileTool {
// Check if the file has been modified since the agent last read it
if let Some(abs_path) = abs_path.as_ref() {
let (last_read_mtime, current_mtime, is_dirty) = self.thread.update(cx, |thread, cx| {
let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| {
let last_read = thread.file_read_times.get(abs_path).copied();
let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime());
let dirty = buffer.read(cx).is_dirty();
(last_read, current, dirty)
let has_save = thread.has_tool("save_file");
let has_restore = thread.has_tool("restore_file_from_disk");
(last_read, current, dirty, has_save, has_restore)
})?;
// Check for unsaved changes first - these indicate modifications we don't know about
if is_dirty {
anyhow::bail!(
"This file cannot be written to because it has unsaved changes. \
Please end the current conversation immediately by telling the user you want to write to this file (mention its path explicitly) but you can't write to it because it has unsaved changes. \
Ask the user to save that buffer's changes and to inform you when it's ok to proceed."
);
let message = match (has_save_tool, has_restore_tool) {
(true, true) => {
"This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
}
(true, false) => {
"This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
}
(false, true) => {
"This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
}
(false, false) => {
"This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
then ask them to save or revert the file manually and inform you when it's ok to proceed."
}
};
anyhow::bail!("{}", message);
}
// Check if the file was modified on disk since we last read it
@@ -2202,9 +2221,21 @@ mod tests {
assert!(result.is_err(), "Edit should fail when buffer is dirty");
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("cannot be written to because it has unsaved changes"),
error_msg.contains("This file has unsaved changes."),
"Error should mention unsaved changes, got: {}",
error_msg
);
assert!(
error_msg.contains("keep or discard"),
"Error should ask whether to keep or discard changes, got: {}",
error_msg
);
// Since save_file and restore_file_from_disk tools aren't added to the thread,
// the error message should ask the user to manually save or revert
assert!(
error_msg.contains("save or revert the file manually"),
"Error should ask user to manually save or revert when tools aren't available, got: {}",
error_msg
);
}
}

View File

@@ -0,0 +1,352 @@
use agent_client_protocol as acp;
use anyhow::Result;
use collections::FxHashSet;
use gpui::{App, Entity, SharedString, Task};
use language::Buffer;
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::{AgentTool, ToolCallEventStream};
/// Discards unsaved changes in open buffers by reloading file contents from disk.
///
/// Use this tool when:
/// - You attempted to edit files but they have unsaved changes the user does not want to keep.
/// - You want to reset files to the on-disk state before retrying an edit.
///
/// Only use this tool after asking the user for permission, because it will discard unsaved changes.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct RestoreFileFromDiskToolInput {
/// The paths of the files to restore from disk.
pub paths: Vec<PathBuf>,
}
pub struct RestoreFileFromDiskTool {
project: Entity<Project>,
}
impl RestoreFileFromDiskTool {
pub fn new(project: Entity<Project>) -> Self {
Self { project }
}
}
impl AgentTool for RestoreFileFromDiskTool {
type Input = RestoreFileFromDiskToolInput;
type Output = String;
fn name() -> &'static str {
"restore_file_from_disk"
}
fn kind() -> acp::ToolKind {
acp::ToolKind::Other
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
match input {
Ok(input) if input.paths.len() == 1 => "Restore file from disk".into(),
Ok(input) => format!("Restore {} files from disk", input.paths.len()).into(),
Err(_) => "Restore files from disk".into(),
}
}
fn run(
self: Arc<Self>,
input: Self::Input,
_event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>> {
let project = self.project.clone();
let input_paths = input.paths;
cx.spawn(async move |cx| {
let mut buffers_to_reload: FxHashSet<Entity<Buffer>> = FxHashSet::default();
let mut restored_paths: Vec<PathBuf> = Vec::new();
let mut clean_paths: Vec<PathBuf> = Vec::new();
let mut not_found_paths: Vec<PathBuf> = Vec::new();
let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
let mut reload_errors: Vec<String> = Vec::new();
for path in input_paths {
let project_path =
project.read_with(cx, |project, cx| project.find_project_path(&path, cx));
let project_path = match project_path {
Ok(Some(project_path)) => project_path,
Ok(None) => {
not_found_paths.push(path);
continue;
}
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
};
let open_buffer_task =
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
let buffer = match open_buffer_task {
Ok(task) => match task.await {
Ok(buffer) => buffer,
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
},
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
};
let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) {
Ok(is_dirty) => is_dirty,
Err(error) => {
dirty_check_errors.push((path, error.to_string()));
continue;
}
};
if is_dirty {
buffers_to_reload.insert(buffer);
restored_paths.push(path);
} else {
clean_paths.push(path);
}
}
if !buffers_to_reload.is_empty() {
let reload_task = project.update(cx, |project, cx| {
project.reload_buffers(buffers_to_reload, true, cx)
});
match reload_task {
Ok(task) => {
if let Err(error) = task.await {
reload_errors.push(error.to_string());
}
}
Err(error) => {
reload_errors.push(error.to_string());
}
}
}
let mut lines: Vec<String> = Vec::new();
if !restored_paths.is_empty() {
lines.push(format!("Restored {} file(s).", restored_paths.len()));
}
if !clean_paths.is_empty() {
lines.push(format!("{} clean.", clean_paths.len()));
}
if !not_found_paths.is_empty() {
lines.push(format!("Not found ({}):", not_found_paths.len()));
for path in &not_found_paths {
lines.push(format!("- {}", path.display()));
}
}
if !open_errors.is_empty() {
lines.push(format!("Open failed ({}):", open_errors.len()));
for (path, error) in &open_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !dirty_check_errors.is_empty() {
lines.push(format!(
"Dirty check failed ({}):",
dirty_check_errors.len()
));
for (path, error) in &dirty_check_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !reload_errors.is_empty() {
lines.push(format!("Reload failed ({}):", reload_errors.len()));
for error in &reload_errors {
lines.push(format!("- {}", error));
}
}
if lines.is_empty() {
Ok("No paths provided.".to_string())
} else {
Ok(lines.join("\n"))
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fs::Fs;
use gpui::TestAppContext;
use language::LineEnding;
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
use util::path;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
}
#[gpui::test]
async fn test_restore_file_from_disk_output_and_effects(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"dirty.txt": "on disk: dirty\n",
"clean.txt": "on disk: clean\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let tool = Arc::new(RestoreFileFromDiskTool::new(project.clone()));
// Make dirty.txt dirty in-memory by saving different content into the buffer without saving to disk.
let dirty_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/dirty.txt", cx)
.expect("dirty.txt should exist in project")
});
let dirty_buffer = project
.update(cx, |project, cx| {
project.open_buffer(dirty_project_path, cx)
})
.await
.unwrap();
dirty_buffer.update(cx, |buffer, cx| {
buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
});
assert!(
dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should be dirty before restore"
);
// Ensure clean.txt is opened but remains clean.
let clean_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/clean.txt", cx)
.expect("clean.txt should exist in project")
});
let clean_buffer = project
.update(cx, |project, cx| {
project.open_buffer(clean_project_path, cx)
})
.await
.unwrap();
assert!(
!clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"clean.txt buffer should start clean"
);
let output = cx
.update(|cx| {
tool.clone().run(
RestoreFileFromDiskToolInput {
paths: vec![
PathBuf::from("root/dirty.txt"),
PathBuf::from("root/clean.txt"),
],
},
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
// Output should mention restored + clean.
assert!(
output.contains("Restored 1 file(s)."),
"expected restored count line, got:\n{output}"
);
assert!(
output.contains("1 clean."),
"expected clean count line, got:\n{output}"
);
// Effect: dirty buffer should be restored back to disk content and become clean.
let dirty_text = dirty_buffer.read_with(cx, |buffer, _| buffer.text());
assert_eq!(
dirty_text, "on disk: dirty\n",
"dirty.txt buffer should be restored to disk contents"
);
assert!(
!dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should not be dirty after restore"
);
// Disk contents should be unchanged (restore-from-disk should not write).
let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
assert_eq!(disk_dirty, "on disk: dirty\n");
// Sanity: clean buffer should remain clean and unchanged.
let clean_text = clean_buffer.read_with(cx, |buffer, _| buffer.text());
assert_eq!(clean_text, "on disk: clean\n");
assert!(
!clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"clean.txt buffer should remain clean"
);
// Test empty paths case.
let output = cx
.update(|cx| {
tool.clone().run(
RestoreFileFromDiskToolInput { paths: vec![] },
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert_eq!(output, "No paths provided.");
// Test not-found path case (path outside the project root).
let output = cx
.update(|cx| {
tool.clone().run(
RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("nonexistent/path.txt")],
},
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert!(
output.contains("Not found (1):"),
"expected not-found header line, got:\n{output}"
);
assert!(
output.contains("- nonexistent/path.txt"),
"expected not-found path bullet, got:\n{output}"
);
let _ = LineEnding::Unix; // keep import used if the buffer edit API changes
}
}

View File

@@ -0,0 +1,351 @@
use agent_client_protocol as acp;
use anyhow::Result;
use collections::FxHashSet;
use gpui::{App, Entity, SharedString, Task};
use language::Buffer;
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::{AgentTool, ToolCallEventStream};
/// Saves files that have unsaved changes.
///
/// Use this tool when you need to edit files but they have unsaved changes that must be saved first.
/// Only use this tool after asking the user for permission to save their unsaved changes.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct SaveFileToolInput {
/// The paths of the files to save.
pub paths: Vec<PathBuf>,
}
pub struct SaveFileTool {
project: Entity<Project>,
}
impl SaveFileTool {
pub fn new(project: Entity<Project>) -> Self {
Self { project }
}
}
impl AgentTool for SaveFileTool {
type Input = SaveFileToolInput;
type Output = String;
fn name() -> &'static str {
"save_file"
}
fn kind() -> acp::ToolKind {
acp::ToolKind::Other
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
match input {
Ok(input) if input.paths.len() == 1 => "Save file".into(),
Ok(input) => format!("Save {} files", input.paths.len()).into(),
Err(_) => "Save files".into(),
}
}
fn run(
self: Arc<Self>,
input: Self::Input,
_event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>> {
let project = self.project.clone();
let input_paths = input.paths;
cx.spawn(async move |cx| {
let mut buffers_to_save: FxHashSet<Entity<Buffer>> = FxHashSet::default();
let mut saved_paths: Vec<PathBuf> = Vec::new();
let mut clean_paths: Vec<PathBuf> = Vec::new();
let mut not_found_paths: Vec<PathBuf> = Vec::new();
let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
let mut save_errors: Vec<(String, String)> = Vec::new();
for path in input_paths {
let project_path =
project.read_with(cx, |project, cx| project.find_project_path(&path, cx));
let project_path = match project_path {
Ok(Some(project_path)) => project_path,
Ok(None) => {
not_found_paths.push(path);
continue;
}
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
};
let open_buffer_task =
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
let buffer = match open_buffer_task {
Ok(task) => match task.await {
Ok(buffer) => buffer,
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
},
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
};
let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) {
Ok(is_dirty) => is_dirty,
Err(error) => {
dirty_check_errors.push((path, error.to_string()));
continue;
}
};
if is_dirty {
buffers_to_save.insert(buffer);
saved_paths.push(path);
} else {
clean_paths.push(path);
}
}
// Save each buffer individually since there's no batch save API.
for buffer in buffers_to_save {
let path_for_buffer = match buffer.read_with(cx, |buffer, _| {
buffer
.file()
.map(|file| file.path().to_rel_path_buf())
.map(|path| path.as_rel_path().as_unix_str().to_owned())
}) {
Ok(path) => path.unwrap_or_else(|| "<unknown>".to_string()),
Err(error) => {
save_errors.push(("<unknown>".to_string(), error.to_string()));
continue;
}
};
let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx));
match save_task {
Ok(task) => {
if let Err(error) = task.await {
save_errors.push((path_for_buffer, error.to_string()));
}
}
Err(error) => {
save_errors.push((path_for_buffer, error.to_string()));
}
}
}
let mut lines: Vec<String> = Vec::new();
if !saved_paths.is_empty() {
lines.push(format!("Saved {} file(s).", saved_paths.len()));
}
if !clean_paths.is_empty() {
lines.push(format!("{} clean.", clean_paths.len()));
}
if !not_found_paths.is_empty() {
lines.push(format!("Not found ({}):", not_found_paths.len()));
for path in &not_found_paths {
lines.push(format!("- {}", path.display()));
}
}
if !open_errors.is_empty() {
lines.push(format!("Open failed ({}):", open_errors.len()));
for (path, error) in &open_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !dirty_check_errors.is_empty() {
lines.push(format!(
"Dirty check failed ({}):",
dirty_check_errors.len()
));
for (path, error) in &dirty_check_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !save_errors.is_empty() {
lines.push(format!("Save failed ({}):", save_errors.len()));
for (path, error) in &save_errors {
lines.push(format!("- {}: {}", path, error));
}
}
if lines.is_empty() {
Ok("No paths provided.".to_string())
} else {
Ok(lines.join("\n"))
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fs::Fs;
use gpui::TestAppContext;
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
use util::path;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
}
#[gpui::test]
async fn test_save_file_output_and_effects(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"dirty.txt": "on disk: dirty\n",
"clean.txt": "on disk: clean\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let tool = Arc::new(SaveFileTool::new(project.clone()));
// Make dirty.txt dirty in-memory.
let dirty_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/dirty.txt", cx)
.expect("dirty.txt should exist in project")
});
let dirty_buffer = project
.update(cx, |project, cx| {
project.open_buffer(dirty_project_path, cx)
})
.await
.unwrap();
dirty_buffer.update(cx, |buffer, cx| {
buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
});
assert!(
dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should be dirty before save"
);
// Ensure clean.txt is opened but remains clean.
let clean_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/clean.txt", cx)
.expect("clean.txt should exist in project")
});
let clean_buffer = project
.update(cx, |project, cx| {
project.open_buffer(clean_project_path, cx)
})
.await
.unwrap();
assert!(
!clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"clean.txt buffer should start clean"
);
let output = cx
.update(|cx| {
tool.clone().run(
SaveFileToolInput {
paths: vec![
PathBuf::from("root/dirty.txt"),
PathBuf::from("root/clean.txt"),
],
},
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
// Output should mention saved + clean.
assert!(
output.contains("Saved 1 file(s)."),
"expected saved count line, got:\n{output}"
);
assert!(
output.contains("1 clean."),
"expected clean count line, got:\n{output}"
);
// Effect: dirty buffer should now be clean and disk should have new content.
assert!(
!dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should not be dirty after save"
);
let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
assert_eq!(
disk_dirty, "in memory: dirty\n",
"dirty.txt disk content should be updated"
);
// Sanity: clean buffer should remain clean and disk unchanged.
let disk_clean = fs.load(path!("/root/clean.txt").as_ref()).await.unwrap();
assert_eq!(disk_clean, "on disk: clean\n");
// Test empty paths case.
let output = cx
.update(|cx| {
tool.clone().run(
SaveFileToolInput { paths: vec![] },
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert_eq!(output, "No paths provided.");
// Test not-found path case.
let output = cx
.update(|cx| {
tool.clone().run(
SaveFileToolInput {
paths: vec![PathBuf::from("nonexistent/path.txt")],
},
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert!(
output.contains("Not found (1):"),
"expected not-found header line, got:\n{output}"
);
assert!(
output.contains("- nonexistent/path.txt"),
"expected not-found path bullet, got:\n{output}"
);
}
}

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

@@ -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)]
@@ -389,6 +389,17 @@ impl AcpThreadView {
),
];
cx.on_release(|this, cx| {
for window in this.notifications.drain(..) {
window
.update(cx, |_, window, _| {
window.remove_window();
})
.ok();
}
})
.detach();
let show_codex_windows_warning = cfg!(windows)
&& project.read(cx).is_local()
&& agent.clone().downcast::<agent_servers::Codex>().is_some();
@@ -1315,7 +1326,7 @@ impl AcpThreadView {
})?;
anyhow::Ok(())
})
.detach();
.detach_and_log_err(cx);
}
fn open_edited_buffer(
@@ -1940,6 +1951,16 @@ impl AcpThreadView {
window: &mut Window,
cx: &Context<Self>,
) -> AnyElement {
let is_indented = entry.is_indented();
let is_first_indented = is_indented
&& self.thread().is_some_and(|thread| {
thread
.read(cx)
.entries()
.get(entry_ix.saturating_sub(1))
.is_none_or(|entry| !entry.is_indented())
});
let primary = match &entry {
AgentThreadEntry::UserMessage(message) => {
let Some(editor) = self
@@ -1972,7 +1993,9 @@ impl AcpThreadView {
v_flex()
.id(("user_message", entry_ix))
.map(|this| {
if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
if is_first_indented {
this.pt_0p5()
} else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
this.pt(rems_from_px(18.))
} else if rules_item.is_some() {
this.pt_3()
@@ -2018,6 +2041,9 @@ impl AcpThreadView {
.shadow_md()
.bg(cx.theme().colors().editor_background)
.border_1()
.when(is_indented, |this| {
this.py_2().px_2().shadow_sm()
})
.when(editing && !editor_focus, |this| this.border_dashed())
.border_color(cx.theme().colors().border)
.map(|this|{
@@ -2112,7 +2138,10 @@ impl AcpThreadView {
)
.into_any()
}
AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
AgentThreadEntry::AssistantMessage(AssistantMessage {
chunks,
indented: _,
}) => {
let is_last = entry_ix + 1 == total_entries;
let style = default_markdown_style(false, false, window, cx);
@@ -2146,6 +2175,7 @@ impl AcpThreadView {
v_flex()
.px_5()
.py_1p5()
.when(is_first_indented, |this| this.pt_0p5())
.when(is_last, |this| this.pb_4())
.w_full()
.text_ui(cx)
@@ -2155,19 +2185,48 @@ impl AcpThreadView {
AgentThreadEntry::ToolCall(tool_call) => {
let has_terminals = tool_call.terminals().next().is_some();
div().w_full().map(|this| {
if has_terminals {
this.children(tool_call.terminals().map(|terminal| {
self.render_terminal_tool_call(
entry_ix, terminal, tool_call, window, cx,
)
}))
} else {
this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
}
})
div()
.w_full()
.map(|this| {
if has_terminals {
this.children(tool_call.terminals().map(|terminal| {
self.render_terminal_tool_call(
entry_ix, terminal, tool_call, window, cx,
)
}))
} else {
this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
}
})
.into_any()
}
.into_any(),
};
let primary = if is_indented {
let line_top = if is_first_indented {
rems_from_px(-12.0)
} else {
rems_from_px(0.0)
};
div()
.relative()
.w_full()
.pl(rems_from_px(20.0))
.bg(cx.theme().colors().panel_background.opacity(0.2))
.child(
div()
.absolute()
.left(rems_from_px(18.0))
.top(line_top)
.bottom_0()
.w_px()
.bg(cx.theme().colors().border.opacity(0.6)),
)
.child(primary)
.into_any_element()
} else {
primary
};
let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry {
@@ -4234,6 +4293,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()
@@ -4994,8 +5060,8 @@ impl AcpThreadView {
});
if let Some(screen_window) = cx
.open_window(options, |_, cx| {
cx.new(|_| {
.open_window(options, |_window, cx| {
cx.new(|_cx| {
AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
})
})
@@ -6421,6 +6487,57 @@ pub(crate) mod tests {
);
}
#[gpui::test]
async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) {
init_test(cx);
let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
let weak_view = thread_view.downgrade();
let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
message_editor.update_in(cx, |editor, window, cx| {
editor.set_text("Hello", window, cx);
});
cx.deactivate_window();
thread_view.update_in(cx, |thread_view, window, cx| {
thread_view.send(window, cx);
});
cx.run_until_parked();
// Verify notification is shown
assert!(
cx.windows()
.iter()
.any(|window| window.downcast::<AgentNotification>().is_some()),
"Expected notification to be shown"
);
// Drop the thread view (simulating navigation to a new thread)
drop(thread_view);
drop(message_editor);
// Trigger an update to flush effects, which will call release_dropped_entities
cx.update(|_window, _cx| {});
cx.run_until_parked();
// Verify the entity was actually released
assert!(
!weak_view.is_upgradable(),
"Thread view entity should be released after dropping"
);
// The notification should be automatically closed via on_release
assert!(
!cx.windows()
.iter()
.any(|window| window.downcast::<AgentNotification>().is_some()),
"Notification should be closed when thread view is dropped"
);
}
async fn setup_thread_view(
agent: impl AgentServer + 'static,
cx: &mut TestAppContext,

View File

@@ -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(),

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

@@ -2,10 +2,12 @@ use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration};
use acp_thread::AcpThread;
use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore};
use agent_servers::AgentServer;
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::{
@@ -262,6 +264,17 @@ 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 {
@@ -287,7 +300,7 @@ impl ActiveView {
}
}
pub fn native_agent(
fn native_agent(
fs: Arc<dyn Fs>,
prompt_store: Option<Entity<PromptStore>>,
history_store: Entity<agent::HistoryStore>,
@@ -442,6 +455,9 @@ 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 {
@@ -665,6 +681,48 @@ 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,
@@ -687,11 +745,14 @@ 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,
};
// Initial sync of agent servers from extensions
@@ -884,37 +945,63 @@ impl AgentPanel {
}
};
let server = ext_agent.server(fs, history);
this.update_in(cx, |this, window, cx| {
let selected_agent = ext_agent.into();
if this.selected_agent != selected_agent {
this.selected_agent = selected_agent;
this.serialize(cx);
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 thread_view = cx.new(|cx| {
crate::acp::AcpThreadView::new(
server,
resume_thread,
summarize_thread,
workspace.clone(),
project,
this.history_store.clone(),
this.prompt_store.clone(),
!loading,
window,
cx,
)
});
this.set_active_view(
ActiveView::ExternalAgentThread { thread_view },
!loading,
let server = ext_agent.server(fs, history);
this.update_in(cx, |agent_panel, window, cx| {
agent_panel._external_thread(
server,
resume_thread,
summarize_thread,
workspace,
project,
loading,
ext_agent,
window,
cx,
);
})
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
@@ -1423,6 +1510,36 @@ 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);
@@ -1477,6 +1594,47 @@ impl AgentPanel {
cx,
);
}
fn _external_thread(
&mut self,
server: Rc<dyn AgentServer>,
resume_thread: Option<DbThreadMetadata>,
summarize_thread: Option<DbThreadMetadata>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
loading: bool,
ext_agent: ExternalAgent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let selected_agent = AgentType::from(ext_agent);
if self.selected_agent != selected_agent {
self.selected_agent = selected_agent;
self.serialize(cx);
}
let thread_view = cx.new(|cx| {
crate::acp::AcpThreadView::new(
server,
resume_thread,
summarize_thread,
workspace.clone(),
project,
self.history_store.clone(),
self.prompt_store.clone(),
!loading,
window,
cx,
)
});
self.set_active_view(
ActiveView::ExternalAgentThread { thread_view },
!loading,
window,
cx,
);
}
}
impl Focusable for AgentPanel {
@@ -2557,6 +2715,38 @@ impl AgentPanel {
}
}
fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
if !self.show_trust_workspace_message {
return None;
}
let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
Some(
Callout::new()
.icon(IconName::Warning)
.severity(Severity::Warning)
.border_position(ui::BorderPosition::Bottom)
.title("You're in Restricted Mode")
.description(description)
.actions_slot(
Button::new("open-trust-modal", "Configure Project Trust")
.label_size(LabelSize::Small)
.style(ButtonStyle::Outlined)
.on_click({
cx.listener(move |this, _, window, cx| {
this.workspace
.update(cx, |workspace, cx| {
workspace
.show_worktree_trust_security_modal(true, window, cx)
})
.log_err();
})
}),
),
)
}
fn key_context(&self) -> KeyContext {
let mut key_context = KeyContext::new_with_defaults();
key_context.add("AgentPanel");
@@ -2609,6 +2799,7 @@ impl Render for AgentPanel {
}
}))
.child(self.render_toolbar(window, cx))
.children(self.render_workspace_trust_message(cx))
.children(self.render_onboarding(window, cx))
.map(|parent| match &self.active_view {
ActiveView::ExternalAgentThread { thread_view, .. } => parent

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,6 +174,16 @@ 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.
@@ -457,6 +470,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

@@ -20,7 +20,7 @@ use project::{
Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse,
PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId,
};
use prompt_store::{PromptId, PromptStore, UserPromptId};
use prompt_store::{PromptStore, UserPromptId};
use rope::Point;
use text::{Anchor, ToPoint as _};
use ui::prelude::*;
@@ -1585,13 +1585,10 @@ pub(crate) fn search_rules(
if metadata.default {
None
} else {
match metadata.id {
PromptId::EditWorkflow => None,
PromptId::User { uuid } => Some(RulesContextEntry {
prompt_id: uuid,
title: metadata.title?,
}),
}
Some(RulesContextEntry {
prompt_id: metadata.id.user_id()?,
title: metadata.title?,
})
}
})
.collect::<Vec<_>>()

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

@@ -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

@@ -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,
@@ -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

@@ -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

@@ -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

@@ -4,6 +4,7 @@ use collections::{HashMap, HashSet};
use dap::{Capabilities, adapters::DebugTaskDefinition, transport::RequestHandling};
use debugger_ui::debugger_panel::DebugPanel;
use editor::{Editor, EditorMode, MultiBuffer};
use extension::ExtensionHostProxy;
use fs::{FakeFs, Fs as _, RemoveOptions};
use futures::StreamExt as _;
@@ -12,22 +13,30 @@ use http_client::BlockedHttpClient;
use language::{
FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry,
language_settings::{Formatter, FormatterList, language_settings},
tree_sitter_typescript,
rust_lang, tree_sitter_typescript,
};
use node_runtime::NodeRuntime;
use project::{
ProjectPath,
debugger::session::ThreadId,
lsp_store::{FormatTrigger, LspFormatTarget},
trusted_worktrees::{PathTrust, TrustedWorktrees},
};
use remote::RemoteClient;
use remote_server::{HeadlessAppState, HeadlessProject};
use rpc::proto;
use serde_json::json;
use settings::{LanguageServerFormatterSpecifier, PrettierSettingsContent, SettingsStore};
use settings::{
InlayHintSettingsContent, LanguageServerFormatterSpecifier, PrettierSettingsContent,
SettingsStore,
};
use std::{
path::Path,
sync::{Arc, atomic::AtomicUsize},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use task::TcpArgumentsTemplate;
use util::{path, rel_path::rel_path};
@@ -90,13 +99,14 @@ async fn test_sharing_an_ssh_remote_project(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
false,
cx,
)
});
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, worktree_id) = client_a
.build_ssh_project(path!("/code/project1"), client_ssh, cx_a)
.build_ssh_project(path!("/code/project1"), client_ssh, false, cx_a)
.await;
// While the SSH worktree is being scanned, user A shares the remote project.
@@ -250,13 +260,14 @@ async fn test_ssh_collaboration_git_branches(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
false,
cx,
)
});
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, _) = client_a
.build_ssh_project("/project", client_ssh, cx_a)
.build_ssh_project("/project", client_ssh, false, cx_a)
.await;
// While the SSH worktree is being scanned, user A shares the remote project.
@@ -454,13 +465,14 @@ async fn test_ssh_collaboration_formatting_with_prettier(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
false,
cx,
)
});
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, worktree_id) = client_a
.build_ssh_project(path!("/project"), client_ssh, cx_a)
.build_ssh_project(path!("/project"), client_ssh, false, cx_a)
.await;
// While the SSH worktree is being scanned, user A shares the remote project.
@@ -615,6 +627,7 @@ async fn test_remote_server_debugger(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
false,
cx,
)
});
@@ -627,7 +640,7 @@ async fn test_remote_server_debugger(
command_palette_hooks::init(cx);
});
let (project_a, _) = client_a
.build_ssh_project(path!("/code"), client_ssh.clone(), cx_a)
.build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a)
.await;
let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a);
@@ -723,6 +736,7 @@ async fn test_slow_adapter_startup_retries(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
false,
cx,
)
});
@@ -735,7 +749,7 @@ async fn test_slow_adapter_startup_retries(
command_palette_hooks::init(cx);
});
let (project_a, _) = client_a
.build_ssh_project(path!("/code"), client_ssh.clone(), cx_a)
.build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a)
.await;
let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a);
@@ -838,3 +852,259 @@ async fn test_slow_adapter_startup_retries(
shutdown_session.await.unwrap();
}
#[gpui::test]
async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &mut TestAppContext) {
use project::trusted_worktrees::RemoteHostLocation;
cx_a.update(|cx| {
release_channel::init(semver::Version::new(0, 0, 0), cx);
});
server_cx.update(|cx| {
release_channel::init(semver::Version::new(0, 0, 0), cx);
});
let mut server = TestServer::start(cx_a.executor().clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let server_name = "override-rust-analyzer";
let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0));
let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
let remote_fs = FakeFs::new(server_cx.executor());
remote_fs
.insert_tree(
path!("/projects"),
json!({
"project_a": {
".zed": {
"settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"#
},
"main.rs": "fn main() {}"
},
"project_b": { "lib.rs": "pub fn lib() {}" }
}),
)
.await;
server_cx.update(HeadlessProject::init);
let remote_http_client = Arc::new(BlockedHttpClient);
let node = NodeRuntime::unavailable();
let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
languages.add(rust_lang());
let capabilities = lsp::ServerCapabilities {
inlay_hint_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
};
let mut fake_language_servers = languages.register_fake_lsp(
"Rust",
FakeLspAdapter {
name: server_name,
capabilities: capabilities.clone(),
initializer: Some(Box::new({
let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
move |fake_server| {
let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>(
move |_params, _| {
lsp_inlay_hint_request_count.fetch_add(1, Ordering::Release);
async move {
Ok(Some(vec![lsp::InlayHint {
position: lsp::Position::new(0, 0),
label: lsp::InlayHintLabel::String("hint".to_string()),
kind: None,
text_edits: None,
tooltip: None,
padding_left: None,
padding_right: None,
data: None,
}]))
}
},
);
}
})),
..FakeLspAdapter::default()
},
);
let _headless_project = server_cx.new(|cx| {
HeadlessProject::new(
HeadlessAppState {
session: server_ssh,
fs: remote_fs.clone(),
http_client: remote_http_client,
node_runtime: node,
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
true,
cx,
)
});
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, worktree_id_a) = client_a
.build_ssh_project(path!("/projects/project_a"), client_ssh.clone(), true, cx_a)
.await;
cx_a.update(|cx| {
release_channel::init(semver::Version::new(0, 0, 0), cx);
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
let language_settings = &mut settings.project.all_languages.defaults;
language_settings.inlay_hints = Some(InlayHintSettingsContent {
enabled: Some(true),
..InlayHintSettingsContent::default()
})
});
});
});
project_a
.update(cx_a, |project, cx| {
project.languages().add(rust_lang());
project.languages().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
name: server_name,
capabilities,
..FakeLspAdapter::default()
},
);
project.find_or_create_worktree(path!("/projects/project_b"), true, cx)
})
.await
.unwrap();
cx_a.run_until_parked();
let worktree_ids = project_a.read_with(cx_a, |project, cx| {
project
.worktrees(cx)
.map(|wt| wt.read(cx).id())
.collect::<Vec<_>>()
});
assert_eq!(worktree_ids.len(), 2);
let remote_host = project_a.read_with(cx_a, |project, cx| {
project
.remote_connection_options(cx)
.map(RemoteHostLocation::from)
});
let trusted_worktrees =
cx_a.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist"));
let can_trust_a =
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
let can_trust_b =
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
assert!(!can_trust_a, "project_a should be restricted initially");
assert!(!can_trust_b, "project_b should be restricted initially");
let worktree_store = project_a.read_with(cx_a, |project, _| project.worktree_store());
let has_restricted = trusted_worktrees.read_with(cx_a, |store, cx| {
store.has_restricted_worktrees(&worktree_store, cx)
});
assert!(has_restricted, "should have restricted worktrees");
let buffer_before_approval = project_a
.update(cx_a, |project, cx| {
project.open_buffer((worktree_id_a, rel_path("main.rs")), cx)
})
.await
.unwrap();
let (editor, cx_a) = cx_a.add_window_view(|window, cx| {
Editor::new(
EditorMode::full(),
cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)),
Some(project_a.clone()),
window,
cx,
)
});
cx_a.run_until_parked();
let fake_language_server = fake_language_servers.next();
cx_a.read(|cx| {
let file = buffer_before_approval.read(cx).file();
assert_eq!(
language_settings(Some("Rust".into()), file, cx).language_servers,
["...".to_string()],
"remote .zed/settings.json must not sync before trust approval"
)
});
editor.update_in(cx_a, |editor, window, cx| {
editor.handle_input("1", window, cx);
});
cx_a.run_until_parked();
cx_a.executor().advance_clock(Duration::from_secs(1));
assert_eq!(
lsp_inlay_hint_request_count.load(Ordering::Acquire),
0,
"inlay hints must not be queried before trust approval"
);
trusted_worktrees.update(cx_a, |store, cx| {
store.trust(
HashSet::from_iter([PathTrust::Worktree(worktree_ids[0])]),
remote_host.clone(),
cx,
);
});
cx_a.run_until_parked();
cx_a.read(|cx| {
let file = buffer_before_approval.read(cx).file();
assert_eq!(
language_settings(Some("Rust".into()), file, cx).language_servers,
["override-rust-analyzer".to_string()],
"remote .zed/settings.json should sync after trust approval"
)
});
let _fake_language_server = fake_language_server.await.unwrap();
editor.update_in(cx_a, |editor, window, cx| {
editor.handle_input("1", window, cx);
});
cx_a.run_until_parked();
cx_a.executor().advance_clock(Duration::from_secs(1));
assert!(
lsp_inlay_hint_request_count.load(Ordering::Acquire) > 0,
"inlay hints should be queried after trust approval"
);
let can_trust_a =
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
let can_trust_b =
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
assert!(can_trust_a, "project_a should be trusted after trust()");
assert!(!can_trust_b, "project_b should still be restricted");
trusted_worktrees.update(cx_a, |store, cx| {
store.trust(
HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]),
remote_host.clone(),
cx,
);
});
let can_trust_a =
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
let can_trust_b =
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
assert!(can_trust_a, "project_a should remain trusted");
assert!(can_trust_b, "project_b should now be trusted");
let has_restricted_after = trusted_worktrees.read_with(cx_a, |store, cx| {
store.has_restricted_worktrees(&worktree_store, cx)
});
assert!(
!has_restricted_after,
"should have no restricted worktrees after trusting both"
);
}

View File

@@ -761,6 +761,7 @@ impl TestClient {
&self,
root_path: impl AsRef<Path>,
ssh: Entity<RemoteClient>,
init_worktree_trust: bool,
cx: &mut TestAppContext,
) -> (Entity<Project>, WorktreeId) {
let project = cx.update(|cx| {
@@ -771,6 +772,7 @@ impl TestClient {
self.app_state.user_store.clone(),
self.app_state.languages.clone(),
self.app_state.fs.clone(),
init_worktree_trust,
cx,
)
});
@@ -839,6 +841,7 @@ impl TestClient {
self.app_state.languages.clone(),
self.app_state.fs.clone(),
None,
false,
cx,
)
})

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

@@ -330,7 +330,7 @@ pub struct PromptMessage {
pub content: MessageContent,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,

View File

@@ -8,8 +8,7 @@ use gpui_tokio::Tokio;
use language::LanguageRegistry;
use language_extension::LspAccess;
use node_runtime::{NodeBinaryOptions, NodeRuntime};
use project::Project;
use project::project_settings::ProjectSettings;
use project::{Project, project_settings::ProjectSettings};
use release_channel::{AppCommitSha, AppVersion};
use reqwest_client::ReqwestClient;
use settings::{Settings, SettingsStore};
@@ -115,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);
let node_runtime = NodeRuntime::new(client.http_client(), None, rx, None);
let extension_host_proxy = ExtensionHostProxy::global(cx);

View File

@@ -179,6 +179,7 @@ async fn setup_project(
app_state.languages.clone(),
app_state.fs.clone(),
None,
false,
cx,
)
})?;

View File

@@ -41,14 +41,16 @@ use multi_buffer::{
use parking_lot::Mutex;
use pretty_assertions::{assert_eq, assert_ne};
use project::{
FakeFs,
FakeFs, Project,
debugger::breakpoint_store::{BreakpointState, SourceBreakpoint},
project_settings::LspSettings,
trusted_worktrees::{PathTrust, TrustedWorktrees},
};
use serde_json::{self, json};
use settings::{
AllLanguageSettingsContent, EditorSettingsContent, IndentGuideBackgroundColoring,
IndentGuideColoring, ProjectSettingsContent, SearchSettingsContent,
IndentGuideColoring, InlayHintSettingsContent, ProjectSettingsContent, SearchSettingsContent,
SettingsStore,
};
use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
use std::{
@@ -25578,6 +25580,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
ˇ log('for else')
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
ˇfor item in items:
@@ -25597,6 +25600,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
// test relative indent is preserved when tab
// for `if`, `elif`, `else`, `while`, `with` and `for`
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
ˇfor item in items:
@@ -25630,6 +25634,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
ˇ return 0
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
ˇtry:
@@ -25646,6 +25651,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
// test relative indent is preserved when tab
// for `try`, `except`, `else`, `finally`, `match` and `def`
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
ˇtry:
@@ -25679,6 +25685,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("else:", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
if i == 2:
@@ -25696,6 +25703,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("except:", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25715,6 +25723,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("else:", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25738,6 +25747,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("finally:", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25762,6 +25772,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("else:", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25787,6 +25798,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("finally:", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25812,6 +25824,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("except:", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25835,6 +25848,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("except:", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25856,6 +25870,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("else:", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
for i in range(10):
@@ -25872,6 +25887,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("a", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def f() -> list[str]:
@@ -25885,6 +25901,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input(":", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
match 1:
case:ˇ
@@ -25908,6 +25925,7 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
# COMMENT:
ˇ
@@ -25920,7 +25938,7 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
{
ˇ
@@ -25980,6 +25998,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo
ˇ}
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
function main() {
ˇfor item in $items; do
@@ -25997,6 +26016,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo
"});
// test relative indent is preserved when tab
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
function main() {
ˇfor item in $items; do
@@ -26031,6 +26051,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo
ˇ}
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
function handle() {
ˇcase \"$1\" in
@@ -26073,6 +26094,7 @@ async fn test_indent_after_input_for_bash(cx: &mut TestAppContext) {
ˇ}
"});
cx.update_editor(|e, window, cx| e.handle_input("#", window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
function main() {
#ˇ for item in $items; do
@@ -26107,6 +26129,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("else", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"foo bar\"
@@ -26122,6 +26145,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("elif", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"foo bar\"
@@ -26139,6 +26163,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("fi", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"foo bar\"
@@ -26156,6 +26181,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("done", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
while read line; do
echo \"$line\"
@@ -26171,6 +26197,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("done", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
for file in *.txt; do
cat \"$file\"
@@ -26191,6 +26218,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("esac", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
case \"$1\" in
start)
@@ -26213,6 +26241,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("*)", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
case \"$1\" in
start)
@@ -26232,6 +26261,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("fi", window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"outer if\"
@@ -26258,6 +26288,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
# COMMENT:
ˇ
@@ -26271,7 +26302,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
@@ -26286,7 +26317,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
else
@@ -26301,7 +26332,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
elif
@@ -26315,7 +26346,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
for file in *.txt; do
ˇ
@@ -26329,7 +26360,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
case \"$1\" in
start)
@@ -26346,7 +26377,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
case \"$1\" in
start)
@@ -26362,7 +26393,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
function test() {
ˇ
@@ -26376,7 +26407,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
echo \"test\";
ˇ
@@ -29335,3 +29366,166 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) {
cx.assert_editor_state(after);
}
#[gpui::test]
async fn test_local_worktree_trust(cx: &mut TestAppContext) {
init_test(cx, |_| {});
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.all_languages.defaults.inlay_hints =
Some(InlayHintSettingsContent {
enabled: Some(true),
..InlayHintSettingsContent::default()
});
});
});
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".zed": {
"settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"#
},
"main.rs": "fn main() {}"
}),
)
.await;
let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0));
let server_name = "override-rust-analyzer";
let project = Project::test_with_worktree_trust(fs, [path!("/project").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
let capabilities = lsp::ServerCapabilities {
inlay_hint_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
};
let mut fake_language_servers = language_registry.register_fake_lsp(
"Rust",
FakeLspAdapter {
name: server_name,
capabilities,
initializer: Some(Box::new({
let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
move |fake_server| {
let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>(
move |_params, _| {
lsp_inlay_hint_request_count.fetch_add(1, atomic::Ordering::Release);
async move {
Ok(Some(vec![lsp::InlayHint {
position: lsp::Position::new(0, 0),
label: lsp::InlayHintLabel::String("hint".to_string()),
kind: None,
text_edits: None,
tooltip: None,
padding_left: None,
padding_right: None,
data: None,
}]))
}
},
);
}
})),
..FakeLspAdapter::default()
},
);
cx.run_until_parked();
let worktree_id = project.read_with(cx, |project, cx| {
project
.worktrees(cx)
.next()
.map(|wt| wt.read(cx).id())
.expect("should have a worktree")
});
let trusted_worktrees =
cx.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist"));
let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
assert!(!can_trust, "worktree should be restricted initially");
let buffer_before_approval = project
.update(cx, |project, cx| {
project.open_buffer((worktree_id, rel_path("main.rs")), cx)
})
.await
.unwrap();
let (editor, cx) = cx.add_window_view(|window, cx| {
Editor::new(
EditorMode::full(),
cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)),
Some(project.clone()),
window,
cx,
)
});
cx.run_until_parked();
let fake_language_server = fake_language_servers.next();
cx.read(|cx| {
let file = buffer_before_approval.read(cx).file();
assert_eq!(
language::language_settings::language_settings(Some("Rust".into()), file, cx)
.language_servers,
["...".to_string()],
"local .zed/settings.json must not apply before trust approval"
)
});
editor.update_in(cx, |editor, window, cx| {
editor.handle_input("1", window, cx);
});
cx.run_until_parked();
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
assert_eq!(
lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire),
0,
"inlay hints must not be queried before trust approval"
);
trusted_worktrees.update(cx, |store, cx| {
store.trust(
std::collections::HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
None,
cx,
);
});
cx.run_until_parked();
cx.read(|cx| {
let file = buffer_before_approval.read(cx).file();
assert_eq!(
language::language_settings::language_settings(Some("Rust".into()), file, cx)
.language_servers,
["override-rust-analyzer".to_string()],
"local .zed/settings.json should apply after trust approval"
)
});
let _fake_language_server = fake_language_server.await.unwrap();
editor.update_in(cx, |editor, window, cx| {
editor.handle_input("1", window, cx);
});
cx.run_until_parked();
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
assert!(
lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire) > 0,
"inlay hints should be queried after trust approval"
);
let can_trust_after =
trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
assert!(can_trust_after, "worktree should be trusted after trust()");
}

View File

@@ -19,7 +19,7 @@ pub struct JsxTagCompletionState {
/// that corresponds to the tag name
/// Note that this is not configurable, i.e. we assume the first
/// named child of a tag node is the tag name
const TS_NODE_TAG_NAME_CHILD_INDEX: usize = 0;
const TS_NODE_TAG_NAME_CHILD_INDEX: u32 = 0;
/// Maximum number of parent elements to walk back when checking if an open tag
/// is already closed.

View File

@@ -305,6 +305,12 @@ impl EditorTestContext {
snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end)
}
pub async fn wait_for_autoindent_applied(&mut self) {
if let Some(fut) = self.update_buffer(|buffer, _| buffer.wait_for_autoindent_applied()) {
fut.await.ok();
}
}
pub fn set_head_text(&mut self, diff_base: &str) {
self.cx.run_until_parked();
let fs =

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);
let node_runtime = NodeRuntime::new(client.http_client(), None, rx, None);
let extension_host_proxy = ExtensionHostProxy::global(cx);
debug_adapter_extension::init(extension_host_proxy.clone(), cx);

View File

@@ -202,6 +202,7 @@ impl ExampleInstance {
app_state.languages.clone(),
app_state.fs.clone(),
None,
false,
cx,
);

View File

@@ -45,7 +45,7 @@ use wasmtime::{
CacheStore, Engine, Store,
component::{Component, ResourceTable},
};
use wasmtime_wasi::{self as wasi, WasiView};
use wasmtime_wasi::p2::{self as wasi, IoView as _};
use wit::Extension;
pub struct WasmHost {
@@ -685,8 +685,8 @@ impl WasmHost {
.await
.context("failed to create extension work dir")?;
let file_perms = wasi::FilePerms::all();
let dir_perms = wasi::DirPerms::all();
let file_perms = wasmtime_wasi::FilePerms::all();
let dir_perms = wasmtime_wasi::DirPerms::all();
let path = SanitizedPath::new(&extension_work_dir).to_string();
#[cfg(target_os = "windows")]
let path = path.replace('\\', "/");
@@ -856,11 +856,13 @@ impl WasmState {
}
}
impl wasi::WasiView for WasmState {
impl wasi::IoView for WasmState {
fn table(&mut self) -> &mut ResourceTable {
&mut self.table
}
}
impl wasi::WasiView for WasmState {
fn ctx(&mut self) -> &mut wasi::WasiCtx {
&mut self.ctx
}

View File

@@ -45,7 +45,7 @@ pub fn new_linker(
f: impl Fn(&mut Linker<WasmState>, fn(&mut WasmState) -> &mut WasmState) -> Result<()>,
) -> Linker<WasmState> {
let mut linker = Linker::new(&wasm_engine(executor));
wasmtime_wasi::add_to_linker_async(&mut linker).unwrap();
wasmtime_wasi::p2::add_to_linker_async(&mut linker).unwrap();
f(&mut linker, wasi_view).unwrap();
linker
}

View File

@@ -1,7 +1,7 @@
use crate::wasm_host::wit::since_v0_6_0::{
dap::{
AttachRequest, BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, LaunchRequest,
StartDebuggingRequestArguments, TcpArguments, TcpArgumentsTemplate,
BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, StartDebuggingRequestArguments,
TcpArguments, TcpArgumentsTemplate,
},
slash_command::SlashCommandOutputSection,
};

View File

@@ -23,6 +23,7 @@ pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon";
pub const LFS_DIR: &str = "lfs";
pub const COMMIT_MESSAGE: &str = "COMMIT_EDITMSG";
pub const INDEX_LOCK: &str = "index.lock";
pub const REPO_EXCLUDE: &str = "info/exclude";
actions!(
git,

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;
@@ -57,7 +58,7 @@ use project::{
git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
project_settings::{GitPathStyle, ProjectSettings},
};
use prompt_store::RULES_FILE_NAMES;
use prompt_store::{PromptId, PromptStore, RULES_FILE_NAMES};
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore, StatusStyle};
use std::future::Future;
@@ -98,6 +99,10 @@ actions!(
ToggleSortByPath,
/// Toggles showing entries in tree vs flat view.
ToggleTreeView,
/// Expands the selected entry to show its children.
ExpandSelectedEntry,
/// Collapses the selected entry to hide its children.
CollapseSelectedEntry,
]
);
@@ -896,6 +901,48 @@ impl GitPanel {
.position(|entry| entry.status_entry().is_some())
}
fn expand_selected_entry(
&mut self,
_: &ExpandSelectedEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(entry) = self.get_selected_entry().cloned() else {
return;
};
if let GitListEntry::Directory(dir_entry) = entry {
if dir_entry.expanded {
self.select_next(&SelectNext, window, cx);
} else {
self.toggle_directory(&dir_entry.key, window, cx);
}
} else {
self.select_next(&SelectNext, window, cx);
}
}
fn collapse_selected_entry(
&mut self,
_: &CollapseSelectedEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(entry) = self.get_selected_entry().cloned() else {
return;
};
if let GitListEntry::Directory(dir_entry) = entry {
if dir_entry.expanded {
self.toggle_directory(&dir_entry.key, window, cx);
} else {
self.select_previous(&SelectPrevious, window, cx);
}
} else {
self.select_previous(&SelectPrevious, window, cx);
}
}
fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
if let Some(first_entry) = self.first_status_entry_index() {
self.selected_entry = Some(first_entry);
@@ -914,28 +961,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>) {
@@ -944,25 +1007,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>) {
@@ -2376,6 +2450,31 @@ impl GitPanel {
}
}
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
.update(cx, |s, cx| s.load(PromptId::CommitMessage, cx))
.ok()?
.await
.ok()
};
load.await.unwrap_or_else(|| DEFAULT_PROMPT.to_string())
}
/// Generates a commit message using an LLM.
pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) {
@@ -2406,6 +2505,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| {
@@ -2441,14 +2547,14 @@ impl GitPanel {
let rules_content = Self::load_project_rules(&project, &repo_work_dir, &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()
})?;
let text_empty = subject.trim().is_empty();
const PROMPT: &str = include_str!("commit_message_prompt.txt");
let rules_section = match &rules_content {
Some(rules) => format!(
"\n\nThe user has provided the following project rules that you should follow when writing the commit message:\n\
@@ -2464,7 +2570,7 @@ impl GitPanel {
};
let content = format!(
"{PROMPT}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}"
"{prompt}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}"
);
let request = LanguageModelRequest {
@@ -5264,6 +5370,8 @@ impl Render for GitPanel {
.on_action(cx.listener(Self::stash_all))
.on_action(cx.listener(Self::stash_pop))
})
.on_action(cx.listener(Self::collapse_selected_entry))
.on_action(cx.listener(Self::expand_selected_entry))
.on_action(cx.listener(Self::select_first))
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_previous))

View File

@@ -421,6 +421,7 @@ async fn open_remote_worktree(
app_state.user_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
true,
cx,
)
})?;

View File

@@ -15,9 +15,6 @@ pub(crate) struct MetalAtlas(Mutex<MetalAtlasState>);
impl MetalAtlas {
pub(crate) fn new(device: Device) -> Self {
MetalAtlas(Mutex::new(MetalAtlasState {
// Shared memory can be used only if CPU and GPU share the same memory space.
// https://developer.apple.com/documentation/metal/setting-resource-storage-modes
unified_memory: device.has_unified_memory(),
device: AssertSend(device),
monochrome_textures: Default::default(),
polychrome_textures: Default::default(),
@@ -32,7 +29,6 @@ impl MetalAtlas {
struct MetalAtlasState {
device: AssertSend<Device>,
unified_memory: bool,
monochrome_textures: AtlasTextureList<MetalAtlasTexture>,
polychrome_textures: AtlasTextureList<MetalAtlasTexture>,
tiles_by_key: FxHashMap<AtlasKey, AtlasTile>,
@@ -150,11 +146,6 @@ impl MetalAtlasState {
}
texture_descriptor.set_pixel_format(pixel_format);
texture_descriptor.set_usage(usage);
texture_descriptor.set_storage_mode(if self.unified_memory {
metal::MTLStorageMode::Shared
} else {
metal::MTLStorageMode::Managed
});
let metal_texture = self.device.new_texture(&texture_descriptor);
let texture_list = match kind {

View File

@@ -76,22 +76,12 @@ impl InstanceBufferPool {
self.buffers.clear();
}
pub(crate) fn acquire(
&mut self,
device: &metal::Device,
unified_memory: bool,
) -> InstanceBuffer {
pub(crate) fn acquire(&mut self, device: &metal::Device) -> InstanceBuffer {
let buffer = self.buffers.pop().unwrap_or_else(|| {
let options = if unified_memory {
MTLResourceOptions::StorageModeShared
// Buffers are write only which can benefit from the combined cache
// https://developer.apple.com/documentation/metal/mtlresourceoptions/cpucachemodewritecombined
| MTLResourceOptions::CPUCacheModeWriteCombined
} else {
MTLResourceOptions::StorageModeManaged
};
device.new_buffer(self.buffer_size as u64, options)
device.new_buffer(
self.buffer_size as u64,
MTLResourceOptions::StorageModeManaged,
)
});
InstanceBuffer {
metal_buffer: buffer,
@@ -109,7 +99,6 @@ impl InstanceBufferPool {
pub(crate) struct MetalRenderer {
device: metal::Device,
layer: metal::MetalLayer,
unified_memory: bool,
presents_with_transaction: bool,
command_queue: CommandQueue,
paths_rasterization_pipeline_state: metal::RenderPipelineState,
@@ -190,10 +179,6 @@ impl MetalRenderer {
output
}
// Shared memory can be used only if CPU and GPU share the same memory space.
// https://developer.apple.com/documentation/metal/setting-resource-storage-modes
let unified_memory = device.has_unified_memory();
let unit_vertices = [
to_float2_bits(point(0., 0.)),
to_float2_bits(point(1., 0.)),
@@ -205,12 +190,7 @@ impl MetalRenderer {
let unit_vertices = device.new_buffer_with_data(
unit_vertices.as_ptr() as *const c_void,
mem::size_of_val(&unit_vertices) as u64,
if unified_memory {
MTLResourceOptions::StorageModeShared
| MTLResourceOptions::CPUCacheModeWriteCombined
} else {
MTLResourceOptions::StorageModeManaged
},
MTLResourceOptions::StorageModeManaged,
);
let paths_rasterization_pipeline_state = build_path_rasterization_pipeline_state(
@@ -288,7 +268,6 @@ impl MetalRenderer {
device,
layer,
presents_with_transaction: false,
unified_memory,
command_queue,
paths_rasterization_pipeline_state,
path_sprites_pipeline_state,
@@ -358,23 +337,14 @@ impl MetalRenderer {
texture_descriptor.set_width(size.width.0 as u64);
texture_descriptor.set_height(size.height.0 as u64);
texture_descriptor.set_pixel_format(metal::MTLPixelFormat::BGRA8Unorm);
texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private);
texture_descriptor
.set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead);
self.path_intermediate_texture = Some(self.device.new_texture(&texture_descriptor));
if self.path_sample_count > 1 {
// https://developer.apple.com/documentation/metal/choosing-a-resource-storage-mode-for-apple-gpus
// Rendering MSAA textures are done in a single pass, so we can use memory-less storage on Apple Silicon
let storage_mode = if self.unified_memory {
metal::MTLStorageMode::Memoryless
} else {
metal::MTLStorageMode::Private
};
let mut msaa_descriptor = texture_descriptor;
msaa_descriptor.set_texture_type(metal::MTLTextureType::D2Multisample);
msaa_descriptor.set_storage_mode(storage_mode);
msaa_descriptor.set_storage_mode(metal::MTLStorageMode::Private);
msaa_descriptor.set_sample_count(self.path_sample_count as _);
self.path_intermediate_msaa_texture = Some(self.device.new_texture(&msaa_descriptor));
} else {
@@ -408,10 +378,7 @@ impl MetalRenderer {
};
loop {
let mut instance_buffer = self
.instance_buffer_pool
.lock()
.acquire(&self.device, self.unified_memory);
let mut instance_buffer = self.instance_buffer_pool.lock().acquire(&self.device);
let command_buffer =
self.draw_primitives(scene, &mut instance_buffer, drawable, viewport_size);
@@ -583,14 +550,10 @@ impl MetalRenderer {
command_encoder.end_encoding();
if !self.unified_memory {
// Sync the instance buffer to the GPU
instance_buffer.metal_buffer.did_modify_range(NSRange {
location: 0,
length: instance_offset as NSUInteger,
});
}
instance_buffer.metal_buffer.did_modify_range(NSRange {
location: 0,
length: instance_offset as NSUInteger,
});
Ok(command_buffer.to_owned())
}

View File

@@ -1961,7 +1961,7 @@ impl Window {
}
/// Determine whether the given action is available along the dispatch path to the currently focused element.
pub fn is_action_available(&self, action: &dyn Action, cx: &mut App) -> bool {
pub fn is_action_available(&self, action: &dyn Action, cx: &App) -> bool {
let node_id =
self.focus_node_id_in_rendered_frame(self.focused(cx).map(|handle| handle.id));
self.rendered_frame
@@ -1969,6 +1969,14 @@ impl Window {
.is_action_available(action, node_id)
}
/// Determine whether the given action is available along the dispatch path to the given focus_handle.
pub fn is_action_available_in(&self, action: &dyn Action, focus_handle: &FocusHandle) -> bool {
let node_id = self.focus_node_id_in_rendered_frame(Some(focus_handle.id));
self.rendered_frame
.dispatch_tree
.is_action_available(action, node_id)
}
/// The position of the mouse relative to the window.
pub fn mouse_position(&self) -> Point<Pixels> {
self.mouse_position

View File

@@ -33,6 +33,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
app_state.languages.clone(),
app_state.fs.clone(),
None,
false,
cx,
);

View File

@@ -1133,8 +1133,8 @@ fn check_interpolation(
check_node_edits(
depth,
range,
old_node.child(i).unwrap(),
new_node.child(i).unwrap(),
old_node.child(i as u32).unwrap(),
new_node.child(i as u32).unwrap(),
old_buffer,
new_buffer,
edits,

View File

@@ -1,6 +1,6 @@
use anthropic::{
ANTHROPIC_API_URL, AnthropicError, AnthropicModelMode, ContentDelta, Event, ResponseContent,
ToolResultContent, ToolResultPart, Usage,
ANTHROPIC_API_URL, AnthropicError, AnthropicModelMode, ContentDelta, CountTokensRequest, Event,
ResponseContent, ToolResultContent, ToolResultPart, Usage,
};
use anyhow::{Result, anyhow};
use collections::{BTreeMap, HashMap};
@@ -219,68 +219,215 @@ pub struct AnthropicModel {
request_limiter: RateLimiter,
}
pub fn count_anthropic_tokens(
/// Convert a LanguageModelRequest to an Anthropic CountTokensRequest.
pub fn into_anthropic_count_tokens_request(
request: LanguageModelRequest,
cx: &App,
) -> BoxFuture<'static, Result<u64>> {
cx.background_spawn(async move {
let messages = request.messages;
let mut tokens_from_images = 0;
let mut string_messages = Vec::with_capacity(messages.len());
model: String,
mode: AnthropicModelMode,
) -> CountTokensRequest {
let mut new_messages: Vec<anthropic::Message> = Vec::new();
let mut system_message = String::new();
for message in messages {
use language_model::MessageContent;
for message in request.messages {
if message.contents_empty() {
continue;
}
let mut string_contents = String::new();
match message.role {
Role::User | Role::Assistant => {
let anthropic_message_content: Vec<anthropic::RequestContent> = message
.content
.into_iter()
.filter_map(|content| match content {
MessageContent::Text(text) => {
let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) {
text.trim_end().to_string()
} else {
text
};
if !text.is_empty() {
Some(anthropic::RequestContent::Text {
text,
cache_control: None,
})
} else {
None
}
}
MessageContent::Thinking {
text: thinking,
signature,
} => {
if !thinking.is_empty() {
Some(anthropic::RequestContent::Thinking {
thinking,
signature: signature.unwrap_or_default(),
cache_control: None,
})
} else {
None
}
}
MessageContent::RedactedThinking(data) => {
if !data.is_empty() {
Some(anthropic::RequestContent::RedactedThinking { data })
} else {
None
}
}
MessageContent::Image(image) => Some(anthropic::RequestContent::Image {
source: anthropic::ImageSource {
source_type: "base64".to_string(),
media_type: "image/png".to_string(),
data: image.source.to_string(),
},
cache_control: None,
}),
MessageContent::ToolUse(tool_use) => {
Some(anthropic::RequestContent::ToolUse {
id: tool_use.id.to_string(),
name: tool_use.name.to_string(),
input: tool_use.input,
cache_control: None,
})
}
MessageContent::ToolResult(tool_result) => {
Some(anthropic::RequestContent::ToolResult {
tool_use_id: tool_result.tool_use_id.to_string(),
is_error: tool_result.is_error,
content: match tool_result.content {
LanguageModelToolResultContent::Text(text) => {
ToolResultContent::Plain(text.to_string())
}
LanguageModelToolResultContent::Image(image) => {
ToolResultContent::Multipart(vec![ToolResultPart::Image {
source: anthropic::ImageSource {
source_type: "base64".to_string(),
media_type: "image/png".to_string(),
data: image.source.to_string(),
},
}])
}
},
cache_control: None,
})
}
})
.collect();
let anthropic_role = match message.role {
Role::User => anthropic::Role::User,
Role::Assistant => anthropic::Role::Assistant,
Role::System => unreachable!("System role should never occur here"),
};
if let Some(last_message) = new_messages.last_mut()
&& last_message.role == anthropic_role
{
last_message.content.extend(anthropic_message_content);
continue;
}
for content in message.content {
match content {
MessageContent::Text(text) => {
string_contents.push_str(&text);
new_messages.push(anthropic::Message {
role: anthropic_role,
content: anthropic_message_content,
});
}
Role::System => {
if !system_message.is_empty() {
system_message.push_str("\n\n");
}
system_message.push_str(&message.string_contents());
}
}
}
CountTokensRequest {
model,
messages: new_messages,
system: if system_message.is_empty() {
None
} else {
Some(anthropic::StringOrContents::String(system_message))
},
thinking: if request.thinking_allowed
&& let AnthropicModelMode::Thinking { budget_tokens } = mode
{
Some(anthropic::Thinking::Enabled { budget_tokens })
} else {
None
},
tools: request
.tools
.into_iter()
.map(|tool| anthropic::Tool {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
})
.collect(),
tool_choice: request.tool_choice.map(|choice| match choice {
LanguageModelToolChoice::Auto => anthropic::ToolChoice::Auto,
LanguageModelToolChoice::Any => anthropic::ToolChoice::Any,
LanguageModelToolChoice::None => anthropic::ToolChoice::None,
}),
}
}
/// Estimate tokens using tiktoken. Used as a fallback when the API is unavailable,
/// or by providers (like Zed Cloud) that don't have direct Anthropic API access.
pub fn count_anthropic_tokens_with_tiktoken(request: LanguageModelRequest) -> Result<u64> {
let messages = request.messages;
let mut tokens_from_images = 0;
let mut string_messages = Vec::with_capacity(messages.len());
for message in messages {
let mut string_contents = String::new();
for content in message.content {
match content {
MessageContent::Text(text) => {
string_contents.push_str(&text);
}
MessageContent::Thinking { .. } => {
// Thinking blocks are not included in the input token count.
}
MessageContent::RedactedThinking(_) => {
// Thinking blocks are not included in the input token count.
}
MessageContent::Image(image) => {
tokens_from_images += image.estimate_tokens();
}
MessageContent::ToolUse(_tool_use) => {
// TODO: Estimate token usage from tool uses.
}
MessageContent::ToolResult(tool_result) => match &tool_result.content {
LanguageModelToolResultContent::Text(text) => {
string_contents.push_str(text);
}
MessageContent::Thinking { .. } => {
// Thinking blocks are not included in the input token count.
}
MessageContent::RedactedThinking(_) => {
// Thinking blocks are not included in the input token count.
}
MessageContent::Image(image) => {
LanguageModelToolResultContent::Image(image) => {
tokens_from_images += image.estimate_tokens();
}
MessageContent::ToolUse(_tool_use) => {
// TODO: Estimate token usage from tool uses.
}
MessageContent::ToolResult(tool_result) => match &tool_result.content {
LanguageModelToolResultContent::Text(text) => {
string_contents.push_str(text);
}
LanguageModelToolResultContent::Image(image) => {
tokens_from_images += image.estimate_tokens();
}
},
}
}
if !string_contents.is_empty() {
string_messages.push(tiktoken_rs::ChatCompletionRequestMessage {
role: match message.role {
Role::User => "user".into(),
Role::Assistant => "assistant".into(),
Role::System => "system".into(),
},
content: Some(string_contents),
name: None,
function_call: None,
});
},
}
}
// Tiktoken doesn't yet support these models, so we manually use the
// same tokenizer as GPT-4.
tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages)
.map(|tokens| (tokens + tokens_from_images) as u64)
})
.boxed()
if !string_contents.is_empty() {
string_messages.push(tiktoken_rs::ChatCompletionRequestMessage {
role: match message.role {
Role::User => "user".into(),
Role::Assistant => "assistant".into(),
Role::System => "system".into(),
},
content: Some(string_contents),
name: None,
function_call: None,
});
}
}
// Tiktoken doesn't yet support these models, so we manually use the
// same tokenizer as GPT-4.
tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages)
.map(|tokens| (tokens + tokens_from_images) as u64)
}
impl AnthropicModel {
@@ -386,7 +533,40 @@ impl LanguageModel for AnthropicModel {
request: LanguageModelRequest,
cx: &App,
) -> BoxFuture<'static, Result<u64>> {
count_anthropic_tokens(request, cx)
let http_client = self.http_client.clone();
let model_id = self.model.request_id().to_string();
let mode = self.model.mode();
let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
let api_url = AnthropicLanguageModelProvider::api_url(cx);
(
state.api_key_state.key(&api_url).map(|k| k.to_string()),
api_url.to_string(),
)
});
async move {
// If no API key, fall back to tiktoken estimation
let Some(api_key) = api_key else {
return count_anthropic_tokens_with_tiktoken(request);
};
let count_request =
into_anthropic_count_tokens_request(request.clone(), model_id, mode);
match anthropic::count_tokens(http_client.as_ref(), &api_url, &api_key, count_request)
.await
{
Ok(response) => Ok(response.input_tokens),
Err(err) => {
log::error!(
"Anthropic count_tokens API failed, falling back to tiktoken: {err:?}"
);
count_anthropic_tokens_with_tiktoken(request)
}
}
}
.boxed()
}
fn stream_completion(

View File

@@ -42,7 +42,9 @@ use thiserror::Error;
use ui::{TintColor, prelude::*};
use util::{ResultExt as _, maybe};
use crate::provider::anthropic::{AnthropicEventMapper, count_anthropic_tokens, into_anthropic};
use crate::provider::anthropic::{
AnthropicEventMapper, count_anthropic_tokens_with_tiktoken, into_anthropic,
};
use crate::provider::google::{GoogleEventMapper, into_google};
use crate::provider::open_ai::{OpenAiEventMapper, count_open_ai_tokens, into_open_ai};
use crate::provider::x_ai::count_xai_tokens;
@@ -667,9 +669,9 @@ impl LanguageModel for CloudLanguageModel {
cx: &App,
) -> BoxFuture<'static, Result<u64>> {
match self.model.provider {
cloud_llm_client::LanguageModelProvider::Anthropic => {
count_anthropic_tokens(request, cx)
}
cloud_llm_client::LanguageModelProvider::Anthropic => cx
.background_spawn(async move { count_anthropic_tokens_with_tiktoken(request) })
.boxed(),
cloud_llm_client::LanguageModelProvider::OpenAi => {
let model = match open_ai::Model::from_id(&self.model.id.0) {
Ok(model) => model,

View File

@@ -9,6 +9,8 @@ use serde::Deserialize;
use smol::io::BufReader;
use smol::{fs, lock::Mutex};
use std::fmt::Display;
use std::future::Future;
use std::pin::Pin;
use std::{
env::{self, consts},
ffi::OsString,
@@ -46,6 +48,7 @@ struct NodeRuntimeState {
last_options: Option<NodeBinaryOptions>,
options: watch::Receiver<Option<NodeBinaryOptions>>,
shell_env_loaded: Shared<oneshot::Receiver<()>>,
trust_task: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
}
impl NodeRuntime {
@@ -53,9 +56,11 @@ impl NodeRuntime {
http: Arc<dyn HttpClient>,
shell_env_loaded: Option<oneshot::Receiver<()>>,
options: watch::Receiver<Option<NodeBinaryOptions>>,
trust_task: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
) -> Self {
NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {
http,
trust_task,
instance: None,
last_options: None,
options,
@@ -70,11 +75,15 @@ impl NodeRuntime {
last_options: None,
options: watch::channel(Some(NodeBinaryOptions::default())).1,
shell_env_loaded: oneshot::channel().1.shared(),
trust_task: None,
})))
}
async fn instance(&self) -> Box<dyn NodeRuntimeTrait> {
let mut state = self.0.lock().await;
if let Some(trust_task) = state.trust_task.take() {
trust_task.await;
}
let options = loop {
if let Some(options) = state.options.borrow().as_ref() {

View File

@@ -3,6 +3,7 @@ use std::sync::Arc;
use client::TelemetrySettings;
use fs::Fs;
use gpui::{Action, App, IntoElement};
use project::project_settings::ProjectSettings;
use settings::{BaseKeymap, Settings, update_settings_file};
use theme::{
Appearance, SystemAppearance, ThemeAppearanceMode, ThemeName, ThemeRegistry, ThemeSelection,
@@ -10,8 +11,8 @@ use theme::{
};
use ui::{
Divider, ParentElement as _, StatefulInteractiveElement, SwitchField, TintColor,
ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, prelude::*,
rems_from_px,
ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip,
prelude::*, rems_from_px,
};
use vim_mode_setting::VimModeSetting;
@@ -409,6 +410,48 @@ fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoEleme
})
}
fn render_worktree_auto_trust_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
let toggle_state = if ProjectSettings::get_global(cx).session.trust_all_worktrees {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
};
let tooltip_description = "Zed can only allow services like language servers, project settings, and MCP servers to run after you mark a new project as trusted.";
SwitchField::new(
"onboarding-auto-trust-worktrees",
Some("Trust All Projects By Default"),
Some("Automatically mark all new projects as trusted to unlock all Zed's features".into()),
toggle_state,
{
let fs = <dyn Fs>::global(cx);
move |&selection, _, cx| {
let trust = match selection {
ToggleState::Selected => true,
ToggleState::Unselected => false,
ToggleState::Indeterminate => {
return;
}
};
update_settings_file(fs.clone(), cx, move |setting, _| {
setting.session.get_or_insert_default().trust_all_worktrees = Some(trust);
});
telemetry::event!(
"Welcome Page Worktree Auto Trust Toggled",
options = if trust { "on" } else { "off" }
);
}
},
)
.tab_index({
*tab_index += 1;
*tab_index - 1
})
.tooltip(Tooltip::text(tooltip_description))
}
fn render_setting_import_button(
tab_index: isize,
label: SharedString,
@@ -481,6 +524,7 @@ pub(crate) fn render_basics_page(cx: &mut App) -> impl IntoElement {
.child(render_base_keymap_section(&mut tab_index, cx))
.child(render_import_settings_section(&mut tab_index, cx))
.child(render_vim_mode_switch(&mut tab_index, cx))
.child(render_worktree_auto_trust_switch(&mut tab_index, cx))
.child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
.child(render_telemetry_section(&mut tab_index, cx))
}

View File

@@ -40,6 +40,7 @@ clock.workspace = true
collections.workspace = true
context_server.workspace = true
dap.workspace = true
db.workspace = true
extension.workspace = true
fancy-regex.workspace = true
fs.workspace = true
@@ -96,6 +97,7 @@ tracing.workspace = true
[dev-dependencies]
client = { workspace = true, features = ["test-support"] }
db = { workspace = true, features = ["test-support"] }
collections = { workspace = true, features = ["test-support"] }
context_server = { workspace = true, features = ["test-support"] }
buffer_diff = { workspace = true, features = ["test-support"] }

View File

@@ -15,6 +15,7 @@ use util::{ResultExt as _, rel_path::RelPath};
use crate::{
Project,
project_settings::{ContextServerSettings, ProjectSettings},
trusted_worktrees::wait_for_workspace_trust,
worktree_store::WorktreeStore,
};
@@ -332,6 +333,15 @@ impl ContextServerStore {
pub fn start_server(&mut self, server: Arc<ContextServer>, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
let wait_task = this.update(cx, |context_server_store, cx| {
context_server_store.project.update(cx, |project, cx| {
let remote_host = project.remote_connection_options(cx);
wait_for_workspace_trust(remote_host, "context servers", cx)
})
})??;
if let Some(wait_task) = wait_task {
wait_task.await;
}
let this = this.upgrade().context("Context server store dropped")?;
let settings = this
.update(cx, |this, _| {
@@ -572,6 +582,15 @@ impl ContextServerStore {
}
async fn maintain_servers(this: WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> {
let wait_task = this.update(cx, |context_server_store, cx| {
context_server_store.project.update(cx, |project, cx| {
let remote_host = project.remote_connection_options(cx);
wait_for_workspace_trust(remote_host, "context servers", cx)
})
})??;
if let Some(wait_task) = wait_task {
wait_task.await;
}
let (mut configured_servers, registry, worktree_store) = this.update(cx, |this, _| {
(
this.context_server_settings.clone(),

View File

@@ -38,6 +38,7 @@ use crate::{
prettier_store::{self, PrettierStore, PrettierStoreEvent},
project_settings::{LspSettings, ProjectSettings},
toolchain_store::{LocalToolchainStore, ToolchainStoreEvent},
trusted_worktrees::{PathTrust, TrustedWorktrees, TrustedWorktreesEvent},
worktree_store::{WorktreeStore, WorktreeStoreEvent},
yarn::YarnPathStore,
};
@@ -54,8 +55,8 @@ use futures::{
};
use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, EventEmitter, PromptLevel, SharedString, Task,
WeakEntity,
App, AppContext, AsyncApp, Context, Entity, EventEmitter, PromptLevel, SharedString,
Subscription, Task, WeakEntity,
};
use http_client::HttpClient;
use itertools::Itertools as _;
@@ -96,13 +97,14 @@ use serde::Serialize;
use serde_json::Value;
use settings::{Settings, SettingsLocation, SettingsStore};
use sha2::{Digest, Sha256};
use smol::channel::Sender;
use smol::channel::{Receiver, Sender};
use snippet::Snippet;
use std::{
any::TypeId,
borrow::Cow,
cell::RefCell,
cmp::{Ordering, Reverse},
collections::hash_map,
convert::TryInto,
ffi::OsStr,
future::ready,
@@ -296,6 +298,7 @@ pub struct LocalLspStore {
LanguageServerId,
HashMap<Option<SharedString>, HashMap<PathBuf, Option<SharedString>>>,
>,
restricted_worktrees_tasks: HashMap<WorktreeId, (Subscription, Receiver<()>)>,
}
impl LocalLspStore {
@@ -367,7 +370,8 @@ impl LocalLspStore {
) -> LanguageServerId {
let worktree = worktree_handle.read(cx);
let root_path = worktree.abs_path();
let worktree_id = worktree.id();
let worktree_abs_path = worktree.abs_path();
let toolchain = key.toolchain.clone();
let override_options = settings.initialization_options.clone();
@@ -375,19 +379,49 @@ impl LocalLspStore {
let server_id = self.languages.next_language_server_id();
log::trace!(
"attempting to start language server {:?}, path: {root_path:?}, id: {server_id}",
"attempting to start language server {:?}, path: {worktree_abs_path:?}, id: {server_id}",
adapter.name.0
);
let untrusted_worktree_task =
TrustedWorktrees::try_get_global(cx).and_then(|trusted_worktrees| {
let can_trust = trusted_worktrees.update(cx, |trusted_worktrees, cx| {
trusted_worktrees.can_trust(worktree_id, cx)
});
if can_trust {
self.restricted_worktrees_tasks.remove(&worktree_id);
None
} else {
match self.restricted_worktrees_tasks.entry(worktree_id) {
hash_map::Entry::Occupied(o) => Some(o.get().1.clone()),
hash_map::Entry::Vacant(v) => {
let (tx, rx) = smol::channel::bounded::<()>(1);
let subscription = cx.subscribe(&trusted_worktrees, move |_, e, _| {
if let TrustedWorktreesEvent::Trusted(_, trusted_paths) = e {
if trusted_paths.contains(&PathTrust::Worktree(worktree_id)) {
tx.send_blocking(()).ok();
}
}
});
v.insert((subscription, rx.clone()));
Some(rx)
}
}
}
});
let update_binary_status = untrusted_worktree_task.is_none();
let binary = self.get_language_server_binary(
worktree_abs_path.clone(),
adapter.clone(),
settings,
toolchain.clone(),
delegate.clone(),
true,
untrusted_worktree_task,
cx,
);
let pending_workspace_folders: Arc<Mutex<BTreeSet<Uri>>> = Default::default();
let pending_workspace_folders = Arc::<Mutex<BTreeSet<Uri>>>::default();
let pending_server = cx.spawn({
let adapter = adapter.clone();
@@ -420,7 +454,7 @@ impl LocalLspStore {
server_id,
server_name,
binary,
&root_path,
&worktree_abs_path,
code_action_kinds,
Some(pending_workspace_folders),
cx,
@@ -556,8 +590,10 @@ impl LocalLspStore {
pending_workspace_folders,
};
self.languages
.update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
if update_binary_status {
self.languages
.update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
}
self.language_servers.insert(server_id, state);
self.language_server_ids
@@ -571,19 +607,34 @@ impl LocalLspStore {
fn get_language_server_binary(
&self,
worktree_abs_path: Arc<Path>,
adapter: Arc<CachedLspAdapter>,
settings: Arc<LspSettings>,
toolchain: Option<Toolchain>,
delegate: Arc<dyn LspAdapterDelegate>,
allow_binary_download: bool,
untrusted_worktree_task: Option<Receiver<()>>,
cx: &mut App,
) -> Task<Result<LanguageServerBinary>> {
if let Some(settings) = &settings.binary
&& let Some(path) = settings.path.as_ref().map(PathBuf::from)
{
let settings = settings.clone();
let languages = self.languages.clone();
return cx.background_spawn(async move {
if let Some(untrusted_worktree_task) = untrusted_worktree_task {
log::info!(
"Waiting for worktree {worktree_abs_path:?} to be trusted, before starting language server {}",
adapter.name(),
);
untrusted_worktree_task.recv().await.ok();
log::info!(
"Worktree {worktree_abs_path:?} is trusted, starting language server {}",
adapter.name(),
);
languages
.update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
}
let mut env = delegate.shell_env().await;
env.extend(settings.env.unwrap_or_default());
@@ -614,6 +665,18 @@ impl LocalLspStore {
};
cx.spawn(async move |cx| {
if let Some(untrusted_worktree_task) = untrusted_worktree_task {
log::info!(
"Waiting for worktree {worktree_abs_path:?} to be trusted, before starting language server {}",
adapter.name(),
);
untrusted_worktree_task.recv().await.ok();
log::info!(
"Worktree {worktree_abs_path:?} is trusted, starting language server {}",
adapter.name(),
);
}
let (existing_binary, maybe_download_binary) = adapter
.clone()
.get_language_server_command(delegate.clone(), toolchain, lsp_binary_options, cx)
@@ -3258,6 +3321,7 @@ impl LocalLspStore {
id_to_remove: WorktreeId,
cx: &mut Context<LspStore>,
) -> Vec<LanguageServerId> {
self.restricted_worktrees_tasks.remove(&id_to_remove);
self.diagnostics.remove(&id_to_remove);
self.prettier_store.update(cx, |prettier_store, cx| {
prettier_store.remove_worktree(id_to_remove, cx);
@@ -3974,6 +4038,7 @@ impl LspStore {
buffers_opened_in_servers: HashMap::default(),
buffer_pull_diagnostics_result_ids: HashMap::default(),
workspace_pull_diagnostics_result_ids: HashMap::default(),
restricted_worktrees_tasks: HashMap::default(),
watched_manifest_filenames: ManifestProvidersStore::global(cx)
.manifest_file_names(),
}),

View File

@@ -0,0 +1,411 @@
use collections::{HashMap, HashSet};
use gpui::{App, Entity, SharedString};
use std::path::PathBuf;
use db::{
query,
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
use crate::{
trusted_worktrees::{PathTrust, RemoteHostLocation, find_worktree_in_store},
worktree_store::WorktreeStore,
};
// https://www.sqlite.org/limits.html
// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
// > which defaults to <..> 32766 for SQLite versions after 3.32.0.
#[allow(unused)]
const MAX_QUERY_PLACEHOLDERS: usize = 32000;
#[allow(unused)]
pub struct ProjectDb(ThreadSafeConnection);
impl Domain for ProjectDb {
const NAME: &str = stringify!(ProjectDb);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE IF NOT EXISTS trusted_worktrees (
trust_id INTEGER PRIMARY KEY AUTOINCREMENT,
absolute_path TEXT,
user_name TEXT,
host_name TEXT
) STRICT;
)];
}
db::static_connection!(PROJECT_DB, ProjectDb, []);
impl ProjectDb {
pub(crate) async fn save_trusted_worktrees(
&self,
trusted_worktrees: HashMap<Option<RemoteHostLocation>, HashSet<PathBuf>>,
trusted_workspaces: HashSet<Option<RemoteHostLocation>>,
) -> anyhow::Result<()> {
use anyhow::Context as _;
use db::sqlez::statement::Statement;
use itertools::Itertools as _;
PROJECT_DB
.clear_trusted_worktrees()
.await
.context("clearing previous trust state")?;
let trusted_worktrees = trusted_worktrees
.into_iter()
.flat_map(|(host, abs_paths)| {
abs_paths
.into_iter()
.map(move |abs_path| (Some(abs_path), host.clone()))
})
.chain(trusted_workspaces.into_iter().map(|host| (None, host)))
.collect::<Vec<_>>();
let mut first_worktree;
let mut last_worktree = 0_usize;
for (count, placeholders) in std::iter::once("(?, ?, ?)")
.cycle()
.take(trusted_worktrees.len())
.chunks(MAX_QUERY_PLACEHOLDERS / 3)
.into_iter()
.map(|chunk| {
let mut count = 0;
let placeholders = chunk
.inspect(|_| {
count += 1;
})
.join(", ");
(count, placeholders)
})
.collect::<Vec<_>>()
{
first_worktree = last_worktree;
last_worktree = last_worktree + count;
let query = format!(
r#"INSERT INTO trusted_worktrees(absolute_path, user_name, host_name)
VALUES {placeholders};"#
);
let trusted_worktrees = trusted_worktrees[first_worktree..last_worktree].to_vec();
self.write(move |conn| {
let mut statement = Statement::prepare(conn, query)?;
let mut next_index = 1;
for (abs_path, host) in trusted_worktrees {
let abs_path = abs_path.as_ref().map(|abs_path| abs_path.to_string_lossy());
next_index = statement.bind(
&abs_path.as_ref().map(|abs_path| abs_path.as_ref()),
next_index,
)?;
next_index = statement.bind(
&host
.as_ref()
.and_then(|host| Some(host.user_name.as_ref()?.as_str())),
next_index,
)?;
next_index = statement.bind(
&host.as_ref().map(|host| host.host_identifier.as_str()),
next_index,
)?;
}
statement.exec()
})
.await
.context("inserting new trusted state")?;
}
Ok(())
}
pub(crate) fn fetch_trusted_worktrees(
&self,
worktree_store: Option<Entity<WorktreeStore>>,
host: Option<RemoteHostLocation>,
cx: &App,
) -> anyhow::Result<HashMap<Option<RemoteHostLocation>, HashSet<PathTrust>>> {
let trusted_worktrees = PROJECT_DB.trusted_worktrees()?;
Ok(trusted_worktrees
.into_iter()
.map(|(abs_path, user_name, host_name)| {
let db_host = match (user_name, host_name) {
(_, None) => None,
(None, Some(host_name)) => Some(RemoteHostLocation {
user_name: None,
host_identifier: SharedString::new(host_name),
}),
(Some(user_name), Some(host_name)) => Some(RemoteHostLocation {
user_name: Some(SharedString::new(user_name)),
host_identifier: SharedString::new(host_name),
}),
};
match abs_path {
Some(abs_path) => {
if db_host != host {
(db_host, PathTrust::AbsPath(abs_path))
} else if let Some(worktree_store) = &worktree_store {
find_worktree_in_store(worktree_store.read(cx), &abs_path, cx)
.map(PathTrust::Worktree)
.map(|trusted_worktree| (host.clone(), trusted_worktree))
.unwrap_or_else(|| (db_host.clone(), PathTrust::AbsPath(abs_path)))
} else {
(db_host, PathTrust::AbsPath(abs_path))
}
}
None => (db_host, PathTrust::Workspace),
}
})
.fold(HashMap::default(), |mut acc, (remote_host, path_trust)| {
acc.entry(remote_host)
.or_insert_with(HashSet::default)
.insert(path_trust);
acc
}))
}
query! {
fn trusted_worktrees() -> Result<Vec<(Option<PathBuf>, Option<String>, Option<String>)>> {
SELECT absolute_path, user_name, host_name
FROM trusted_worktrees
}
}
query! {
pub async fn clear_trusted_worktrees() -> Result<()> {
DELETE FROM trusted_worktrees
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use collections::{HashMap, HashSet};
use gpui::{SharedString, TestAppContext};
use serde_json::json;
use settings::SettingsStore;
use smol::lock::Mutex;
use util::path;
use crate::{
FakeFs, Project,
persistence::PROJECT_DB,
trusted_worktrees::{PathTrust, RemoteHostLocation},
};
static TEST_LOCK: Mutex<()> = Mutex::new(());
#[gpui::test]
async fn test_save_and_fetch_trusted_worktrees(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let _guard = TEST_LOCK.lock().await;
PROJECT_DB.clear_trusted_worktrees().await.unwrap();
cx.update(|cx| {
if cx.try_global::<SettingsStore>().is_none() {
let settings = SettingsStore::test(cx);
cx.set_global(settings);
}
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/"),
json!({
"project_a": { "main.rs": "" },
"project_b": { "lib.rs": "" }
}),
)
.await;
let project = Project::test(
fs,
[path!("/project_a").as_ref(), path!("/project_b").as_ref()],
cx,
)
.await;
let worktree_store = project.read_with(cx, |p, _| p.worktree_store());
let mut trusted_paths: HashMap<Option<RemoteHostLocation>, HashSet<PathBuf>> =
HashMap::default();
trusted_paths.insert(
None,
HashSet::from_iter([
PathBuf::from(path!("/project_a")),
PathBuf::from(path!("/project_b")),
]),
);
PROJECT_DB
.save_trusted_worktrees(trusted_paths, HashSet::default())
.await
.unwrap();
let fetched = cx.update(|cx| {
PROJECT_DB.fetch_trusted_worktrees(Some(worktree_store.clone()), None, cx)
});
let fetched = fetched.unwrap();
let local_trust = fetched.get(&None).expect("should have local host entry");
assert_eq!(local_trust.len(), 2);
assert!(
local_trust
.iter()
.all(|p| matches!(p, PathTrust::Worktree(_)))
);
let fetched_no_store = cx
.update(|cx| PROJECT_DB.fetch_trusted_worktrees(None, None, cx))
.unwrap();
let local_trust_no_store = fetched_no_store
.get(&None)
.expect("should have local host entry");
assert_eq!(local_trust_no_store.len(), 2);
assert!(
local_trust_no_store
.iter()
.all(|p| matches!(p, PathTrust::AbsPath(_)))
);
}
#[gpui::test]
async fn test_save_and_fetch_workspace_trust(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let _guard = TEST_LOCK.lock().await;
PROJECT_DB.clear_trusted_worktrees().await.unwrap();
cx.update(|cx| {
if cx.try_global::<SettingsStore>().is_none() {
let settings = SettingsStore::test(cx);
cx.set_global(settings);
}
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root"), json!({ "main.rs": "" }))
.await;
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
let worktree_store = project.read_with(cx, |p, _| p.worktree_store());
let trusted_workspaces = HashSet::from_iter([None]);
PROJECT_DB
.save_trusted_worktrees(HashMap::default(), trusted_workspaces)
.await
.unwrap();
let fetched = cx.update(|cx| {
PROJECT_DB.fetch_trusted_worktrees(Some(worktree_store.clone()), None, cx)
});
let fetched = fetched.unwrap();
let local_trust = fetched.get(&None).expect("should have local host entry");
assert!(local_trust.contains(&PathTrust::Workspace));
let fetched_no_store = cx
.update(|cx| PROJECT_DB.fetch_trusted_worktrees(None, None, cx))
.unwrap();
let local_trust_no_store = fetched_no_store
.get(&None)
.expect("should have local host entry");
assert!(local_trust_no_store.contains(&PathTrust::Workspace));
}
#[gpui::test]
async fn test_save_and_fetch_remote_host_trust(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let _guard = TEST_LOCK.lock().await;
PROJECT_DB.clear_trusted_worktrees().await.unwrap();
cx.update(|cx| {
if cx.try_global::<SettingsStore>().is_none() {
let settings = SettingsStore::test(cx);
cx.set_global(settings);
}
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root"), json!({ "main.rs": "" }))
.await;
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
let worktree_store = project.read_with(cx, |p, _| p.worktree_store());
let remote_host = Some(RemoteHostLocation {
user_name: Some(SharedString::from("testuser")),
host_identifier: SharedString::from("remote.example.com"),
});
let mut trusted_paths: HashMap<Option<RemoteHostLocation>, HashSet<PathBuf>> =
HashMap::default();
trusted_paths.insert(
remote_host.clone(),
HashSet::from_iter([PathBuf::from("/home/testuser/project")]),
);
PROJECT_DB
.save_trusted_worktrees(trusted_paths, HashSet::default())
.await
.unwrap();
let fetched = cx.update(|cx| {
PROJECT_DB.fetch_trusted_worktrees(Some(worktree_store.clone()), None, cx)
});
let fetched = fetched.unwrap();
let remote_trust = fetched
.get(&remote_host)
.expect("should have remote host entry");
assert_eq!(remote_trust.len(), 1);
assert!(remote_trust
.iter()
.any(|p| matches!(p, PathTrust::AbsPath(path) if path == &PathBuf::from("/home/testuser/project"))));
let fetched_no_store = cx
.update(|cx| PROJECT_DB.fetch_trusted_worktrees(None, None, cx))
.unwrap();
let remote_trust_no_store = fetched_no_store
.get(&remote_host)
.expect("should have remote host entry");
assert_eq!(remote_trust_no_store.len(), 1);
assert!(remote_trust_no_store
.iter()
.any(|p| matches!(p, PathTrust::AbsPath(path) if path == &PathBuf::from("/home/testuser/project"))));
}
#[gpui::test]
async fn test_clear_trusted_worktrees(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let _guard = TEST_LOCK.lock().await;
PROJECT_DB.clear_trusted_worktrees().await.unwrap();
cx.update(|cx| {
if cx.try_global::<SettingsStore>().is_none() {
let settings = SettingsStore::test(cx);
cx.set_global(settings);
}
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root"), json!({ "main.rs": "" }))
.await;
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
let worktree_store = project.read_with(cx, |p, _| p.worktree_store());
let trusted_workspaces = HashSet::from_iter([None]);
PROJECT_DB
.save_trusted_worktrees(HashMap::default(), trusted_workspaces)
.await
.unwrap();
PROJECT_DB.clear_trusted_worktrees().await.unwrap();
let fetched = cx.update(|cx| {
PROJECT_DB.fetch_trusted_worktrees(Some(worktree_store.clone()), None, cx)
});
let fetched = fetched.unwrap();
assert!(fetched.is_empty(), "should be empty after clear");
let fetched_no_store = cx
.update(|cx| PROJECT_DB.fetch_trusted_worktrees(None, None, cx))
.unwrap();
assert!(fetched_no_store.is_empty(), "should be empty after clear");
}
}

View File

@@ -10,6 +10,7 @@ pub mod image_store;
pub mod lsp_command;
pub mod lsp_store;
mod manifest_tree;
mod persistence;
pub mod prettier_store;
mod project_search;
pub mod project_settings;
@@ -19,6 +20,7 @@ pub mod task_store;
pub mod telemetry_snapshot;
pub mod terminals;
pub mod toolchain_store;
pub mod trusted_worktrees;
pub mod worktree_store;
#[cfg(test)]
@@ -39,6 +41,7 @@ use crate::{
git_store::GitStore,
lsp_store::{SymbolLocation, log_store::LogKind},
project_search::SearchResultsHandle,
trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
};
pub use agent_server_store::{AgentServerStore, AgentServersUpdated, ExternalAgentServerName};
pub use git_store::{
@@ -1069,6 +1072,7 @@ impl Project {
languages: Arc<LanguageRegistry>,
fs: Arc<dyn Fs>,
env: Option<HashMap<String, String>>,
init_worktree_trust: bool,
cx: &mut App,
) -> Entity<Self> {
cx.new(|cx: &mut Context<Self>| {
@@ -1077,6 +1081,15 @@ impl Project {
.detach();
let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx);
let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
if init_worktree_trust {
trusted_worktrees::track_worktree_trust(
worktree_store.clone(),
None,
None,
None,
cx,
);
}
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
@@ -1250,6 +1263,7 @@ impl Project {
user_store: Entity<UserStore>,
languages: Arc<LanguageRegistry>,
fs: Arc<dyn Fs>,
init_worktree_trust: bool,
cx: &mut App,
) -> Entity<Self> {
cx.new(|cx: &mut Context<Self>| {
@@ -1258,8 +1272,14 @@ impl Project {
.detach();
let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx);
let (remote_proto, path_style) =
remote.read_with(cx, |remote, _| (remote.proto_client(), remote.path_style()));
let (remote_proto, path_style, connection_options) =
remote.read_with(cx, |remote, _| {
(
remote.proto_client(),
remote.path_style(),
remote.connection_options(),
)
});
let worktree_store = cx.new(|_| {
WorktreeStore::remote(
false,
@@ -1268,8 +1288,23 @@ impl Project {
path_style,
)
});
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
if init_worktree_trust {
match &connection_options {
RemoteConnectionOptions::Wsl(..) | RemoteConnectionOptions::Ssh(..) => {
trusted_worktrees::track_worktree_trust(
worktree_store.clone(),
Some(RemoteHostLocation::from(connection_options)),
None,
Some((remote_proto.clone(), REMOTE_SERVER_PROJECT_ID)),
cx,
);
}
RemoteConnectionOptions::Docker(..) => {}
}
}
let weak_self = cx.weak_entity();
let context_server_store =
@@ -1450,6 +1485,9 @@ impl Project {
remote_proto.add_entity_request_handler(Self::handle_language_server_prompt_request);
remote_proto.add_entity_message_handler(Self::handle_hide_toast);
remote_proto.add_entity_request_handler(Self::handle_update_buffer_from_remote_server);
remote_proto.add_entity_request_handler(Self::handle_trust_worktrees);
remote_proto.add_entity_request_handler(Self::handle_restrict_worktrees);
BufferStore::init(&remote_proto);
LspStore::init(&remote_proto);
SettingsObserver::init(&remote_proto);
@@ -1810,6 +1848,7 @@ impl Project {
Arc::new(languages),
fs,
None,
false,
cx,
)
})
@@ -1834,6 +1873,25 @@ impl Project {
fs: Arc<dyn Fs>,
root_paths: impl IntoIterator<Item = &Path>,
cx: &mut gpui::TestAppContext,
) -> Entity<Project> {
Self::test_project(fs, root_paths, false, cx).await
}
#[cfg(any(test, feature = "test-support"))]
pub async fn test_with_worktree_trust(
fs: Arc<dyn Fs>,
root_paths: impl IntoIterator<Item = &Path>,
cx: &mut gpui::TestAppContext,
) -> Entity<Project> {
Self::test_project(fs, root_paths, true, cx).await
}
#[cfg(any(test, feature = "test-support"))]
async fn test_project(
fs: Arc<dyn Fs>,
root_paths: impl IntoIterator<Item = &Path>,
init_worktree_trust: bool,
cx: &mut gpui::TestAppContext,
) -> Entity<Project> {
use clock::FakeSystemClock;
@@ -1850,6 +1908,7 @@ impl Project {
Arc::new(languages),
fs,
None,
init_worktree_trust,
cx,
)
});
@@ -4757,9 +4816,14 @@ impl Project {
envelope: TypedEnvelope<proto::UpdateWorktree>,
mut cx: AsyncApp,
) -> Result<()> {
this.update(&mut cx, |this, cx| {
this.update(&mut cx, |project, cx| {
let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
if let Some(worktree) = this.worktree_for_id(worktree_id, cx) {
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
trusted_worktrees.can_trust(worktree_id, cx)
});
}
if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
worktree.update(cx, |worktree, _| {
let worktree = worktree.as_remote_mut().unwrap();
worktree.update_from_remote(envelope.payload);
@@ -4786,6 +4850,61 @@ impl Project {
BufferStore::handle_update_buffer(buffer_store, envelope, cx).await
}
async fn handle_trust_worktrees(
this: Entity<Self>,
envelope: TypedEnvelope<proto::TrustWorktrees>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
let trusted_worktrees = cx
.update(|cx| TrustedWorktrees::try_get_global(cx))?
.context("missing trusted worktrees")?;
trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
let remote_host = this
.read(cx)
.remote_connection_options(cx)
.map(RemoteHostLocation::from);
trusted_worktrees.trust(
envelope
.payload
.trusted_paths
.into_iter()
.filter_map(|proto_path| PathTrust::from_proto(proto_path))
.collect(),
remote_host,
cx,
);
})?;
Ok(proto::Ack {})
}
async fn handle_restrict_worktrees(
this: Entity<Self>,
envelope: TypedEnvelope<proto::RestrictWorktrees>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
let trusted_worktrees = cx
.update(|cx| TrustedWorktrees::try_get_global(cx))?
.context("missing trusted worktrees")?;
trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
let mut restricted_paths = envelope
.payload
.worktree_ids
.into_iter()
.map(WorktreeId::from_proto)
.map(PathTrust::Worktree)
.collect::<HashSet<_>>();
if envelope.payload.restrict_workspace {
restricted_paths.insert(PathTrust::Workspace);
}
let remote_host = this
.read(cx)
.remote_connection_options(cx)
.map(RemoteHostLocation::from);
trusted_worktrees.restrict(restricted_paths, remote_host, cx);
})?;
Ok(proto::Ack {})
}
async fn handle_update_buffer(
this: Entity<Self>,
envelope: TypedEnvelope<proto::UpdateBuffer>,

View File

@@ -23,13 +23,14 @@ use settings::{
DapSettingsContent, InvalidSettingsError, LocalSettingsKind, RegisterSetting, Settings,
SettingsLocation, SettingsStore, parse_json_with_comments, watch_config_file,
};
use std::{path::PathBuf, sync::Arc, time::Duration};
use std::{cell::OnceCell, collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration};
use task::{DebugTaskFile, TaskTemplates, VsCodeDebugTaskFile, VsCodeTaskFile};
use util::{ResultExt, rel_path::RelPath, serde::default_true};
use worktree::{PathChange, UpdatedEntriesSet, Worktree, WorktreeId};
use crate::{
task_store::{TaskSettingsLocation, TaskStore},
trusted_worktrees::{PathTrust, TrustedWorktrees, TrustedWorktreesEvent},
worktree_store::{WorktreeStore, WorktreeStoreEvent},
};
@@ -83,6 +84,12 @@ pub struct SessionSettings {
///
/// Default: true
pub restore_unsaved_buffers: bool,
/// Whether or not to skip worktree trust checks.
/// When trusted, project settings are synchronized automatically,
/// language and MCP servers are downloaded and started automatically.
///
/// Default: false
pub trust_all_worktrees: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
@@ -570,6 +577,7 @@ impl Settings for ProjectSettings {
load_direnv: project.load_direnv.clone().unwrap(),
session: SessionSettings {
restore_unsaved_buffers: content.session.unwrap().restore_unsaved_buffers.unwrap(),
trust_all_worktrees: content.session.unwrap().trust_all_worktrees.unwrap(),
},
}
}
@@ -595,6 +603,9 @@ pub struct SettingsObserver {
worktree_store: Entity<WorktreeStore>,
project_id: u64,
task_store: Entity<TaskStore>,
pending_local_settings:
HashMap<PathTrust, BTreeMap<(WorktreeId, Arc<RelPath>), Option<String>>>,
_trusted_worktrees_watcher: Option<Subscription>,
_user_settings_watcher: Option<Subscription>,
_global_task_config_watcher: Task<()>,
_global_debug_config_watcher: Task<()>,
@@ -620,11 +631,61 @@ impl SettingsObserver {
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
let _trusted_worktrees_watcher =
TrustedWorktrees::try_get_global(cx).map(|trusted_worktrees| {
cx.subscribe(
&trusted_worktrees,
move |settings_observer, _, e, cx| match e {
TrustedWorktreesEvent::Trusted(_, trusted_paths) => {
for trusted_path in trusted_paths {
if let Some(pending_local_settings) = settings_observer
.pending_local_settings
.remove(trusted_path)
{
for ((worktree_id, directory_path), settings_contents) in
pending_local_settings
{
apply_local_settings(
worktree_id,
&directory_path,
LocalSettingsKind::Settings,
&settings_contents,
cx,
);
if let Some(downstream_client) =
&settings_observer.downstream_client
{
downstream_client
.send(proto::UpdateWorktreeSettings {
project_id: settings_observer.project_id,
worktree_id: worktree_id.to_proto(),
path: directory_path.to_proto(),
content: settings_contents,
kind: Some(
local_settings_kind_to_proto(
LocalSettingsKind::Settings,
)
.into(),
),
})
.log_err();
}
}
}
}
}
TrustedWorktreesEvent::Restricted(..) => {}
},
)
});
Self {
worktree_store,
task_store,
mode: SettingsObserverMode::Local(fs.clone()),
downstream_client: None,
_trusted_worktrees_watcher,
pending_local_settings: HashMap::default(),
_user_settings_watcher: None,
project_id: REMOTE_SERVER_PROJECT_ID,
_global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
@@ -677,6 +738,8 @@ impl SettingsObserver {
mode: SettingsObserverMode::Remote,
downstream_client: None,
project_id: REMOTE_SERVER_PROJECT_ID,
_trusted_worktrees_watcher: None,
pending_local_settings: HashMap::default(),
_user_settings_watcher: user_settings_watcher,
_global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
fs.clone(),
@@ -975,36 +1038,32 @@ impl SettingsObserver {
let worktree_id = worktree.read(cx).id();
let remote_worktree_id = worktree.read(cx).id();
let task_store = self.task_store.clone();
let can_trust_worktree = OnceCell::new();
for (directory, kind, file_content) in settings_contents {
let mut applied = true;
match kind {
LocalSettingsKind::Settings | LocalSettingsKind::Editorconfig => cx
.update_global::<SettingsStore, _>(|store, cx| {
let result = store.set_local_settings(
worktree_id,
directory.clone(),
kind,
file_content.as_deref(),
cx,
);
match result {
Err(InvalidSettingsError::LocalSettings { path, message }) => {
log::error!("Failed to set local settings in {path:?}: {message}");
cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Err(
InvalidSettingsError::LocalSettings { path, message },
)));
}
Err(e) => {
log::error!("Failed to set local settings: {e}");
}
Ok(()) => {
cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(directory
.as_std_path()
.join(local_settings_file_relative_path().as_std_path()))));
}
LocalSettingsKind::Settings => {
if *can_trust_worktree.get_or_init(|| {
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
trusted_worktrees.can_trust(worktree_id, cx)
})
} else {
true
}
}),
}) {
apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
} else {
applied = false;
self.pending_local_settings
.entry(PathTrust::Worktree(worktree_id))
.or_default()
.insert((worktree_id, directory.clone()), file_content.clone());
}
}
LocalSettingsKind::Editorconfig => {
apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
}
LocalSettingsKind::Tasks => {
let result = task_store.update(cx, |task_store, cx| {
task_store.update_user_tasks(
@@ -1067,16 +1126,18 @@ impl SettingsObserver {
}
};
if let Some(downstream_client) = &self.downstream_client {
downstream_client
.send(proto::UpdateWorktreeSettings {
project_id: self.project_id,
worktree_id: remote_worktree_id.to_proto(),
path: directory.to_proto(),
content: file_content.clone(),
kind: Some(local_settings_kind_to_proto(kind).into()),
})
.log_err();
if applied {
if let Some(downstream_client) = &self.downstream_client {
downstream_client
.send(proto::UpdateWorktreeSettings {
project_id: self.project_id,
worktree_id: remote_worktree_id.to_proto(),
path: directory.to_proto(),
content: file_content.clone(),
kind: Some(local_settings_kind_to_proto(kind).into()),
})
.log_err();
}
}
}
}
@@ -1193,6 +1254,37 @@ impl SettingsObserver {
}
}
fn apply_local_settings(
worktree_id: WorktreeId,
directory: &Arc<RelPath>,
kind: LocalSettingsKind,
file_content: &Option<String>,
cx: &mut Context<'_, SettingsObserver>,
) {
cx.update_global::<SettingsStore, _>(|store, cx| {
let result = store.set_local_settings(
worktree_id,
directory.clone(),
kind,
file_content.as_deref(),
cx,
);
match result {
Err(InvalidSettingsError::LocalSettings { path, message }) => {
log::error!("Failed to set local settings in {path:?}: {message}");
cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Err(
InvalidSettingsError::LocalSettings { path, message },
)));
}
Err(e) => log::error!("Failed to set local settings: {e}"),
Ok(()) => cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(directory
.as_std_path()
.join(local_settings_file_relative_path().as_std_path())))),
}
})
}
pub fn local_settings_kind_from_proto(kind: proto::LocalSettingsKind) -> LocalSettingsKind {
match kind {
proto::LocalSettingsKind::Settings => LocalSettingsKind::Settings,

File diff suppressed because it is too large Load Diff

View File

@@ -62,7 +62,7 @@ fn main() -> Result<(), anyhow::Error> {
let client = Client::production(cx);
let http_client = FakeHttpClient::with_200_response();
let (_, rx) = watch::channel(None);
let node = NodeRuntime::new(http_client, None, rx);
let node = NodeRuntime::new(http_client, None, rx, None);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
let registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
let fs = Arc::new(RealFs::new(None, cx.background_executor().clone()));
@@ -73,6 +73,7 @@ fn main() -> Result<(), anyhow::Error> {
registry,
fs,
Some(Default::default()),
false,
cx,
);

View File

@@ -55,7 +55,7 @@ pub struct PromptMetadata {
#[serde(tag = "kind")]
pub enum PromptId {
User { uuid: UserPromptId },
EditWorkflow,
CommitMessage,
}
impl PromptId {
@@ -63,8 +63,31 @@ impl PromptId {
UserPromptId::new().into()
}
pub fn user_id(&self) -> Option<UserPromptId> {
match self {
Self::User { uuid } => Some(*uuid),
_ => None,
}
}
pub fn is_built_in(&self) -> bool {
!matches!(self, PromptId::User { .. })
match self {
Self::User { .. } => false,
Self::CommitMessage => true,
}
}
pub fn can_edit(&self) -> bool {
match self {
Self::User { .. } | Self::CommitMessage => true,
}
}
pub fn default_content(&self) -> Option<&'static str> {
match self {
Self::User { .. } => None,
Self::CommitMessage => Some(include_str!("../../git_ui/src/commit_message_prompt.txt")),
}
}
}
@@ -94,7 +117,7 @@ impl std::fmt::Display for PromptId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PromptId::User { uuid } => write!(f, "{}", uuid.0),
PromptId::EditWorkflow => write!(f, "Edit workflow"),
PromptId::CommitMessage => write!(f, "Commit message"),
}
}
}
@@ -176,10 +199,24 @@ impl PromptStore {
let metadata = db_env.create_database(&mut txn, Some("metadata.v2"))?;
let bodies = db_env.create_database(&mut txn, Some("bodies.v2"))?;
// Remove edit workflow prompt, as we decided to opt into it using
// a slash command instead.
metadata.delete(&mut txn, &PromptId::EditWorkflow).ok();
bodies.delete(&mut txn, &PromptId::EditWorkflow).ok();
// Insert default commit message prompt if not present
if metadata.get(&txn, &PromptId::CommitMessage)?.is_none() {
metadata.put(
&mut txn,
&PromptId::CommitMessage,
&PromptMetadata {
id: PromptId::CommitMessage,
title: Some("Git Commit Message".into()),
default: false,
saved_at: Utc::now(),
},
)?;
}
if bodies.get(&txn, &PromptId::CommitMessage)?.is_none() {
let commit_message_prompt =
include_str!("../../git_ui/src/commit_message_prompt.txt");
bodies.put(&mut txn, &PromptId::CommitMessage, commit_message_prompt)?;
}
txn.commit()?;
@@ -387,8 +424,8 @@ impl PromptStore {
body: Rope,
cx: &Context<Self>,
) -> Task<Result<()>> {
if id.is_built_in() {
return Task::ready(Err(anyhow!("built-in prompts cannot be saved")));
if !id.can_edit() {
return Task::ready(Err(anyhow!("this prompt cannot be edited")));
}
let prompt_metadata = PromptMetadata {
@@ -430,7 +467,7 @@ impl PromptStore {
) -> Task<Result<()>> {
let mut cache = self.metadata_cache.write();
if id.is_built_in() {
if !id.can_edit() {
title = cache
.metadata_by_id
.get(&id)

View File

@@ -158,3 +158,22 @@ message UpdateUserSettings {
uint64 project_id = 1;
string contents = 2;
}
message TrustWorktrees {
uint64 project_id = 1;
repeated PathTrust trusted_paths = 2;
}
message PathTrust {
oneof content {
uint64 workspace = 1;
uint64 worktree_id = 2;
string abs_path = 3;
}
}
message RestrictWorktrees {
uint64 project_id = 1;
bool restrict_workspace = 2;
repeated uint64 worktree_ids = 3;
}

View File

@@ -448,7 +448,10 @@ message Envelope {
ExternalExtensionAgentsUpdated external_extension_agents_updated = 401;
GitCreateRemote git_create_remote = 402;
GitRemoveRemote git_remove_remote = 403;// current max
GitRemoveRemote git_remove_remote = 403;
TrustWorktrees trust_worktrees = 404;
RestrictWorktrees restrict_worktrees = 405; // current max
}
reserved 87 to 88, 396;

View File

@@ -310,6 +310,8 @@ messages!(
(GitCreateBranch, Background),
(GitChangeBranch, Background),
(GitRenameBranch, Background),
(TrustWorktrees, Background),
(RestrictWorktrees, Background),
(CheckForPushedCommits, Background),
(CheckForPushedCommitsResponse, Background),
(GitDiff, Background),
@@ -529,7 +531,9 @@ request_messages!(
(GetAgentServerCommand, AgentServerCommand),
(RemoteStarted, Ack),
(GitGetWorktrees, GitWorktreesResponse),
(GitCreateWorktree, Ack)
(GitCreateWorktree, Ack),
(TrustWorktrees, Ack),
(RestrictWorktrees, Ack),
);
lsp_messages!(
@@ -702,7 +706,9 @@ entity_messages!(
ExternalAgentLoadingStatusUpdated,
NewExternalAgentVersionAvailable,
GitGetWorktrees,
GitCreateWorktree
GitCreateWorktree,
TrustWorktrees,
RestrictWorktrees,
);
entity_messages!(

View File

@@ -16,6 +16,7 @@ use gpui::{
use language::{CursorShape, Point};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use project::trusted_worktrees;
use release_channel::ReleaseChannel;
use remote::{
ConnectionIdentifier, DockerConnectionOptions, RemoteClient, RemoteConnection,
@@ -646,6 +647,7 @@ pub async fn open_remote_project(
app_state.languages.clone(),
app_state.fs.clone(),
None,
false,
cx,
);
cx.new(|cx| {
@@ -788,11 +790,20 @@ pub async fn open_remote_project(
continue;
}
if created_new_window {
window
.update(cx, |_, window, _| window.remove_window())
.ok();
}
window
.update(cx, |workspace, window, cx| {
if created_new_window {
window.remove_window();
}
trusted_worktrees::track_worktree_trust(
workspace.project().read(cx).worktree_store(),
None,
None,
None,
cx,
);
})
.ok();
}
Ok(items) => {

View File

@@ -1000,6 +1000,7 @@ impl RemoteServerProjects {
app_state.user_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
true,
cx,
),
)

View File

@@ -26,6 +26,7 @@ anyhow.workspace = true
askpass.workspace = true
clap.workspace = true
client.workspace = true
collections.workspace = true
dap_adapters.workspace = true
debug_adapter_extension.workspace = true
env_logger.workspace = true
@@ -81,7 +82,6 @@ action_log.workspace = true
agent = { workspace = true, features = ["test-support"] }
client = { workspace = true, features = ["test-support"] }
clock = { workspace = true, features = ["test-support"] }
collections.workspace = true
dap = { workspace = true, features = ["test-support"] }
editor = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }

View File

@@ -1,4 +1,5 @@
use anyhow::{Context as _, Result, anyhow};
use collections::HashSet;
use language::File;
use lsp::LanguageServerId;
@@ -21,6 +22,7 @@ use project::{
project_settings::SettingsObserver,
search::SearchQuery,
task_store::TaskStore,
trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
worktree_store::WorktreeStore,
};
use rpc::{
@@ -86,6 +88,7 @@ impl HeadlessProject {
languages,
extension_host_proxy: proxy,
}: HeadlessAppState,
init_worktree_trust: bool,
cx: &mut Context<Self>,
) -> Self {
debug_adapter_extension::init(proxy.clone(), cx);
@@ -97,6 +100,16 @@ impl HeadlessProject {
store
});
if init_worktree_trust {
project::trusted_worktrees::track_worktree_trust(
worktree_store.clone(),
None::<RemoteHostLocation>,
Some((session.clone(), REMOTE_SERVER_PROJECT_ID)),
None,
cx,
);
}
let environment =
cx.new(|cx| ProjectEnvironment::new(None, worktree_store.downgrade(), None, true, cx));
let manifest_tree = ManifestTree::new(worktree_store.clone(), cx);
@@ -264,6 +277,8 @@ impl HeadlessProject {
session.add_entity_request_handler(Self::handle_get_directory_environment);
session.add_entity_message_handler(Self::handle_toggle_lsp_logs);
session.add_entity_request_handler(Self::handle_open_image_by_path);
session.add_entity_request_handler(Self::handle_trust_worktrees);
session.add_entity_request_handler(Self::handle_restrict_worktrees);
session.add_entity_request_handler(BufferStore::handle_update_buffer);
session.add_entity_message_handler(BufferStore::handle_close_buffer);
@@ -595,6 +610,53 @@ impl HeadlessProject {
})
}
pub async fn handle_trust_worktrees(
_: Entity<Self>,
envelope: TypedEnvelope<proto::TrustWorktrees>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
let trusted_worktrees = cx
.update(|cx| TrustedWorktrees::try_get_global(cx))?
.context("missing trusted worktrees")?;
trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
trusted_worktrees.trust(
envelope
.payload
.trusted_paths
.into_iter()
.filter_map(PathTrust::from_proto)
.collect(),
None,
cx,
);
})?;
Ok(proto::Ack {})
}
pub async fn handle_restrict_worktrees(
_: Entity<Self>,
envelope: TypedEnvelope<proto::RestrictWorktrees>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
let trusted_worktrees = cx
.update(|cx| TrustedWorktrees::try_get_global(cx))?
.context("missing trusted worktrees")?;
trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
let mut restricted_paths = envelope
.payload
.worktree_ids
.into_iter()
.map(WorktreeId::from_proto)
.map(PathTrust::Worktree)
.collect::<HashSet<_>>();
if envelope.payload.restrict_workspace {
restricted_paths.insert(PathTrust::Workspace);
}
trusted_worktrees.restrict(restricted_paths, None, cx);
})?;
Ok(proto::Ack {})
}
pub async fn handle_open_new_buffer(
this: Entity<Self>,
_message: TypedEnvelope<proto::OpenNewBuffer>,

View File

@@ -1933,6 +1933,7 @@ pub async fn init_test(
languages,
extension_host_proxy: proxy,
},
false,
cx,
)
});
@@ -1977,5 +1978,5 @@ fn build_project(ssh: Entity<RemoteClient>, cx: &mut TestAppContext) -> Entity<P
Project::init(&client, cx);
});
cx.update(|cx| Project::remote(ssh, client, node, user_store, languages, fs, cx))
cx.update(|cx| Project::remote(ssh, client, node, user_store, languages, fs, false, cx))
}

View File

@@ -2,6 +2,7 @@ use crate::HeadlessProject;
use crate::headless_project::HeadlessAppState;
use anyhow::{Context as _, Result, anyhow};
use client::ProxySettings;
use project::trusted_worktrees;
use util::ResultExt;
use extension::ExtensionHostProxy;
@@ -34,6 +35,7 @@ use smol::Async;
use smol::channel::{Receiver, Sender};
use smol::io::AsyncReadExt;
use smol::{net::unix::UnixListener, stream::StreamExt as _};
use std::pin::Pin;
use std::{
env,
ffi::OsStr,
@@ -417,6 +419,7 @@ pub fn execute_run(
log::info!("gpui app started, initializing server");
let session = start_server(listeners, log_rx, cx, is_wsl_interop);
trusted_worktrees::init(Some((session.clone(), REMOTE_SERVER_PROJECT_ID)), None, cx);
GitHostingProviderRegistry::set_global(git_hosting_provider_registry, cx);
git_hosting_providers::init(cx);
@@ -449,10 +452,13 @@ pub fn execute_run(
)
};
let trust_task = trusted_worktrees::wait_for_default_workspace_trust("Node runtime", cx)
.map(|trust_task| Box::pin(trust_task) as Pin<Box<_>>);
let node_runtime = NodeRuntime::new(
http_client.clone(),
Some(shell_env_loaded_rx),
node_settings_rx,
trust_task,
);
let mut languages = LanguageRegistry::new(cx.background_executor().clone());
@@ -468,6 +474,7 @@ pub fn execute_run(
languages,
extension_host_proxy,
},
true,
cx,
)
});

View File

@@ -21,9 +21,7 @@ use std::sync::atomic::AtomicBool;
use std::time::Duration;
use theme::ThemeSettings;
use title_bar::platform_title_bar::PlatformTitleBar;
use ui::{
Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Render, Tooltip, prelude::*,
};
use ui::{Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
use util::{ResultExt, TryFutureExt};
use workspace::{Workspace, WorkspaceSettings, client_side_decorations};
use zed_actions::assistant::InlineAssist;
@@ -44,15 +42,12 @@ actions!(
/// Duplicates the selected rule.
DuplicateRule,
/// Toggles whether the selected rule is a default rule.
ToggleDefaultRule
ToggleDefaultRule,
/// Restores a built-in rule to its default content.
RestoreDefaultContent
]
);
const BUILT_IN_TOOLTIP_TEXT: &str = concat!(
"This rule supports special functionality.\n",
"It's read-only, but you can remove it from your default rules."
);
pub trait InlineAssistDelegate {
fn assist(
&self,
@@ -270,23 +265,35 @@ impl PickerDelegate for RulePickerDelegate {
.background_spawn(async move {
let matches = search.await;
let (default_rules, non_default_rules): (Vec<_>, Vec<_>) =
matches.iter().partition(|rule| rule.default);
let (built_in_rules, user_rules): (Vec<_>, Vec<_>) =
matches.into_iter().partition(|rule| rule.id.is_built_in());
let (default_rules, other_rules): (Vec<_>, Vec<_>) =
user_rules.into_iter().partition(|rule| rule.default);
let mut filtered_entries = Vec::new();
if !default_rules.is_empty() {
filtered_entries.push(RulePickerEntry::Header("Default Rules".into()));
if !built_in_rules.is_empty() {
filtered_entries.push(RulePickerEntry::Header("Built-in Rules".into()));
for rule in default_rules {
filtered_entries.push(RulePickerEntry::Rule(rule.clone()));
for rule in built_in_rules {
filtered_entries.push(RulePickerEntry::Rule(rule));
}
filtered_entries.push(RulePickerEntry::Separator);
}
for rule in non_default_rules {
filtered_entries.push(RulePickerEntry::Rule(rule.clone()));
if !default_rules.is_empty() {
filtered_entries.push(RulePickerEntry::Header("Default Rules".into()));
for rule in default_rules {
filtered_entries.push(RulePickerEntry::Rule(rule));
}
filtered_entries.push(RulePickerEntry::Separator);
}
for rule in other_rules {
filtered_entries.push(RulePickerEntry::Rule(rule));
}
let selected_index = prev_prompt_id
@@ -341,21 +348,27 @@ impl PickerDelegate for RulePickerDelegate {
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
match self.filtered_entries.get(ix)? {
RulePickerEntry::Header(title) => Some(
ListSubHeader::new(title.clone())
.end_slot(
IconButton::new("info", IconName::Info)
.style(ButtonStyle::Transparent)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text(
"Default Rules are attached by default with every new thread.",
))
.into_any_element(),
)
.inset(true)
.into_any_element(),
),
RulePickerEntry::Header(title) => {
let tooltip_text = if title.as_ref() == "Built-in Rules" {
"Built-in rules are those included out of the box with Zed."
} else {
"Default Rules are attached by default with every new thread."
};
Some(
ListSubHeader::new(title.clone())
.end_slot(
IconButton::new("info", IconName::Info)
.style(ButtonStyle::Transparent)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text(tooltip_text))
.into_any_element(),
)
.inset(true)
.into_any_element(),
)
}
RulePickerEntry::Separator => Some(
h_flex()
.py_1()
@@ -376,7 +389,7 @@ impl PickerDelegate for RulePickerDelegate {
.truncate()
.mr_10(),
)
.end_slot::<IconButton>(default.then(|| {
.end_slot::<IconButton>((default && !prompt_id.is_built_in()).then(|| {
IconButton::new("toggle-default-rule", IconName::Paperclip)
.toggle_state(true)
.icon_color(Color::Accent)
@@ -386,62 +399,52 @@ impl PickerDelegate for RulePickerDelegate {
cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
}))
}))
.end_hover_slot(
h_flex()
.child(if prompt_id.is_built_in() {
div()
.id("built-in-rule")
.child(Icon::new(IconName::FileLock).color(Color::Muted))
.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Built-in rule",
None,
BUILT_IN_TOOLTIP_TEXT,
cx,
)
})
.into_any()
} else {
IconButton::new("delete-rule", IconName::Trash)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Delete Rule"))
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(RulePickerEvent::Deleted { prompt_id })
}))
.into_any_element()
})
.child(
IconButton::new("toggle-default-rule", IconName::Plus)
.selected_icon(IconName::Dash)
.toggle_state(default)
.icon_size(IconSize::Small)
.icon_color(if default {
Color::Accent
} else {
Color::Muted
})
.map(|this| {
if default {
this.tooltip(Tooltip::text(
"Remove from Default Rules",
))
.when(!prompt_id.is_built_in(), |this| {
this.end_hover_slot(
h_flex()
.child(
IconButton::new("delete-rule", IconName::Trash)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Delete Rule"))
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(RulePickerEvent::Deleted { prompt_id })
})),
)
.child(
IconButton::new("toggle-default-rule", IconName::Plus)
.selected_icon(IconName::Dash)
.toggle_state(default)
.icon_size(IconSize::Small)
.icon_color(if default {
Color::Accent
} else {
this.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Add to Default Rules",
None,
"Always included in every thread.",
cx,
)
Color::Muted
})
.map(|this| {
if default {
this.tooltip(Tooltip::text(
"Remove from Default Rules",
))
} else {
this.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Add to Default Rules",
None,
"Always included in every thread.",
cx,
)
})
}
})
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(RulePickerEvent::ToggledDefault {
prompt_id,
})
}
})
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
})),
),
)
})),
),
)
})
.into_any_element(),
)
}
@@ -573,7 +576,7 @@ impl RulesLibrary {
pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
const SAVE_THROTTLE: Duration = Duration::from_millis(500);
if prompt_id.is_built_in() {
if !prompt_id.can_edit() {
return;
}
@@ -661,6 +664,33 @@ impl RulesLibrary {
}
}
pub fn restore_default_content_for_active_rule(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(active_rule_id) = self.active_rule_id {
self.restore_default_content(active_rule_id, window, cx);
}
}
pub fn restore_default_content(
&mut self,
prompt_id: PromptId,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(default_content) = prompt_id.default_content() else {
return;
};
if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
rule_editor.body_editor.update(cx, |editor, cx| {
editor.set_text(default_content, window, cx);
});
}
}
pub fn toggle_default_for_rule(
&mut self,
prompt_id: PromptId,
@@ -721,7 +751,7 @@ impl RulesLibrary {
});
let mut editor = Editor::for_buffer(buffer, None, window, cx);
if prompt_id.is_built_in() {
if !prompt_id.can_edit() {
editor.set_read_only(true);
editor.set_show_edit_predictions(Some(false), window, cx);
}
@@ -1148,30 +1178,38 @@ impl RulesLibrary {
fn render_active_rule_editor(
&self,
editor: &Entity<Editor>,
read_only: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
let settings = ThemeSettings::get_global(cx);
let text_color = if read_only {
cx.theme().colors().text_muted
} else {
cx.theme().colors().text
};
div()
.w_full()
.on_action(cx.listener(Self::move_down_from_title))
.pl_1()
.border_1()
.border_color(transparent_black())
.rounded_sm()
.group_hover("active-editor-header", |this| {
this.border_color(cx.theme().colors().border_variant)
.when(!read_only, |this| {
this.group_hover("active-editor-header", |this| {
this.border_color(cx.theme().colors().border_variant)
})
})
.on_action(cx.listener(Self::move_down_from_title))
.child(EditorElement::new(
&editor,
EditorStyle {
background: cx.theme().system().transparent,
local_player: cx.theme().players().local(),
text: TextStyle {
color: cx.theme().colors().editor_foreground,
color: text_color,
font_family: settings.ui_font.family.clone(),
font_features: settings.ui_font.features.clone(),
font_size: HeadlineSize::Large.rems().into(),
font_size: HeadlineSize::Medium.rems().into(),
font_weight: settings.ui_font.weight,
line_height: relative(settings.buffer_line_height.value()),
..Default::default()
@@ -1186,6 +1224,68 @@ impl RulesLibrary {
))
}
fn render_duplicate_rule_button(&self) -> impl IntoElement {
IconButton::new("duplicate-rule", IconName::BookCopy)
.tooltip(move |_window, cx| Tooltip::for_action("Duplicate Rule", &DuplicateRule, cx))
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(DuplicateRule), cx);
})
}
fn render_built_in_rule_controls(&self) -> impl IntoElement {
h_flex()
.gap_1()
.child(self.render_duplicate_rule_button())
.child(
IconButton::new("restore-default", IconName::RotateCcw)
.tooltip(move |_window, cx| {
Tooltip::for_action(
"Restore to Default Content",
&RestoreDefaultContent,
cx,
)
})
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(RestoreDefaultContent), cx);
}),
)
}
fn render_regular_rule_controls(&self, default: bool) -> impl IntoElement {
h_flex()
.gap_1()
.child(
IconButton::new("toggle-default-rule", IconName::Paperclip)
.toggle_state(default)
.when(default, |this| this.icon_color(Color::Accent))
.map(|this| {
if default {
this.tooltip(Tooltip::text("Remove from Default Rules"))
} else {
this.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Add to Default Rules",
None,
"Always included in every thread.",
cx,
)
})
}
})
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(ToggleDefaultRule), cx);
}),
)
.child(self.render_duplicate_rule_button())
.child(
IconButton::new("delete-rule", IconName::Trash)
.tooltip(move |_window, cx| Tooltip::for_action("Delete Rule", &DeleteRule, cx))
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(DeleteRule), cx);
}),
)
}
fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
div()
.id("rule-editor")
@@ -1198,9 +1298,9 @@ impl RulesLibrary {
let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
let rule_editor = &self.rule_editors[&prompt_id];
let focus_handle = rule_editor.body_editor.focus_handle(cx);
let model = LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default| default.model);
let registry = LanguageModelRegistry::read_global(cx);
let model = registry.default_model().map(|default| default.model);
let built_in = prompt_id.is_built_in();
Some(
v_flex()
@@ -1214,14 +1314,15 @@ impl RulesLibrary {
.child(
h_flex()
.group("active-editor-header")
.pt_2()
.pl_1p5()
.pr_2p5()
.h_12()
.px_2()
.gap_2()
.justify_between()
.child(
self.render_active_rule_editor(&rule_editor.title_editor, cx),
)
.child(self.render_active_rule_editor(
&rule_editor.title_editor,
built_in,
cx,
))
.child(
h_flex()
.h_full()
@@ -1258,89 +1359,15 @@ impl RulesLibrary {
.color(Color::Muted),
)
}))
.child(if prompt_id.is_built_in() {
div()
.id("built-in-rule")
.child(
Icon::new(IconName::FileLock)
.color(Color::Muted),
)
.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Built-in rule",
None,
BUILT_IN_TOOLTIP_TEXT,
cx,
)
})
.into_any()
} else {
IconButton::new("delete-rule", IconName::Trash)
.tooltip(move |_window, cx| {
Tooltip::for_action(
"Delete Rule",
&DeleteRule,
cx,
)
})
.on_click(|_, window, cx| {
window
.dispatch_action(Box::new(DeleteRule), cx);
})
.into_any_element()
})
.child(
IconButton::new("duplicate-rule", IconName::BookCopy)
.tooltip(move |_window, cx| {
Tooltip::for_action(
"Duplicate Rule",
&DuplicateRule,
cx,
)
})
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(DuplicateRule),
cx,
);
}),
)
.child(
IconButton::new(
"toggle-default-rule",
IconName::Paperclip,
)
.toggle_state(rule_metadata.default)
.icon_color(if rule_metadata.default {
Color::Accent
.map(|this| {
if built_in {
this.child(self.render_built_in_rule_controls())
} else {
Color::Muted
})
.map(|this| {
if rule_metadata.default {
this.tooltip(Tooltip::text(
"Remove from Default Rules",
))
} else {
this.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Add to Default Rules",
None,
"Always included in every thread.",
cx,
)
})
}
})
.on_click(
|_, window, cx| {
window.dispatch_action(
Box::new(ToggleDefaultRule),
cx,
);
},
),
),
this.child(self.render_regular_rule_controls(
rule_metadata.default,
))
}
}),
),
)
.child(
@@ -1385,6 +1412,9 @@ impl Render for RulesLibrary {
.on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
this.toggle_default_for_active_rule(window, cx)
}))
.on_action(cx.listener(|this, &RestoreDefaultContent, window, cx| {
this.restore_default_content_for_active_rule(window, cx)
}))
.size_full()
.overflow_hidden()
.font(ui_font)

View File

@@ -38,6 +38,9 @@ pub struct AgentSettingsContent {
pub default_height: Option<f32>,
/// The default model to use when creating new chats and for other features when a specific model is not specified.
pub default_model: Option<LanguageModelSelection>,
/// Favorite models to show at the top of the model selector.
#[serde(default)]
pub favorite_models: Vec<LanguageModelSelection>,
/// Model to use for the inline assistant. Defaults to default_model when not specified.
pub inline_assistant_model: Option<LanguageModelSelection>,
/// Model to use for the inline assistant when streaming tools are enabled.
@@ -176,6 +179,16 @@ impl AgentSettingsContent {
pub fn set_profile(&mut self, profile_id: Arc<str>) {
self.default_profile = Some(profile_id);
}
pub fn add_favorite_model(&mut self, model: LanguageModelSelection) {
if !self.favorite_models.contains(&model) {
self.favorite_models.push(model);
}
}
pub fn remove_favorite_model(&mut self, model: &LanguageModelSelection) {
self.favorite_models.retain(|m| m != model);
}
}
#[with_fallible_options]

View File

@@ -187,6 +187,12 @@ pub struct SessionSettingsContent {
///
/// Default: true
pub restore_unsaved_buffers: Option<bool>,
/// Whether or not to skip worktree trust checks.
/// When trusted, project settings are synchronized automatically,
/// language and MCP servers are downloaded and started automatically.
///
/// Default: false
pub trust_all_worktrees: Option<bool>,
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom, Debug)]

View File

@@ -138,6 +138,28 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None,
files: USER,
}),
SettingsPageItem::SectionHeader("Security"),
SettingsPageItem::SettingItem(SettingItem {
title: "Trust All Projects By Default",
description: "When opening Zed, avoid Restricted Mode by auto-trusting all projects, enabling use of all features without having to give permission to each new project.",
field: Box::new(SettingField {
json_path: Some("session.trust_all_projects"),
pick: |settings_content| {
settings_content
.session
.as_ref()
.and_then(|session| session.trust_all_worktrees.as_ref())
},
write: |settings_content, value| {
settings_content
.session
.get_or_insert_default()
.trust_all_worktrees = value;
},
}),
metadata: None,
files: USER,
}),
SettingsPageItem::SectionHeader("Workspace Restoration"),
SettingsPageItem::SettingItem(SettingItem {
title: "Restore Unsaved Buffers",

View File

@@ -30,18 +30,20 @@ use gpui::{
Subscription, WeakEntity, Window, actions, div,
};
use onboarding_banner::OnboardingBanner;
use project::{Project, WorktreeSettings, git_store::GitStoreEvent};
use project::{
Project, WorktreeSettings, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees,
};
use remote::RemoteConnectionOptions;
use settings::{Settings, SettingsLocation};
use std::sync::Arc;
use theme::ActiveTheme;
use title_bar_settings::TitleBarSettings;
use ui::{
Avatar, Button, ButtonLike, ButtonStyle, Chip, ContextMenu, Icon, IconName, IconSize,
IconWithIndicator, Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, h_flex, prelude::*,
Avatar, ButtonLike, Chip, ContextMenu, IconWithIndicator, Indicator, PopoverMenu,
PopoverMenuHandle, TintColor, Tooltip, prelude::*,
};
use util::{ResultExt, rel_path::RelPath};
use workspace::{Workspace, notifications::NotifyResultExt};
use workspace::{ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt};
use zed_actions::{OpenRecent, OpenRemote};
pub use onboarding_banner::restore_banner;
@@ -163,6 +165,7 @@ impl Render for TitleBar {
title_bar
.when(title_bar_settings.show_project_items, |title_bar| {
title_bar
.children(self.render_restricted_mode(cx))
.children(self.render_project_host(cx))
.child(self.render_project_name(cx))
})
@@ -291,7 +294,12 @@ impl TitleBar {
_ => {}
}),
);
subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
subscriptions.push(cx.observe(&user_store, |_a, _, cx| cx.notify()));
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
subscriptions.push(cx.subscribe(&trusted_worktrees, |_, _, _, cx| {
cx.notify();
}));
}
let banner = cx.new(|cx| {
OnboardingBanner::new(
@@ -317,7 +325,7 @@ impl TitleBar {
client,
_subscriptions: subscriptions,
banner,
screen_share_popover_handle: Default::default(),
screen_share_popover_handle: PopoverMenuHandle::default(),
}
}
@@ -427,6 +435,48 @@ impl TitleBar {
)
}
pub fn render_restricted_mode(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx)
.map(|trusted_worktrees| {
trusted_worktrees
.read(cx)
.has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx)
})
.unwrap_or(false);
if !has_restricted_worktrees {
return None;
}
Some(
Button::new("restricted_mode_trigger", "Restricted Mode")
.style(ButtonStyle::Tinted(TintColor::Warning))
.label_size(LabelSize::Small)
.color(Color::Warning)
.icon(IconName::Warning)
.icon_color(Color::Warning)
.icon_size(IconSize::Small)
.icon_position(IconPosition::Start)
.tooltip(|_, cx| {
Tooltip::with_meta(
"You're in Restricted Mode",
Some(&ToggleWorktreeSecurity),
"Mark this project as trusted and unlock all features",
cx,
)
})
.on_click({
cx.listener(move |this, _, window, cx| {
this.workspace
.update(cx, |workspace, cx| {
workspace.show_worktree_trust_security_modal(true, window, cx)
})
.log_err();
})
})
.into_any_element(),
)
}
pub fn render_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.project.read(cx).is_via_remote_server() {
return self.render_remote_project_connection(cx);

View File

@@ -1,3 +1,5 @@
mod configured_api_card;
mod tool_call;
pub use configured_api_card::*;
pub use tool_call::*;

View File

@@ -0,0 +1,176 @@
use crate::prelude::*;
use gpui::{AnyElement, IntoElement, ParentElement, SharedString};
#[derive(IntoElement, RegisterComponent)]
pub struct ToolCall {
icon: IconName,
title: SharedString,
actions_slot: Option<AnyElement>,
content: Option<AnyElement>,
use_card_layout: bool,
}
impl ToolCall {
pub fn new(title: impl Into<SharedString>) -> Self {
Self {
icon: IconName::ToolSearch,
title: title.into(),
actions_slot: None,
use_card_layout: false,
content: None,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = icon;
self
}
pub fn actions_slot(mut self, action: impl IntoElement) -> Self {
self.actions_slot = Some(action.into_any_element());
self
}
pub fn content(mut self, content: impl IntoElement) -> Self {
self.content = Some(content.into_any_element());
self
}
pub fn use_card_layout(mut self, use_card_layout: bool) -> Self {
self.use_card_layout = use_card_layout;
self
}
}
impl RenderOnce for ToolCall {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
v_flex()
.when(self.use_card_layout, |this| {
this.border_1()
.border_color(cx.theme().colors().border)
.rounded_md()
.overflow_hidden()
})
.child(
h_flex()
.gap_1()
.justify_between()
.when(self.use_card_layout, |this| {
this.p_1()
.bg(cx.theme().colors().element_background.opacity(0.2))
})
.child(
h_flex()
.w_full()
.when(self.use_card_layout, |this| this.px_1())
.hover(|s| s.bg(cx.theme().colors().element_hover))
.gap_1p5()
.rounded_xs()
.child(
Icon::new(self.icon)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(
Label::new(self.title)
.size(LabelSize::Small)
.color(Color::Muted),
),
)
.when_some(self.actions_slot, |this, action| this.child(action)),
)
.when_some(self.content, |this, content| {
this.child(
div()
.map(|this| {
if self.use_card_layout {
this.p_2()
.border_t_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_background)
} else {
this.pl_4()
.ml_1p5()
.border_l_1()
.border_color(cx.theme().colors().border)
}
})
.child(content),
)
})
}
}
impl Component for ToolCall {
fn scope() -> ComponentScope {
ComponentScope::Agent
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let container = || {
v_flex()
.p_2()
.w_128()
.border_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().panel_background)
};
let muted_icon_button = |id: &'static str, icon: IconName| {
IconButton::new(id, icon)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
};
let examples = vec![
single_example(
"Non-card (header only)",
container()
.child(
ToolCall::new("Search repository")
.icon(IconName::ToolSearch)
.actions_slot(muted_icon_button(
"toolcall-noncard-expand",
IconName::ChevronDown,
)),
)
.into_any_element(),
),
single_example(
"Non-card + content",
container()
.child(
ToolCall::new("Edit file: src/main.rs")
.icon(IconName::File)
.content(
Label::new("Tool output here — markdown, list, etc.")
.size(LabelSize::Small)
.color(Color::Muted),
),
)
.into_any_element(),
),
single_example(
"Card layout + actions",
container()
.child(
ToolCall::new("Run Command")
.icon(IconName::ToolTerminal)
.use_card_layout(true)
.actions_slot(muted_icon_button(
"toolcall-card-expand",
IconName::ChevronDown,
))
.content(
Label::new("git status")
.size(LabelSize::Small)
.buffer_font(cx),
),
)
.into_any_element(),
),
];
Some(example_group(examples).vertical().into_any_element())
}
}

View File

@@ -1,73 +1,161 @@
use crate::component_prelude::*;
use crate::prelude::*;
use crate::{Checkbox, ListBulletItem, ToggleState};
use gpui::Action;
use gpui::FocusHandle;
use gpui::IntoElement;
use gpui::Stateful;
use smallvec::{SmallVec, smallvec};
use theme::ActiveTheme;
type ActionHandler = Box<dyn FnOnce(Stateful<Div>) -> Stateful<Div>>;
#[derive(IntoElement, RegisterComponent)]
pub struct AlertModal {
id: ElementId,
header: Option<AnyElement>,
children: SmallVec<[AnyElement; 2]>,
title: SharedString,
primary_action: SharedString,
dismiss_label: SharedString,
footer: Option<AnyElement>,
title: Option<SharedString>,
primary_action: Option<SharedString>,
dismiss_label: Option<SharedString>,
width: Option<DefiniteLength>,
key_context: Option<String>,
action_handlers: Vec<ActionHandler>,
focus_handle: Option<FocusHandle>,
}
impl AlertModal {
pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
header: None,
children: smallvec![],
title: title.into(),
primary_action: "Ok".into(),
dismiss_label: "Cancel".into(),
footer: None,
title: None,
primary_action: None,
dismiss_label: None,
width: None,
key_context: None,
action_handlers: Vec::new(),
focus_handle: None,
}
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
pub fn header(mut self, header: impl IntoElement) -> Self {
self.header = Some(header.into_any_element());
self
}
pub fn footer(mut self, footer: impl IntoElement) -> Self {
self.footer = Some(footer.into_any_element());
self
}
pub fn primary_action(mut self, primary_action: impl Into<SharedString>) -> Self {
self.primary_action = primary_action.into();
self.primary_action = Some(primary_action.into());
self
}
pub fn dismiss_label(mut self, dismiss_label: impl Into<SharedString>) -> Self {
self.dismiss_label = dismiss_label.into();
self.dismiss_label = Some(dismiss_label.into());
self
}
pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
self.width = Some(width.into());
self
}
pub fn key_context(mut self, key_context: impl Into<String>) -> Self {
self.key_context = Some(key_context.into());
self
}
pub fn on_action<A: Action>(
mut self,
listener: impl Fn(&A, &mut Window, &mut App) + 'static,
) -> Self {
self.action_handlers
.push(Box::new(move |div| div.on_action(listener)));
self
}
pub fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self {
self.focus_handle = Some(focus_handle.clone());
self
}
}
impl RenderOnce for AlertModal {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
v_flex()
let width = self.width.unwrap_or_else(|| px(440.).into());
let has_default_footer = self.primary_action.is_some() || self.dismiss_label.is_some();
let mut modal = v_flex()
.when_some(self.key_context, |this, key_context| {
this.key_context(key_context.as_str())
})
.when_some(self.focus_handle, |this, focus_handle| {
this.track_focus(&focus_handle)
})
.id(self.id)
.elevation_3(cx)
.w(px(440.))
.p_5()
.child(
.w(width)
.bg(cx.theme().colors().elevated_surface_background)
.overflow_hidden();
for handler in self.action_handlers {
modal = handler(modal);
}
if let Some(header) = self.header {
modal = modal.child(header);
} else if let Some(title) = self.title {
modal = modal.child(
v_flex()
.pt_3()
.pr_3()
.pl_3()
.pb_1()
.child(Headline::new(title).size(HeadlineSize::Small)),
);
}
if !self.children.is_empty() {
modal = modal.child(
v_flex()
.p_3()
.text_ui(cx)
.text_color(Color::Muted.color(cx))
.gap_1()
.child(Headline::new(self.title).size(HeadlineSize::Small))
.children(self.children),
)
.child(
);
}
if let Some(footer) = self.footer {
modal = modal.child(footer);
} else if has_default_footer {
let primary_action = self.primary_action.unwrap_or_else(|| "Ok".into());
let dismiss_label = self.dismiss_label.unwrap_or_else(|| "Cancel".into());
modal = modal.child(
h_flex()
.h(rems(1.75))
.p_3()
.items_center()
.child(div().flex_1())
.child(
h_flex()
.items_center()
.gap_1()
.child(
Button::new(self.dismiss_label.clone(), self.dismiss_label.clone())
.color(Color::Muted),
)
.child(Button::new(
self.primary_action.clone(),
self.primary_action,
)),
),
)
.justify_end()
.gap_1()
.child(Button::new(dismiss_label.clone(), dismiss_label).color(Color::Muted))
.child(Button::new(primary_action.clone(), primary_action)),
);
}
modal
}
}
@@ -90,24 +178,75 @@ impl Component for AlertModal {
Some("A modal dialog that presents an alert message with primary and dismiss actions.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.p_4()
.children(vec![example_group(
vec![
single_example(
"Basic Alert",
AlertModal::new("simple-modal", "Do you want to leave the current call?")
.child("The current window will be closed, and connections to any shared projects will be terminated."
)
.primary_action("Leave Call")
.into_any_element(),
)
],
)])
.into_any_element()
.children(vec![
example_group(vec![single_example(
"Basic Alert",
AlertModal::new("simple-modal")
.title("Do you want to leave the current call?")
.child(
"The current window will be closed, and connections to any shared projects will be terminated."
)
.primary_action("Leave Call")
.dismiss_label("Cancel")
.into_any_element(),
)]),
example_group(vec![single_example(
"Custom Header",
AlertModal::new("custom-header-modal")
.header(
v_flex()
.p_3()
.bg(cx.theme().colors().background)
.gap_1()
.child(
h_flex()
.gap_1()
.child(Icon::new(IconName::Warning).color(Color::Warning))
.child(Headline::new("Unrecognized Workspace").size(HeadlineSize::Small))
)
.child(
h_flex()
.pl(IconSize::default().rems() + rems(0.5))
.child(Label::new("~/projects/my-project").color(Color::Muted))
)
)
.child(
"Untrusted workspaces are opened in Restricted Mode to protect your system.
Review .zed/settings.json for any extensions or commands configured by this project.",
)
.child(
v_flex()
.mt_1()
.child(Label::new("Restricted mode prevents:").color(Color::Muted))
.child(ListBulletItem::new("Project settings from being applied"))
.child(ListBulletItem::new("Language servers from running"))
.child(ListBulletItem::new("MCP integrations from installing"))
)
.footer(
h_flex()
.p_3()
.justify_between()
.child(
Checkbox::new("trust-parent", ToggleState::Unselected)
.label("Trust all projects in parent directory")
)
.child(
h_flex()
.gap_1()
.child(Button::new("restricted", "Stay in Restricted Mode").color(Color::Muted))
.child(Button::new("trust", "Trust and Continue").style(ButtonStyle::Filled))
)
)
.width(rems(40.))
.into_any_element(),
)]),
])
.into_any_element(),
)
}
}

View File

@@ -911,7 +911,7 @@ pub fn surrounding_html_tag(
while let Some(cur_node) = last_child_node {
if cur_node.child_count() >= 2 {
let first_child = cur_node.child(0);
let last_child = cur_node.child(cur_node.child_count() - 1);
let last_child = cur_node.child(cur_node.child_count() as u32 - 1);
if let (Some(first_child), Some(last_child)) = (first_child, last_child) {
let open_tag = open_tag(buffer.chars_for_range(first_child.byte_range()));
let close_tag = close_tag(buffer.chars_for_range(last_child.byte_range()));

View File

@@ -171,28 +171,19 @@ impl Render for ModalLayer {
};
div()
.occlude()
.absolute()
.size_full()
.top_0()
.left_0()
.when(active_modal.modal.fade_out_background(cx), |el| {
.inset_0()
.occlude()
.when(active_modal.modal.fade_out_background(cx), |this| {
let mut background = cx.theme().colors().elevated_surface_background;
background.fade_out(0.2);
el.bg(background)
this.bg(background)
})
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, window, cx| {
this.hide_modal(window, cx);
}),
)
.child(
v_flex()
.h(px(0.0))
.top_20()
.flex()
.flex_col()
.items_center()
.track_focus(&active_modal.focus_handle)
.child(

View File

@@ -0,0 +1,373 @@
//! A UI interface for managing the [`TrustedWorktrees`] data.
use std::{
borrow::Cow,
path::{Path, PathBuf},
sync::Arc,
};
use collections::{HashMap, HashSet};
use gpui::{DismissEvent, EventEmitter, FocusHandle, Focusable, WeakEntity};
use project::{
WorktreeId,
trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
worktree_store::WorktreeStore,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use ui::{
AlertModal, Checkbox, FluentBuilder, KeyBinding, ListBulletItem, ToggleState, prelude::*,
};
use crate::{DismissDecision, ModalView, ToggleWorktreeSecurity};
pub struct SecurityModal {
restricted_paths: HashMap<Option<WorktreeId>, RestrictedPath>,
home_dir: Option<PathBuf>,
trust_parents: bool,
worktree_store: WeakEntity<WorktreeStore>,
remote_host: Option<RemoteHostLocation>,
focus_handle: FocusHandle,
trusted: Option<bool>,
}
#[derive(Debug, PartialEq, Eq)]
struct RestrictedPath {
abs_path: Option<Arc<Path>>,
is_file: bool,
host: Option<RemoteHostLocation>,
}
impl Focusable for SecurityModal {
fn focus_handle(&self, _: &ui::App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<DismissEvent> for SecurityModal {}
impl ModalView for SecurityModal {
fn fade_out_background(&self) -> bool {
true
}
fn on_before_dismiss(&mut self, _: &mut Window, _: &mut Context<Self>) -> DismissDecision {
match self.trusted {
Some(false) => telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"),
Some(true) => telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"),
None => telemetry::event!("Dismissed", source = "Worktree Trust Modal"),
}
DismissDecision::Dismiss(true)
}
}
impl Render for SecurityModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.restricted_paths.is_empty() {
self.dismiss(cx);
return v_flex().into_any_element();
}
let header_label = if self.restricted_paths.len() == 1 {
"Unrecognized Project"
} else {
"Unrecognized Projects"
};
let trust_label = self.build_trust_label();
AlertModal::new("security-modal")
.width(rems(40.))
.key_context("SecurityModal")
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| {
this.trust_and_dismiss(cx);
}))
.on_action(cx.listener(|security_modal, _: &ToggleWorktreeSecurity, _window, cx| {
security_modal.trusted = Some(false);
security_modal.dismiss(cx);
}))
.header(
v_flex()
.p_3()
.gap_1()
.rounded_t_md()
.bg(cx.theme().colors().editor_background.opacity(0.5))
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.child(
h_flex()
.gap_2()
.child(Icon::new(IconName::Warning).color(Color::Warning))
.child(Label::new(header_label)),
)
.children(self.restricted_paths.values().map(|restricted_path| {
let abs_path = restricted_path.abs_path.as_ref().and_then(|abs_path| {
if restricted_path.is_file {
abs_path.parent()
} else {
Some(abs_path.as_ref())
}
});
let label = match abs_path {
Some(abs_path) => match &restricted_path.host {
Some(remote_host) => match &remote_host.user_name {
Some(user_name) => format!(
"{} ({}@{})",
self.shorten_path(abs_path).display(),
user_name,
remote_host.host_identifier
),
None => format!(
"{} ({})",
self.shorten_path(abs_path).display(),
remote_host.host_identifier
),
},
None => self.shorten_path(abs_path).display().to_string(),
},
None => match &restricted_path.host {
Some(remote_host) => match &remote_host.user_name {
Some(user_name) => format!(
"Empty project ({}@{})",
user_name, remote_host.host_identifier
),
None => {
format!("Empty project ({})", remote_host.host_identifier)
}
},
None => "Empty project".to_string(),
},
};
h_flex()
.pl(IconSize::default().rems() + rems(0.5))
.child(Label::new(label).color(Color::Muted))
})),
)
.child(
v_flex()
.gap_2()
.child(
v_flex()
.child(
Label::new(
"Untrusted projects are opened in Restricted Mode to protect your system.",
)
.color(Color::Muted),
)
.child(
Label::new(
"Review .zed/settings.json for any extensions or commands configured by this project.",
)
.color(Color::Muted),
),
)
.child(
v_flex()
.child(Label::new("Restricted Mode prevents:").color(Color::Muted))
.child(ListBulletItem::new("Project settings from being applied"))
.child(ListBulletItem::new("Language servers from running"))
.child(ListBulletItem::new("MCP Server integrations from installing")),
)
.map(|this| match trust_label {
Some(trust_label) => this.child(
Checkbox::new("trust-parents", ToggleState::from(self.trust_parents))
.label(trust_label)
.on_click(cx.listener(
|security_modal, state: &ToggleState, _, cx| {
security_modal.trust_parents = state.selected();
cx.notify();
cx.stop_propagation();
},
)),
),
None => this,
}),
)
.footer(
h_flex()
.px_3()
.pb_3()
.gap_1()
.justify_end()
.child(
Button::new("rm", "Stay in Restricted Mode")
.key_binding(
KeyBinding::for_action(
&ToggleWorktreeSecurity,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(move |security_modal, _, _, cx| {
security_modal.trusted = Some(false);
security_modal.dismiss(cx);
cx.stop_propagation();
})),
)
.child(
Button::new("tc", "Trust and Continue")
.style(ButtonStyle::Filled)
.layer(ui::ElevationIndex::ModalSurface)
.key_binding(
KeyBinding::for_action(&menu::Confirm, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(move |security_modal, _, _, cx| {
security_modal.trust_and_dismiss(cx);
cx.stop_propagation();
})),
),
)
.into_any_element()
}
}
impl SecurityModal {
pub fn new(
worktree_store: WeakEntity<WorktreeStore>,
remote_host: Option<impl Into<RemoteHostLocation>>,
cx: &mut Context<Self>,
) -> Self {
let mut this = Self {
worktree_store,
remote_host: remote_host.map(|host| host.into()),
restricted_paths: HashMap::default(),
focus_handle: cx.focus_handle(),
trust_parents: false,
home_dir: std::env::home_dir(),
trusted: None,
};
this.refresh_restricted_paths(cx);
this
}
fn build_trust_label(&self) -> Option<Cow<'static, str>> {
let mut has_restricted_files = false;
let available_parents = self
.restricted_paths
.values()
.filter(|restricted_path| {
has_restricted_files |= restricted_path.is_file;
!restricted_path.is_file
})
.filter_map(|restricted_path| restricted_path.abs_path.as_ref()?.parent())
.collect::<SmallVec<[_; 2]>>();
match available_parents.len() {
0 => {
if has_restricted_files {
Some(Cow::Borrowed("Trust all single files"))
} else {
None
}
}
1 => Some(Cow::Owned(format!(
"Trust all projects in the {:?} folder",
self.shorten_path(available_parents[0])
))),
_ => Some(Cow::Borrowed("Trust all projects in the parent folders")),
}
}
fn shorten_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {
match &self.home_dir {
Some(home_dir) => path
.strip_prefix(home_dir)
.map(|stripped| Path::new("~").join(stripped))
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(path)),
None => Cow::Borrowed(path),
}
}
fn trust_and_dismiss(&mut self, cx: &mut Context<Self>) {
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
let mut paths_to_trust = self
.restricted_paths
.keys()
.map(|worktree_id| match worktree_id {
Some(worktree_id) => PathTrust::Worktree(*worktree_id),
None => PathTrust::Workspace,
})
.collect::<HashSet<_>>();
if self.trust_parents {
paths_to_trust.extend(self.restricted_paths.values().filter_map(
|restricted_paths| {
if restricted_paths.is_file {
Some(PathTrust::Workspace)
} else {
let parent_abs_path =
restricted_paths.abs_path.as_ref()?.parent()?.to_owned();
Some(PathTrust::AbsPath(parent_abs_path))
}
},
));
}
trusted_worktrees.trust(paths_to_trust, self.remote_host.clone(), cx);
});
}
self.trusted = Some(true);
self.dismiss(cx);
}
pub fn dismiss(&mut self, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
pub fn refresh_restricted_paths(&mut self, cx: &mut Context<Self>) {
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
if let Some(worktree_store) = self.worktree_store.upgrade() {
let mut new_restricted_worktrees = trusted_worktrees
.read(cx)
.restricted_worktrees(worktree_store.read(cx), self.remote_host.clone(), cx)
.into_iter()
.filter_map(|restricted_path| {
let restricted_path = match restricted_path {
Some((worktree_id, abs_path)) => {
let worktree =
worktree_store.read(cx).worktree_for_id(worktree_id, cx)?;
(
Some(worktree_id),
RestrictedPath {
abs_path: Some(abs_path),
is_file: worktree.read(cx).is_single_file(),
host: self.remote_host.clone(),
},
)
}
None => (
None,
RestrictedPath {
abs_path: None,
is_file: false,
host: self.remote_host.clone(),
},
),
};
Some(restricted_path)
})
.collect::<HashMap<_, _>>();
// Do not clutter the UI:
// * trusting regular local worktrees assumes the workspace is trusted either, on the same host.
// * trusting a workspace trusts all single-file worktrees on the same host.
if new_restricted_worktrees.len() > 1 {
new_restricted_worktrees.remove(&None);
}
if self.restricted_paths != new_restricted_worktrees {
self.trust_parents = false;
self.restricted_paths = new_restricted_worktrees;
cx.notify();
}
}
} else if !self.restricted_paths.is_empty() {
self.restricted_paths.clear();
cx.notify();
}
}
}

View File

@@ -9,6 +9,7 @@ pub mod pane_group;
mod path_list;
mod persistence;
pub mod searchable;
mod security_modal;
pub mod shared_screen;
mod status_bar;
pub mod tasks;
@@ -77,7 +78,9 @@ use project::{
DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
WorktreeSettings,
debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
project_settings::ProjectSettings,
toolchain_store::ToolchainStoreEvent,
trusted_worktrees::TrustedWorktrees,
};
use remote::{
RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions,
@@ -86,7 +89,9 @@ use remote::{
use schemars::JsonSchema;
use serde::Deserialize;
use session::AppSession;
use settings::{CenteredPaddingSettings, Settings, SettingsLocation, update_settings_file};
use settings::{
CenteredPaddingSettings, Settings, SettingsLocation, SettingsStore, update_settings_file,
};
use shared_screen::SharedScreen;
use sqlez::{
bindable::{Bind, Column, StaticColumnCount},
@@ -137,6 +142,7 @@ use crate::{
SerializedAxis,
model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup},
},
security_modal::SecurityModal,
utility_pane::{DraggedUtilityPane, UtilityPaneFrame, UtilityPaneSlot, UtilityPaneState},
};
@@ -277,6 +283,12 @@ actions!(
ZoomIn,
/// Zooms out of the active pane.
ZoomOut,
/// If any worktrees are in restricted mode, shows a modal with possible actions.
/// If the modal is shown already, closes it without trusting any worktree.
ToggleWorktreeSecurity,
/// Clears all trusted worktrees, placing them in restricted mode on next open.
/// Requires restart to take effect on already opened projects.
ClearTrustedWorktrees,
/// Stops following a collaborator.
Unfollow,
/// Restores the banner.
@@ -1217,6 +1229,17 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
cx.observe_global::<SettingsStore>(|_, cx| {
if ProjectSettings::get_global(cx).session.trust_all_worktrees {
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
trusted_worktrees.auto_trust_all(cx);
})
}
}
})
.detach();
cx.subscribe_in(&project, window, move |this, _, event, window, cx| {
match event {
project::Event::RemoteIdChanged(_) => {
@@ -1474,7 +1497,7 @@ impl Workspace {
}),
];
cx.defer_in(window, |this, window, cx| {
cx.defer_in(window, move |this, window, cx| {
this.update_window_title(window, cx);
this.show_initial_notifications(cx);
});
@@ -1559,6 +1582,7 @@ impl Workspace {
app_state.languages.clone(),
app_state.fs.clone(),
env,
true,
cx,
);
@@ -5938,6 +5962,25 @@ impl Workspace {
}
},
))
.on_action(cx.listener(
|workspace: &mut Workspace, _: &ToggleWorktreeSecurity, window, cx| {
workspace.show_worktree_trust_security_modal(true, window, cx);
},
))
.on_action(
cx.listener(|_: &mut Workspace, _: &ClearTrustedWorktrees, _, cx| {
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
let clear_task = trusted_worktrees.update(cx, |trusted_worktrees, cx| {
trusted_worktrees.clear_trusted_paths(cx)
});
cx.spawn(async move |_, cx| {
clear_task.await;
cx.update(|cx| reload(cx)).ok();
})
.detach();
}
}),
)
.on_action(cx.listener(
|workspace: &mut Workspace, _: &ReopenClosedItem, window, cx| {
workspace.reopen_closed_item(window, cx).detach();
@@ -6418,6 +6461,41 @@ impl Workspace {
file.project.all_languages.defaults.show_edit_predictions = Some(!show_edit_predictions)
});
}
pub fn show_worktree_trust_security_modal(
&mut self,
toggle: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(security_modal) = self.active_modal::<SecurityModal>(cx) {
if toggle {
security_modal.update(cx, |security_modal, cx| {
security_modal.dismiss(cx);
})
} else {
security_modal.update(cx, |security_modal, cx| {
security_modal.refresh_restricted_paths(cx);
});
}
} else {
let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx)
.map(|trusted_worktrees| {
trusted_worktrees
.read(cx)
.has_restricted_worktrees(&self.project().read(cx).worktree_store(), cx)
})
.unwrap_or(false);
if has_restricted_worktrees {
let project = self.project().read(cx);
let remote_host = project.remote_connection_options(cx);
let worktree_store = project.worktree_store().downgrade();
self.toggle_modal(window, cx, |_, cx| {
SecurityModal::new(worktree_store, remote_host, cx)
});
}
}
}
}
fn leader_border_for_pane(
@@ -7968,6 +8046,7 @@ pub fn open_remote_project_with_new_connection(
app_state.user_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
true,
cx,
)
})?;

View File

@@ -13,6 +13,10 @@ pub enum IgnoreStackEntry {
Global {
ignore: Arc<Gitignore>,
},
RepoExclude {
ignore: Arc<Gitignore>,
parent: Arc<IgnoreStackEntry>,
},
Some {
abs_base_path: Arc<Path>,
ignore: Arc<Gitignore>,
@@ -21,6 +25,12 @@ pub enum IgnoreStackEntry {
All,
}
#[derive(Debug)]
pub enum IgnoreKind {
Gitignore(Arc<Path>),
RepoExclude,
}
impl IgnoreStack {
pub fn none() -> Self {
Self {
@@ -43,13 +53,19 @@ impl IgnoreStack {
}
}
pub fn append(self, abs_base_path: Arc<Path>, ignore: Arc<Gitignore>) -> Self {
pub fn append(self, kind: IgnoreKind, ignore: Arc<Gitignore>) -> Self {
let top = match self.top.as_ref() {
IgnoreStackEntry::All => self.top.clone(),
_ => Arc::new(IgnoreStackEntry::Some {
abs_base_path,
ignore,
parent: self.top.clone(),
_ => Arc::new(match kind {
IgnoreKind::Gitignore(abs_base_path) => IgnoreStackEntry::Some {
abs_base_path,
ignore,
parent: self.top.clone(),
},
IgnoreKind::RepoExclude => IgnoreStackEntry::RepoExclude {
ignore,
parent: self.top.clone(),
},
}),
};
Self {
@@ -84,6 +100,17 @@ impl IgnoreStack {
ignore::Match::Whitelist(_) => false,
}
}
IgnoreStackEntry::RepoExclude { ignore, parent } => {
match ignore.matched(abs_path, is_dir) {
ignore::Match::None => IgnoreStack {
repo_root: self.repo_root.clone(),
top: parent.clone(),
}
.is_abs_path_ignored(abs_path, is_dir),
ignore::Match::Ignore(_) => true,
ignore::Match::Whitelist(_) => false,
}
}
IgnoreStackEntry::Some {
abs_base_path,
ignore,

View File

@@ -19,7 +19,8 @@ use futures::{
};
use fuzzy::CharBag;
use git::{
COMMIT_MESSAGE, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, INDEX_LOCK, LFS_DIR, status::GitSummary,
COMMIT_MESSAGE, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, INDEX_LOCK, LFS_DIR, REPO_EXCLUDE,
status::GitSummary,
};
use gpui::{
App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Priority,
@@ -71,6 +72,8 @@ use util::{
};
pub use worktree_settings::WorktreeSettings;
use crate::ignore::IgnoreKind;
pub const FS_WATCH_LATENCY: Duration = Duration::from_millis(100);
/// A set of local or remote files that are being opened as part of a project.
@@ -233,6 +236,9 @@ impl Default for WorkDirectory {
pub struct LocalSnapshot {
snapshot: Snapshot,
global_gitignore: Option<Arc<Gitignore>>,
/// Exclude files for all git repositories in the worktree, indexed by their absolute path.
/// The boolean indicates whether the gitignore needs to be updated.
repo_exclude_by_work_dir_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
/// All of the gitignore files in the worktree, indexed by their absolute path.
/// The boolean indicates whether the gitignore needs to be updated.
ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
@@ -393,6 +399,7 @@ impl Worktree {
let mut snapshot = LocalSnapshot {
ignores_by_parent_abs_path: Default::default(),
global_gitignore: Default::default(),
repo_exclude_by_work_dir_abs_path: Default::default(),
git_repositories: Default::default(),
snapshot: Snapshot::new(
cx.entity_id().as_u64(),
@@ -2565,13 +2572,21 @@ impl LocalSnapshot {
} else {
IgnoreStack::none()
};
if let Some((repo_exclude, _)) = repo_root
.as_ref()
.and_then(|abs_path| self.repo_exclude_by_work_dir_abs_path.get(abs_path))
{
ignore_stack = ignore_stack.append(IgnoreKind::RepoExclude, repo_exclude.clone());
}
ignore_stack.repo_root = repo_root;
for (parent_abs_path, ignore) in new_ignores.into_iter().rev() {
if ignore_stack.is_abs_path_ignored(parent_abs_path, true) {
ignore_stack = IgnoreStack::all();
break;
} else if let Some(ignore) = ignore {
ignore_stack = ignore_stack.append(parent_abs_path.into(), ignore);
ignore_stack =
ignore_stack.append(IgnoreKind::Gitignore(parent_abs_path.into()), ignore);
}
}
@@ -3646,13 +3661,23 @@ impl BackgroundScanner {
let root_abs_path = self.state.lock().await.snapshot.abs_path.clone();
let repo = if self.scanning_enabled {
let (ignores, repo) = discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await;
let (ignores, exclude, repo) =
discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await;
self.state
.lock()
.await
.snapshot
.ignores_by_parent_abs_path
.extend(ignores);
if let Some(exclude) = exclude {
self.state
.lock()
.await
.snapshot
.repo_exclude_by_work_dir_abs_path
.insert(root_abs_path.as_path().into(), (exclude, false));
}
repo
} else {
None
@@ -3914,6 +3939,7 @@ impl BackgroundScanner {
let mut relative_paths = Vec::with_capacity(abs_paths.len());
let mut dot_git_abs_paths = Vec::new();
let mut work_dirs_needing_exclude_update = Vec::new();
abs_paths.sort_unstable();
abs_paths.dedup_by(|a, b| a.starts_with(b));
{
@@ -3987,6 +4013,18 @@ impl BackgroundScanner {
continue;
};
let absolute_path = abs_path.to_path_buf();
if absolute_path.ends_with(Path::new(DOT_GIT).join(REPO_EXCLUDE)) {
if let Some(repository) = snapshot
.git_repositories
.values()
.find(|repo| repo.common_dir_abs_path.join(REPO_EXCLUDE) == absolute_path)
{
work_dirs_needing_exclude_update
.push(repository.work_directory_abs_path.clone());
}
}
if abs_path.file_name() == Some(OsStr::new(GITIGNORE)) {
for (_, repo) in snapshot
.git_repositories
@@ -4032,6 +4070,19 @@ impl BackgroundScanner {
return;
}
if !work_dirs_needing_exclude_update.is_empty() {
let mut state = self.state.lock().await;
for work_dir_abs_path in work_dirs_needing_exclude_update {
if let Some((_, needs_update)) = state
.snapshot
.repo_exclude_by_work_dir_abs_path
.get_mut(&work_dir_abs_path)
{
*needs_update = true;
}
}
}
self.state.lock().await.snapshot.scan_id += 1;
let (scan_job_tx, scan_job_rx) = channel::unbounded();
@@ -4299,7 +4350,8 @@ impl BackgroundScanner {
match build_gitignore(&child_abs_path, self.fs.as_ref()).await {
Ok(ignore) => {
let ignore = Arc::new(ignore);
ignore_stack = ignore_stack.append(job.abs_path.clone(), ignore.clone());
ignore_stack = ignore_stack
.append(IgnoreKind::Gitignore(job.abs_path.clone()), ignore.clone());
new_ignore = Some(ignore);
}
Err(error) => {
@@ -4561,11 +4613,24 @@ impl BackgroundScanner {
.await;
if path.is_empty()
&& let Some((ignores, repo)) = new_ancestor_repo.take()
&& let Some((ignores, exclude, repo)) = new_ancestor_repo.take()
{
log::trace!("updating ancestor git repository");
state.snapshot.ignores_by_parent_abs_path.extend(ignores);
if let Some((ancestor_dot_git, work_directory)) = repo {
if let Some(exclude) = exclude {
let work_directory_abs_path = self
.state
.lock()
.await
.snapshot
.work_directory_abs_path(&work_directory);
state
.snapshot
.repo_exclude_by_work_dir_abs_path
.insert(work_directory_abs_path.into(), (exclude, false));
}
state
.insert_git_repository_for_path(
work_directory,
@@ -4663,6 +4728,36 @@ impl BackgroundScanner {
{
let snapshot = &mut self.state.lock().await.snapshot;
let abs_path = snapshot.abs_path.clone();
snapshot.repo_exclude_by_work_dir_abs_path.retain(
|work_dir_abs_path, (exclude, needs_update)| {
if *needs_update {
*needs_update = false;
ignores_to_update.push(work_dir_abs_path.clone());
if let Some((_, repository)) = snapshot
.git_repositories
.iter()
.find(|(_, repo)| &repo.work_directory_abs_path == work_dir_abs_path)
{
let exclude_abs_path =
repository.common_dir_abs_path.join(REPO_EXCLUDE);
if let Ok(current_exclude) = self
.executor
.block(build_gitignore(&exclude_abs_path, self.fs.as_ref()))
{
*exclude = Arc::new(current_exclude);
}
}
}
snapshot
.git_repositories
.iter()
.any(|(_, repo)| &repo.work_directory_abs_path == work_dir_abs_path)
},
);
snapshot
.ignores_by_parent_abs_path
.retain(|parent_abs_path, (_, needs_update)| {
@@ -4717,7 +4812,8 @@ impl BackgroundScanner {
let mut ignore_stack = job.ignore_stack;
if let Some((ignore, _)) = snapshot.ignores_by_parent_abs_path.get(&job.abs_path) {
ignore_stack = ignore_stack.append(job.abs_path.clone(), ignore.clone());
ignore_stack =
ignore_stack.append(IgnoreKind::Gitignore(job.abs_path.clone()), ignore.clone());
}
let mut entries_by_id_edits = Vec::new();
@@ -4892,6 +4988,9 @@ impl BackgroundScanner {
let preserve = ids_to_preserve.contains(work_directory_id);
if !preserve {
affected_repo_roots.push(entry.dot_git_abs_path.parent().unwrap().into());
snapshot
.repo_exclude_by_work_dir_abs_path
.remove(&entry.work_directory_abs_path);
}
preserve
});
@@ -4931,8 +5030,10 @@ async fn discover_ancestor_git_repo(
root_abs_path: &SanitizedPath,
) -> (
HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
Option<Arc<Gitignore>>,
Option<(PathBuf, WorkDirectory)>,
) {
let mut exclude = None;
let mut ignores = HashMap::default();
for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() {
if index != 0 {
@@ -4968,6 +5069,7 @@ async fn discover_ancestor_git_repo(
// also mark where in the git repo the root folder is located.
return (
ignores,
exclude,
Some((
ancestor_dot_git,
WorkDirectory::AboveProject {
@@ -4979,12 +5081,17 @@ async fn discover_ancestor_git_repo(
};
}
let repo_exclude_abs_path = ancestor_dot_git.join(REPO_EXCLUDE);
if let Ok(repo_exclude) = build_gitignore(&repo_exclude_abs_path, fs.as_ref()).await {
exclude = Some(Arc::new(repo_exclude));
}
// Reached root of git repository.
break;
}
}
(ignores, None)
(ignores, exclude, None)
}
fn build_diff(

View File

@@ -1,7 +1,7 @@
use crate::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle};
use anyhow::Result;
use fs::{FakeFs, Fs, RealFs, RemoveOptions};
use git::GITIGNORE;
use git::{DOT_GIT, GITIGNORE, REPO_EXCLUDE};
use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext};
use parking_lot::Mutex;
use postage::stream::Stream;
@@ -2412,6 +2412,94 @@ async fn test_global_gitignore(executor: BackgroundExecutor, cx: &mut TestAppCon
});
}
#[gpui::test]
async fn test_repo_exclude(executor: BackgroundExecutor, cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(executor);
let project_dir = Path::new(path!("/project"));
fs.insert_tree(
project_dir,
json!({
".git": {
"info": {
"exclude": ".env.*"
}
},
".env.example": "secret=xxxx",
".env.local": "secret=1234",
".gitignore": "!.env.example",
"README.md": "# Repo Exclude",
"src": {
"main.rs": "fn main() {}",
},
}),
)
.await;
let worktree = Worktree::local(
project_dir,
true,
fs.clone(),
Default::default(),
true,
&mut cx.to_async(),
)
.await
.unwrap();
worktree
.update(cx, |worktree, _| {
worktree.as_local().unwrap().scan_complete()
})
.await;
cx.run_until_parked();
// .gitignore overrides .git/info/exclude
worktree.update(cx, |worktree, _cx| {
let expected_excluded_paths = [];
let expected_ignored_paths = [".env.local"];
let expected_tracked_paths = [".env.example", "README.md", "src/main.rs"];
let expected_included_paths = [];
check_worktree_entries(
worktree,
&expected_excluded_paths,
&expected_ignored_paths,
&expected_tracked_paths,
&expected_included_paths,
);
});
// Ignore statuses are updated when .git/info/exclude file changes
fs.write(
&project_dir.join(DOT_GIT).join(REPO_EXCLUDE),
".env.example".as_bytes(),
)
.await
.unwrap();
worktree
.update(cx, |worktree, _| {
worktree.as_local().unwrap().scan_complete()
})
.await;
cx.run_until_parked();
worktree.update(cx, |worktree, _cx| {
let expected_excluded_paths = [];
let expected_ignored_paths = [];
let expected_tracked_paths = [".env.example", ".env.local", "README.md", "src/main.rs"];
let expected_included_paths = [];
check_worktree_entries(
worktree,
&expected_excluded_paths,
&expected_ignored_paths,
&expected_tracked_paths,
&expected_included_paths,
);
});
}
#[track_caller]
fn check_worktree_entries(
tree: &Worktree,

View File

@@ -2,7 +2,7 @@
description = "The fast, collaborative code editor."
edition.workspace = true
name = "zed"
version = "0.218.0"
version = "0.219.0"
publish.workspace = true
license = "GPL-3.0-or-later"
authors = ["Zed Team <hi@zed.dev>"]

View File

@@ -27,7 +27,7 @@ use reqwest_client::ReqwestClient;
use assets::Assets;
use node_runtime::{NodeBinaryOptions, NodeRuntime};
use parking_lot::Mutex;
use project::project_settings::ProjectSettings;
use project::{project_settings::ProjectSettings, trusted_worktrees};
use recent_projects::{SshSettings, open_remote_project};
use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
use session::{AppSession, Session};
@@ -36,6 +36,7 @@ use std::{
env,
io::{self, IsTerminal},
path::{Path, PathBuf},
pin::Pin,
process,
sync::{Arc, OnceLock},
time::Instant,
@@ -406,6 +407,7 @@ pub fn main() {
});
app.run(move |cx| {
trusted_worktrees::init(None, None, cx);
menu::init();
zed_actions::init();
@@ -474,7 +476,15 @@ pub fn main() {
tx.send(Some(options)).log_err();
})
.detach();
let node_runtime = NodeRuntime::new(client.http_client(), Some(shell_env_loaded_rx), rx);
let trust_task = trusted_worktrees::wait_for_default_workspace_trust("Node runtime", cx)
.map(|trust_task| Box::pin(trust_task) as Pin<Box<_>>);
let node_runtime = NodeRuntime::new(
client.http_client(),
Some(shell_env_loaded_rx),
rx,
trust_task,
);
debug_adapter_extension::init(extension_host_proxy.clone(), cx);
languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx);

157
docs/.rules Normal file
View File

@@ -0,0 +1,157 @@
# Zed Documentation Guidelines
## Voice and Tone
### Core Principles
- **Practical over promotional**: Focus on what users can do, not on selling Zed. Avoid marketing language like "powerful," "revolutionary," or "best-in-class."
- **Honest about limitations**: When Zed lacks a feature or doesn't match another tool's depth, say so directly. Pair limitations with workarounds or alternative workflows.
- **Direct and concise**: Use short sentences. Get to the point. Developers are scanning, not reading novels.
- **Second person**: Address the reader as "you." Avoid "the user" or "one."
- **Present tense**: "Zed opens the file" not "Zed will open the file."
### What to Avoid
- Superlatives without substance ("incredibly fast," "seamlessly integrated")
- Hedging language ("simply," "just," "easily")—if something is simple, the instructions will show it
- Apologetic tone for missing features—state the limitation and move on
- Comparisons that disparage other tools—be factual, not competitive
- Meta-commentary about honesty ("the honest take is...", "to be frank...", "honestly...")—let honesty show through frank assessments, not announcements
## Content Structure
### Page Organization
1. **Start with the goal**: Open with what the reader will accomplish, not background
2. **Front-load the action**: Put the most common task first, edge cases later
3. **Use headers liberally**: Readers scan; headers help them find what they need
4. **End with "what's next"**: Link to related docs or logical next steps
### Section Patterns
For how-to content:
1. Brief context (1-2 sentences max)
2. Steps or instructions
3. Example (code block or screenshot reference)
4. Tips or gotchas (if any)
For reference content:
1. What it is (definition)
2. How to access/configure it
3. Options/parameters table
4. Examples
## Formatting Conventions
### Keybindings
- Use backticks for key combinations: `Cmd+Shift+P`
- Show both macOS and Linux/Windows when they differ: `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
- Use `+` to join simultaneous keys, space for sequences: `Cmd+K Cmd+C`
### Code and Settings
- Inline code for setting names, file paths, commands: `format_on_save`, `.zed/settings.json`, `zed .`
- Code blocks for JSON config, multi-line commands, or file contents
- Always show complete, working examples—not fragments
### Terminal Commands
Use `sh` code blocks for terminal commands, not plain backticks:
```sh
brew install zed-editor/zed/zed
```
Not:
```
brew install zed-editor/zed/zed
```
For single inline commands in prose, backticks are fine: `zed .`
### Tables
Use tables for:
- Keybinding comparisons between editors
- Settings mappings (e.g., VS Code → Zed)
- Feature comparisons with clear columns
Format:
```
| Action | Shortcut | Notes |
| --- | --- | --- |
| Open File | `Cmd+O` | Works from any context |
```
### Tips and Notes
Use blockquote format with bold label:
```
> **Tip:** Practical advice that helps bridge gaps or saves time.
```
Reserve tips for genuinely useful information, not padding.
## Writing Guidelines
### Settings Documentation
- **Settings Editor first**: Show how to find and change settings in the UI before showing JSON
- **JSON as secondary**: Present JSON examples as "Or add this to your settings.json" for users who prefer direct editing
- **Complete examples**: Include the full JSON structure, not just the value
### Migration Guides
- **Jobs to be done**: Frame around tasks ("How do I search files?") not features ("File Search Feature")
- **Acknowledge the source**: Respect that users have muscle memory and preferences from their previous editor
- **Keybindings tables**: Essential for migration docs—show what maps, what's different, what's missing
- **Trade-offs section**: Be explicit about what the user gains and loses in the switch
### Feature Documentation
- **Start with the default**: Document the out-of-box experience first
- **Configuration options**: Group related settings together
- **Cross-link generously**: Link to related features, settings reference, and relevant guides
## Terminology
| Use | Instead of |
| --- | --- |
| folder | directory (in user-facing text) |
| project | workspace (Zed doesn't have workspaces) |
| Settings Editor | settings UI, preferences |
| command palette | command bar, action search |
| language server | LSP (spell out first use, then LSP is fine) |
| panel | tool window, sidebar (be specific: "Project Panel," "Terminal Panel") |
## Examples
### Good: Direct and actionable
```
To format on save, open the Settings Editor (`Cmd+,`) and search for `format_on_save`. Set it to `on`.
Or add this to your settings.json:
{
"format_on_save": "on"
}
```
### Bad: Wordy and promotional
```
Zed provides a powerful and seamless formatting experience. Simply navigate to the settings and you'll find the format_on_save option which enables Zed's incredible auto-formatting capabilities.
```
### Good: Honest about limitations
```
Zed doesn't index your project like IntelliJ does. You open a folder and start working immediately—no waiting. The trade-off: cross-project analysis relies on language servers, which may not go as deep.
**How to adapt:**
- Use `Cmd+Shift+F` for project-wide text search
- Use `Cmd+O` for symbol search (powered by your language server)
```
### Bad: Defensive or dismissive
```
While some users might miss indexing, Zed's approach is actually better because it's faster.
```

View File

@@ -23,6 +23,9 @@
- [Visual Customization](./visual-customization.md)
- [Vim Mode](./vim.md)
- [Helix Mode](./helix.md)
- [Privacy and Security](./ai/privacy-and-security.md)
- [Worktree Trust](./worktree-trust.md)
- [AI Improvement](./ai/ai-improvement.md)
<!-- - [Globs](./globs.md) -->
<!-- - [Fonts](./fonts.md) -->
@@ -69,8 +72,6 @@
- [Models](./ai/models.md)
- [Plans and Usage](./ai/plans-and-usage.md)
- [Billing](./ai/billing.md)
- [Privacy and Security](./ai/privacy-and-security.md)
- [AI Improvement](./ai/ai-improvement.md)
# Extensions
@@ -86,9 +87,10 @@
- [Agent Server Extensions](./extensions/agent-servers.md)
- [MCP Server Extensions](./extensions/mcp-extensions.md)
# Migrate
# Coming From...
- [VS Code](./migrate/vs-code.md)
- [IntelliJ IDEA](./migrate/intellij.md)
# Language Support

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