Compare commits

...

26 Commits

Author SHA1 Message Date
KyleBarton
bb3e3d01dd Remove implicit dependency on node env for data_dir devcontainer cli 2025-12-23 11:29:54 -08:00
Kirill Bulatov
251033f88f Fix the argument order when starting devcontainers (#45584)
Release Notes:

- (Preview only) Fix devcontainers not starting when certain env
variables were set

Co-authored-by: KyleBarton <kjb@initialcapacity.io>
2025-12-23 19:10:51 +00:00
Xiaobo Liu
9f90c1a1b7 git_ui: Show copy-SHA button on commit header hover (#45478)
Release Notes:

- git: Added the ability to copy a commit's SHA in the commit view.

---------

Signed-off-by: Xiaobo Liu <cppcoffee@gmail.com>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-23 17:11:56 +00:00
Danilo Leal
d43cc46288 agent_ui: Add more items in the right-click context menu (#45575)
Follow up to https://github.com/zed-industries/zed/pull/45440 adding an
item for "Open Thread as Markdown" and another for scroll to top and
scroll to bottom.

<img width="500" height="646" alt="Screenshot 2025-12-23 at 1  12@2x"
src="https://github.com/user-attachments/assets/c82e26bb-c255-4d73-b733-ef6ea269fabe"
/>

Release Notes:

- N/A
2025-12-23 13:22:42 -03:00
Daniel Byiringiro
fdb8e71b43 docs: Remove reference to outdated curated issues board (#45568)
The documentation referenced a “Curated board of issues” GitHub Project
that no longer exists.
The linked project returns a 404, and only three public projects are
currently available under
zed-industries.

This PR removes the outdated reference. Documentation-only change.

Release Notes:

- N/A
2025-12-23 15:15:58 +00:00
zchira
6bc433ed43 agent_ui: Add right-click context menu to the thread view (#45440)
Closes #23158

Release Notes:

- Added a right-click context menu for the thread view in the agent
panel.

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-23 12:09:46 -03:00
Danilo Leal
1281f4672c markdown: Add support for right-click menu copy item (#45572)
In https://github.com/zed-industries/zed/pull/45440, we're implementing
the ability to right-click in the agent panel and copy the rendered
markdown. However, that presented itself as not as straightforward as
just making the menu item fire the `CopyAsMarkdown` action because any
selection in markdown is cleared after a new mouse click, and for the
right-click copy menu item to work, we need to persist that selection
even after the menu itself is opened and the "Copy" menu item is
clicked.

This all demanded a bit of work in the markdown file itself, and given
we may want to use this functionality for other non-agent thread view
markdown use cases in the future, I felt like it'd be better breaking it
down into a separate PR that we can more easily track in the future.

The context menu still needs to be built in the place where the markdown
is created and rendered, though. This PR only adds the infrastructure
needed so that this menu can simply fire the `CopyAsMarkdown` and make
the copying work.

Release Notes:

- N/A
2025-12-23 12:09:10 -03:00
Rocky Shi
ed705c0cbc Conditionally display debugger panel icon based on a setting (#45544)
Closes [#ISSUE](https://github.com/zed-industries/zed/issues/45506)

Release Notes:

- Conditionally display the debugger panel icon based on a setting to
avoid too many error logs
2025-12-23 13:28:04 +01:00
Joseph T. Lyons
8980333e23 Add support for automatic Markdown task list continuation when using uppercase X (#45561)
Release Notes:

- Added support for automatic Markdown task list continuation when using
uppercase X
2025-12-23 08:07:48 +00:00
Cole Miller
acee48bfda git: Fix "Commit Tracked" being shown when files are partially staged (#45551)
Release Notes:

- N/A
2025-12-22 21:32:55 -05:00
Finn Evers
71298e6949 extension_ci: Use larger runners for extension bundling (#45540)
`2x4` is not nearly enough for some of the grammars in use, hence change
this to a larger runner.

Also, reduce the size for the Rust runners a bit, as they don't need to
be quite as large for the amount of Rust code we have in extensions.

Release Notes:

- N/A
2025-12-22 22:08:42 +00:00
Max Brunsfeld
07ada58466 Improve edit prediction example capture (#45536)
This PR improves the `edit prediction: Capture Example` in several ways:
* fixed bugs in how the uncommitted diff was calculated
* added a `edit_predictions.examples_dir` setting that can be set in
order to have the action automatically save examples into the given
folder
* moved the action into the `edit_predictions` crate, in preparation for
collecting this data passively from end users, when they have opted in
to data sharing, similar to what we did for Zeta 1

Release Notes:

- N/A
2025-12-22 20:40:02 +00:00
Kirill Bulatov
dd521a96fb Bump proto extension to 0.3.1 (#45531)
Includes https://github.com/zed-industries/zed/pull/45413

Release Notes:

- N/A
2025-12-22 18:40:27 +00:00
Danilo Leal
f9d9721b93 agent_ui: Expand model favoriting feature to external agents (#45528)
This PR adds the ability to favorite models for external agents—writing
to the settings in the `agent_servers` key—as well as a handful of other
improvements:

- Make the cycling keybinding `alt-enter` work for the inline assistant
as well as previous user messages
- Better organized the keybinding files removing some outdated
agent-related keybinding definitions
- Renamed the inline assistant key context to "InlineAssistant" as
"PromptEditor" is old and confusing
- Made the keybindings to rate an inline assistant response visible in
the thumbs up/down button's tooltip
- Created a unified component for the model selector tooltip given we
had 3 different places creating the same element
- Make the "Cycle Favorited Models" row in the tooltip visible only if
there is more than one favorite models

Release Notes:

- agent: External agents also now support the favoriting model feature,
which comes with a handy keybinding to cycle through the favorite list.
2025-12-22 14:06:54 -03:00
Alejandro Fernández Gómez
cff3ac6f93 docs: Fix download_file documentation (#45517)
Fix a small error in the docs for the extension capabilities

Release Notes:

- N/A
2025-12-22 10:17:26 +00:00
Finn Evers
746b76488c util: Keep default permissions when extracting Zip with unset permissions (#45515)
This ensures that we do not extract files with no permissions (`0o000`),
because these would become unusable on the host

Release Notes:

- N/A
2025-12-22 09:28:11 +00:00
Marshall Bowers
397fcf6083 docs: Fix Edit Prediction docs for Codestral (#45509)
This PR fixes the Edit Prediction docs for Codestral after they got
mangled in https://github.com/zed-industries/zed/pull/45503.

Release Notes:

- N/A
2025-12-22 04:10:26 +00:00
morgankrey
9adb3e1daa docs: Testing automatic documentation updates locally (2025-12-21) (#45503)
## Documentation Update Summary

### Changes Made

| File | Change | Related Code |
| --- | --- | --- |
| `docs/src/ai/edit-prediction.md` | Updated Codestral setup
instructions to use Settings Editor path instead of outdated
`agent::OpenSettings` action reference | Settings Editor provider
configuration flow |

### Rationale

The primary documentation update addresses outdated instructions in the
Codestral setup section. The original text referenced an
`agent::OpenSettings` action that directed users to an "Agent Panel
settings view" which no longer reflects the current UI flow. The updated
instructions now guide users through the Settings Editor with
platform-specific keyboard shortcuts and provide an alternative status
bar path.

### Review Notes

- **Codestral instructions**: Reviewers should verify the Settings
Editor navigation path (`Cmd+,` → search "Edit Predictions" →
**Configure Providers**) matches the current Zed UI
- **Status bar alternative**: The alternative path via "edit prediction
icon in the status bar" should be confirmed as accurate

---

## Update from 2025-12-21 20:25

---
**Source**: [#44914](https://github.com/zed-industries/zed/pull/44914) -
settings_ui: Add Edit keybindings button
**Author**: @probably-neb

Now I have all the context needed to create a comprehensive
documentation update summary.

## Documentation Update Summary

### Changes Made
| File | Change | Related Code |
| --- | --- | --- |
| docs/src/ai/agent-panel.md | Added documentation for `agent::PasteRaw`
action, explaining automatic @mention formatting for pasted code and how
to bypass it | PR #45254 |

### Rationale
PR #45254 ("agent_ui: Improve UX when pasting code into message editor")
introduced the `agent::PasteRaw` action, which allows users to paste
clipboard content without automatic formatting. When users copy
multi-line code from an editor buffer and paste it into the Agent panel,
Zed now automatically formats it as an @mention with file context. The
`PasteRaw` action provides a way to bypass this behavior when raw text
is preferred.

This documentation update ensures users can discover both:
1. The new automatic @mention formatting behavior
2. The keybinding to bypass it when needed

### Review Notes
- The new paragraph was placed in the "Adding Context" section,
immediately after the existing note about image pasting support—this
maintains logical flow since both relate to pasting behavior
- Uses the standard `{#kb agent::PasteRaw}` syntax for keybinding
references, consistent with other keybinding documentation in the file
- The documentation passed Prettier formatting validation without
modifications

---

### Condensed Version (for commit message)
```
docs(agent-panel): Document PasteRaw action for bypassing auto @mention formatting

Added explanation that multi-line code pasted from editor buffers is
automatically formatted as @mentions, with keybinding to paste raw text.

Related: PR #45254
```

Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
2025-12-21 20:31:24 -06:00
Daeksell
1469d94683 Fix dock panel button tooltip not dismissed when state changes via keyboard shortcut (#44746)
Closes #44720

Release Notes:

- Fixed dock panel button tooltips not being dismissed when toggling
panels via keyboard shortcut




**Problem:** When hovering over a dock panel button and using a keyboard
shortcut to toggle the panel, the tooltip remains visible with stale
content. This is inconsistent with mouse click behavior, where the
tooltip is dismissed on mouse down.

**Solution:** Include the panel's active state in the button's element
ID. When the state changes, the element ID changes (e.g., `"DebugPanel"`
→ `"DebugPanel-active"`), which causes GPUI to discard the old element
state including the cached tooltip.

**Testing:** Manually verified:
1. Hover over a dock panel button, wait for tooltip
2. Press keyboard shortcut to toggle the panel
3. Tooltip is now dismissed (consistent with mouse click behavior)


https://github.com/user-attachments/assets/ed92fb6c-6c22-44e2-87e3-5461d35f7106

---------

Co-authored-by: MrSubidubi <finn@zed.dev>
2025-12-22 00:50:54 +01:00
Yves Ineichen
3b626c8ac1 Allow empty splits on panes (#40245)
Draft as a base for continuing the discussion in #8008 : adds a
`SplitOperation` enum to support bindings like `["pane::SplitLeft",
{"operation": "Clear"}]`

To be discussed @MrSubidubi and others:

- Naming: Generally not happy with names yet and specifically `Empty` is
unclear, e.g., what does this mean for terminal panes? Added placeholder
code to split without cloning, but unsure what users would expect in
this case.
- ~~I removed `SplitAndMoveXyz` actions but I guess we should keep them
for backwards compatibility?~~
- May have missed details in the move implementation. Will check the
code again for opportunities to refactor more code after we agree on the
approach.
- ~~Tests should go to `crates/collab/src/tests/integration_tests.rs`?~~

Closes #8008

Release Notes:

- Add `pane::Split` mode (`{ClonePane,EmptyPane,MovePane}`) to allow
creating an empty buffer.

---------

Co-authored-by: Finn Evers <finn.evers@outlook.de>
Co-authored-by: MrSubidubi <finn@zed.dev>
2025-12-21 23:50:02 +00:00
Kirill Bulatov
3dc0614dba Small worktree trust fixes (#45500)
* Abs path trust should transitively trust all single file worktrees on
the same host
* Init worktree trust on the client side even when devcontainers are
run: remote host unconditionally checks trust, hence the client has to
keep track of it and respond with approves/declines.
Do trust all devcontainers' remote worktrees, as containers are isolated
and "safe".

Release Notes:

- N/A
2025-12-21 23:07:49 +00:00
Mayank Verma
045e154915 gpui: Fix hover state getting stuck when rapidly hovering over elements (#45437)
Closes #45436

Release Notes:

- N/A

---------

Co-authored-by: MrSubidubi <finn@zed.dev>
2025-12-21 22:07:30 +00:00
Finn Evers
dc72e1c4ba collab: Fix capitalization of copilot name alias (#45497)
This fixes copilot currently not passing the CLA check. 

Release Notes:

- N/A
2025-12-21 21:36:54 +00:00
Lukas Wirth
0884305e43 gpui(windows): Don't log incorrect errors on SetActiveWindow calls (#45493)
The function returns the previous focus handle, which may be null if
there is no previous focus. Unfortunately that also overlaps with the
error return value, so winapi will hand us a error 0 back in those cases
which we log ...


Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-21 16:14:23 +00:00
Nereuxofficial
83449293b6 Add autocomplete for initialization_options (#43104)
Closes #18287

Release Notes:

- Added autocomplete for lsp initialization_options

## Description
This MR adds the following code-changes:
- `initialization_options_schema` to the `LspAdapter` to get JSON
Schema's from the language server
- Adds a post-processing step to inject schema request paths into the
settings schema in `SettingsStore::json_schema`
- Adds an implementation for fetching the schema for rust-analyzer which
fetches it from the binary it is provided with
- Similarly for ruff
<img width="857" height="836" alt="image"
src="https://github.com/user-attachments/assets/3cc10883-364f-4f04-b3b9-3c3881f64252"
/>


## Open Questions(Would be nice to get some advice here)
- Binary Fetching:
- I'm pretty sure the binary fetching is suboptimal. The main problem
here was getting access to the delegate but i figured that out
eventually in a way that i _hope_ should be fine.
- The toolchain and binary options can differ from what the user has
configured potentially leading to mismatches in the autocomplete values
returned(these are probably rarely changed though). I could not really
find a way to fetch these in this context so the provided ones are for
now just `default` values.
- For the trait API it is just provided a binary, since i wanted to use
the potentially cached binary from the CachedLspAdapter. Is that fine
our should the arguments be passed to the LspAdapter such that it can
potentially download the LSP?
- As for those LSPs with JSON schema files in their repositories i can
add the files to zed manually e.g. in
languages/language/initialization_options_schema.json, which could cause
mismatches with the actual binary. Is there a preferred approach for Zed
here also with regards to updating them?
2025-12-21 10:29:38 -05:00
Marco Mihai Condrache
213cb30445 gpui: Enable direct-to-display optimization for metal (#45434)
Continuing of #44334 

I removed disabling of vsync which was causing jitter on some external
displays

cc: @maxbrunsfeld @Anthony-Eid 

Release Notes:

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

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>
2025-12-21 11:17:02 +02:00
93 changed files with 3007 additions and 929 deletions

View File

@@ -25,6 +25,7 @@ self-hosted-runner:
- namespace-profile-32x64-ubuntu-2204
# Namespace Ubuntu 24.04 (like ubuntu-latest)
- namespace-profile-2x4-ubuntu-2404
- namespace-profile-8x32-ubuntu-2404
# Namespace Limited Preview
- namespace-profile-8x16-ubuntu-2004-arm-m4
- namespace-profile-8x32-ubuntu-2004-arm-m4

View File

@@ -51,7 +51,7 @@ jobs:
needs:
- orchestrate
if: needs.orchestrate.outputs.check_rust == 'true'
runs-on: namespace-profile-16x32-ubuntu-2204
runs-on: namespace-profile-4x8-ubuntu-2204
steps:
- name: steps::checkout_repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -79,7 +79,7 @@ jobs:
needs:
- orchestrate
if: needs.orchestrate.outputs.check_extension == 'true'
runs-on: namespace-profile-2x4-ubuntu-2404
runs-on: namespace-profile-8x32-ubuntu-2404
steps:
- name: steps::checkout_repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

View File

@@ -23,7 +23,6 @@ In particular we love PRs that are:
If you're looking for concrete ideas:
- [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).

15
Cargo.lock generated
View File

@@ -5212,6 +5212,7 @@ dependencies = [
"anyhow",
"arrayvec",
"brotli",
"buffer_diff",
"client",
"clock",
"cloud_api_types",
@@ -5249,7 +5250,9 @@ dependencies = [
"strum 0.27.2",
"telemetry",
"telemetry_events",
"text",
"thiserror 2.0.17",
"time",
"ui",
"util",
"uuid",
@@ -5354,8 +5357,10 @@ dependencies = [
"anyhow",
"buffer_diff",
"client",
"clock",
"cloud_llm_client",
"codestral",
"collections",
"command_palette_hooks",
"copilot",
"edit_prediction",
@@ -5364,18 +5369,20 @@ dependencies = [
"feature_flags",
"fs",
"futures 0.3.31",
"git",
"gpui",
"indoc",
"language",
"log",
"language_model",
"lsp",
"markdown",
"menu",
"multi_buffer",
"paths",
"pretty_assertions",
"project",
"regex",
"release_channel",
"semver",
"serde_json",
"settings",
"supermaven",
@@ -5388,6 +5395,7 @@ dependencies = [
"workspace",
"zed_actions",
"zeta_prompt",
"zlog",
]
[[package]]
@@ -8645,6 +8653,7 @@ dependencies = [
"extension",
"gpui",
"language",
"lsp",
"paths",
"project",
"schemars",
@@ -20969,7 +20978,7 @@ dependencies = [
[[package]]
name = "zed_proto"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"zed_extension_api 0.7.0",
]

View File

@@ -241,6 +241,7 @@
"ctrl-alt-l": "agent::OpenRulesLibrary",
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-alt-/": "agent::ToggleModelSelector",
"alt-tab": "agent::CycleFavoriteModels",
"ctrl-shift-j": "agent::ToggleNavigationMenu",
"ctrl-alt-i": "agent::ToggleOptionsMenu",
"ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
@@ -253,7 +254,6 @@
"ctrl-y": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
"ctrl-alt-z": "agent::RejectOnce",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -285,38 +285,6 @@
"ctrl-alt-t": "agent::NewThread",
},
},
{
"context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
"bindings": {
"enter": "agent::Chat",
"ctrl-enter": "agent::ChatWithFollow",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{
"context": "MessageEditor && !Picker > Editor && use_modifier_to_send",
"bindings": {
"ctrl-enter": "agent::Chat",
"enter": "editor::Newline",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{
"context": "EditMessageEditor > Editor",
"bindings": {
"escape": "menu::Cancel",
"enter": "menu::Confirm",
"alt-enter": "editor::Newline",
},
},
{
"context": "AgentFeedbackMessageEditor > Editor",
"bindings": {
@@ -331,14 +299,25 @@
"ctrl-enter": "menu::Confirm",
},
},
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "agent::ChatWithFollow",
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
"context": "AcpThread > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
},
},
{
@@ -346,11 +325,7 @@
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "agent::Chat",
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
"enter": "editor::Newline",
},
},
{
@@ -817,7 +792,7 @@
},
},
{
"context": "PromptEditor",
"context": "InlineAssistant",
"bindings": {
"ctrl-[": "agent::CyclePreviousInlineAssist",
"ctrl-]": "agent::CycleNextInlineAssist",

View File

@@ -282,6 +282,7 @@
"cmd-alt-p": "agent::ManageProfiles",
"cmd-i": "agent::ToggleProfileSelector",
"cmd-alt-/": "agent::ToggleModelSelector",
"alt-tab": "agent::CycleFavoriteModels",
"cmd-shift-j": "agent::ToggleNavigationMenu",
"cmd-alt-m": "agent::ToggleOptionsMenu",
"cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
@@ -294,7 +295,6 @@
"cmd-y": "agent::AllowOnce",
"cmd-alt-y": "agent::AllowAlways",
"cmd-alt-z": "agent::RejectOnce",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -326,41 +326,6 @@
"cmd-alt-t": "agent::NewThread",
},
},
{
"context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
"cmd-enter": "agent::ChatWithFollow",
"cmd-i": "agent::ToggleProfileSelector",
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"cmd-shift-v": "agent::PasteRaw",
},
},
{
"context": "MessageEditor && !Picker > Editor && use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"cmd-enter": "agent::Chat",
"enter": "editor::Newline",
"cmd-i": "agent::ToggleProfileSelector",
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"cmd-shift-v": "agent::PasteRaw",
},
},
{
"context": "EditMessageEditor > Editor",
"use_key_equivalents": true,
"bindings": {
"escape": "menu::Cancel",
"enter": "menu::Confirm",
"alt-enter": "editor::Newline",
},
},
{
"context": "AgentFeedbackMessageEditor > Editor",
"use_key_equivalents": true,
@@ -382,16 +347,25 @@
"cmd-enter": "menu::Confirm",
},
},
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,
"bindings": {
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"cmd-enter": "agent::ChatWithFollow",
"cmd-shift-v": "agent::PasteRaw",
"cmd-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
"context": "AcpThread > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -399,11 +373,7 @@
"use_key_equivalents": true,
"bindings": {
"cmd-enter": "agent::Chat",
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
"enter": "editor::Newline",
},
},
{
@@ -883,7 +853,7 @@
},
},
{
"context": "PromptEditor",
"context": "InlineAssistant > Editor",
"use_key_equivalents": true,
"bindings": {
"cmd-alt-/": "agent::ToggleModelSelector",

View File

@@ -241,6 +241,7 @@
"shift-alt-l": "agent::OpenRulesLibrary",
"shift-alt-p": "agent::ManageProfiles",
"ctrl-i": "agent::ToggleProfileSelector",
"alt-tab": "agent::CycleFavoriteModels",
"shift-alt-/": "agent::ToggleModelSelector",
"shift-alt-j": "agent::ToggleNavigationMenu",
"shift-alt-i": "agent::ToggleOptionsMenu",
@@ -254,7 +255,6 @@
"shift-alt-a": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
"shift-alt-z": "agent::RejectOnce",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -287,41 +287,6 @@
"ctrl-alt-t": "agent::NewThread",
},
},
{
"context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
"ctrl-enter": "agent::ChatWithFollow",
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{
"context": "MessageEditor && !Picker > Editor && use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "agent::Chat",
"enter": "editor::Newline",
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{
"context": "EditMessageEditor > Editor",
"use_key_equivalents": true,
"bindings": {
"escape": "menu::Cancel",
"enter": "menu::Confirm",
"alt-enter": "editor::Newline",
},
},
{
"context": "AgentFeedbackMessageEditor > Editor",
"use_key_equivalents": true,
@@ -337,16 +302,25 @@
"ctrl-enter": "menu::Confirm",
},
},
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "agent::ChatWithFollow",
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
"context": "AcpThread > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -354,11 +328,7 @@
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "agent::Chat",
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
"enter": "editor::Newline",
},
},
{
@@ -826,7 +796,7 @@
},
},
{
"context": "PromptEditor",
"context": "InlineAssistant",
"use_key_equivalents": true,
"bindings": {
"ctrl-[": "agent::CyclePreviousInlineAssist",

View File

@@ -24,7 +24,7 @@
},
},
{
"context": "InlineAssistEditor",
"context": "InlineAssistant > Editor",
"use_key_equivalents": true,
"bindings": {
"ctrl-shift-backspace": "editor::Cancel",

View File

@@ -24,7 +24,7 @@
},
},
{
"context": "InlineAssistEditor",
"context": "InlineAssistant > Editor",
"use_key_equivalents": true,
"bindings": {
"cmd-shift-backspace": "editor::Cancel",

View File

@@ -202,12 +202,6 @@ 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
}
}
/// Icon for a model in the model selector.

View File

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

View File

@@ -1,10 +1,14 @@
use std::{any::Any, path::Path, rc::Rc, sync::Arc};
use agent_client_protocol as acp;
use agent_servers::{AgentServer, AgentServerDelegate};
use agent_settings::AgentSettings;
use anyhow::Result;
use collections::HashSet;
use fs::Fs;
use gpui::{App, Entity, SharedString, Task};
use prompt_store::PromptStore;
use settings::{LanguageModelSelection, Settings as _, update_settings_file};
use crate::{HistoryStore, NativeAgent, NativeAgentConnection, templates::Templates};
@@ -71,6 +75,38 @@ impl AgentServer for NativeAgentServer {
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
self
}
fn favorite_model_ids(&self, cx: &mut App) -> HashSet<acp::ModelId> {
AgentSettings::get_global(cx).favorite_model_ids()
}
fn toggle_favorite_model(
&self,
model_id: acp::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);
}
});
}
}
/// Convert a ModelId (e.g. "anthropic/claude-3-5-sonnet") to a LanguageModelSelection.
fn model_id_to_selection(model_id: &acp::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(),
}
}
#[cfg(test)]

View File

@@ -4,6 +4,8 @@ mod codex;
mod custom;
mod gemini;
use collections::HashSet;
#[cfg(any(test, feature = "test-support"))]
pub mod e2e_tests;
@@ -56,9 +58,19 @@ impl AgentServerDelegate {
pub trait AgentServer: Send {
fn logo(&self) -> ui::IconName;
fn name(&self) -> SharedString;
fn connect(
&self,
root_dir: Option<&Path>,
delegate: AgentServerDelegate,
cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>>;
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
fn default_mode(&self, _cx: &mut App) -> Option<agent_client_protocol::SessionModeId> {
None
}
fn set_default_mode(
&self,
_mode_id: Option<agent_client_protocol::SessionModeId>,
@@ -79,14 +91,18 @@ pub trait AgentServer: Send {
) {
}
fn connect(
&self,
root_dir: Option<&Path>,
delegate: AgentServerDelegate,
cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>>;
fn favorite_model_ids(&self, _cx: &mut App) -> HashSet<agent_client_protocol::ModelId> {
HashSet::default()
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
fn toggle_favorite_model(
&self,
_model_id: agent_client_protocol::ModelId,
_should_be_favorite: bool,
_fs: Arc<dyn Fs>,
_cx: &App,
) {
}
}
impl dyn AgentServer {

View File

@@ -1,4 +1,5 @@
use agent_client_protocol as acp;
use collections::HashSet;
use fs::Fs;
use settings::{SettingsStore, update_settings_file};
use std::path::Path;
@@ -72,6 +73,48 @@ impl AgentServer for ClaudeCode {
});
}
fn favorite_model_ids(&self, cx: &mut App) -> HashSet<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).claude.clone()
});
settings
.as_ref()
.map(|s| {
s.favorite_models
.iter()
.map(|id| acp::ModelId::new(id.clone()))
.collect()
})
.unwrap_or_default()
}
fn toggle_favorite_model(
&self,
model_id: acp::ModelId,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
update_settings_file(fs, cx, move |settings, _| {
let favorite_models = &mut settings
.agent_servers
.get_or_insert_default()
.claude
.get_or_insert_default()
.favorite_models;
let model_id_str = model_id.to_string();
if should_be_favorite {
if !favorite_models.contains(&model_id_str) {
favorite_models.push(model_id_str);
}
} else {
favorite_models.retain(|id| id != &model_id_str);
}
});
}
fn connect(
&self,
root_dir: Option<&Path>,

View File

@@ -5,6 +5,7 @@ use std::{any::Any, path::Path};
use acp_thread::AgentConnection;
use agent_client_protocol as acp;
use anyhow::{Context as _, Result};
use collections::HashSet;
use fs::Fs;
use gpui::{App, AppContext as _, SharedString, Task};
use project::agent_server_store::{AllAgentServersSettings, CODEX_NAME};
@@ -73,6 +74,48 @@ impl AgentServer for Codex {
});
}
fn favorite_model_ids(&self, cx: &mut App) -> HashSet<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).codex.clone()
});
settings
.as_ref()
.map(|s| {
s.favorite_models
.iter()
.map(|id| acp::ModelId::new(id.clone()))
.collect()
})
.unwrap_or_default()
}
fn toggle_favorite_model(
&self,
model_id: acp::ModelId,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
update_settings_file(fs, cx, move |settings, _| {
let favorite_models = &mut settings
.agent_servers
.get_or_insert_default()
.codex
.get_or_insert_default()
.favorite_models;
let model_id_str = model_id.to_string();
if should_be_favorite {
if !favorite_models.contains(&model_id_str) {
favorite_models.push(model_id_str);
}
} else {
favorite_models.retain(|id| id != &model_id_str);
}
});
}
fn connect(
&self,
root_dir: Option<&Path>,

View File

@@ -2,6 +2,7 @@ use crate::{AgentServer, AgentServerDelegate, load_proxy_env};
use acp_thread::AgentConnection;
use agent_client_protocol as acp;
use anyhow::{Context as _, Result};
use collections::HashSet;
use fs::Fs;
use gpui::{App, AppContext as _, SharedString, Task};
use project::agent_server_store::{AllAgentServersSettings, ExternalAgentServerName};
@@ -54,6 +55,7 @@ impl AgentServer for CustomAgentServer {
.or_insert_with(|| settings::CustomAgentServerSettings::Extension {
default_model: None,
default_mode: None,
favorite_models: Vec::new(),
});
match settings {
@@ -90,6 +92,7 @@ impl AgentServer for CustomAgentServer {
.or_insert_with(|| settings::CustomAgentServerSettings::Extension {
default_model: None,
default_mode: None,
favorite_models: Vec::new(),
});
match settings {
@@ -101,6 +104,66 @@ impl AgentServer for CustomAgentServer {
});
}
fn favorite_model_ids(&self, cx: &mut App) -> HashSet<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings
.get::<AllAgentServersSettings>(None)
.custom
.get(&self.name())
.cloned()
});
settings
.as_ref()
.map(|s| {
s.favorite_models()
.iter()
.map(|id| acp::ModelId::new(id.clone()))
.collect()
})
.unwrap_or_default()
}
fn toggle_favorite_model(
&self,
model_id: acp::ModelId,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
let name = self.name();
update_settings_file(fs, cx, move |settings, _| {
let settings = settings
.agent_servers
.get_or_insert_default()
.custom
.entry(name.clone())
.or_insert_with(|| settings::CustomAgentServerSettings::Extension {
default_model: None,
default_mode: None,
favorite_models: Vec::new(),
});
let favorite_models = match settings {
settings::CustomAgentServerSettings::Custom {
favorite_models, ..
}
| settings::CustomAgentServerSettings::Extension {
favorite_models, ..
} => favorite_models,
};
let model_id_str = model_id.to_string();
if should_be_favorite {
if !favorite_models.contains(&model_id_str) {
favorite_models.push(model_id_str);
}
} else {
favorite_models.retain(|id| id != &model_id_str);
}
});
}
fn connect(
&self,
root_dir: Option<&Path>,

View File

@@ -460,6 +460,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
ignore_system_version: None,
default_mode: None,
default_model: None,
favorite_models: vec![],
}),
gemini: Some(crate::gemini::tests::local_command().into()),
codex: Some(BuiltinAgentServerSettings {
@@ -469,6 +470,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
ignore_system_version: None,
default_mode: None,
default_model: None,
favorite_models: vec![],
}),
custom: collections::HashMap::default(),
},

View File

@@ -31,7 +31,7 @@ use rope::Point;
use settings::Settings;
use std::{cell::RefCell, fmt::Write, rc::Rc, sync::Arc};
use theme::ThemeSettings;
use ui::prelude::*;
use ui::{ContextMenu, prelude::*};
use util::{ResultExt, debug_panic};
use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::{Chat, PasteRaw};
@@ -132,6 +132,21 @@ impl MessageEditor {
placement: Some(ContextMenuPlacement::Above),
});
editor.register_addon(MessageEditorAddon::new());
editor.set_custom_context_menu(|editor, _point, window, cx| {
let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx));
Some(ContextMenu::build(window, cx, |menu, _, _| {
menu.action("Cut", Box::new(editor::actions::Cut))
.action_disabled_when(
!has_selection,
"Copy",
Box::new(editor::actions::Copy),
)
.action("Paste", Box::new(editor::actions::Paste))
}))
});
editor
});
let mention_set =

View File

@@ -3,19 +3,19 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc};
use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelList, AgentModelSelector};
use agent_client_protocol::ModelId;
use agent_servers::AgentServer;
use agent_settings::AgentSettings;
use anyhow::Result;
use collections::{HashSet, IndexMap};
use fs::Fs;
use futures::FutureExt;
use fuzzy::{StringMatchCandidate, match_strings};
use gpui::{
Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Task, WeakEntity,
Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
WeakEntity,
};
use itertools::Itertools;
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
use settings::Settings;
use settings::SettingsStore;
use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, prelude::*};
use util::ResultExt;
use zed_actions::agent::OpenSettings;
@@ -54,7 +54,9 @@ pub struct AcpModelPickerDelegate {
selected_index: usize,
selected_description: Option<(usize, SharedString, bool)>,
selected_model: Option<AgentModelInfo>,
favorites: HashSet<ModelId>,
_refresh_models_task: Task<()>,
_settings_subscription: Subscription,
focus_handle: FocusHandle,
}
@@ -102,6 +104,19 @@ impl AcpModelPickerDelegate {
})
};
let agent_server_for_subscription = agent_server.clone();
let settings_subscription =
cx.observe_global_in::<SettingsStore>(window, move |picker, window, cx| {
// Only refresh if the favorites actually changed to avoid redundant work
// when other settings are modified (e.g., user editing settings.json)
let new_favorites = agent_server_for_subscription.favorite_model_ids(cx);
if new_favorites != picker.delegate.favorites {
picker.delegate.favorites = new_favorites;
picker.refresh(window, cx);
}
});
let favorites = agent_server.favorite_model_ids(cx);
Self {
selector,
agent_server,
@@ -111,7 +126,9 @@ impl AcpModelPickerDelegate {
selected_model: None,
selected_index: 0,
selected_description: None,
favorites,
_refresh_models_task: refresh_models_task,
_settings_subscription: settings_subscription,
focus_handle,
}
}
@@ -120,40 +137,37 @@ impl AcpModelPickerDelegate {
self.selected_model.as_ref()
}
pub fn favorites_count(&self) -> usize {
self.favorites.len()
}
pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if !self.selector.supports_favorites() {
if self.favorites.is_empty() {
return;
}
let favorites = AgentSettings::get_global(cx).favorite_model_ids();
if favorites.is_empty() {
return;
}
let Some(models) = self.models.clone() else {
let Some(models) = &self.models 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 all_models: Vec<&AgentModelInfo> = match models {
AgentModelList::Flat(list) => list.iter().collect(),
AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(),
};
let favorite_models = all_models
.iter()
.filter(|model| favorites.contains(&model.id))
let favorite_models: Vec<_> = all_models
.into_iter()
.filter(|model| self.favorites.contains(&model.id))
.unique_by(|model| &model.id)
.cloned()
.collect::<Vec<_>>();
.collect();
let current_id = self.selected_model.as_ref().map(|m| m.id.clone());
if favorite_models.is_empty() {
return;
}
let current_id = self.selected_model.as_ref().map(|m| &m.id);
let current_index_in_favorites = current_id
.as_ref()
.and_then(|id| favorite_models.iter().position(|m| &m.id == id))
.unwrap_or(usize::MAX);
@@ -220,11 +234,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let favorites = if self.selector.supports_favorites() {
AgentSettings::get_global(cx).favorite_model_ids()
} else {
Default::default()
};
let favorites = self.favorites.clone();
cx.spawn_in(window, async move |this, cx| {
let filtered_models = match this
@@ -317,21 +327,20 @@ impl PickerDelegate for AcpModelPickerDelegate {
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();
let agent_server = self.agent_server.clone();
move |cx: &App| {
crate::favorite_models::toggle_model_id_in_settings(
cx.listener(move |_, _, _, cx| {
agent_server.toggle_favorite_model(
model_id.clone(),
!is_favorite,
fs.clone(),
cx,
);
}
})
};
Some(
@@ -357,10 +366,8 @@ impl PickerDelegate for AcpModelPickerDelegate {
})
.is_selected(is_selected)
.is_focused(selected)
.when(supports_favorites, |this| {
this.is_favorite(is_favorite)
.on_toggle_favorite(handle_action_click)
}),
.is_favorite(is_favorite)
.on_toggle_favorite(handle_action_click),
)
.into_any_element(),
)
@@ -603,6 +610,46 @@ mod tests {
.collect()
}
#[gpui::test]
async fn test_fuzzy_match(cx: &mut TestAppContext) {
let models = create_model_list(vec![
(
"zed",
vec![
"Claude 3.7 Sonnet",
"Claude 3.7 Sonnet Thinking",
"gpt-4.1",
"gpt-4.1-nano",
],
),
("openai", vec!["gpt-3.5-turbo", "gpt-4.1", "gpt-4.1-nano"]),
("ollama", vec!["mistral", "deepseek"]),
]);
// Results should preserve models order whenever possible.
// In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical
// similarity scores, but `zed/gpt-4.1` was higher in the models list,
// so it should appear first in the results.
let results = fuzzy_search(models.clone(), "41".into(), cx.executor()).await;
assert_models_eq(
results,
vec![
("zed", vec!["gpt-4.1", "gpt-4.1-nano"]),
("openai", vec!["gpt-4.1", "gpt-4.1-nano"]),
],
);
// Fuzzy search
let results = fuzzy_search(models.clone(), "4n".into(), cx.executor()).await;
assert_models_eq(
results,
vec![
("zed", vec!["gpt-4.1-nano"]),
("openai", vec!["gpt-4.1-nano"]),
],
);
}
#[gpui::test]
fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
let models = create_model_list(vec![
@@ -739,42 +786,48 @@ mod tests {
}
#[gpui::test]
async fn test_fuzzy_match(cx: &mut TestAppContext) {
let models = create_model_list(vec![
(
"zed",
vec![
"Claude 3.7 Sonnet",
"Claude 3.7 Sonnet Thinking",
"gpt-4.1",
"gpt-4.1-nano",
],
),
("openai", vec!["gpt-3.5-turbo", "gpt-4.1", "gpt-4.1-nano"]),
("ollama", vec!["mistral", "deepseek"]),
fn test_favorites_count_returns_correct_count(_cx: &mut TestAppContext) {
let empty_favorites: HashSet<ModelId> = HashSet::default();
assert_eq!(empty_favorites.len(), 0);
let one_favorite = create_favorites(vec!["model-a"]);
assert_eq!(one_favorite.len(), 1);
let multiple_favorites = create_favorites(vec!["model-a", "model-b", "model-c"]);
assert_eq!(multiple_favorites.len(), 3);
let with_duplicates = create_favorites(vec!["model-a", "model-a", "model-b"]);
assert_eq!(with_duplicates.len(), 2);
}
#[gpui::test]
fn test_is_favorite_flag_set_correctly_in_entries(_cx: &mut TestAppContext) {
let models = AgentModelList::Flat(vec![
acp_thread::AgentModelInfo {
id: acp::ModelId::new("favorite-model".to_string()),
name: "Favorite".into(),
description: None,
icon: None,
},
acp_thread::AgentModelInfo {
id: acp::ModelId::new("regular-model".to_string()),
name: "Regular".into(),
description: None,
icon: None,
},
]);
let favorites = create_favorites(vec!["favorite-model"]);
// Results should preserve models order whenever possible.
// In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical
// similarity scores, but `zed/gpt-4.1` was higher in the models list,
// so it should appear first in the results.
let results = fuzzy_search(models.clone(), "41".into(), cx.executor()).await;
assert_models_eq(
results,
vec![
("zed", vec!["gpt-4.1", "gpt-4.1-nano"]),
("openai", vec!["gpt-4.1", "gpt-4.1-nano"]),
],
);
let entries = info_list_to_picker_entries(models, &favorites);
// Fuzzy search
let results = fuzzy_search(models.clone(), "4n".into(), cx.executor()).await;
assert_models_eq(
results,
vec![
("zed", vec!["gpt-4.1-nano"]),
("openai", vec!["gpt-4.1-nano"]),
],
);
for entry in &entries {
if let AcpModelPickerEntry::Model(info, is_favorite) = entry {
if info.id.0.as_ref() == "favorite-model" {
assert!(*is_favorite, "favorite-model should have is_favorite=true");
} else if info.id.0.as_ref() == "regular-model" {
assert!(!*is_favorite, "regular-model should have is_favorite=false");
}
}
}
}
}

View File

@@ -2,17 +2,13 @@ use std::rc::Rc;
use std::sync::Arc;
use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector};
use agent_servers::AgentServer;
use agent_settings::AgentSettings;
use fs::Fs;
use gpui::{Entity, FocusHandle};
use picker::popover_menu::PickerPopoverMenu;
use settings::Settings as _;
use ui::{ButtonLike, KeyBinding, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
use zed_actions::agent::ToggleModelSelector;
use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
use crate::CycleFavoriteModels;
use crate::acp::{AcpModelSelector, model_selector::acp_model_selector};
use crate::ui::ModelSelectorTooltip;
pub struct AcpModelSelectorPopover {
selector: Entity<AcpModelSelector>,
@@ -23,7 +19,7 @@ pub struct AcpModelSelectorPopover {
impl AcpModelSelectorPopover {
pub(crate) fn new(
selector: Rc<dyn AgentModelSelector>,
agent_server: Rc<dyn AgentServer>,
agent_server: Rc<dyn agent_servers::AgentServer>,
fs: Arc<dyn Fs>,
menu_handle: PopoverMenuHandle<AcpModelSelector>,
focus_handle: FocusHandle,
@@ -64,7 +60,8 @@ impl AcpModelSelectorPopover {
impl Render for AcpModelSelectorPopover {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let model = self.selector.read(cx).delegate.active_model();
let selector = self.selector.read(cx);
let model = selector.delegate.active_model();
let model_name = model
.as_ref()
.map(|model| model.name.clone())
@@ -80,43 +77,13 @@ 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();
let show_cycle_row = selector.delegate.favorites_count() > 1;
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()
let tooltip = Tooltip::element({
move |_, _cx| {
ModelSelectorTooltip::new(focus_handle.clone())
.show_cycle_row(show_cycle_row)
.into_any_element()
}
});

View File

@@ -47,8 +47,9 @@ use terminal_view::terminal_panel::TerminalPanel;
use text::Anchor;
use theme::{AgentFontSize, ThemeSettings};
use ui::{
Callout, CommonAnimationExt, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding,
PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, prelude::*,
Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider, DividerColor,
ElevationIndex, KeyBinding, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar,
prelude::*, right_click_menu,
};
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
use workspace::{CollaboratorId, NewTerminal, Workspace};
@@ -2038,7 +2039,7 @@ impl AcpThreadView {
}
})
.text_xs()
.child(editor.clone().into_any_element()),
.child(editor.clone().into_any_element())
)
.when(editor_focus, |this| {
let base_container = h_flex()
@@ -2154,7 +2155,6 @@ impl AcpThreadView {
if this_is_blank {
return None;
}
Some(
self.render_thinking_block(
entry_ix,
@@ -2180,7 +2180,7 @@ impl AcpThreadView {
.when(is_last, |this| this.pb_4())
.w_full()
.text_ui(cx)
.child(message_body)
.child(self.render_message_context_menu(entry_ix, message_body, cx))
.into_any()
}
}
@@ -2287,6 +2287,70 @@ impl AcpThreadView {
}
}
fn render_message_context_menu(
&self,
entry_ix: usize,
message_body: AnyElement,
cx: &Context<Self>,
) -> AnyElement {
let entity = cx.entity();
let workspace = self.workspace.clone();
right_click_menu(format!("agent_context_menu-{}", entry_ix))
.trigger(move |_, _, _| message_body)
.menu(move |window, cx| {
let focus = window.focused(cx);
let entity = entity.clone();
let workspace = workspace.clone();
ContextMenu::build(window, cx, move |menu, _, cx| {
let is_at_top = entity.read(cx).list_state.logical_scroll_top().item_ix == 0;
let scroll_item = if is_at_top {
ContextMenuEntry::new("Scroll to Bottom").handler({
let entity = entity.clone();
move |_, cx| {
entity.update(cx, |this, cx| {
this.scroll_to_bottom(cx);
});
}
})
} else {
ContextMenuEntry::new("Scroll to Top").handler({
let entity = entity.clone();
move |_, cx| {
entity.update(cx, |this, cx| {
this.scroll_to_top(cx);
});
}
})
};
let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown")
.handler({
let entity = entity.clone();
let workspace = workspace.clone();
move |window, cx| {
if let Some(workspace) = workspace.upgrade() {
entity
.update(cx, |this, cx| {
this.open_thread_as_markdown(workspace, window, cx)
})
.detach_and_log_err(cx);
}
}
});
menu.when_some(focus, |menu, focus| menu.context(focus))
.action("Copy", Box::new(markdown::CopyAsMarkdown))
.separator()
.item(scroll_item)
.item(open_thread_as_markdown)
})
})
.into_any_element()
}
fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
cx.theme()
.colors()
@@ -4288,37 +4352,6 @@ impl AcpThreadView {
v_flex()
.on_action(cx.listener(Self::expand_message_editor))
.on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
if let Some(profile_selector) = this.profile_selector.as_ref() {
profile_selector.read(cx).menu_handle().toggle(window, cx);
} else if let Some(mode_selector) = this.mode_selector() {
mode_selector.read(cx).menu_handle().toggle(window, cx);
}
}))
.on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
if let Some(profile_selector) = this.profile_selector.as_ref() {
profile_selector.update(cx, |profile_selector, cx| {
profile_selector.cycle_profile(cx);
});
} else if let Some(mode_selector) = this.mode_selector() {
mode_selector.update(cx, |mode_selector, cx| {
mode_selector.cycle_mode(window, cx);
});
}
}))
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
if let Some(model_selector) = this.model_selector.as_ref() {
model_selector
.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()
@@ -6005,6 +6038,37 @@ impl Render for AcpThreadView {
.on_action(cx.listener(Self::allow_always))
.on_action(cx.listener(Self::allow_once))
.on_action(cx.listener(Self::reject_once))
.on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
if let Some(profile_selector) = this.profile_selector.as_ref() {
profile_selector.read(cx).menu_handle().toggle(window, cx);
} else if let Some(mode_selector) = this.mode_selector() {
mode_selector.read(cx).menu_handle().toggle(window, cx);
}
}))
.on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
if let Some(profile_selector) = this.profile_selector.as_ref() {
profile_selector.update(cx, |profile_selector, cx| {
profile_selector.cycle_profile(cx);
});
} else if let Some(mode_selector) = this.mode_selector() {
mode_selector.update(cx, |mode_selector, cx| {
mode_selector.cycle_mode(window, cx);
});
}
}))
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
if let Some(model_selector) = this.model_selector.as_ref() {
model_selector
.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);
});
}
}))
.track_focus(&self.focus_handle)
.bg(cx.theme().colors().panel_background)
.child(match &self.thread_state {

View File

@@ -1370,6 +1370,7 @@ async fn open_new_agent_servers_entry_in_settings_editor(
env: Some(HashMap::default()),
default_mode: None,
default_model: None,
favorite_models: vec![],
},
);
}

View File

@@ -1,6 +1,7 @@
use crate::{
ModelUsageContext,
language_model_selector::{LanguageModelSelector, language_model_selector},
ui::ModelSelectorTooltip,
};
use fs::Fs;
use gpui::{Entity, FocusHandle, SharedString};
@@ -9,7 +10,6 @@ use picker::popover_menu::PickerPopoverMenu;
use settings::update_settings_file;
use std::sync::Arc;
use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
use zed_actions::agent::ToggleModelSelector;
pub struct AgentModelSelector {
selector: Entity<LanguageModelSelector>,
@@ -81,6 +81,12 @@ impl AgentModelSelector {
pub fn active_model(&self, cx: &App) -> Option<language_model::ConfiguredModel> {
self.selector.read(cx).delegate.active_model(cx)
}
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 AgentModelSelector {
@@ -98,8 +104,18 @@ impl Render for AgentModelSelector {
Color::Muted
};
let show_cycle_row = self.selector.read(cx).delegate.favorites_count() > 1;
let focus_handle = self.focus_handle.clone();
let tooltip = Tooltip::element({
move |_, _cx| {
ModelSelectorTooltip::new(focus_handle.clone())
.show_cycle_row(show_cycle_row)
.into_any_element()
}
});
PickerPopoverMenu::new(
self.selector.clone(),
ButtonLike::new("active-model")
@@ -125,9 +141,7 @@ impl Render for AgentModelSelector {
.color(color)
.size(IconSize::XSmall),
),
move |_window, cx| {
Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
},
tooltip,
gpui::Corner::TopRight,
cx,
)

View File

@@ -1,6 +1,5 @@
use std::sync::Arc;
use agent_client_protocol::ModelId;
use fs::Fs;
use language_model::LanguageModel;
use settings::{LanguageModelSelection, update_settings_file};
@@ -13,20 +12,11 @@ fn language_model_to_selection(model: &Arc<dyn LanguageModel>) -> LanguageModelS
}
}
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,
cx: &mut App,
) {
let selection = language_model_to_selection(&model);
update_settings_file(fs, cx, move |settings, _| {
@@ -38,20 +28,3 @@ pub fn toggle_in_settings(
}
});
}
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

@@ -40,7 +40,9 @@ use crate::completion_provider::{
use crate::mention_set::paste_images_as_context;
use crate::mention_set::{MentionSet, crease_for_mention};
use crate::terminal_codegen::TerminalCodegen;
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
use crate::{
CycleFavoriteModels, CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext,
};
actions!(inline_assistant, [ThumbsUpResult, ThumbsDownResult]);
@@ -148,7 +150,7 @@ impl<T: 'static> Render for PromptEditor<T> {
.into_any_element();
v_flex()
.key_context("PromptEditor")
.key_context("InlineAssistant")
.capture_action(cx.listener(Self::paste))
.block_mouse_except_scroll()
.size_full()
@@ -162,10 +164,6 @@ impl<T: 'static> Render for PromptEditor<T> {
.bg(cx.theme().colors().editor_background)
.child(
h_flex()
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
this.model_selector
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
}))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(Self::move_up))
@@ -174,6 +172,15 @@ impl<T: 'static> Render for PromptEditor<T> {
.on_action(cx.listener(Self::thumbs_down))
.capture_action(cx.listener(Self::cycle_prev))
.capture_action(cx.listener(Self::cycle_next))
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
this.model_selector
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
}))
.on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
this.model_selector.update(cx, |model_selector, cx| {
model_selector.cycle_favorite_models(window, cx);
});
}))
.child(
WithRemSize::new(ui_font_size)
.h_full()
@@ -855,7 +862,7 @@ impl<T: 'static> PromptEditor<T> {
.map(|this| {
if rated {
this.disabled(true)
.icon_color(Color::Ignored)
.icon_color(Color::Disabled)
.tooltip(move |_, cx| {
Tooltip::with_meta(
"Good Result",
@@ -865,8 +872,15 @@ impl<T: 'static> PromptEditor<T> {
)
})
} else {
this.icon_color(Color::Muted)
.tooltip(Tooltip::text("Good Result"))
this.icon_color(Color::Muted).tooltip(
move |_, cx| {
Tooltip::for_action(
"Good Result",
&ThumbsUpResult,
cx,
)
},
)
}
})
.on_click(cx.listener(|this, _, window, cx| {
@@ -879,7 +893,7 @@ impl<T: 'static> PromptEditor<T> {
.map(|this| {
if rated {
this.disabled(true)
.icon_color(Color::Ignored)
.icon_color(Color::Disabled)
.tooltip(move |_, cx| {
Tooltip::with_meta(
"Bad Result",
@@ -889,8 +903,15 @@ impl<T: 'static> PromptEditor<T> {
)
})
} else {
this.icon_color(Color::Muted)
.tooltip(Tooltip::text("Bad Result"))
this.icon_color(Color::Muted).tooltip(
move |_, cx| {
Tooltip::for_action(
"Bad Result",
&ThumbsDownResult,
cx,
)
},
)
}
})
.on_click(cx.listener(|this, _, window, cx| {
@@ -1088,7 +1109,6 @@ impl<T: 'static> PromptEditor<T> {
let colors = cx.theme().colors();
div()
.key_context("InlineAssistEditor")
.size_full()
.p_2()
.pl_1()

View File

@@ -20,14 +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>;
type OnToggleFavorite = Arc<dyn Fn(Arc<dyn LanguageModel>, bool, &mut 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,
on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &mut App) + 'static,
popover_styles: bool,
focus_handle: FocusHandle,
window: &mut Window,
@@ -133,7 +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,
on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &mut App) + 'static,
popover_styles: bool,
focus_handle: FocusHandle,
window: &mut Window,
@@ -250,6 +250,10 @@ impl LanguageModelPickerDelegate {
(self.get_active_model)(cx)
}
pub fn favorites_count(&self) -> usize {
self.all_models.favorites.len()
}
pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if self.all_models.favorites.is_empty() {
return;
@@ -561,7 +565,10 @@ impl PickerDelegate for LanguageModelPickerDelegate {
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)
cx.listener(move |picker, _, window, cx| {
on_toggle_favorite(model.clone(), !is_favorite, cx);
picker.refresh(window, cx);
})
};
Some(

View File

@@ -1,8 +1,8 @@
use crate::{
language_model_selector::{LanguageModelSelector, language_model_selector},
ui::BurnModeTooltip,
ui::{BurnModeTooltip, ModelSelectorTooltip},
};
use agent_settings::{AgentSettings, CompletionMode};
use agent_settings::CompletionMode;
use anyhow::Result;
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection, SlashCommandWorkingSet};
use assistant_slash_commands::{DefaultSlashCommand, FileSlashCommand, selections_creases};
@@ -2252,43 +2252,18 @@ impl TextThreadEditor {
.color(color)
.size(IconSize::XSmall);
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();
let show_cycle_row = self
.language_model_selector
.read(cx)
.delegate
.favorites_count()
> 1;
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()
let tooltip = Tooltip::element({
move |_, _cx| {
ModelSelectorTooltip::new(focus_handle.clone())
.show_cycle_row(show_cycle_row)
.into_any_element()
}
});

View File

@@ -1,5 +1,8 @@
use gpui::{Action, FocusHandle, prelude::*};
use gpui::{Action, ClickEvent, FocusHandle, prelude::*};
use ui::{ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
use zed_actions::agent::ToggleModelSelector;
use crate::CycleFavoriteModels;
enum ModelIcon {
Name(IconName),
@@ -48,7 +51,7 @@ pub struct ModelSelectorListItem {
is_selected: bool,
is_focused: bool,
is_favorite: bool,
on_toggle_favorite: Option<Box<dyn Fn(&App) + 'static>>,
on_toggle_favorite: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
impl ModelSelectorListItem {
@@ -89,7 +92,10 @@ impl ModelSelectorListItem {
self
}
pub fn on_toggle_favorite(mut self, handler: impl Fn(&App) + 'static) -> Self {
pub fn on_toggle_favorite(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_toggle_favorite = Some(Box::new(handler));
self
}
@@ -141,7 +147,7 @@ impl RenderOnce for ModelSelectorListItem {
.icon_color(color)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text(tooltip))
.on_click(move |_, _, cx| (handle_click)(cx)),
.on_click(move |event, window, cx| (handle_click)(event, window, cx)),
)
}
}))
@@ -187,3 +193,57 @@ impl RenderOnce for ModelSelectorFooter {
)
}
}
#[derive(IntoElement)]
pub struct ModelSelectorTooltip {
focus_handle: FocusHandle,
show_cycle_row: bool,
}
impl ModelSelectorTooltip {
pub fn new(focus_handle: FocusHandle) -> Self {
Self {
focus_handle,
show_cycle_row: true,
}
}
pub fn show_cycle_row(mut self, show: bool) -> Self {
self.show_cycle_row = show;
self
}
}
impl RenderOnce for ModelSelectorTooltip {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new("Change Model"))
.child(KeyBinding::for_action_in(
&ToggleModelSelector,
&self.focus_handle,
cx,
)),
)
.when(self.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,
&self.focus_handle,
cx,
)),
)
})
}
}

View File

@@ -314,6 +314,12 @@ impl BufferDiffSnapshot {
self.inner.hunks.is_empty()
}
pub fn base_text_string(&self) -> Option<String> {
self.inner
.base_text_exists
.then(|| self.inner.base_text.text())
}
pub fn secondary_diff(&self) -> Option<&BufferDiffSnapshot> {
self.secondary_diff.as_deref()
}

View File

@@ -113,7 +113,7 @@ impl CopilotSweAgentBot {
const USER_ID: i32 = 198982749;
/// The alias of the GitHub copilot user. Although https://api.github.com/users/copilot
/// yields a 404, GitHub still refers to the copilot bot user as @Copilot in some cases.
const NAME_ALIAS: &'static str = "copilot";
const NAME_ALIAS: &'static str = "Copilot";
/// Returns the `created_at` timestamp for the Dependabot bot user.
fn created_at() -> &'static NaiveDateTime {

View File

@@ -6745,8 +6745,13 @@ async fn test_preview_tabs(cx: &mut TestAppContext) {
});
// Split pane to the right
pane.update(cx, |pane, cx| {
pane.split(workspace::SplitDirection::Right, cx);
pane.update_in(cx, |pane, window, cx| {
pane.split(
workspace::SplitDirection::Right,
workspace::SplitMode::default(),
window,
cx,
);
});
cx.run_until_parked();
let right_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());

View File

@@ -1579,8 +1579,10 @@ impl Panel for DebugPanel {
Some(proto::PanelId::DebugPanel)
}
fn icon(&self, _window: &Window, _cx: &App) -> Option<IconName> {
Some(IconName::Debug)
fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
DebuggerSettings::get_global(cx)
.button
.then_some(IconName::Debug)
}
fn icon_tooltip(&self, _window: &Window, cx: &App) -> Option<&'static str> {

View File

@@ -19,6 +19,7 @@ ai_onboarding.workspace = true
anyhow.workspace = true
arrayvec.workspace = true
brotli.workspace = true
buffer_diff.workspace = true
client.workspace = true
cloud_llm_client.workspace = true
collections.workspace = true
@@ -52,7 +53,9 @@ settings.workspace = true
strum.workspace = true
telemetry.workspace = true
telemetry_events.workspace = true
text.workspace = true
thiserror.workspace = true
time.workspace = true
ui.workspace = true
util.workspace = true
uuid.workspace = true

View File

@@ -0,0 +1,375 @@
use crate::{
EditPredictionStore, StoredEvent,
cursor_excerpt::editable_and_context_ranges_for_cursor_position, example_spec::ExampleSpec,
};
use anyhow::Result;
use buffer_diff::BufferDiffSnapshot;
use collections::HashMap;
use gpui::{App, Entity, Task};
use language::{Buffer, ToPoint as _};
use project::Project;
use std::{collections::hash_map, fmt::Write as _, path::Path, sync::Arc};
use text::{BufferSnapshot as TextBufferSnapshot, ToOffset as _};
pub fn capture_example(
project: Entity<Project>,
buffer: Entity<Buffer>,
cursor_anchor: language::Anchor,
last_event_is_expected_patch: bool,
cx: &mut App,
) -> Option<Task<Result<ExampleSpec>>> {
let ep_store = EditPredictionStore::try_global(cx)?;
let snapshot = buffer.read(cx).snapshot();
let file = snapshot.file()?;
let worktree_id = file.worktree_id(cx);
let repository = project.read(cx).active_repository(cx)?;
let repository_snapshot = repository.read(cx).snapshot();
let worktree = project.read(cx).worktree_for_id(worktree_id, cx)?;
let cursor_path = worktree.read(cx).root_name().join(file.path());
if worktree.read(cx).abs_path() != repository_snapshot.work_directory_abs_path {
return None;
}
let repository_url = repository_snapshot
.remote_origin_url
.clone()
.or_else(|| repository_snapshot.remote_upstream_url.clone())?;
let revision = repository_snapshot.head_commit.as_ref()?.sha.to_string();
let mut events = ep_store.update(cx, |store, cx| {
store.edit_history_for_project_with_pause_split_last_event(&project, cx)
});
let git_store = project.read(cx).git_store().clone();
Some(cx.spawn(async move |mut cx| {
let snapshots_by_path = collect_snapshots(&project, &git_store, &events, &mut cx).await?;
let cursor_excerpt = cx
.background_executor()
.spawn(async move { compute_cursor_excerpt(&snapshot, cursor_anchor) })
.await;
let uncommitted_diff = cx
.background_executor()
.spawn(async move { compute_uncommitted_diff(snapshots_by_path) })
.await;
let mut edit_history = String::new();
let mut expected_patch = String::new();
if last_event_is_expected_patch {
if let Some(stored_event) = events.pop() {
zeta_prompt::write_event(&mut expected_patch, &stored_event.event);
}
}
for stored_event in &events {
zeta_prompt::write_event(&mut edit_history, &stored_event.event);
if !edit_history.ends_with('\n') {
edit_history.push('\n');
}
}
let name = generate_timestamp_name();
Ok(ExampleSpec {
name,
repository_url,
revision,
uncommitted_diff,
cursor_path: cursor_path.as_std_path().into(),
cursor_position: cursor_excerpt,
edit_history,
expected_patch,
})
}))
}
fn compute_cursor_excerpt(
snapshot: &language::BufferSnapshot,
cursor_anchor: language::Anchor,
) -> String {
let cursor_point = cursor_anchor.to_point(snapshot);
let (_editable_range, context_range) =
editable_and_context_ranges_for_cursor_position(cursor_point, snapshot, 100, 50);
let context_start_offset = context_range.start.to_offset(snapshot);
let cursor_offset = cursor_anchor.to_offset(snapshot);
let cursor_offset_in_excerpt = cursor_offset.saturating_sub(context_start_offset);
let mut excerpt = snapshot.text_for_range(context_range).collect::<String>();
if cursor_offset_in_excerpt <= excerpt.len() {
excerpt.insert_str(cursor_offset_in_excerpt, zeta_prompt::CURSOR_MARKER);
}
excerpt
}
async fn collect_snapshots(
project: &Entity<Project>,
git_store: &Entity<project::git_store::GitStore>,
events: &[StoredEvent],
cx: &mut gpui::AsyncApp,
) -> Result<HashMap<Arc<Path>, (TextBufferSnapshot, BufferDiffSnapshot)>> {
let mut snapshots_by_path = HashMap::default();
for stored_event in events {
let zeta_prompt::Event::BufferChange { path, .. } = stored_event.event.as_ref();
if let Some((project_path, full_path)) = project.read_with(cx, |project, cx| {
let project_path = project.find_project_path(path, cx)?;
let full_path = project
.worktree_for_id(project_path.worktree_id, cx)?
.read(cx)
.root_name()
.join(&project_path.path)
.as_std_path()
.into();
Some((project_path, full_path))
})? {
if let hash_map::Entry::Vacant(entry) = snapshots_by_path.entry(full_path) {
let buffer = project
.update(cx, |project, cx| {
project.open_buffer(project_path.clone(), cx)
})?
.await?;
let diff = git_store
.update(cx, |git_store, cx| {
git_store.open_uncommitted_diff(buffer.clone(), cx)
})?
.await?;
let diff_snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx))?;
entry.insert((stored_event.old_snapshot.clone(), diff_snapshot));
}
}
}
Ok(snapshots_by_path)
}
fn compute_uncommitted_diff(
snapshots_by_path: HashMap<Arc<Path>, (TextBufferSnapshot, BufferDiffSnapshot)>,
) -> String {
let mut uncommitted_diff = String::new();
for (full_path, (before_text, diff_snapshot)) in snapshots_by_path {
if let Some(head_text) = &diff_snapshot.base_text_string() {
let file_diff = language::unified_diff(head_text, &before_text.text());
if !file_diff.is_empty() {
let path_str = full_path.to_string_lossy();
writeln!(uncommitted_diff, "--- a/{path_str}").ok();
writeln!(uncommitted_diff, "+++ b/{path_str}").ok();
uncommitted_diff.push_str(&file_diff);
if !uncommitted_diff.ends_with('\n') {
uncommitted_diff.push('\n');
}
}
}
}
uncommitted_diff
}
fn generate_timestamp_name() -> String {
let format = time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]");
match format {
Ok(format) => {
let now = time::OffsetDateTime::now_local()
.unwrap_or_else(|_| time::OffsetDateTime::now_utc());
now.format(&format)
.unwrap_or_else(|_| "unknown-time".to_string())
}
Err(_) => "unknown-time".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use client::{Client, UserStore};
use clock::FakeSystemClock;
use gpui::{AppContext as _, TestAppContext, http_client::FakeHttpClient};
use indoc::indoc;
use language::{Anchor, Point};
use project::{FakeFs, Project};
use serde_json::json;
use settings::SettingsStore;
use std::path::Path;
#[gpui::test]
async fn test_capture_example(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let committed_contents = indoc! {"
fn main() {
one();
two();
three();
four();
five();
six();
seven();
eight();
nine();
}
"};
let disk_contents = indoc! {"
fn main() {
// comment 1
one();
two();
three();
four();
five();
six();
seven();
eight();
// comment 2
nine();
}
"};
fs.insert_tree(
"/project",
json!({
".git": {},
"src": {
"main.rs": disk_contents,
}
}),
)
.await;
fs.set_head_for_repo(
Path::new("/project/.git"),
&[("src/main.rs", committed_contents.to_string())],
"abc123def456",
);
fs.set_remote_for_repo(
Path::new("/project/.git"),
"origin",
"https://github.com/test/repo.git",
);
let project = Project::test(fs.clone(), ["/project".as_ref()], cx).await;
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer("/project/src/main.rs", cx)
})
.await
.unwrap();
let ep_store = cx.read(|cx| EditPredictionStore::try_global(cx).unwrap());
ep_store.update(cx, |ep_store, cx| {
ep_store.register_buffer(&buffer, &project, cx)
});
cx.run_until_parked();
buffer.update(cx, |buffer, cx| {
let point = Point::new(6, 0);
buffer.edit([(point..point, " // comment 3\n")], None, cx);
let point = Point::new(4, 0);
buffer.edit([(point..point, " // comment 4\n")], None, cx);
pretty_assertions::assert_eq!(
buffer.text(),
indoc! {"
fn main() {
// comment 1
one();
two();
// comment 4
three();
four();
// comment 3
five();
six();
seven();
eight();
// comment 2
nine();
}
"}
);
});
cx.run_until_parked();
let mut example = cx
.update(|cx| {
capture_example(project.clone(), buffer.clone(), Anchor::MIN, false, cx).unwrap()
})
.await
.unwrap();
example.name = "test".to_string();
pretty_assertions::assert_eq!(
example,
ExampleSpec {
name: "test".to_string(),
repository_url: "https://github.com/test/repo.git".to_string(),
revision: "abc123def456".to_string(),
uncommitted_diff: indoc! {"
--- a/project/src/main.rs
+++ b/project/src/main.rs
@@ -1,4 +1,5 @@
fn main() {
+ // comment 1
one();
two();
three();
@@ -7,5 +8,6 @@
six();
seven();
eight();
+ // comment 2
nine();
}
"}
.to_string(),
cursor_path: Path::new("project/src/main.rs").into(),
cursor_position: indoc! {"
<|user_cursor|>fn main() {
// comment 1
one();
two();
// comment 4
three();
four();
// comment 3
five();
six();
seven();
eight();
// comment 2
nine();
}
"}
.to_string(),
edit_history: indoc! {"
--- a/project/src/main.rs
+++ b/project/src/main.rs
@@ -2,8 +2,10 @@
// comment 1
one();
two();
+ // comment 4
three();
four();
+ // comment 3
five();
six();
seven();
"}
.to_string(),
expected_patch: "".to_string(),
}
);
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
zlog::init_test();
let http_client = FakeHttpClient::with_404_response();
let client = Client::new(Arc::new(FakeSystemClock::new()), http_client, cx);
language_model::init(client.clone(), cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
EditPredictionStore::global(&client, &user_store, cx);
})
}
}

View File

@@ -35,6 +35,7 @@ use semver::Version;
use serde::de::DeserializeOwned;
use settings::{EditPredictionProvider, SettingsStore, update_settings_file};
use std::collections::{VecDeque, hash_map};
use text::Edit;
use workspace::Workspace;
use std::ops::Range;
@@ -57,9 +58,9 @@ pub mod open_ai_response;
mod prediction;
pub mod sweep_ai;
#[cfg(any(test, feature = "test-support", feature = "cli-support"))]
pub mod udiff;
mod capture_example;
mod zed_edit_prediction_delegate;
pub mod zeta1;
pub mod zeta2;
@@ -74,6 +75,7 @@ pub use crate::prediction::EditPrediction;
pub use crate::prediction::EditPredictionId;
use crate::prediction::EditPredictionResult;
pub use crate::sweep_ai::SweepAi;
pub use capture_example::capture_example;
pub use language_model::ApiKeyState;
pub use telemetry_events::EditPredictionRating;
pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate;
@@ -231,8 +233,15 @@ pub struct EditPredictionFinishedDebugEvent {
pub type RequestDebugInfo = predict_edits_v3::DebugInfo;
/// An event with associated metadata for reconstructing buffer state.
#[derive(Clone)]
pub struct StoredEvent {
pub event: Arc<zeta_prompt::Event>,
pub old_snapshot: TextBufferSnapshot,
}
struct ProjectState {
events: VecDeque<Arc<zeta_prompt::Event>>,
events: VecDeque<StoredEvent>,
last_event: Option<LastEvent>,
recent_paths: VecDeque<ProjectPath>,
registered_buffers: HashMap<gpui::EntityId, RegisteredBuffer>,
@@ -248,7 +257,7 @@ struct ProjectState {
}
impl ProjectState {
pub fn events(&self, cx: &App) -> Vec<Arc<zeta_prompt::Event>> {
pub fn events(&self, cx: &App) -> Vec<StoredEvent> {
self.events
.iter()
.cloned()
@@ -260,7 +269,7 @@ impl ProjectState {
.collect()
}
pub fn events_split_by_pause(&self, cx: &App) -> Vec<Arc<zeta_prompt::Event>> {
pub fn events_split_by_pause(&self, cx: &App) -> Vec<StoredEvent> {
self.events
.iter()
.cloned()
@@ -415,7 +424,7 @@ impl LastEvent {
&self,
license_detection_watchers: &HashMap<WorktreeId, Rc<LicenseDetectionWatcher>>,
cx: &App,
) -> Option<Arc<zeta_prompt::Event>> {
) -> Option<StoredEvent> {
let path = buffer_path_with_id_fallback(self.new_file.as_ref(), &self.new_snapshot, cx);
let old_path = buffer_path_with_id_fallback(self.old_file.as_ref(), &self.old_snapshot, cx);
@@ -430,19 +439,22 @@ impl LastEvent {
})
});
let diff = language::unified_diff(&self.old_snapshot.text(), &self.new_snapshot.text());
let diff = compute_diff_between_snapshots(&self.old_snapshot, &self.new_snapshot)?;
if path == old_path && diff.is_empty() {
None
} else {
Some(Arc::new(zeta_prompt::Event::BufferChange {
old_path,
path,
diff,
in_open_source_repo,
// TODO: Actually detect if this edit was predicted or not
predicted: false,
}))
Some(StoredEvent {
event: Arc::new(zeta_prompt::Event::BufferChange {
old_path,
path,
diff,
in_open_source_repo,
// TODO: Actually detect if this edit was predicted or not
predicted: false,
}),
old_snapshot: self.old_snapshot.clone(),
})
}
}
@@ -475,6 +487,52 @@ impl LastEvent {
}
}
pub(crate) fn compute_diff_between_snapshots(
old_snapshot: &TextBufferSnapshot,
new_snapshot: &TextBufferSnapshot,
) -> Option<String> {
let edits: Vec<Edit<usize>> = new_snapshot
.edits_since::<usize>(&old_snapshot.version)
.collect();
let (first_edit, last_edit) = edits.first().zip(edits.last())?;
let old_start_point = old_snapshot.offset_to_point(first_edit.old.start);
let old_end_point = old_snapshot.offset_to_point(last_edit.old.end);
let new_start_point = new_snapshot.offset_to_point(first_edit.new.start);
let new_end_point = new_snapshot.offset_to_point(last_edit.new.end);
const CONTEXT_LINES: u32 = 3;
let old_context_start_row = old_start_point.row.saturating_sub(CONTEXT_LINES);
let new_context_start_row = new_start_point.row.saturating_sub(CONTEXT_LINES);
let old_context_end_row =
(old_end_point.row + 1 + CONTEXT_LINES).min(old_snapshot.max_point().row);
let new_context_end_row =
(new_end_point.row + 1 + CONTEXT_LINES).min(new_snapshot.max_point().row);
let old_start_line_offset = old_snapshot.point_to_offset(Point::new(old_context_start_row, 0));
let new_start_line_offset = new_snapshot.point_to_offset(Point::new(new_context_start_row, 0));
let old_end_line_offset = old_snapshot
.point_to_offset(Point::new(old_context_end_row + 1, 0).min(old_snapshot.max_point()));
let new_end_line_offset = new_snapshot
.point_to_offset(Point::new(new_context_end_row + 1, 0).min(new_snapshot.max_point()));
let old_edit_range = old_start_line_offset..old_end_line_offset;
let new_edit_range = new_start_line_offset..new_end_line_offset;
let old_region_text: String = old_snapshot.text_for_range(old_edit_range).collect();
let new_region_text: String = new_snapshot.text_for_range(new_edit_range).collect();
let diff = language::unified_diff_with_offsets(
&old_region_text,
&new_region_text,
old_context_start_row,
new_context_start_row,
);
Some(diff)
}
fn buffer_path_with_id_fallback(
file: Option<&Arc<dyn File>>,
snapshot: &TextBufferSnapshot,
@@ -643,7 +701,7 @@ impl EditPredictionStore {
&self,
project: &Entity<Project>,
cx: &App,
) -> Vec<Arc<zeta_prompt::Event>> {
) -> Vec<StoredEvent> {
self.projects
.get(&project.entity_id())
.map(|project_state| project_state.events(cx))
@@ -654,7 +712,7 @@ impl EditPredictionStore {
&self,
project: &Entity<Project>,
cx: &App,
) -> Vec<Arc<zeta_prompt::Event>> {
) -> Vec<StoredEvent> {
self.projects
.get(&project.entity_id())
.map(|project_state| project_state.events_split_by_pause(cx))
@@ -1536,8 +1594,10 @@ impl EditPredictionStore {
self.get_or_init_project(&project, cx);
let project_state = self.projects.get(&project.entity_id()).unwrap();
let events = project_state.events(cx);
let has_events = !events.is_empty();
let stored_events = project_state.events(cx);
let has_events = !stored_events.is_empty();
let events: Vec<Arc<zeta_prompt::Event>> =
stored_events.into_iter().map(|e| e.event).collect();
let debug_tx = project_state.debug_tx.clone();
let snapshot = active_buffer.read(cx).snapshot();

View File

@@ -1,5 +1,5 @@
use super::*;
use crate::{udiff::apply_diff_to_string, zeta1::MAX_EVENT_TOKENS};
use crate::{compute_diff_between_snapshots, udiff::apply_diff_to_string, zeta1::MAX_EVENT_TOKENS};
use client::{UserStore, test::FakeServer};
use clock::{FakeSystemClock, ReplicaId};
use cloud_api_types::{CreateLlmTokenResponse, LlmToken};
@@ -360,7 +360,7 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex
ep_store.edit_history_for_project(&project, cx)
});
assert_eq!(events.len(), 1);
let zeta_prompt::Event::BufferChange { diff, .. } = events[0].as_ref();
let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref();
assert_eq!(
diff.as_str(),
indoc! {"
@@ -377,7 +377,7 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex
ep_store.edit_history_for_project_with_pause_split_last_event(&project, cx)
});
assert_eq!(events.len(), 2);
let zeta_prompt::Event::BufferChange { diff, .. } = events[0].as_ref();
let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref();
assert_eq!(
diff.as_str(),
indoc! {"
@@ -389,7 +389,7 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex
"}
);
let zeta_prompt::Event::BufferChange { diff, .. } = events[1].as_ref();
let zeta_prompt::Event::BufferChange { diff, .. } = events[1].event.as_ref();
assert_eq!(
diff.as_str(),
indoc! {"
@@ -2082,6 +2082,74 @@ async fn test_unauthenticated_with_custom_url_allows_prediction_impl(cx: &mut Te
);
}
#[gpui::test]
fn test_compute_diff_between_snapshots(cx: &mut TestAppContext) {
let buffer = cx.new(|cx| {
Buffer::local(
indoc! {"
zero
one
two
three
four
five
six
seven
eight
nine
ten
eleven
twelve
thirteen
fourteen
fifteen
sixteen
seventeen
eighteen
nineteen
twenty
twenty-one
twenty-two
twenty-three
twenty-four
"},
cx,
)
});
let old_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
buffer.update(cx, |buffer, cx| {
let point = Point::new(12, 0);
buffer.edit([(point..point, "SECOND INSERTION\n")], None, cx);
let point = Point::new(8, 0);
buffer.edit([(point..point, "FIRST INSERTION\n")], None, cx);
});
let new_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
let diff = compute_diff_between_snapshots(&old_snapshot, &new_snapshot).unwrap();
assert_eq!(
diff,
indoc! {"
@@ -6,10 +6,12 @@
five
six
seven
+FIRST INSERTION
eight
nine
ten
eleven
+SECOND INSERTION
twelve
thirteen
fourteen
"}
);
}
#[ctor::ctor]
fn init_logger() {
zlog::init_test();

View File

@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use std::{fmt::Write as _, mem, path::Path, sync::Arc};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExampleSpec {
#[serde(default)]
pub name: String,

View File

@@ -45,6 +45,11 @@ pub async fn run_format_prompt(
let snapshot = state.buffer.read_with(&cx, |buffer, _| buffer.snapshot())?;
let project = state.project.clone();
let (_, input) = ep_store.update(&mut cx, |ep_store, cx| {
let events = ep_store
.edit_history_for_project(&project, cx)
.into_iter()
.map(|e| e.event)
.collect();
anyhow::Ok(zeta2_prompt_input(
&snapshot,
example
@@ -53,7 +58,7 @@ pub async fn run_format_prompt(
.context("context must be set")?
.files
.clone(),
ep_store.edit_history_for_project(&project, cx),
events,
example.spec.cursor_path.clone(),
example
.buffer

View File

@@ -15,8 +15,7 @@ doctest = false
[dependencies]
anyhow.workspace = true
buffer_diff.workspace = true
git.workspace = true
log.workspace = true
collections.workspace = true
time.workspace = true
client.workspace = true
cloud_llm_client.workspace = true
@@ -50,11 +49,18 @@ zed_actions.workspace = true
zeta_prompt.workspace = true
[dev-dependencies]
clock.workspace = true
copilot = { workspace = true, features = ["test-support"] }
editor = { workspace = true, features = ["test-support"] }
futures.workspace = true
indoc.workspace = true
language_model.workspace = true
lsp = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
release_channel.workspace = true
semver.workspace = true
serde_json.workspace = true
theme = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
zlog.workspace = true

View File

@@ -915,11 +915,8 @@ impl EditPredictionButton {
.when(
cx.has_flag::<PredictEditsRatePredictionsFeatureFlag>(),
|this| {
this.action(
"Capture Edit Prediction Example",
CaptureExample.boxed_clone(),
)
.action("Rate Predictions", RatePredictions.boxed_clone())
this.action("Capture Prediction Example", CaptureExample.boxed_clone())
.action("Rate Predictions", RatePredictions.boxed_clone())
},
);
}

View File

@@ -2,25 +2,17 @@ mod edit_prediction_button;
mod edit_prediction_context_view;
mod rate_prediction_modal;
use std::any::{Any as _, TypeId};
use std::path::Path;
use std::sync::Arc;
use command_palette_hooks::CommandPaletteFilter;
use edit_prediction::{
EditPredictionStore, ResetOnboarding, Zeta2FeatureFlag, example_spec::ExampleSpec,
};
use edit_prediction::{ResetOnboarding, Zeta2FeatureFlag, capture_example};
use edit_prediction_context_view::EditPredictionContextView;
use editor::Editor;
use feature_flags::FeatureFlagAppExt as _;
use git::repository::DiffType;
use gpui::{Window, actions};
use language::ToPoint as _;
use log;
use gpui::actions;
use language::language_settings::AllLanguageSettings;
use project::DisableAiSettings;
use rate_prediction_modal::RatePredictionsModal;
use settings::{Settings as _, SettingsStore};
use text::ToOffset as _;
use std::any::{Any as _, TypeId};
use ui::{App, prelude::*};
use workspace::{SplitDirection, Workspace};
@@ -56,7 +48,9 @@ pub fn init(cx: &mut App) {
}
});
workspace.register_action(capture_edit_prediction_example);
workspace.register_action(|workspace, _: &CaptureExample, window, cx| {
capture_example_as_markdown(workspace, window, cx);
});
workspace.register_action_renderer(|div, _, _, cx| {
let has_flag = cx.has_flag::<Zeta2FeatureFlag>();
div.when(has_flag, |div| {
@@ -138,182 +132,48 @@ fn feature_gate_predict_edits_actions(cx: &mut App) {
.detach();
}
fn capture_edit_prediction_example(
fn capture_example_as_markdown(
workspace: &mut Workspace,
_: &CaptureExample,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let Some(ep_store) = EditPredictionStore::try_global(cx) else {
return;
};
let project = workspace.project().clone();
let (worktree_root, repository) = {
let project_ref = project.read(cx);
let worktree_root = project_ref
.visible_worktrees(cx)
.next()
.map(|worktree| worktree.read(cx).abs_path());
let repository = project_ref.active_repository(cx);
(worktree_root, repository)
};
let (Some(worktree_root), Some(repository)) = (worktree_root, repository) else {
log::error!("CaptureExampleSpec: missing worktree or active repository");
return;
};
let repository_snapshot = repository.read(cx).snapshot();
if worktree_root.as_ref() != repository_snapshot.work_directory_abs_path.as_ref() {
log::error!(
"repository is not at worktree root (repo={:?}, worktree={:?})",
repository_snapshot.work_directory_abs_path,
worktree_root
);
return;
}
let Some(repository_url) = repository_snapshot
.remote_origin_url
.clone()
.or_else(|| repository_snapshot.remote_upstream_url.clone())
else {
log::error!("active repository has no origin/upstream remote url");
return;
};
let Some(revision) = repository_snapshot
.head_commit
.as_ref()
.map(|commit| commit.sha.to_string())
else {
log::error!("active repository has no head commit");
return;
};
let mut events = ep_store.update(cx, |store, cx| {
store.edit_history_for_project_with_pause_split_last_event(&project, cx)
});
let Some(editor) = workspace.active_item_as::<Editor>(cx) else {
log::error!("no active editor");
return;
};
let Some(project_path) = editor.read(cx).project_path(cx) else {
log::error!("active editor has no project path");
return;
};
let Some((buffer, cursor_anchor)) = editor
.read(cx)
.buffer()
.read(cx)
.text_anchor_for_position(editor.read(cx).selections.newest_anchor().head(), cx)
else {
log::error!("failed to resolve cursor buffer/anchor");
return;
};
let snapshot = buffer.read(cx).snapshot();
let cursor_point = cursor_anchor.to_point(&snapshot);
let (_editable_range, context_range) =
edit_prediction::cursor_excerpt::editable_and_context_ranges_for_cursor_position(
cursor_point,
&snapshot,
100,
50,
);
let cursor_path: Arc<Path> = repository
.read(cx)
.project_path_to_repo_path(&project_path, cx)
.map(|repo_path| Path::new(repo_path.as_unix_str()).into())
.unwrap_or_else(|| Path::new(project_path.path.as_unix_str()).into());
let cursor_position = {
let context_start_offset = context_range.start.to_offset(&snapshot);
let cursor_offset = cursor_anchor.to_offset(&snapshot);
let cursor_offset_in_excerpt = cursor_offset.saturating_sub(context_start_offset);
let mut excerpt = snapshot.text_for_range(context_range).collect::<String>();
if cursor_offset_in_excerpt <= excerpt.len() {
excerpt.insert_str(cursor_offset_in_excerpt, zeta_prompt::CURSOR_MARKER);
}
excerpt
};
) -> Option<()> {
let markdown_language = workspace
.app_state()
.languages
.language_for_name("Markdown");
let fs = workspace.app_state().fs.clone();
let project = workspace.project().clone();
let editor = workspace.active_item_as::<Editor>(cx)?;
let editor = editor.read(cx);
let (buffer, cursor_anchor) = editor
.buffer()
.read(cx)
.text_anchor_for_position(editor.selections.newest_anchor().head(), cx)?;
let example = capture_example(project.clone(), buffer, cursor_anchor, true, cx)?;
let examples_dir = AllLanguageSettings::get_global(cx)
.edit_predictions
.examples_dir
.clone();
cx.spawn_in(window, async move |workspace_entity, cx| {
let markdown_language = markdown_language.await?;
let example_spec = example.await?;
let buffer = if let Some(dir) = examples_dir {
fs.create_dir(&dir).await.ok();
let mut path = dir.join(&example_spec.name.replace(' ', "--").replace(':', "-"));
path.set_extension("md");
project.update(cx, |project, cx| project.open_local_buffer(&path, cx))
} else {
project.update(cx, |project, cx| project.create_buffer(false, cx))
}?
.await?;
let uncommitted_diff_rx = repository.update(cx, |repository, cx| {
repository.diff(DiffType::HeadToWorktree, cx)
})?;
let uncommitted_diff = match uncommitted_diff_rx.await {
Ok(Ok(diff)) => diff,
Ok(Err(error)) => {
log::error!("failed to compute uncommitted diff: {error:#}");
return Ok(());
}
Err(error) => {
log::error!("uncommitted diff channel dropped: {error:#}");
return Ok(());
}
};
let mut edit_history = String::new();
let mut expected_patch = String::new();
if let Some(last_event) = events.pop() {
for event in &events {
zeta_prompt::write_event(&mut edit_history, event);
if !edit_history.ends_with('\n') {
edit_history.push('\n');
}
edit_history.push('\n');
}
zeta_prompt::write_event(&mut expected_patch, &last_event);
}
let format =
time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]");
let name = match format {
Ok(format) => {
let now = time::OffsetDateTime::now_local()
.unwrap_or_else(|_| time::OffsetDateTime::now_utc());
now.format(&format)
.unwrap_or_else(|_| "unknown-time".to_string())
}
Err(_) => "unknown-time".to_string(),
};
let markdown = ExampleSpec {
name,
repository_url,
revision,
uncommitted_diff,
cursor_path,
cursor_position,
edit_history,
expected_patch,
}
.to_markdown();
let buffer = project
.update(cx, |project, cx| project.create_buffer(false, cx))?
.await?;
buffer.update(cx, |buffer, cx| {
buffer.set_text(markdown, cx);
buffer.set_text(example_spec.to_markdown(), cx);
buffer.set_language(Some(markdown_language), cx);
})?;
workspace_entity.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(
Box::new(
@@ -327,4 +187,5 @@ fn capture_edit_prediction_example(
})
})
.detach_and_log_err(cx);
None
}

View File

@@ -18346,7 +18346,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon
);
update_test_project_settings(cx, |project_settings| {
project_settings.lsp.insert(
project_settings.lsp.0.insert(
"Some other server name".into(),
LspSettings {
binary: None,
@@ -18367,7 +18367,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon
);
update_test_project_settings(cx, |project_settings| {
project_settings.lsp.insert(
project_settings.lsp.0.insert(
language_server_name.into(),
LspSettings {
binary: None,
@@ -18388,7 +18388,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon
);
update_test_project_settings(cx, |project_settings| {
project_settings.lsp.insert(
project_settings.lsp.0.insert(
language_server_name.into(),
LspSettings {
binary: None,
@@ -18409,7 +18409,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon
);
update_test_project_settings(cx, |project_settings| {
project_settings.lsp.insert(
project_settings.lsp.0.insert(
language_server_name.into(),
LspSettings {
binary: None,
@@ -29602,6 +29602,17 @@ async fn test_newline_task_list_continuation(cx: &mut TestAppContext) {
- [ ] ˇ
"});
// Case 2.1: Works with uppercase checked marker too
cx.set_state(indoc! {"
- [X] completed taskˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- [X] completed task
- [ ] ˇ
"});
// Case 3: Cursor position doesn't matter - content after marker is what counts
cx.set_state(indoc! {"
- [ ] taˇsk

View File

@@ -164,11 +164,6 @@ pub fn deploy_context_menu(
window.focus(&editor.focus_handle(cx), cx);
}
// Don't show context menu for inline editors
if !editor.mode().is_full() {
return;
}
let display_map = editor.display_snapshot(cx);
let source_anchor = display_map.display_point_to_anchor(point, text::Bias::Right);
let context_menu = if let Some(custom) = editor.custom_context_menu.take() {
@@ -179,6 +174,11 @@ pub fn deploy_context_menu(
};
menu
} else {
// Don't show context menu for inline editors (only applies to default menu)
if !editor.mode().is_full() {
return;
}
// Don't show the context menu if there isn't a project associated with this editor
let Some(project) = editor.project.clone() else {
return;

View File

@@ -1760,16 +1760,19 @@ impl PickerDelegate for FileFinderDelegate {
menu.context(focus_handle)
.action(
"Split Left",
pane::SplitLeft.boxed_clone(),
pane::SplitLeft::default().boxed_clone(),
)
.action(
"Split Right",
pane::SplitRight.boxed_clone(),
pane::SplitRight::default().boxed_clone(),
)
.action(
"Split Up",
pane::SplitUp::default().boxed_clone(),
)
.action("Split Up", pane::SplitUp.boxed_clone())
.action(
"Split Down",
pane::SplitDown.boxed_clone(),
pane::SplitDown::default().boxed_clone(),
)
}
}))

View File

@@ -156,8 +156,16 @@ impl GitRepository for FakeGitRepository {
})
}
fn remote_url(&self, _name: &str) -> BoxFuture<'_, Option<String>> {
async move { None }.boxed()
fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
let name = name.to_string();
let fut = self.with_state_async(false, move |state| {
state
.remotes
.get(&name)
.context("remote not found")
.cloned()
});
async move { fut.await.ok() }.boxed()
}
fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {

View File

@@ -1857,6 +1857,18 @@ impl FakeFs {
.unwrap();
}
pub fn set_remote_for_repo(
&self,
dot_git: &Path,
name: impl Into<String>,
url: impl Into<String>,
) {
self.with_git_state(dot_git, true, |state| {
state.remotes.insert(name.into(), url.into());
})
.unwrap();
}
pub fn insert_branches(&self, dot_git: &Path, branches: &[&str]) {
self.with_git_state(dot_git, true, |state| {
if let Some(first) = branches.first()

View File

@@ -8,9 +8,9 @@ use git::{
parse_git_remote_url,
};
use gpui::{
AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, Context, Element, Entity,
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
PromptLevel, Render, Styled, Task, WeakEntity, Window, actions,
AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, ClipboardItem, Context,
Element, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement,
ParentElement, PromptLevel, Render, Styled, Task, WeakEntity, Window, actions,
};
use language::{
Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _,
@@ -24,7 +24,7 @@ use std::{
sync::Arc,
};
use theme::ActiveTheme;
use ui::{DiffStat, Tooltip, prelude::*};
use ui::{ButtonLike, DiffStat, Tooltip, prelude::*};
use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff};
use workspace::item::TabTooltipContent;
use workspace::{
@@ -383,6 +383,7 @@ impl CommitView {
fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let commit = &self.commit;
let author_name = commit.author_name.clone();
let commit_sha = commit.sha.clone();
let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
.unwrap_or_else(|_| time::OffsetDateTime::now_utc());
let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
@@ -429,6 +430,19 @@ impl CommitView {
.full_width()
});
let clipboard_has_link = cx
.read_from_clipboard()
.and_then(|entry| entry.text())
.map_or(false, |clipboard_text| {
clipboard_text.trim() == commit_sha.as_ref()
});
let (copy_icon, copy_icon_color) = if clipboard_has_link {
(IconName::Check, Color::Success)
} else {
(IconName::Copy, Color::Muted)
};
h_flex()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
@@ -454,13 +468,47 @@ impl CommitView {
h_flex()
.gap_1()
.child(Label::new(author_name).color(Color::Default))
.child(
Label::new(format!("Commit:{}", commit.sha))
.color(Color::Muted)
.size(LabelSize::Small)
.truncate()
.buffer_font(cx),
),
.child({
ButtonLike::new("sha")
.child(
h_flex()
.group("sha_btn")
.size_full()
.max_w_32()
.gap_0p5()
.child(
Label::new(commit_sha.clone())
.color(Color::Muted)
.size(LabelSize::Small)
.truncate()
.buffer_font(cx),
)
.child(
div().visible_on_hover("sha_btn").child(
Icon::new(copy_icon)
.color(copy_icon_color)
.size(IconSize::Small),
),
),
)
.tooltip({
let commit_sha = commit_sha.clone();
move |_, cx| {
Tooltip::with_meta(
"Copy Commit SHA",
None,
commit_sha.clone(),
cx,
)
}
})
.on_click(move |_, _, cx| {
cx.stop_propagation();
cx.write_to_clipboard(ClipboardItem::new_string(
commit_sha.to_string(),
));
})
}),
)
.child(
h_flex()

View File

@@ -3638,7 +3638,7 @@ impl GitPanel {
self.entry_count += 1;
let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo)
.as_bool()
.unwrap_or(false);
.unwrap_or(true);
if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) {
self.conflicted_count += 1;

View File

@@ -2154,7 +2154,6 @@ impl Interactivity {
|| cx.active_drag.is_some() && !self.drag_over_styles.is_empty()
{
let hitbox = hitbox.clone();
let was_hovered = hitbox.is_hovered(window);
let hover_state = self.hover_style.as_ref().and_then(|_| {
element_state
.as_ref()
@@ -2162,8 +2161,12 @@ impl Interactivity {
.cloned()
});
let current_view = window.current_view();
window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| {
let hovered = hitbox.is_hovered(window);
let was_hovered = hover_state
.as_ref()
.is_some_and(|state| state.borrow().element);
if phase == DispatchPhase::Capture && hovered != was_hovered {
if let Some(hover_state) = &hover_state {
hover_state.borrow_mut().element = hovered;
@@ -2179,12 +2182,13 @@ impl Interactivity {
.as_ref()
.and_then(|element| element.hover_state.as_ref())
.cloned();
let was_group_hovered = group_hitbox_id.is_hovered(window);
let current_view = window.current_view();
window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| {
let group_hovered = group_hitbox_id.is_hovered(window);
let was_group_hovered = hover_state
.as_ref()
.is_some_and(|state| state.borrow().group);
if phase == DispatchPhase::Capture && group_hovered != was_group_hovered {
if let Some(hover_state) = &hover_state {
hover_state.borrow_mut().group = group_hovered;

View File

@@ -46,9 +46,9 @@ pub unsafe fn new_renderer(
_native_window: *mut c_void,
_native_view: *mut c_void,
_bounds: crate::Size<f32>,
_transparent: bool,
transparent: bool,
) -> Renderer {
MetalRenderer::new(context)
MetalRenderer::new(context, transparent)
}
pub(crate) struct InstanceBufferPool {
@@ -128,7 +128,7 @@ pub struct PathRasterizationVertex {
}
impl MetalRenderer {
pub fn new(instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>) -> Self {
pub fn new(instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>, transparent: bool) -> Self {
// Prefer lowpower integrated GPUs on Intel Mac. On Apple
// Silicon, there is only ever one GPU, so this is equivalent to
// `metal::Device::system_default()`.
@@ -152,7 +152,9 @@ impl MetalRenderer {
let layer = metal::MetalLayer::new();
layer.set_device(&device);
layer.set_pixel_format(MTLPixelFormat::BGRA8Unorm);
layer.set_opaque(false);
// Support direct-to-display rendering if the window is not transparent
// https://developer.apple.com/documentation/metal/managing-your-game-window-for-metal-in-macos
layer.set_opaque(!transparent);
layer.set_maximum_drawable_count(3);
unsafe {
let _: () = msg_send![&*layer, setAllowsNextDrawableTimeout: NO];
@@ -352,8 +354,8 @@ impl MetalRenderer {
}
}
pub fn update_transparency(&self, _transparent: bool) {
// todo(mac)?
pub fn update_transparency(&self, transparent: bool) {
self.layer.set_opaque(!transparent);
}
pub fn destroy(&self) {

View File

@@ -42,7 +42,7 @@ impl WindowsWindowInner {
let handled = match msg {
// eagerly activate the window, so calls to `active_window` will work correctly
WM_MOUSEACTIVATE => {
unsafe { SetActiveWindow(handle).log_err() };
unsafe { SetActiveWindow(handle).ok() };
None
}
WM_ACTIVATE => self.handle_activate_msg(wparam),

View File

@@ -740,8 +740,8 @@ impl PlatformWindow for WindowsWindow {
ShowWindowAsync(hwnd, SW_RESTORE).ok().log_err();
}
SetActiveWindow(hwnd).log_err();
SetFocus(Some(hwnd)).log_err();
SetActiveWindow(hwnd).ok();
SetFocus(Some(hwnd)).ok();
}
// premium ragebait by windows, this is needed because the window

View File

@@ -20,6 +20,7 @@ dap.workspace = true
extension.workspace = true
gpui.workspace = true
language.workspace = true
lsp.workspace = true
paths.workspace = true
project.workspace = true
schemars.workspace = true

View File

@@ -2,9 +2,11 @@
use std::{str::FromStr, sync::Arc};
use anyhow::{Context as _, Result};
use gpui::{App, AsyncApp, BorrowAppContext as _, Entity, WeakEntity};
use gpui::{App, AsyncApp, BorrowAppContext as _, Entity, Task, WeakEntity};
use language::{LanguageRegistry, language_settings::all_language_settings};
use project::LspStore;
use lsp::LanguageServerBinaryOptions;
use project::{LspStore, lsp_store::LocalLspAdapterDelegate};
use settings::LSP_SETTINGS_SCHEMA_URL_PREFIX;
use util::schemars::{AllowTrailingCommas, DefaultDenyUnknownFields};
// Origin: https://github.com/SchemaStore/schemastore
@@ -75,23 +77,28 @@ fn handle_schema_request(
lsp_store: Entity<LspStore>,
uri: String,
cx: &mut AsyncApp,
) -> Result<String> {
let languages = lsp_store.read_with(cx, |lsp_store, _| lsp_store.languages.clone())?;
let schema = resolve_schema_request(&languages, uri, cx)?;
serde_json::to_string(&schema).context("Failed to serialize schema")
) -> Task<Result<String>> {
let languages = lsp_store.read_with(cx, |lsp_store, _| lsp_store.languages.clone());
cx.spawn(async move |cx| {
let languages = languages?;
let schema = resolve_schema_request(&languages, lsp_store, uri, cx).await?;
serde_json::to_string(&schema).context("Failed to serialize schema")
})
}
pub fn resolve_schema_request(
pub async fn resolve_schema_request(
languages: &Arc<LanguageRegistry>,
lsp_store: Entity<LspStore>,
uri: String,
cx: &mut AsyncApp,
) -> Result<serde_json::Value> {
let path = uri.strip_prefix("zed://schemas/").context("Invalid URI")?;
resolve_schema_request_inner(languages, path, cx)
resolve_schema_request_inner(languages, lsp_store, path, cx).await
}
pub fn resolve_schema_request_inner(
pub async fn resolve_schema_request_inner(
languages: &Arc<LanguageRegistry>,
lsp_store: Entity<LspStore>,
path: &str,
cx: &mut AsyncApp,
) -> Result<serde_json::Value> {
@@ -99,37 +106,106 @@ pub fn resolve_schema_request_inner(
let schema_name = schema_name.unwrap_or(path);
let schema = match schema_name {
"settings" => cx.update(|cx| {
let font_names = &cx.text_system().all_font_names();
let language_names = &languages
.language_names()
"settings" if rest.is_some_and(|r| r.starts_with("lsp/")) => {
let lsp_name = rest
.and_then(|r| {
r.strip_prefix(
LSP_SETTINGS_SCHEMA_URL_PREFIX
.strip_prefix("zed://schemas/settings/")
.unwrap(),
)
})
.context("Invalid LSP schema path")?;
let adapter = languages
.all_lsp_adapters()
.into_iter()
.map(|name| name.to_string())
.find(|adapter| adapter.name().as_ref() as &str == lsp_name)
.with_context(|| format!("LSP adapter not found: {}", lsp_name))?;
let delegate = cx.update(|inner_cx| {
lsp_store.update(inner_cx, |lsp_store, inner_cx| {
let Some(local) = lsp_store.as_local() else {
return None;
};
let Some(worktree) = local.worktree_store.read(inner_cx).worktrees().next() else {
return None;
};
Some(LocalLspAdapterDelegate::from_local_lsp(
local, &worktree, inner_cx,
))
})
})?.context("Failed to create adapter delegate - either LSP store is not in local mode or no worktree is available")?;
let adapter_for_schema = adapter.clone();
let binary = adapter
.get_language_server_command(
delegate,
None,
LanguageServerBinaryOptions {
allow_path_lookup: true,
allow_binary_download: false,
pre_release: false,
},
cx,
)
.await
.await
.0.with_context(|| format!("Failed to find language server {lsp_name} to generate initialization params schema"))?;
adapter_for_schema
.adapter
.clone()
.initialization_options_schema(&binary)
.await
.unwrap_or_else(|| {
serde_json::json!({
"type": "object",
"additionalProperties": true
})
})
}
"settings" => {
let lsp_adapter_names = languages
.all_lsp_adapters()
.into_iter()
.map(|adapter| adapter.name().to_string())
.collect::<Vec<_>>();
let mut icon_theme_names = vec![];
let mut theme_names = vec![];
if let Some(registry) = theme::ThemeRegistry::try_global(cx) {
icon_theme_names.extend(
registry
.list_icon_themes()
.into_iter()
.map(|icon_theme| icon_theme.name),
);
theme_names.extend(registry.list_names());
}
let icon_theme_names = icon_theme_names.as_slice();
let theme_names = theme_names.as_slice();
cx.update(|cx| {
let font_names = &cx.text_system().all_font_names();
let language_names = &languages
.language_names()
.into_iter()
.map(|name| name.to_string())
.collect::<Vec<_>>();
cx.global::<settings::SettingsStore>().json_schema(
&settings::SettingsJsonSchemaParams {
language_names,
font_names,
theme_names,
icon_theme_names,
},
)
})?,
let mut icon_theme_names = vec![];
let mut theme_names = vec![];
if let Some(registry) = theme::ThemeRegistry::try_global(cx) {
icon_theme_names.extend(
registry
.list_icon_themes()
.into_iter()
.map(|icon_theme| icon_theme.name),
);
theme_names.extend(registry.list_names());
}
let icon_theme_names = icon_theme_names.as_slice();
let theme_names = theme_names.as_slice();
cx.global::<settings::SettingsStore>().json_schema(
&settings::SettingsJsonSchemaParams {
language_names,
font_names,
theme_names,
icon_theme_names,
lsp_adapter_names: &lsp_adapter_names,
},
)
})?
}
"keymap" => cx.update(settings::KeymapFile::generate_json_schema_for_registered_actions)?,
"action" => {
let normalized_action_name = rest.context("No Action name provided")?;

View File

@@ -67,7 +67,7 @@ use task::RunnableTag;
pub use task_context::{ContextLocation, ContextProvider, RunnableRange};
pub use text_diff::{
DiffOptions, apply_diff_patch, line_diff, text_diff, text_diff_with_options, unified_diff,
word_diff_ranges,
unified_diff_with_offsets, word_diff_ranges,
};
use theme::SyntaxTheme;
pub use toolchain::{
@@ -461,6 +461,14 @@ pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller {
Ok(None)
}
/// Returns the JSON schema of the initialization_options for the language server.
async fn initialization_options_schema(
self: Arc<Self>,
_language_server_binary: &LanguageServerBinary,
) -> Option<serde_json::Value> {
None
}
async fn workspace_configuration(
self: Arc<Self>,
_: &Arc<dyn LspAdapterDelegate>,

View File

@@ -392,6 +392,7 @@ pub struct EditPredictionSettings {
/// Whether edit predictions are enabled in the assistant panel.
/// This setting has no effect if globally disabled.
pub enabled_in_text_threads: bool,
pub examples_dir: Option<Arc<Path>>,
}
impl EditPredictionSettings {
@@ -699,6 +700,7 @@ impl settings::Settings for AllLanguageSettings {
copilot: copilot_settings,
codestral: codestral_settings,
enabled_in_text_threads,
examples_dir: edit_predictions.examples_dir,
},
defaults: default_language_settings,
languages,

View File

@@ -1,25 +1,139 @@
use crate::{CharClassifier, CharKind, CharScopeContext, LanguageScope};
use anyhow::{Context, anyhow};
use imara_diff::{
Algorithm, UnifiedDiffBuilder, diff,
intern::{InternedInput, Token},
Algorithm, Sink, diff,
intern::{InternedInput, Interner, Token},
sources::lines_with_terminator,
};
use std::{iter, ops::Range, sync::Arc};
use std::{fmt::Write, iter, ops::Range, sync::Arc};
const MAX_WORD_DIFF_LEN: usize = 512;
const MAX_WORD_DIFF_LINE_COUNT: usize = 8;
/// Computes a diff between two strings, returning a unified diff string.
pub fn unified_diff(old_text: &str, new_text: &str) -> String {
unified_diff_with_offsets(old_text, new_text, 0, 0)
}
/// Computes a diff between two strings, returning a unified diff string with
/// hunk headers adjusted to reflect the given starting line numbers (1-indexed).
pub fn unified_diff_with_offsets(
old_text: &str,
new_text: &str,
old_start_line: u32,
new_start_line: u32,
) -> String {
let input = InternedInput::new(old_text, new_text);
diff(
Algorithm::Histogram,
&input,
UnifiedDiffBuilder::new(&input),
OffsetUnifiedDiffBuilder::new(&input, old_start_line, new_start_line),
)
}
/// A unified diff builder that applies line number offsets to hunk headers.
struct OffsetUnifiedDiffBuilder<'a> {
before: &'a [Token],
after: &'a [Token],
interner: &'a Interner<&'a str>,
pos: u32,
before_hunk_start: u32,
after_hunk_start: u32,
before_hunk_len: u32,
after_hunk_len: u32,
old_line_offset: u32,
new_line_offset: u32,
buffer: String,
dst: String,
}
impl<'a> OffsetUnifiedDiffBuilder<'a> {
fn new(input: &'a InternedInput<&'a str>, old_line_offset: u32, new_line_offset: u32) -> Self {
Self {
before_hunk_start: 0,
after_hunk_start: 0,
before_hunk_len: 0,
after_hunk_len: 0,
old_line_offset,
new_line_offset,
buffer: String::with_capacity(8),
dst: String::new(),
interner: &input.interner,
before: &input.before,
after: &input.after,
pos: 0,
}
}
fn print_tokens(&mut self, tokens: &[Token], prefix: char) {
for &token in tokens {
writeln!(&mut self.buffer, "{prefix}{}", self.interner[token]).unwrap();
}
}
fn flush(&mut self) {
if self.before_hunk_len == 0 && self.after_hunk_len == 0 {
return;
}
let end = (self.pos + 3).min(self.before.len() as u32);
self.update_pos(end, end);
writeln!(
&mut self.dst,
"@@ -{},{} +{},{} @@",
self.before_hunk_start + 1 + self.old_line_offset,
self.before_hunk_len,
self.after_hunk_start + 1 + self.new_line_offset,
self.after_hunk_len,
)
.unwrap();
write!(&mut self.dst, "{}", &self.buffer).unwrap();
self.buffer.clear();
self.before_hunk_len = 0;
self.after_hunk_len = 0;
}
fn update_pos(&mut self, print_to: u32, move_to: u32) {
self.print_tokens(&self.before[self.pos as usize..print_to as usize], ' ');
let len = print_to - self.pos;
self.pos = move_to;
self.before_hunk_len += len;
self.after_hunk_len += len;
}
}
impl Sink for OffsetUnifiedDiffBuilder<'_> {
type Out = String;
fn process_change(&mut self, before: Range<u32>, after: Range<u32>) {
if before.start - self.pos > 6 {
self.flush();
}
if self.before_hunk_len == 0 && self.after_hunk_len == 0 {
self.pos = before.start.saturating_sub(3);
self.before_hunk_start = self.pos;
self.after_hunk_start = after.start.saturating_sub(3);
}
self.update_pos(before.start, before.end);
self.before_hunk_len += before.end - before.start;
self.after_hunk_len += after.end - after.start;
self.print_tokens(
&self.before[before.start as usize..before.end as usize],
'-',
);
self.print_tokens(&self.after[after.start as usize..after.end as usize], '+');
}
fn finish(mut self) -> Self::Out {
self.flush();
self.dst
}
}
/// Computes a diff between two strings, returning a vector of old and new row
/// ranges.
pub fn line_diff(old_text: &str, new_text: &str) -> Vec<(Range<u32>, Range<u32>)> {
@@ -327,4 +441,30 @@ mod tests {
let patch = unified_diff(old_text, new_text);
assert_eq!(apply_diff_patch(old_text, &patch).unwrap(), new_text);
}
#[test]
fn test_unified_diff_with_offsets() {
let old_text = "foo\nbar\nbaz\n";
let new_text = "foo\nBAR\nbaz\n";
let expected_diff_body = " foo\n-bar\n+BAR\n baz\n";
let diff_no_offset = unified_diff(old_text, new_text);
assert_eq!(
diff_no_offset,
format!("@@ -1,3 +1,3 @@\n{}", expected_diff_body)
);
let diff_with_offset = unified_diff_with_offsets(old_text, new_text, 9, 11);
assert_eq!(
diff_with_offset,
format!("@@ -10,3 +12,3 @@\n{}", expected_diff_body)
);
let diff_with_offset = unified_diff_with_offsets(old_text, new_text, 99, 104);
assert_eq!(
diff_with_offset,
format!("@@ -100,3 +105,3 @@\n{}", expected_diff_body)
);
}
}

View File

@@ -22,7 +22,7 @@ rewrap_prefixes = [
]
unordered_list = ["- ", "* ", "+ "]
ordered_list = [{ pattern = "(\\d+)\\. ", format = "{1}. " }]
task_list = { prefixes = ["- [ ] ", "- [x] "], continuation = "- [ ] " }
task_list = { prefixes = ["- [ ] ", "- [x] ", "- [X] "], continuation = "- [ ] " }
auto_indent_on_paste = false
auto_indent_using_last_non_empty_line = false

View File

@@ -26,6 +26,7 @@ use settings::Settings;
use smol::lock::OnceCell;
use std::cmp::{Ordering, Reverse};
use std::env::consts;
use std::process::Stdio;
use terminal::terminal_settings::TerminalSettings;
use util::command::new_smol_command;
use util::fs::{make_file_executable, remove_matching};
@@ -2173,6 +2174,119 @@ pub(crate) struct RuffLspAdapter {
fs: Arc<dyn Fs>,
}
impl RuffLspAdapter {
fn convert_ruff_schema(raw_schema: &serde_json::Value) -> serde_json::Value {
let Some(schema_object) = raw_schema.as_object() else {
return raw_schema.clone();
};
let mut root_properties = serde_json::Map::new();
for (key, value) in schema_object {
let parts: Vec<&str> = key.split('.').collect();
if parts.is_empty() {
continue;
}
let mut current = &mut root_properties;
for (i, part) in parts.iter().enumerate() {
let is_last = i == parts.len() - 1;
if is_last {
let mut schema_entry = serde_json::Map::new();
if let Some(doc) = value.get("doc").and_then(|d| d.as_str()) {
schema_entry.insert(
"markdownDescription".to_string(),
serde_json::Value::String(doc.to_string()),
);
}
if let Some(default_val) = value.get("default") {
schema_entry.insert("default".to_string(), default_val.clone());
}
if let Some(value_type) = value.get("value_type").and_then(|v| v.as_str()) {
if value_type.contains('|') {
let enum_values: Vec<serde_json::Value> = value_type
.split('|')
.map(|s| s.trim().trim_matches('"'))
.filter(|s| !s.is_empty())
.map(|s| serde_json::Value::String(s.to_string()))
.collect();
if !enum_values.is_empty() {
schema_entry
.insert("type".to_string(), serde_json::json!("string"));
schema_entry.insert(
"enum".to_string(),
serde_json::Value::Array(enum_values),
);
}
} else if value_type.starts_with("list[") {
schema_entry.insert("type".to_string(), serde_json::json!("array"));
if let Some(item_type) = value_type
.strip_prefix("list[")
.and_then(|s| s.strip_suffix(']'))
{
let json_type = match item_type {
"str" => "string",
"int" => "integer",
"bool" => "boolean",
_ => "string",
};
schema_entry.insert(
"items".to_string(),
serde_json::json!({"type": json_type}),
);
}
} else if value_type.starts_with("dict[") {
schema_entry.insert("type".to_string(), serde_json::json!("object"));
} else {
let json_type = match value_type {
"bool" => "boolean",
"int" | "usize" => "integer",
"str" => "string",
_ => "string",
};
schema_entry.insert(
"type".to_string(),
serde_json::Value::String(json_type.to_string()),
);
}
}
current.insert(part.to_string(), serde_json::Value::Object(schema_entry));
} else {
let next_current = current
.entry(part.to_string())
.or_insert_with(|| {
serde_json::json!({
"type": "object",
"properties": {}
})
})
.as_object_mut()
.expect("should be an object")
.entry("properties")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.expect("properties should be an object");
current = next_current;
}
}
}
serde_json::json!({
"type": "object",
"properties": root_properties
})
}
}
#[cfg(target_os = "macos")]
impl RuffLspAdapter {
const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
@@ -2225,6 +2339,36 @@ impl LspAdapter for RuffLspAdapter {
fn name(&self) -> LanguageServerName {
Self::SERVER_NAME
}
async fn initialization_options_schema(
self: Arc<Self>,
language_server_binary: &LanguageServerBinary,
) -> Option<serde_json::Value> {
let mut command = util::command::new_smol_command(&language_server_binary.path);
command
.args(&["config", "--output-format", "json"])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let cmd = command
.spawn()
.map_err(|e| log::debug!("failed to spawn command {command:?}: {e}"))
.ok()?;
let output = cmd
.output()
.await
.map_err(|e| log::debug!("failed to execute command {command:?}: {e}"))
.ok()?;
if !output.status.success() {
return None;
}
let raw_schema: serde_json::Value = serde_json::from_slice(output.stdout.as_slice())
.map_err(|e| log::debug!("failed to parse ruff's JSON schema output: {e}"))
.ok()?;
let converted_schema = Self::convert_ruff_schema(&raw_schema);
Some(converted_schema)
}
}
impl LspInstaller for RuffLspAdapter {
@@ -2568,4 +2712,149 @@ mod tests {
);
}
}
#[test]
fn test_convert_ruff_schema() {
use super::RuffLspAdapter;
let raw_schema = serde_json::json!({
"line-length": {
"doc": "The line length to use when enforcing long-lines violations",
"default": "88",
"value_type": "int",
"scope": null,
"example": "line-length = 120",
"deprecated": null
},
"lint.select": {
"doc": "A list of rule codes or prefixes to enable",
"default": "[\"E4\", \"E7\", \"E9\", \"F\"]",
"value_type": "list[RuleSelector]",
"scope": null,
"example": "select = [\"E4\", \"E7\", \"E9\", \"F\", \"B\", \"Q\"]",
"deprecated": null
},
"lint.isort.case-sensitive": {
"doc": "Sort imports taking into account case sensitivity.",
"default": "false",
"value_type": "bool",
"scope": null,
"example": "case-sensitive = true",
"deprecated": null
},
"format.quote-style": {
"doc": "Configures the preferred quote character for strings.",
"default": "\"double\"",
"value_type": "\"double\" | \"single\" | \"preserve\"",
"scope": null,
"example": "quote-style = \"single\"",
"deprecated": null
}
});
let converted = RuffLspAdapter::convert_ruff_schema(&raw_schema);
assert!(converted.is_object());
assert_eq!(
converted.get("type").and_then(|v| v.as_str()),
Some("object")
);
let properties = converted
.get("properties")
.expect("should have properties")
.as_object()
.expect("properties should be an object");
assert!(properties.contains_key("line-length"));
assert!(properties.contains_key("lint"));
assert!(properties.contains_key("format"));
let line_length = properties
.get("line-length")
.expect("should have line-length")
.as_object()
.expect("line-length should be an object");
assert_eq!(
line_length.get("type").and_then(|v| v.as_str()),
Some("integer")
);
assert_eq!(
line_length.get("default").and_then(|v| v.as_str()),
Some("88")
);
let lint = properties
.get("lint")
.expect("should have lint")
.as_object()
.expect("lint should be an object");
let lint_props = lint
.get("properties")
.expect("lint should have properties")
.as_object()
.expect("lint properties should be an object");
assert!(lint_props.contains_key("select"));
assert!(lint_props.contains_key("isort"));
let select = lint_props.get("select").expect("should have select");
assert_eq!(select.get("type").and_then(|v| v.as_str()), Some("array"));
let isort = lint_props
.get("isort")
.expect("should have isort")
.as_object()
.expect("isort should be an object");
let isort_props = isort
.get("properties")
.expect("isort should have properties")
.as_object()
.expect("isort properties should be an object");
let case_sensitive = isort_props
.get("case-sensitive")
.expect("should have case-sensitive");
assert_eq!(
case_sensitive.get("type").and_then(|v| v.as_str()),
Some("boolean")
);
assert!(case_sensitive.get("markdownDescription").is_some());
let format = properties
.get("format")
.expect("should have format")
.as_object()
.expect("format should be an object");
let format_props = format
.get("properties")
.expect("format should have properties")
.as_object()
.expect("format properties should be an object");
let quote_style = format_props
.get("quote-style")
.expect("should have quote-style");
assert_eq!(
quote_style.get("type").and_then(|v| v.as_str()),
Some("string")
);
let enum_values = quote_style
.get("enum")
.expect("should have enum")
.as_array()
.expect("enum should be an array");
assert_eq!(enum_values.len(), 3);
assert!(enum_values.contains(&serde_json::json!("double")));
assert!(enum_values.contains(&serde_json::json!("single")));
assert!(enum_values.contains(&serde_json::json!("preserve")));
}
}

View File

@@ -18,6 +18,7 @@ use smol::fs::{self};
use std::cmp::Reverse;
use std::fmt::Display;
use std::ops::Range;
use std::process::Stdio;
use std::{
borrow::Cow,
path::{Path, PathBuf},
@@ -66,6 +67,68 @@ enum LibcType {
}
impl RustLspAdapter {
fn convert_rust_analyzer_schema(raw_schema: &serde_json::Value) -> serde_json::Value {
let Some(schema_array) = raw_schema.as_array() else {
return raw_schema.clone();
};
let mut root_properties = serde_json::Map::new();
for item in schema_array {
if let Some(props) = item.get("properties").and_then(|p| p.as_object()) {
for (key, value) in props {
let parts: Vec<&str> = key.split('.').collect();
if parts.is_empty() {
continue;
}
let parts_to_process = if parts.first() == Some(&"rust-analyzer") {
&parts[1..]
} else {
&parts[..]
};
if parts_to_process.is_empty() {
continue;
}
let mut current = &mut root_properties;
for (i, part) in parts_to_process.iter().enumerate() {
let is_last = i == parts_to_process.len() - 1;
if is_last {
current.insert(part.to_string(), value.clone());
} else {
let next_current = current
.entry(part.to_string())
.or_insert_with(|| {
serde_json::json!({
"type": "object",
"properties": {}
})
})
.as_object_mut()
.expect("should be an object")
.entry("properties")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.expect("properties should be an object");
current = next_current;
}
}
}
}
}
serde_json::json!({
"type": "object",
"properties": root_properties
})
}
#[cfg(target_os = "linux")]
async fn determine_libc_type() -> LibcType {
use futures::pin_mut;
@@ -448,6 +511,37 @@ impl LspAdapter for RustLspAdapter {
Some(label)
}
async fn initialization_options_schema(
self: Arc<Self>,
language_server_binary: &LanguageServerBinary,
) -> Option<serde_json::Value> {
let mut command = util::command::new_smol_command(&language_server_binary.path);
command
.arg("--print-config-schema")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let cmd = command
.spawn()
.map_err(|e| log::debug!("failed to spawn command {command:?}: {e}"))
.ok()?;
let output = cmd
.output()
.await
.map_err(|e| log::debug!("failed to execute command {command:?}: {e}"))
.ok()?;
if !output.status.success() {
return None;
}
let raw_schema: serde_json::Value = serde_json::from_slice(output.stdout.as_slice())
.map_err(|e| log::debug!("failed to parse rust-analyzer's JSON schema output: {e}"))
.ok()?;
// Convert rust-analyzer's array-based schema format to nested JSON Schema
let converted_schema = Self::convert_rust_analyzer_schema(&raw_schema);
Some(converted_schema)
}
async fn label_for_symbol(
&self,
name: &str,
@@ -1912,4 +2006,90 @@ mod tests {
);
check([], "/project/src/main.rs", "--");
}
#[test]
fn test_convert_rust_analyzer_schema() {
let raw_schema = serde_json::json!([
{
"title": "Assist",
"properties": {
"rust-analyzer.assist.emitMustUse": {
"markdownDescription": "Insert #[must_use] when generating `as_` methods for enum variants.",
"default": false,
"type": "boolean"
}
}
},
{
"title": "Assist",
"properties": {
"rust-analyzer.assist.expressionFillDefault": {
"markdownDescription": "Placeholder expression to use for missing expressions in assists.",
"default": "todo",
"type": "string"
}
}
},
{
"title": "Cache Priming",
"properties": {
"rust-analyzer.cachePriming.enable": {
"markdownDescription": "Warm up caches on project load.",
"default": true,
"type": "boolean"
}
}
}
]);
let converted = RustLspAdapter::convert_rust_analyzer_schema(&raw_schema);
assert_eq!(
converted.get("type").and_then(|v| v.as_str()),
Some("object")
);
let properties = converted
.pointer("/properties")
.expect("should have properties")
.as_object()
.expect("properties should be object");
assert!(properties.contains_key("assist"));
assert!(properties.contains_key("cachePriming"));
assert!(!properties.contains_key("rust-analyzer"));
let assist_props = properties
.get("assist")
.expect("should have assist")
.pointer("/properties")
.expect("assist should have properties")
.as_object()
.expect("assist properties should be object");
assert!(assist_props.contains_key("emitMustUse"));
assert!(assist_props.contains_key("expressionFillDefault"));
let emit_must_use = assist_props
.get("emitMustUse")
.expect("should have emitMustUse");
assert_eq!(
emit_must_use.get("type").and_then(|v| v.as_str()),
Some("boolean")
);
assert_eq!(
emit_must_use.get("default").and_then(|v| v.as_bool()),
Some(false)
);
let cache_priming_props = properties
.get("cachePriming")
.expect("should have cachePriming")
.pointer("/properties")
.expect("cachePriming should have properties")
.as_object()
.expect("cachePriming properties should be object");
assert!(cache_priming_props.contains_key("enable"));
}
}

View File

@@ -345,6 +345,7 @@ impl LspAdapter for VtslsLspAdapter {
let lsp_settings = content
.project
.lsp
.0
.entry(VTSLS_SERVER_NAME.into())
.or_default();

View File

@@ -22,9 +22,9 @@ use collections::{HashMap, HashSet};
use gpui::{
AnyElement, App, BorderStyle, Bounds, ClipboardItem, CursorStyle, DispatchPhase, Edges, Entity,
FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image,
ImageFormat, KeyContext, Length, MouseDownEvent, MouseEvent, MouseMoveEvent, MouseUpEvent,
Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledText, Task,
TextLayout, TextRun, TextStyle, TextStyleRefinement, actions, img, point, quad,
ImageFormat, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, MouseMoveEvent,
MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledText,
Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, actions, img, point, quad,
};
use language::{Language, LanguageRegistry, Rope};
use parser::CodeBlockMetadata;
@@ -112,6 +112,7 @@ pub struct Markdown {
options: Options,
copied_code_blocks: HashSet<ElementId>,
code_block_scroll_handles: HashMap<usize, ScrollHandle>,
context_menu_selected_text: Option<String>,
}
struct Options {
@@ -181,6 +182,7 @@ impl Markdown {
},
copied_code_blocks: HashSet::default(),
code_block_scroll_handles: HashMap::default(),
context_menu_selected_text: None,
};
this.parse(cx);
this
@@ -205,6 +207,7 @@ impl Markdown {
},
copied_code_blocks: HashSet::default(),
code_block_scroll_handles: HashMap::default(),
context_menu_selected_text: None,
};
this.parse(cx);
this
@@ -289,6 +292,14 @@ impl Markdown {
}
}
pub fn selected_text(&self) -> Option<String> {
if self.selection.end <= self.selection.start {
None
} else {
Some(self.source[self.selection.start..self.selection.end].to_string())
}
}
fn copy(&self, text: &RenderedText, _: &mut Window, cx: &mut Context<Self>) {
if self.selection.end <= self.selection.start {
return;
@@ -297,7 +308,11 @@ impl Markdown {
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
fn copy_as_markdown(&self, _: &mut Window, cx: &mut Context<Self>) {
fn copy_as_markdown(&mut self, _: &mut Window, cx: &mut Context<Self>) {
if let Some(text) = self.context_menu_selected_text.take() {
cx.write_to_clipboard(ClipboardItem::new_string(text));
return;
}
if self.selection.end <= self.selection.start {
return;
}
@@ -305,6 +320,10 @@ impl Markdown {
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
fn capture_selection_for_context_menu(&mut self) {
self.context_menu_selected_text = self.selected_text();
}
fn parse(&mut self, cx: &mut Context<Self>) {
if self.source.is_empty() {
return;
@@ -665,6 +684,19 @@ impl MarkdownElement {
let on_open_url = self.on_url_click.take();
self.on_mouse_event(window, cx, {
let hitbox = hitbox.clone();
move |markdown, event: &MouseDownEvent, phase, window, _| {
if phase.capture()
&& event.button == MouseButton::Right
&& hitbox.is_hovered(window)
{
// Capture selected text so it survives until menu item is clicked
markdown.capture_selection_for_context_menu();
}
}
});
self.on_mouse_event(window, cx, {
let rendered_text = rendered_text.clone();
let hitbox = hitbox.clone();
@@ -713,7 +745,7 @@ impl MarkdownElement {
window.prevent_default();
cx.notify();
}
} else if phase.capture() {
} else if phase.capture() && event.button == MouseButton::Left {
markdown.selection = Selection::default();
markdown.pressed_link = None;
cx.notify();

View File

@@ -1868,6 +1868,7 @@ pub struct BuiltinAgentServerSettings {
pub ignore_system_version: Option<bool>,
pub default_mode: Option<String>,
pub default_model: Option<String>,
pub favorite_models: Vec<String>,
}
impl BuiltinAgentServerSettings {
@@ -1891,6 +1892,7 @@ impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
ignore_system_version: value.ignore_system_version,
default_mode: value.default_mode,
default_model: value.default_model,
favorite_models: value.favorite_models,
}
}
}
@@ -1922,6 +1924,10 @@ pub enum CustomAgentServerSettings {
///
/// Default: None
default_model: Option<String>,
/// The favorite models for this agent.
///
/// Default: []
favorite_models: Vec<String>,
},
Extension {
/// The default mode to use for this agent.
@@ -1936,6 +1942,10 @@ pub enum CustomAgentServerSettings {
///
/// Default: None
default_model: Option<String>,
/// The favorite models for this agent.
///
/// Default: []
favorite_models: Vec<String>,
},
}
@@ -1962,6 +1972,17 @@ impl CustomAgentServerSettings {
}
}
}
pub fn favorite_models(&self) -> &[String] {
match self {
CustomAgentServerSettings::Custom {
favorite_models, ..
}
| CustomAgentServerSettings::Extension {
favorite_models, ..
} => favorite_models,
}
}
}
impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
@@ -1973,6 +1994,7 @@ impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
env,
default_mode,
default_model,
favorite_models,
} => CustomAgentServerSettings::Custom {
command: AgentServerCommand {
path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
@@ -1981,13 +2003,16 @@ impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
},
default_mode,
default_model,
favorite_models,
},
settings::CustomAgentServerSettings::Extension {
default_mode,
default_model,
favorite_models,
} => CustomAgentServerSettings::Extension {
default_mode,
default_model,
favorite_models,
},
}
}
@@ -2313,6 +2338,7 @@ mod extension_agent_tests {
ignore_system_version: None,
default_mode: None,
default_model: None,
favorite_models: vec![],
};
let BuiltinAgentServerSettings { path, .. } = settings.into();
@@ -2329,6 +2355,7 @@ mod extension_agent_tests {
env: None,
default_mode: None,
default_model: None,
favorite_models: vec![],
};
let converted: CustomAgentServerSettings = settings.into();

View File

@@ -5756,6 +5756,7 @@ impl Repository {
cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
}
fn load_blob_content(&mut self, oid: Oid, cx: &App) -> Task<Result<String>> {
let repository_id = self.snapshot.id;
let rx = self.send_job(None, move |state, _| async move {

View File

@@ -257,7 +257,7 @@ struct DynamicRegistrations {
pub struct LocalLspStore {
weak: WeakEntity<LspStore>,
worktree_store: Entity<WorktreeStore>,
pub worktree_store: Entity<WorktreeStore>,
toolchain_store: Entity<LocalToolchainStore>,
http_client: Arc<dyn HttpClient>,
environment: Entity<ProjectEnvironment>,
@@ -13953,7 +13953,7 @@ impl LocalLspAdapterDelegate {
})
}
fn from_local_lsp(
pub fn from_local_lsp(
local: &LocalLspStore,
worktree: &Entity<Worktree>,
cx: &mut App,

View File

@@ -1,5 +1,5 @@
use anyhow::{Context, Result};
use gpui::{App, AsyncApp, Entity, Global, WeakEntity};
use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity};
use lsp::LanguageServer;
use crate::LspStore;
@@ -22,7 +22,7 @@ impl lsp::request::Request for SchemaContentRequest {
const METHOD: &'static str = "vscode/content";
}
type SchemaRequestHandler = fn(Entity<LspStore>, String, &mut AsyncApp) -> Result<String>;
type SchemaRequestHandler = fn(Entity<LspStore>, String, &mut AsyncApp) -> Task<Result<String>>;
pub struct SchemaHandlingImpl(SchemaRequestHandler);
impl Global for SchemaHandlingImpl {}
@@ -72,9 +72,7 @@ pub fn notify_schema_changed(lsp_store: Entity<LspStore>, uri: String, cx: &App)
pub fn register_requests(lsp_store: WeakEntity<LspStore>, language_server: &LanguageServer) {
language_server
.on_request::<SchemaContentRequest, _, _>(move |params, cx| {
let handler = cx.try_read_global::<SchemaHandlingImpl, _>(|handler, _| {
handler.0
});
let handler = cx.try_read_global::<SchemaHandlingImpl, _>(|handler, _| handler.0);
let mut cx = cx.clone();
let uri = params.clone().pop();
let lsp_store = lsp_store.clone();
@@ -82,7 +80,7 @@ pub fn register_requests(lsp_store: WeakEntity<LspStore>, language_server: &Lang
let lsp_store = lsp_store.upgrade().context("LSP store has been dropped")?;
let uri = uri.context("No URI")?;
let handle_schema_request = handler.context("No schema handler registered")?;
handle_schema_request(lsp_store, uri, &mut cx)
handle_schema_request(lsp_store, uri, &mut cx).await
};
async move {
zlog::trace!(LOGGER => "Handling schema request for {:?}", &params);

View File

@@ -1293,17 +1293,33 @@ impl Project {
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,
);
let trust_remote_project = match &connection_options {
RemoteConnectionOptions::Ssh(..) | RemoteConnectionOptions::Wsl(..) => false,
RemoteConnectionOptions::Docker(..) => true,
};
let remote_host = RemoteHostLocation::from(connection_options);
trusted_worktrees::track_worktree_trust(
worktree_store.clone(),
Some(remote_host.clone()),
None,
Some((remote_proto.clone(), REMOTE_SERVER_PROJECT_ID)),
cx,
);
if trust_remote_project {
if let Some(trusted_worktres) = TrustedWorktrees::try_get_global(cx) {
trusted_worktres.update(cx, |trusted_worktres, cx| {
trusted_worktres.trust(
worktree_store
.read(cx)
.worktrees()
.map(|worktree| worktree.read(cx).id())
.map(PathTrust::Worktree)
.collect(),
Some(remote_host),
cx,
);
})
}
RemoteConnectionOptions::Docker(..) => {}
}
}

View File

@@ -337,6 +337,13 @@ impl TrustedWorktreesStore {
if restricted_host != remote_host {
return true;
}
// When trusting an abs path on the host, we transitively trust all single file worktrees on this host too.
if is_file && !new_trusted_abs_paths.is_empty() {
trusted_paths.insert(PathTrust::Worktree(*restricted_worktree));
return false;
}
let retain = (!is_file || new_trusted_other_worktrees.is_empty())
&& new_trusted_abs_paths.iter().all(|new_trusted_path| {
!restricted_worktree_path.starts_with(new_trusted_path)
@@ -1045,6 +1052,13 @@ mod tests {
"single-file worktree should be restricted initially"
);
let can_trust_directory =
trusted_worktrees.update(cx, |store, cx| store.can_trust(dir_worktree_id, cx));
assert!(
!can_trust_directory,
"directory worktree should be restricted initially"
);
trusted_worktrees.update(cx, |store, cx| {
store.trust(
HashSet::from_iter([PathTrust::Worktree(dir_worktree_id)]),
@@ -1064,6 +1078,78 @@ mod tests {
);
}
#[gpui::test]
async fn test_parent_path_trust_enables_single_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/"),
json!({
"project": { "main.rs": "fn main() {}" },
"standalone.rs": "fn standalone() {}"
}),
)
.await;
let project = Project::test(
fs,
[path!("/project").as_ref(), path!("/standalone.rs").as_ref()],
cx,
)
.await;
let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
let (dir_worktree_id, file_worktree_id) = worktree_store.read_with(cx, |store, cx| {
let worktrees: Vec<_> = store.worktrees().collect();
assert_eq!(worktrees.len(), 2);
let (dir_worktree, file_worktree) = if worktrees[0].read(cx).is_single_file() {
(&worktrees[1], &worktrees[0])
} else {
(&worktrees[0], &worktrees[1])
};
assert!(!dir_worktree.read(cx).is_single_file());
assert!(file_worktree.read(cx).is_single_file());
(dir_worktree.read(cx).id(), file_worktree.read(cx).id())
});
let trusted_worktrees = init_trust_global(worktree_store, cx);
let can_trust_file =
trusted_worktrees.update(cx, |store, cx| store.can_trust(file_worktree_id, cx));
assert!(
!can_trust_file,
"single-file worktree should be restricted initially"
);
let can_trust_directory =
trusted_worktrees.update(cx, |store, cx| store.can_trust(dir_worktree_id, cx));
assert!(
!can_trust_directory,
"directory worktree should be restricted initially"
);
trusted_worktrees.update(cx, |store, cx| {
store.trust(
HashSet::from_iter([PathTrust::AbsPath(PathBuf::from(path!("/project")))]),
None,
cx,
);
});
let can_trust_dir =
trusted_worktrees.update(cx, |store, cx| store.can_trust(dir_worktree_id, cx));
let can_trust_file_after =
trusted_worktrees.update(cx, |store, cx| store.can_trust(file_worktree_id, cx));
assert!(
can_trust_dir,
"directory worktree should be trusted after its parent is trusted"
);
assert!(
can_trust_file_after,
"single-file worktree should be trusted after directory worktree trust via its parent directory trust"
);
}
#[gpui::test]
async fn test_abs_path_trust_covers_multiple_worktrees(cx: &mut TestAppContext) {
init_test(cx);

View File

@@ -53,7 +53,9 @@ async fn check_for_docker() -> Result<(), DevContainerError> {
}
}
async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result<PathBuf, DevContainerError> {
async fn ensure_devcontainer_cli(
node_runtime: &NodeRuntime,
) -> Result<(PathBuf, bool), DevContainerError> {
let mut command = util::command::new_smol_command(&dev_container_cli());
command.arg("--version");
@@ -63,23 +65,42 @@ async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result<PathBuf, D
e
);
let Ok(node_runtime_path) = node_runtime.binary_path().await else {
return Err(DevContainerError::NodeRuntimeNotAvailable);
};
let datadir_cli_path = paths::devcontainer_dir()
.join("node_modules")
.join(".bin")
.join(&dev_container_cli());
.join("@devcontainers")
.join("cli")
.join(format!("{}.js", &dev_container_cli()));
log::debug!(
"devcontainer not found in path, using local location: ${}",
datadir_cli_path.display()
);
let mut command =
util::command::new_smol_command(&datadir_cli_path.as_os_str().display().to_string());
util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
command.arg(datadir_cli_path.display().to_string());
command.arg("--version");
if let Err(e) = command.output().await {
log::error!(
match command.output().await {
Err(e) => log::error!(
"Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
e
);
} else {
log::info!("Found devcontainer CLI in Data dir");
return Ok(datadir_cli_path.clone());
),
Ok(output) => {
if output.status.success() {
log::info!("Found devcontainer CLI in Data dir");
return Ok((datadir_cli_path.clone(), false));
} else {
log::error!(
"Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
output
);
}
}
}
if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
@@ -101,7 +122,9 @@ async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result<PathBuf, D
return Err(DevContainerError::DevContainerCliNotAvailable);
};
let mut command = util::command::new_smol_command(&datadir_cli_path.display().to_string());
let mut command =
util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
command.arg(datadir_cli_path.display().to_string());
command.arg("--version");
if let Err(e) = command.output().await {
log::error!(
@@ -110,22 +133,42 @@ async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result<PathBuf, D
);
Err(DevContainerError::DevContainerCliNotAvailable)
} else {
Ok(datadir_cli_path)
Ok((datadir_cli_path, false))
}
} else {
log::info!("Found devcontainer cli on $PATH, using it");
Ok(PathBuf::from(&dev_container_cli()))
Ok((PathBuf::from(&dev_container_cli()), true))
}
}
async fn devcontainer_up(
path_to_cli: &PathBuf,
found_in_path: bool,
node_runtime: &NodeRuntime,
path: Arc<Path>,
) -> Result<DevContainerUp, DevContainerError> {
let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
command.arg("up");
command.arg("--workspace-folder");
command.arg(path.display().to_string());
let Ok(node_runtime_path) = node_runtime.binary_path().await else {
log::error!("Unable to find node runtime path");
return Err(DevContainerError::NodeRuntimeNotAvailable);
};
let mut command = if found_in_path {
let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
command.arg("up");
command.arg("--workspace-folder");
command.arg(path.display().to_string());
command
} else {
let mut command =
util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
command.arg(path_to_cli.display().to_string());
command.arg("up");
command.arg("--workspace-folder");
command.arg(path.display().to_string());
command
};
log::debug!("Running full devcontainer up command: {:?}", command);
match command.output().await {
Ok(output) => {
@@ -235,7 +278,7 @@ pub(crate) async fn start_dev_container(
) -> Result<(Connection, String), DevContainerError> {
check_for_docker().await?;
let path_to_devcontainer_cli = ensure_devcontainer_cli(node_runtime).await?;
let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
let Some(directory) = project_directory(cx) else {
return Err(DevContainerError::DevContainerNotFound);
@@ -245,7 +288,13 @@ pub(crate) async fn start_dev_container(
container_id,
remote_workspace_folder,
..
}) = devcontainer_up(&path_to_devcontainer_cli, directory.clone()).await
}) = devcontainer_up(
&path_to_devcontainer_cli,
found_in_path,
&node_runtime,
directory.clone(),
)
.await
{
let project_name = get_project_name(
&path_to_devcontainer_cli,
@@ -273,6 +322,7 @@ pub(crate) enum DevContainerError {
DevContainerUpFailed,
DevContainerNotFound,
DevContainerParseFailed,
NodeRuntimeNotAvailable,
}
#[cfg(test)]

View File

@@ -158,6 +158,9 @@ fn handle_rpc_messages_over_child_process_stdio(
}
};
let status = remote_proxy_process.status().await?.code().unwrap_or(1);
if status != 0 {
anyhow::bail!("Remote server exited with status {status}");
}
match result {
Ok(_) => Ok(status),
Err(error) => Err(error),

View File

@@ -582,19 +582,21 @@ impl RemoteConnection for DockerExecConnection {
return Task::ready(Err(anyhow!("Remote binary path not set")));
};
let mut docker_args = vec![
"exec".to_string(),
"-w".to_string(),
self.remote_dir_for_server.clone(),
"-i".to_string(),
self.connection_options.container_id.to_string(),
];
let mut docker_args = vec!["exec".to_string()];
for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
if let Some(value) = std::env::var(env_var).ok() {
docker_args.push("-e".to_string());
docker_args.push(format!("{}='{}'", env_var, value));
}
}
docker_args.extend([
"-w".to_string(),
self.remote_dir_for_server.clone(),
"-i".to_string(),
self.connection_options.container_id.to_string(),
]);
let val = remote_binary_relpath
.display(self.path_style())
.into_owned();

View File

@@ -56,6 +56,7 @@ merge_from_overwrites!(
std::sync::Arc<str>,
gpui::SharedString,
std::path::PathBuf,
std::sync::Arc<std::path::Path>,
gpui::Modifiers,
gpui::FontFeatures,
gpui::FontWeight

View File

@@ -33,8 +33,9 @@ pub use serde_helper::*;
pub use settings_file::*;
pub use settings_json::*;
pub use settings_store::{
InvalidSettingsError, LocalSettingsKind, MigrationStatus, ParseStatus, Settings, SettingsFile,
SettingsJsonSchemaParams, SettingsKey, SettingsLocation, SettingsParseResult, SettingsStore,
InvalidSettingsError, LSP_SETTINGS_SCHEMA_URL_PREFIX, LocalSettingsKind, MigrationStatus,
ParseStatus, Settings, SettingsFile, SettingsJsonSchemaParams, SettingsKey, SettingsLocation,
SettingsParseResult, SettingsStore,
};
pub use vscode_import::{VsCodeSettings, VsCodeSettingsSource};

View File

@@ -363,6 +363,13 @@ pub struct BuiltinAgentServerSettings {
///
/// Default: None
pub default_model: Option<String>,
/// The favorite models for this agent.
///
/// These are the model IDs as reported by the agent.
///
/// Default: []
#[serde(default)]
pub favorite_models: Vec<String>,
}
#[with_fallible_options]
@@ -387,6 +394,13 @@ pub enum CustomAgentServerSettings {
///
/// Default: None
default_model: Option<String>,
/// The favorite models for this agent.
///
/// These are the model IDs as reported by the agent.
///
/// Default: []
#[serde(default)]
favorite_models: Vec<String>,
},
Extension {
/// The default mode to use for this agent.
@@ -401,5 +415,12 @@ pub enum CustomAgentServerSettings {
///
/// Default: None
default_model: Option<String>,
/// The favorite models for this agent.
///
/// These are the model IDs as reported by the agent.
///
/// Default: []
#[serde(default)]
favorite_models: Vec<String>,
},
}

View File

@@ -1,4 +1,4 @@
use std::num::NonZeroU32;
use std::{num::NonZeroU32, path::Path};
use collections::{HashMap, HashSet};
use gpui::{Modifiers, SharedString};
@@ -167,6 +167,8 @@ pub struct EditPredictionSettingsContent {
/// Whether edit predictions are enabled in the assistant prompt editor.
/// This has no effect if globally disabled.
pub enabled_in_text_threads: Option<bool>,
/// The directory where manually captured edit prediction examples are stored.
pub examples_dir: Option<Arc<Path>>,
}
#[with_fallible_options]

View File

@@ -11,6 +11,19 @@ use crate::{
SlashCommandSettings,
};
#[with_fallible_options]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct LspSettingsMap(pub HashMap<Arc<str>, LspSettings>);
impl IntoIterator for LspSettingsMap {
type Item = (Arc<str>, LspSettings);
type IntoIter = std::collections::hash_map::IntoIter<Arc<str>, LspSettings>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
#[with_fallible_options]
#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ProjectSettingsContent {
@@ -29,7 +42,7 @@ pub struct ProjectSettingsContent {
/// name to the lsp value.
/// Default: null
#[serde(default)]
pub lsp: HashMap<Arc<str>, LspSettings>,
pub lsp: LspSettingsMap,
pub terminal: Option<ProjectTerminalSettingsContent>,

View File

@@ -32,7 +32,8 @@ pub type EditorconfigProperties = ec4rs::Properties;
use crate::{
ActiveSettingsProfileName, FontFamilyName, IconThemeName, LanguageSettingsContent,
LanguageToSettingsMap, ThemeName, VsCodeSettings, WorktreeId, fallible_options,
LanguageToSettingsMap, LspSettings, LspSettingsMap, ThemeName, VsCodeSettings, WorktreeId,
fallible_options,
merge_from::MergeFrom,
settings_content::{
ExtensionsSettingsContent, ProjectSettingsContent, SettingsContent, UserSettingsContent,
@@ -41,6 +42,8 @@ use crate::{
use settings_json::{infer_json_indent_size, parse_json_with_comments, update_value_in_json_text};
pub const LSP_SETTINGS_SCHEMA_URL_PREFIX: &str = "zed://schemas/settings/lsp/";
pub trait SettingsKey: 'static + Send + Sync {
/// The name of a key within the JSON file from which this setting should
/// be deserialized. If this is `None`, then the setting will be deserialized
@@ -256,6 +259,7 @@ pub struct SettingsJsonSchemaParams<'a> {
pub font_names: &'a [String],
pub theme_names: &'a [SharedString],
pub icon_theme_names: &'a [SharedString],
pub lsp_adapter_names: &'a [String],
}
impl SettingsStore {
@@ -1025,6 +1029,14 @@ impl SettingsStore {
.subschema_for::<LanguageSettingsContent>()
.to_value();
generator.subschema_for::<LspSettings>();
let lsp_settings_def = generator
.definitions()
.get("LspSettings")
.expect("LspSettings should be defined")
.clone();
replace_subschema::<LanguageToSettingsMap>(&mut generator, || {
json_schema!({
"type": "object",
@@ -1063,6 +1075,38 @@ impl SettingsStore {
})
});
replace_subschema::<LspSettingsMap>(&mut generator, || {
let mut lsp_properties = serde_json::Map::new();
for adapter_name in params.lsp_adapter_names {
let mut base_lsp_settings = lsp_settings_def
.as_object()
.expect("LspSettings should be an object")
.clone();
if let Some(properties) = base_lsp_settings.get_mut("properties") {
if let Some(props_obj) = properties.as_object_mut() {
props_obj.insert(
"initialization_options".to_string(),
serde_json::json!({
"$ref": format!("{LSP_SETTINGS_SCHEMA_URL_PREFIX}{adapter_name}")
}),
);
}
}
lsp_properties.insert(
adapter_name.clone(),
serde_json::Value::Object(base_lsp_settings),
);
}
json_schema!({
"type": "object",
"properties": lsp_properties,
})
});
generator
.root_schema_for::<UserSettingsContent>()
.to_value()
@@ -2304,4 +2348,39 @@ mod tests {
]
)
}
#[gpui::test]
fn test_lsp_settings_schema_generation(cx: &mut App) {
let store = SettingsStore::test(cx);
let schema = store.json_schema(&SettingsJsonSchemaParams {
language_names: &["Rust".to_string(), "TypeScript".to_string()],
font_names: &["Zed Mono".to_string()],
theme_names: &["One Dark".into()],
icon_theme_names: &["Zed Icons".into()],
lsp_adapter_names: &[
"rust-analyzer".to_string(),
"typescript-language-server".to_string(),
],
});
let properties = schema
.pointer("/$defs/LspSettingsMap/properties")
.expect("LspSettingsMap should have properties")
.as_object()
.unwrap();
assert!(properties.contains_key("rust-analyzer"));
assert!(properties.contains_key("typescript-language-server"));
let init_options_ref = properties
.get("rust-analyzer")
.unwrap()
.pointer("/properties/initialization_options/$ref")
.expect("initialization_options should have a $ref")
.as_str()
.unwrap();
assert_eq!(init_options_ref, "zed://schemas/settings/lsp/rust-analyzer");
}
}

View File

@@ -30,8 +30,8 @@ use workspace::{
ActivateNextPane, ActivatePane, ActivatePaneDown, ActivatePaneLeft, ActivatePaneRight,
ActivatePaneUp, ActivatePreviousPane, DraggedSelection, DraggedTab, ItemId, MoveItemToPane,
MoveItemToPaneInDirection, MovePaneDown, MovePaneLeft, MovePaneRight, MovePaneUp, NewTerminal,
Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitRight, SplitUp, SwapPaneDown,
SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace,
Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitMode, SplitRight, SplitUp,
SwapPaneDown, SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace,
dock::{DockPosition, Panel, PanelEvent, PanelHandle},
item::SerializableItem,
move_active_item, move_item, pane,
@@ -192,10 +192,10 @@ impl TerminalPanel {
split_context.clone(),
|menu, split_context| menu.context(split_context),
)
.action("Split Right", SplitRight.boxed_clone())
.action("Split Left", SplitLeft.boxed_clone())
.action("Split Up", SplitUp.boxed_clone())
.action("Split Down", SplitDown.boxed_clone())
.action("Split Right", SplitRight::default().boxed_clone())
.action("Split Left", SplitLeft::default().boxed_clone())
.action("Split Up", SplitUp::default().boxed_clone())
.action("Split Down", SplitDown::default().boxed_clone())
})
.into()
}
@@ -380,47 +380,49 @@ impl TerminalPanel {
}
self.serialize(cx);
}
&pane::Event::Split {
direction,
clone_active_item,
} => {
if clone_active_item {
let fut = self.new_pane_with_cloned_active_terminal(window, cx);
let pane = pane.clone();
cx.spawn_in(window, async move |panel, cx| {
let Some(new_pane) = fut.await else {
&pane::Event::Split { direction, mode } => {
match mode {
SplitMode::ClonePane | SplitMode::EmptyPane => {
let clone = matches!(mode, SplitMode::ClonePane);
let new_pane = self.new_pane_with_active_terminal(clone, window, cx);
let pane = pane.clone();
cx.spawn_in(window, async move |panel, cx| {
let Some(new_pane) = new_pane.await else {
return;
};
panel
.update_in(cx, |panel, window, cx| {
panel
.center
.split(&pane, &new_pane, direction, cx)
.log_err();
window.focus(&new_pane.focus_handle(cx), cx);
})
.ok();
})
.detach();
}
SplitMode::MovePane => {
let Some(item) =
pane.update(cx, |pane, cx| pane.take_active_item(window, cx))
else {
return;
};
panel
.update_in(cx, |panel, window, cx| {
panel
.center
.split(&pane, &new_pane, direction, cx)
.log_err();
window.focus(&new_pane.focus_handle(cx), cx);
})
.ok();
})
.detach();
} else {
let Some(item) = pane.update(cx, |pane, cx| pane.take_active_item(window, cx))
else {
return;
};
let Ok(project) = self
.workspace
.update(cx, |workspace, _| workspace.project().clone())
else {
return;
};
let new_pane =
new_terminal_pane(self.workspace.clone(), project, false, window, cx);
new_pane.update(cx, |pane, cx| {
pane.add_item(item, true, true, None, window, cx);
});
self.center.split(&pane, &new_pane, direction, cx).log_err();
window.focus(&new_pane.focus_handle(cx), cx);
}
let Ok(project) = self
.workspace
.update(cx, |workspace, _| workspace.project().clone())
else {
return;
};
let new_pane =
new_terminal_pane(self.workspace.clone(), project, false, window, cx);
new_pane.update(cx, |pane, cx| {
pane.add_item(item, true, true, None, window, cx);
});
self.center.split(&pane, &new_pane, direction, cx).log_err();
window.focus(&new_pane.focus_handle(cx), cx);
}
};
}
pane::Event::Focus => {
self.active_pane = pane.clone();
@@ -433,8 +435,9 @@ impl TerminalPanel {
}
}
fn new_pane_with_cloned_active_terminal(
fn new_pane_with_active_terminal(
&mut self,
clone: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Option<Entity<Pane>>> {
@@ -446,21 +449,34 @@ impl TerminalPanel {
let weak_workspace = self.workspace.clone();
let project = workspace.project().clone();
let active_pane = &self.active_pane;
let terminal_view = active_pane
.read(cx)
.active_item()
.and_then(|item| item.downcast::<TerminalView>());
let working_directory = terminal_view
.as_ref()
.and_then(|terminal_view| {
terminal_view
.read(cx)
.terminal()
.read(cx)
.working_directory()
})
.or_else(|| default_working_directory(workspace, cx));
let is_zoomed = active_pane.read(cx).is_zoomed();
let terminal_view = if clone {
active_pane
.read(cx)
.active_item()
.and_then(|item| item.downcast::<TerminalView>())
} else {
None
};
let working_directory = if clone {
terminal_view
.as_ref()
.and_then(|terminal_view| {
terminal_view
.read(cx)
.terminal()
.read(cx)
.working_directory()
})
.or_else(|| default_working_directory(workspace, cx))
} else {
default_working_directory(workspace, cx)
};
let is_zoomed = if clone {
active_pane.read(cx).is_zoomed()
} else {
false
};
cx.spawn_in(window, async move |panel, cx| {
let terminal = project
.update(cx, |project, cx| match terminal_view {
@@ -1482,7 +1498,7 @@ impl Render for TerminalPanel {
window.focus(&pane.read(cx).focus_handle(cx), cx);
} else {
let future =
terminal_panel.new_pane_with_cloned_active_terminal(window, cx);
terminal_panel.new_pane_with_active_terminal(true, window, cx);
cx.spawn_in(window, async move |terminal_panel, cx| {
if let Some(new_pane) = future.await {
_ = terminal_panel.update_in(

View File

@@ -109,7 +109,9 @@ pub async fn extract_seekable_zip<R: AsyncRead + AsyncSeek + Unpin>(
.await
.with_context(|| format!("extracting into file {path:?}"))?;
if let Some(perms) = entry.unix_permissions() {
if let Some(perms) = entry.unix_permissions()
&& perms != 0o000
{
use std::os::unix::fs::PermissionsExt;
let permissions = std::fs::Permissions::from_mode(u32::from(perms));
file.set_permissions(permissions)
@@ -132,7 +134,8 @@ mod tests {
use super::*;
async fn compress_zip(src_dir: &Path, dst: &Path) -> Result<()> {
#[allow(unused_variables)]
async fn compress_zip(src_dir: &Path, dst: &Path, keep_file_permissions: bool) -> Result<()> {
let mut out = smol::fs::File::create(dst).await?;
let mut writer = ZipFileWriter::new(&mut out);
@@ -155,8 +158,8 @@ mod tests {
ZipEntryBuilder::new(filename.into(), async_zip::Compression::Deflate);
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(path)?;
let perms = metadata.permissions().mode() as u16;
builder = builder.unix_permissions(perms);
let perms = keep_file_permissions.then(|| metadata.permissions().mode() as u16);
builder = builder.unix_permissions(perms.unwrap_or_default());
writer.write_entry_whole(builder, &data).await?;
}
#[cfg(not(unix))]
@@ -206,7 +209,9 @@ mod tests {
let zip_file = test_dir.path().join("test.zip");
smol::block_on(async {
compress_zip(test_dir.path(), &zip_file).await.unwrap();
compress_zip(test_dir.path(), &zip_file, true)
.await
.unwrap();
let reader = read_archive(&zip_file).await;
let dir = tempfile::tempdir().unwrap();
@@ -237,7 +242,9 @@ mod tests {
// Create zip
let zip_file = test_dir.path().join("test.zip");
compress_zip(test_dir.path(), &zip_file).await.unwrap();
compress_zip(test_dir.path(), &zip_file, true)
.await
.unwrap();
// Extract to new location
let extract_dir = tempfile::tempdir().unwrap();
@@ -251,4 +258,39 @@ mod tests {
assert_eq!(extracted_perms.mode() & 0o777, 0o755);
});
}
#[cfg(unix)]
#[test]
fn test_extract_zip_sets_default_permissions() {
use std::os::unix::fs::PermissionsExt;
smol::block_on(async {
let test_dir = tempfile::tempdir().unwrap();
let executable_path = test_dir.path().join("my_script");
// Create an executable file
std::fs::write(&executable_path, "#!/bin/bash\necho 'Hello'").unwrap();
// Create zip
let zip_file = test_dir.path().join("test.zip");
compress_zip(test_dir.path(), &zip_file, false)
.await
.unwrap();
// Extract to new location
let extract_dir = tempfile::tempdir().unwrap();
let reader = read_archive(&zip_file).await;
extract_zip(extract_dir.path(), reader).await.unwrap();
// Check permissions are preserved
let extracted_path = extract_dir.path().join("my_script");
assert!(extracted_path.exists());
let extracted_perms = std::fs::metadata(&extracted_path).unwrap().permissions();
assert_eq!(
extracted_perms.mode() & 0o777,
0o644,
"Expected default set of permissions for unzipped file with no permissions set."
);
});
}
}

View File

@@ -1468,24 +1468,28 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
action.range.replace(range.clone());
Some(Box::new(action))
}),
VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).filename(|_, filename| {
Some(
VimSplit {
vertical: false,
filename,
}
.boxed_clone(),
)
}),
VimCommand::new(("vs", "plit"), workspace::SplitVertical).filename(|_, filename| {
Some(
VimSplit {
vertical: true,
filename,
}
.boxed_clone(),
)
}),
VimCommand::new(("sp", "lit"), workspace::SplitHorizontal::default()).filename(
|_, filename| {
Some(
VimSplit {
vertical: false,
filename,
}
.boxed_clone(),
)
},
),
VimCommand::new(("vs", "plit"), workspace::SplitVertical::default()).filename(
|_, filename| {
Some(
VimSplit {
vertical: true,
filename,
}
.boxed_clone(),
)
},
),
VimCommand::new(("tabe", "dit"), workspace::NewFile)
.filename(|_action, filename| Some(VimEdit { filename }.boxed_clone())),
VimCommand::new(("tabnew", ""), workspace::NewFile)

View File

@@ -1037,7 +1037,9 @@ impl Render for PanelButtons {
.anchor(menu_anchor)
.attach(menu_attach)
.trigger(move |is_active, _window, _cx| {
IconButton::new(name, icon)
// Include active state in element ID to invalidate the cached
// tooltip when panel state changes (e.g., via keyboard shortcut)
IconButton::new((name, is_active_button as u64), icon)
.icon_size(IconSize::Small)
.toggle_state(is_active_button)
.on_click({

View File

@@ -197,6 +197,41 @@ pub struct DeploySearch {
pub excluded_files: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Debug, Deserialize, JsonSchema, Default)]
#[serde(deny_unknown_fields)]
pub enum SplitMode {
/// Clone the current pane.
#[default]
ClonePane,
/// Create an empty new pane.
EmptyPane,
/// Move the item into a new pane. This will map to nop if only one pane exists.
MovePane,
}
macro_rules! split_structs {
($($name:ident => $doc:literal),* $(,)?) => {
$(
#[doc = $doc]
#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)]
#[action(namespace = pane)]
#[serde(deny_unknown_fields, default)]
pub struct $name {
pub mode: SplitMode,
}
)*
};
}
split_structs!(
SplitLeft => "Splits the pane to the left.",
SplitRight => "Splits the pane to the right.",
SplitUp => "Splits the pane upward.",
SplitDown => "Splits the pane downward.",
SplitHorizontal => "Splits the pane horizontally.",
SplitVertical => "Splits the pane vertically."
);
actions!(
pane,
[
@@ -218,14 +253,6 @@ actions!(
JoinAll,
/// Reopens the most recently closed item.
ReopenClosedItem,
/// Splits the pane to the left, cloning the current item.
SplitLeft,
/// Splits the pane upward, cloning the current item.
SplitUp,
/// Splits the pane to the right, cloning the current item.
SplitRight,
/// Splits the pane downward, cloning the current item.
SplitDown,
/// Splits the pane to the left, moving the current item.
SplitAndMoveLeft,
/// Splits the pane upward, moving the current item.
@@ -234,10 +261,6 @@ actions!(
SplitAndMoveRight,
/// Splits the pane downward, moving the current item.
SplitAndMoveDown,
/// Splits the pane horizontally.
SplitHorizontal,
/// Splits the pane vertically.
SplitVertical,
/// Swaps the current item with the one to the left.
SwapItemLeft,
/// Swaps the current item with the one to the right.
@@ -279,7 +302,7 @@ pub enum Event {
},
Split {
direction: SplitDirection,
clone_active_item: bool,
mode: SplitMode,
},
ItemPinned,
ItemUnpinned,
@@ -311,13 +334,10 @@ impl fmt::Debug for Event {
.debug_struct("RemovedItem")
.field("item", &item.item_id())
.finish(),
Event::Split {
direction,
clone_active_item,
} => f
Event::Split { direction, mode } => f
.debug_struct("Split")
.field("direction", direction)
.field("clone_active_item", clone_active_item)
.field("mode", mode)
.finish(),
Event::JoinAll => f.write_str("JoinAll"),
Event::JoinIntoNext => f.write_str("JoinIntoNext"),
@@ -2295,10 +2315,7 @@ impl Pane {
let save_task = if let Some(project_path) = project_path {
let (worktree, path) = project_path.await?;
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id())?;
let new_path = ProjectPath {
worktree_id,
path: path,
};
let new_path = ProjectPath { worktree_id, path };
pane.update_in(cx, |pane, window, cx| {
if let Some(item) = pane.item_for_path(new_path.clone(), cx) {
@@ -2357,19 +2374,30 @@ impl Pane {
}
}
pub fn split(&mut self, direction: SplitDirection, cx: &mut Context<Self>) {
cx.emit(Event::Split {
direction,
clone_active_item: true,
});
}
pub fn split_and_move(&mut self, direction: SplitDirection, cx: &mut Context<Self>) {
if self.items.len() > 1 {
pub fn split(
&mut self,
direction: SplitDirection,
mode: SplitMode,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.items.len() <= 1 && mode == SplitMode::MovePane {
// MovePane with only one pane present behaves like a SplitEmpty in the opposite direction
let active_item = self.active_item();
cx.emit(Event::Split {
direction,
clone_active_item: false,
direction: direction.opposite(),
mode: SplitMode::EmptyPane,
});
// ensure that we focus the moved pane
// in this case we know that the window is the same as the active_item
if let Some(active_item) = active_item {
cx.defer_in(window, move |_, window, cx| {
let focus_handle = active_item.item_focus_handle(cx);
window.focus(&focus_handle, cx);
});
}
} else {
cx.emit(Event::Split { direction, mode });
}
}
@@ -3824,16 +3852,17 @@ fn default_render_tab_bar_buttons(
.with_handle(pane.split_item_context_menu_handle.clone())
.menu(move |window, cx| {
ContextMenu::build(window, cx, |menu, _, _| {
let mode = SplitMode::MovePane;
if can_split_move {
menu.action("Split Right", SplitAndMoveRight.boxed_clone())
.action("Split Left", SplitAndMoveLeft.boxed_clone())
.action("Split Up", SplitAndMoveUp.boxed_clone())
.action("Split Down", SplitAndMoveDown.boxed_clone())
menu.action("Split Right", SplitRight { mode }.boxed_clone())
.action("Split Left", SplitLeft { mode }.boxed_clone())
.action("Split Up", SplitUp { mode }.boxed_clone())
.action("Split Down", SplitDown { mode }.boxed_clone())
} else {
menu.action("Split Right", SplitRight.boxed_clone())
.action("Split Left", SplitLeft.boxed_clone())
.action("Split Up", SplitUp.boxed_clone())
.action("Split Down", SplitDown.boxed_clone())
menu.action("Split Right", SplitRight::default().boxed_clone())
.action("Split Left", SplitLeft::default().boxed_clone())
.action("Split Up", SplitUp::default().boxed_clone())
.action("Split Down", SplitDown::default().boxed_clone())
}
})
.into()
@@ -3892,33 +3921,35 @@ impl Render for Pane {
.size_full()
.flex_none()
.overflow_hidden()
.on_action(
cx.listener(|pane, _: &SplitLeft, _, cx| pane.split(SplitDirection::Left, cx)),
)
.on_action(cx.listener(|pane, _: &SplitUp, _, cx| pane.split(SplitDirection::Up, cx)))
.on_action(cx.listener(|pane, _: &SplitHorizontal, _, cx| {
pane.split(SplitDirection::horizontal(cx), cx)
.on_action(cx.listener(|pane, split: &SplitLeft, window, cx| {
pane.split(SplitDirection::Left, split.mode, window, cx)
}))
.on_action(cx.listener(|pane, _: &SplitVertical, _, cx| {
pane.split(SplitDirection::vertical(cx), cx)
.on_action(cx.listener(|pane, split: &SplitUp, window, cx| {
pane.split(SplitDirection::Up, split.mode, window, cx)
}))
.on_action(
cx.listener(|pane, _: &SplitRight, _, cx| pane.split(SplitDirection::Right, cx)),
)
.on_action(
cx.listener(|pane, _: &SplitDown, _, cx| pane.split(SplitDirection::Down, cx)),
)
.on_action(cx.listener(|pane, _: &SplitAndMoveUp, _, cx| {
pane.split_and_move(SplitDirection::Up, cx)
.on_action(cx.listener(|pane, split: &SplitHorizontal, window, cx| {
pane.split(SplitDirection::horizontal(cx), split.mode, window, cx)
}))
.on_action(cx.listener(|pane, _: &SplitAndMoveDown, _, cx| {
pane.split_and_move(SplitDirection::Down, cx)
.on_action(cx.listener(|pane, split: &SplitVertical, window, cx| {
pane.split(SplitDirection::vertical(cx), split.mode, window, cx)
}))
.on_action(cx.listener(|pane, _: &SplitAndMoveLeft, _, cx| {
pane.split_and_move(SplitDirection::Left, cx)
.on_action(cx.listener(|pane, split: &SplitRight, window, cx| {
pane.split(SplitDirection::Right, split.mode, window, cx)
}))
.on_action(cx.listener(|pane, _: &SplitAndMoveRight, _, cx| {
pane.split_and_move(SplitDirection::Right, cx)
.on_action(cx.listener(|pane, split: &SplitDown, window, cx| {
pane.split(SplitDirection::Down, split.mode, window, cx)
}))
.on_action(cx.listener(|pane, _: &SplitAndMoveUp, window, cx| {
pane.split(SplitDirection::Up, SplitMode::MovePane, window, cx)
}))
.on_action(cx.listener(|pane, _: &SplitAndMoveDown, window, cx| {
pane.split(SplitDirection::Down, SplitMode::MovePane, window, cx)
}))
.on_action(cx.listener(|pane, _: &SplitAndMoveLeft, window, cx| {
pane.split(SplitDirection::Left, SplitMode::MovePane, window, cx)
}))
.on_action(cx.listener(|pane, _: &SplitAndMoveRight, window, cx| {
pane.split(SplitDirection::Right, SplitMode::MovePane, window, cx)
}))
.on_action(cx.listener(|_, _: &JoinIntoNext, _, cx| {
cx.emit(Event::JoinIntoNext);
@@ -4443,11 +4474,14 @@ impl Render for DraggedTab {
#[cfg(test)]
mod tests {
use std::num::NonZero;
use std::{iter::zip, num::NonZero};
use super::*;
use crate::item::test::{TestItem, TestProjectItem};
use gpui::{TestAppContext, VisualTestContext, size};
use crate::{
Member,
item::test::{TestItem, TestProjectItem},
};
use gpui::{AppContext, Axis, TestAppContext, VisualTestContext, size};
use project::FakeFs;
use settings::SettingsStore;
use theme::LoadThemes;
@@ -7125,6 +7159,32 @@ mod tests {
assert_item_labels(&pane, ["A", "C*", "B"], cx);
}
#[gpui::test]
async fn test_split_empty(cx: &mut TestAppContext) {
for split_direction in SplitDirection::all() {
test_single_pane_split(["A"], split_direction, SplitMode::EmptyPane, cx).await;
}
}
#[gpui::test]
async fn test_split_clone(cx: &mut TestAppContext) {
for split_direction in SplitDirection::all() {
test_single_pane_split(["A"], split_direction, SplitMode::ClonePane, cx).await;
}
}
#[gpui::test]
async fn test_split_move_right_on_single_pane(cx: &mut TestAppContext) {
test_single_pane_split(["A"], SplitDirection::Right, SplitMode::MovePane, cx).await;
}
#[gpui::test]
async fn test_split_move(cx: &mut TestAppContext) {
for split_direction in SplitDirection::all() {
test_single_pane_split(["A", "B"], split_direction, SplitMode::MovePane, cx).await;
}
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
@@ -7220,4 +7280,163 @@ mod tests {
"pane items do not match expectation"
);
}
// Assert the item label, with the active item label expected active index
#[track_caller]
fn assert_item_labels_active_index(
pane: &Entity<Pane>,
expected_states: &[&str],
expected_active_idx: usize,
cx: &mut VisualTestContext,
) {
let actual_states = pane.update(cx, |pane, cx| {
pane.items
.iter()
.enumerate()
.map(|(ix, item)| {
let mut state = item
.to_any_view()
.downcast::<TestItem>()
.unwrap()
.read(cx)
.label
.clone();
if ix == pane.active_item_index {
assert_eq!(ix, expected_active_idx);
}
if item.is_dirty(cx) {
state.push('^');
}
if pane.is_tab_pinned(ix) {
state.push('!');
}
state
})
.collect::<Vec<_>>()
});
assert_eq!(
actual_states, expected_states,
"pane items do not match expectation"
);
}
#[track_caller]
fn assert_pane_ids_on_axis<const COUNT: usize>(
workspace: &Entity<Workspace>,
expected_ids: [&EntityId; COUNT],
expected_axis: Axis,
cx: &mut VisualTestContext,
) {
workspace.read_with(cx, |workspace, _| match &workspace.center.root {
Member::Axis(axis) => {
assert_eq!(axis.axis, expected_axis);
assert_eq!(axis.members.len(), expected_ids.len());
assert!(
zip(expected_ids, &axis.members).all(|(e, a)| {
if let Member::Pane(p) = a {
p.entity_id() == *e
} else {
false
}
}),
"pane ids do not match expectation: {expected_ids:?} != {actual_ids:?}",
actual_ids = axis.members
);
}
Member::Pane(_) => panic!("expected axis"),
});
}
async fn test_single_pane_split<const COUNT: usize>(
pane_labels: [&str; COUNT],
direction: SplitDirection,
operation: SplitMode,
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
let mut pane_before =
workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
for label in pane_labels {
add_labeled_item(&pane_before, label, false, cx);
}
pane_before.update_in(cx, |pane, window, cx| {
pane.split(direction, operation, window, cx)
});
cx.executor().run_until_parked();
let pane_after = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
let num_labels = pane_labels.len();
let last_as_active = format!("{}*", String::from(pane_labels[num_labels - 1]));
// check labels for all split operations
match operation {
SplitMode::EmptyPane => {
assert_item_labels_active_index(&pane_before, &pane_labels, num_labels - 1, cx);
assert_item_labels(&pane_after, [], cx);
}
SplitMode::ClonePane => {
assert_item_labels_active_index(&pane_before, &pane_labels, num_labels - 1, cx);
assert_item_labels(&pane_after, [&last_as_active], cx);
}
SplitMode::MovePane => {
let head = &pane_labels[..(num_labels - 1)];
if num_labels == 1 {
// We special-case this behavior and actually execute an empty pane command
// followed by a refocus of the old pane for this case.
pane_before = workspace.read_with(cx, |workspace, _cx| {
workspace
.panes()
.into_iter()
.find(|pane| *pane != &pane_after)
.unwrap()
.clone()
});
};
assert_item_labels_active_index(
&pane_before,
&head,
head.len().saturating_sub(1),
cx,
);
assert_item_labels(&pane_after, [&last_as_active], cx);
pane_after.update_in(cx, |pane, window, cx| {
window.focused(cx).is_some_and(|focus_handle| {
focus_handle == pane.active_item().unwrap().item_focus_handle(cx)
})
});
}
}
// expected axis depends on split direction
let expected_axis = match direction {
SplitDirection::Right | SplitDirection::Left => Axis::Horizontal,
SplitDirection::Up | SplitDirection::Down => Axis::Vertical,
};
// expected ids depends on split direction
let expected_ids = match direction {
SplitDirection::Right | SplitDirection::Down => {
[&pane_before.entity_id(), &pane_after.entity_id()]
}
SplitDirection::Left | SplitDirection::Up => {
[&pane_after.entity_id(), &pane_before.entity_id()]
}
};
// check pane axes for all operations
match operation {
SplitMode::EmptyPane | SplitMode::ClonePane => {
assert_pane_ids_on_axis(&workspace, expected_ids, expected_axis, cx);
}
SplitMode::MovePane => {
assert_pane_ids_on_axis(&workspace, expected_ids, expected_axis, cx);
}
}
}
}

View File

@@ -4262,16 +4262,19 @@ impl Workspace {
item: item.boxed_clone(),
});
}
pane::Event::Split {
direction,
clone_active_item,
} => {
if *clone_active_item {
self.split_and_clone(pane.clone(), *direction, window, cx)
.detach();
} else {
self.split_and_move(pane.clone(), *direction, window, cx);
}
pane::Event::Split { direction, mode } => {
match mode {
SplitMode::ClonePane => {
self.split_and_clone(pane.clone(), *direction, window, cx)
.detach();
}
SplitMode::EmptyPane => {
self.split_pane(pane.clone(), *direction, window, cx);
}
SplitMode::MovePane => {
self.split_and_move(pane.clone(), *direction, window, cx);
}
};
}
pane::Event::JoinIntoNext => {
self.join_pane_into_next(pane.clone(), window, cx);

View File

@@ -833,12 +833,19 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
cx.spawn_in(window, async move |workspace, cx| {
let res = async move {
let json = app_state.languages.language_for_name("JSONC").await.ok();
let lsp_store = workspace.update(cx, |workspace, cx| {
workspace
.project()
.update(cx, |project, _| project.lsp_store())
})?;
let json_schema_content =
json_schema_store::resolve_schema_request_inner(
&app_state.languages,
lsp_store,
&schema_path,
cx,
)?;
)
.await?;
let json_schema_content =
serde_json::to_string_pretty(&json_schema_content)
.context("Failed to serialize JSON Schema as JSON")?;

View File

@@ -3817,7 +3817,7 @@ mod tests {
})
.unwrap();
cx.dispatch_action(window.into(), pane::SplitRight);
cx.dispatch_action(window.into(), pane::SplitRight::default());
let editor_2 = cx.update(|cx| {
let pane_2 = workspace.read(cx).active_pane().clone();
assert_ne!(pane_1, pane_2);

View File

@@ -32,10 +32,10 @@ pub fn app_menus(cx: &mut App) -> Vec<Menu> {
MenuItem::submenu(Menu {
name: "Editor Layout".into(),
items: vec![
MenuItem::action("Split Up", workspace::SplitUp),
MenuItem::action("Split Down", workspace::SplitDown),
MenuItem::action("Split Left", workspace::SplitLeft),
MenuItem::action("Split Right", workspace::SplitRight),
MenuItem::action("Split Up", workspace::SplitUp::default()),
MenuItem::action("Split Down", workspace::SplitDown::default()),
MenuItem::action("Split Left", workspace::SplitLeft::default()),
MenuItem::action("Split Right", workspace::SplitRight::default()),
],
}),
MenuItem::separator(),

View File

@@ -85,6 +85,8 @@ You can type `@` to mention files, directories, symbols, previous threads, and r
Copying images and pasting them in the panel's message editor is also supported.
When you paste multi-line code selections copied from an editor buffer, Zed automatically formats them as @mentions with the file context. To paste content without this automatic formatting, use {#kb agent::PasteRaw} to paste raw text directly.
### Selection as Context
Additionally, you can also select text in a buffer and add it as context by using the {#kb agent::AddSelectionToThread} keybinding, running the {#action agent::AddSelectionToThread} action, or choosing the "Selection" item in the `@` menu.
@@ -100,6 +102,8 @@ You can also do this at any time with an ongoing thread via the "Agent Options"
After you've configured your LLM providers—either via [a custom API key](./llm-providers.md) or through [Zed's hosted models](./models.md)—you can switch between them by clicking on the model selector on the message editor or by using the {#kb agent::ToggleModelSelector} keybinding.
If you have favorited models configured, you can cycle through them with {#kb agent::CycleFavoriteModels} without opening the model selector.
> The same model can be offered via multiple providers - for example, Claude Sonnet 4 is available via Zed Pro, OpenRouter, Anthropic directly, and more.
> Make sure you've selected the correct model **_provider_** for the model you'd like to use, delineated by the logo to the left of the model in the model selector.

View File

@@ -305,7 +305,7 @@ To use GitHub Copilot as your provider, set this within `settings.json`:
}
```
You should be able to sign-in to GitHub Copilot by clicking on the Copilot icon in the status bar and following the setup instructions.
To sign in to GitHub Copilot, click on the Copilot icon in the status bar. A popup window appears displaying a device code. Click the copy button to copy the code, then click "Connect to GitHub" to open the GitHub verification page in your browser. Paste the code when prompted. The popup window closes automatically after successful authorization.
#### Using GitHub Copilot Enterprise
@@ -348,10 +348,17 @@ You should be able to sign-in to Supermaven by clicking on the Supermaven icon i
### Codestral {#codestral}
To use Mistral's Codestral as your provider, start by going to the Agent Panel settings view by running the {#action agent::OpenSettings} action.
Look for the Mistral item and add a Codestral API key in the corresponding text input.
To use Mistral's Codestral as your provider:
After that, you should be able to switch your provider to it in your `settings.json` file:
1. Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows)
2. Search for "Edit Predictions" and click **Configure Providers**
3. Find the Codestral section and enter your API key from the
[Codestral dashboard](https://console.mistral.ai/codestral)
Alternatively, click the edit prediction icon in the status bar and select
**Configure Providers** from the menu.
After adding your API key, set Codestral as your provider in `settings.json`:
```json [settings]
{

View File

@@ -62,7 +62,7 @@ The `download_file` capability grants extensions the ability to download files u
To allow any file to be downloaded:
```toml
{ kind = "download_file", host = "github.com", path = ["**"] }
{ kind = "download_file", host = "*", path = ["**"] }
```
To allow any file to be downloaded from `github.com`:

View File

@@ -1,6 +1,6 @@
[package]
name = "zed_proto"
version = "0.3.0"
version = "0.3.1"
edition.workspace = true
publish.workspace = true
license = "Apache-2.0"

View File

@@ -1,7 +1,7 @@
id = "proto"
name = "Proto"
description = "Protocol Buffers support."
version = "0.3.0"
version = "0.3.1"
schema_version = 1
authors = ["Zed Industries <support@zed.dev>"]
repository = "https://github.com/zed-industries/zed"

View File

@@ -48,7 +48,7 @@ fn run_clippy() -> Step<Run> {
fn check_rust() -> NamedJob {
let job = Job::default()
.with_repository_owner_guard()
.runs_on(runners::LINUX_DEFAULT)
.runs_on(runners::LINUX_MEDIUM)
.timeout_minutes(3u32)
.add_step(steps::checkout_repo())
.add_step(steps::cache_rust_dependencies_namespace())
@@ -66,7 +66,7 @@ pub(crate) fn check_extension() -> NamedJob {
let (cache_download, cache_hit) = cache_zed_extension_cli();
let job = Job::default()
.with_repository_owner_guard()
.runs_on(runners::LINUX_SMALL)
.runs_on(runners::LINUX_LARGE_RAM)
.timeout_minutes(2u32)
.add_step(steps::checkout_repo())
.add_step(cache_download)

View File

@@ -8,6 +8,9 @@ pub const LINUX_MEDIUM: Runner = Runner("namespace-profile-4x8-ubuntu-2204");
pub const LINUX_X86_BUNDLER: Runner = Runner("namespace-profile-32x64-ubuntu-2004");
pub const LINUX_ARM_BUNDLER: Runner = Runner("namespace-profile-8x32-ubuntu-2004-arm-m4");
// Larger Ubuntu runner with glibc 2.39 for extension bundling
pub const LINUX_LARGE_RAM: Runner = Runner("namespace-profile-8x32-ubuntu-2404");
pub const MAC_DEFAULT: Runner = Runner("self-mini-macos");
pub const WINDOWS_DEFAULT: Runner = Runner("self-32vcpu-windows-2022");