Compare commits

...

32 Commits

Author SHA1 Message Date
Cole Miller
f145614283 wip
Co-authored-by: Remco Smits <djsmits12@gmail.com>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Piotr Osiewicz <peterosiewicz@gmail.com>
2025-06-04 12:05:31 -04:00
Anthony Eid
81f8e2ed4a Limit BufferSnapshot::surrounding_word search to 256 characters (#32016)
This is the first step to closing #16120. Part of the problem was that
`surrounding_word` would search the whole line for matches with no
limit.

Co-authored-by: Conrad Irwin \<conrad@zed.dev\>
Co-authored-by: Ben Kunkle \<ben@zed.dev\>
Co-authored-by: Cole Miller \<cole@zed.dev\>

Release Notes:

- N/A
2025-06-03 21:08:59 +00:00
Gilles Peiffer
b9256dd469 editor: Apply common_prefix_len refactor suggestion (#31957)
This adds João's nice suggestion from
https://github.com/zed-industries/zed/pull/31818#discussion_r2118582616.

Release Notes:

- N/A

---------

Co-authored-by: João Marcos <marcospb19@hotmail.com>
2025-06-03 15:07:14 -06:00
Bennet Bo Fenner
27d3da678c editor: Fix panic when full width crease is wrapped (#31960)
Closes #31919

Release Notes:

- Fixed a panic that could sometimes occur when the agent panel was too
narrow and contained context included via `@`.

---------

Co-authored-by: Antonio <antonio@zed.dev>
Co-authored-by: Antonio Scandurra <me@as-cii.com>
2025-06-03 22:59:27 +02:00
Conrad Irwin
03357f3f7b Fix panic when re-editing old message with creases (#32017)
Co-authored-by: Cole Miller <m@cole-miller.net>

Release Notes:

- agent: Fixed a panic when re-editing old messages

---------

Co-authored-by: Cole Miller <m@cole-miller.net>
Co-authored-by: Cole Miller <cole@zed.dev>
2025-06-03 20:56:18 +00:00
Kirill Bulatov
4aabba6cf6 Improve Zed prompts for file path selection (#32014)
Part of https://github.com/zed-industries/zed/discussions/31653
`"use_system_path_prompts": false` is needed in settings for these to
appear as modals for new file save and file open.

Fixed a very subpar experience of the "save new file" Zed modal,
compared to a similar "open file path" Zed modal by uniting their code.

Before:


https://github.com/user-attachments/assets/c4082b70-6cdc-4598-a416-d491011c8ac4


After:



https://github.com/user-attachments/assets/21ca672a-ae40-426c-b68f-9efee4f93c8c


Also 

* alters both prompts to start in the current worktree directory, with
the fallback to home directory.
* adjusts the code to handle Windows paths better

Release Notes:

- Improved Zed prompts for file path selection

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-06-03 20:35:25 +00:00
Michael Sloan
8c46a4f594 Make completions menu stay open after after it's manually requested (#32015)
Also includes a clarity refactoring to remove
`ignore_completion_provider`.

Closes #15549

Release Notes:

- Fixed completions menu closing on typing after being requested while
`show_completions_on_input: false`.
2025-06-03 20:33:52 +00:00
Luke Naylor
522abe8e59 Change default formatter settings for LaTeX (#28727)
Closes: https://github.com/rzukic/zed-latex/issues/77 

## Default formatter: `latexindent`
Before, formatting was delegated to the language server, which just ran
a `latexindent` executable. There was no benefit to running it through
the language server over running it as an "external" formatter in zed.
In fact this was an issue because there was no way to provide an
explicit path for the executable (causing above extension issue). Having
the default settings configure the formatter directly gives more control
to user and removes the number of indirections making it clearer how to
tweak things like the executable path, or extra CLI args, etc...

## Alternative: `prettier`
Default settings have also been added to allow prettier as the formatter
(by just setting `"formatter": "prettier"` in the "LaTeX" language
settings). This was not possible before because an extra line needed to
be added to the `prettier` crate (similarly to what was done for
https://github.com/zed-industries/zed/issues/19024) to find the plugin
correctly.
> [!NOTE]
> The `prettier-plugin-latex` node module also contained a
`dist/standalone.js` but using that instead of
`dist/prettier-plugin-latex.js` gave an error, and indeed the latter
worked as intended (along with its questionable choices for formatting).

Release Notes:

- LaTeX: added default `latexindent` formatter settings without relying
on `texlab`, as well as allowing `prettier` to be chosen for formatting

---------

Co-authored-by: Peter Tripp <peter@zed.dev>
2025-06-03 19:51:30 +00:00
Michael Sloan
5ae8c4cf09 Fix word completions clobbering the text after the cursor (#32010)
Release Notes:

- N/A
2025-06-03 19:37:26 +00:00
Smit Barmase
d8195a8fd7 project_panel: Highlight containing folder which would be the target of the drop operation (#31976)
Part of https://github.com/zed-industries/zed/issues/14496

This PR adds highlighting on the containing folder which would be the
target of the drop operation. It only highlights those directories where
actual drop is possible, i.e. same directory where drag started is not
highlighted.

- [x] Tests


https://github.com/user-attachments/assets/46528467-e07a-4574-a8d5-beab25e70162

Release Notes:

- Improved project panel to show a highlight on the containing folder
which would be the target of the drop operation.
2025-06-04 00:34:37 +05:30
Danilo Leal
2645591cd5 agent: Allow to accept and reject all via the panel (#31971)
This PR introduces the "Reject All" and "Accept All" buttons in the
panel's edit bar, which appears as soon as the agent starts editing a
file. I'm also adding here a new method to the thread called
`has_pending_edit_tool_uses`, which is a more specific way of knowing,
in comparison to the `is_generating` method, whether or not the
reject/accept all actions can be triggered.

Previously, without this new method, you'd be waiting for the whole
generation to end (e.g., the agent would be generating markdown with
things like change summary) to be able to click those buttons, when the
edit was already there, ready for you. It always felt like waiting for
the whole thing was unnecessary when you really wanted to just wait for
the _edits_ to be done, as so to avoid any potential conflicting state.

<img
src="https://github.com/user-attachments/assets/0927f3a6-c9ee-46ae-8f7b-97157d39a7b5"
width="500"/>

---

Release Notes:

- agent: Added ability to reject and accept all changes from the agent
panel.

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
2025-06-03 15:20:25 -03:00
Danilo Leal
526a7c0702 agent: Support AGENT.md and AGENTS.md as rules file names (#31998)
These started to be used more recently, so we should also support them.

Release Notes:

- agent: Added support for `AGENT.md` and `AGENTS.md` as rules file
names.
2025-06-03 15:19:39 -03:00
Danilo Leal
e793740168 agent: Refine rules library window design (#31994)
Just polishing up a bit the Rules Library design. I think the most
confusing part here was the icon that was being used to tag a rule as
default; I've heard feedback more than once saying that was confusing,
so I'm now switching to a rather standard star icon, which I'd assume is
well-understood as a "favoriting" affordance.

Release Notes:

- N/A
2025-06-03 14:59:17 -03:00
Danilo Leal
dea0a58727 docs: Update mentions to GitHub to use correct capitalization (#31996)
That type of thing... 😅 "Github" is the incorrect formatting; "GitHub"
is the correct.

Release Notes:

- N/A
2025-06-03 14:55:24 -03:00
Agus Zubiaga
b7abc9d493 agent: Display full terminal output without scrolling (#31922)
The terminal tool card used a fixed height and scrolling, but this meant
that it was too tall for commands that only outputted a few lines, and
the nested scrolling was undesirable.

This PR makes the card be as too as needed to fit the entire output (no
scrolling), and allows the user to collapse it to fewer lines when
applicable. Making it work the same way as the edit tool card. In fact,
both tools now use a shared UI component.


https://github.com/user-attachments/assets/1127e21d-1d41-4a4b-a99f-7cd70fccbb56


Release Notes:

- Agent: Display full terminal output
- Agent: Allow collapsing terminal output
2025-06-03 10:54:25 -07:00
Peter Tripp
01a77bb231 Add sql language docs (#32003)
Closes: https://github.com/zed-industries/zed/issues/9537

Pairs with removing `prettier-plugin-sql` from the sql extension:
- https://github.com/zed-extensions/sql/pull/19

Release Notes:

- N/A
2025-06-03 13:52:42 -04:00
Daniel Zhu
de225fd242 file_finder: Add option to create new file (#31567)
https://github.com/user-attachments/assets/7c8a05a1-8d59-4371-a1d6-a8cb82aa13b9

While implementing this, I noticed that currently when the search panel
displays only one result, the box oscillates a bit up and down like so:


https://github.com/user-attachments/assets/dd1520e2-fa0b-4307-b27a-984e69b0a644

Not sure how to fix this at the moment, maybe that could be another PR?

Release Notes:

- Add option to create new file in project search panel.
2025-06-03 10:44:57 -07:00
Oleksiy Syvokon
1bc052d76b docs: Gemini thinking budget configuration (#32002)
Release Notes:

- N/A
2025-06-03 20:41:42 +03:00
Ben Kunkle
29cb95a3ca Remove support for changing magnification of active pane (#31981)
Closes #4265
Closes #24600

This setting causes many visual defects, and introduces unnecessary
(maintenance) complexity. as seen by #4265 and #24600


CC: @iamnbutler - How do you feel about this? I recommend looking at
https://github.com/zed-industries/zed/pull/24150#issuecomment-2866706506
for more context

Release Notes:

- Removed support

---------

Co-authored-by: Peter <peter@zed.dev>
2025-06-03 13:32:32 -04:00
Cole Miller
1307b81721 Allow configuring custom git hosting providers in project settings (#31929)
Closes #29229

Release Notes:

- Extended the support for configuring custom git hosting providers to
cover project settings in addition to global settings.

---------

Co-authored-by: Anthony Eid <hello@anthonyeid.me>
2025-06-03 12:23:01 -04:00
Piotr Osiewicz
203754d0db docs: Demote rdbg support in docs (#31993)
Closes #ISSUE

Release Notes:

- N/A
2025-06-03 16:12:21 +00:00
Umesh Yadav
c9c603b1d1 Add support for OpenRouter as a language model provider (#29496)
This pull request adds full integration with OpenRouter, allowing users
to access a wide variety of language models through a single API key.

**Implementation Details:**

* **Provider Registration:** Registers OpenRouter as a new language
model provider within the application's model registry. This includes UI
for API key authentication, token counting, streaming completions, and
tool-call handling.
* **Dedicated Crate:** Adds a new `open_router` crate to manage
interactions with the OpenRouter HTTP API, including model discovery and
streaming helpers.
* **UI & Configuration:** Extends workspace manifests, the settings
schema, icons, and default configurations to surface the OpenRouter
provider and its settings within the UI.
* **Readability:** Reformats JSON arrays within the settings files for
improved readability.

**Design Decisions & Discussion Points:**

* **Code Reuse:** I leveraged much of the existing logic from the
`openai` provider integration due to the significant similarities
between the OpenAI and OpenRouter API specifications.
* **Default Model:** I set the default model to `openrouter/auto`. This
model automatically routes user prompts to the most suitable underlying
model on OpenRouter, providing a convenient starting point.
* **Model Population Strategy:**
* <strike>I've implemented dynamic population of available models by
querying the OpenRouter API upon initialization.
* Currently, this involves three separate API calls: one for all models,
one for tool-use models, and one for models good at programming.
* The data from the tool-use API call sets a `tool_use` flag for
relevant models.
* The data from the programming models API call is used to sort the
list, prioritizing coding-focused models in the dropdown.</strike>
* <strike>**Feedback Welcome:** I acknowledge this multi-call approach
is API-intensive. I am open to feedback and alternative implementation
suggestions if the team believes this can be optimized.</strike>
    * **Update: Now this has been simplified to one api call.**
* **UI/UX Considerations:**
* <strike>Authentication Method: Currently, I've implemented the
standard API key input in settings, similar to other providers like
OpenAI/Anthropic. However, OpenRouter also supports OAuth 2.0 with PKCE.
This could offer a potentially smoother, more integrated setup
experience for users (e.g., clicking a button to authorize instead of
copy-pasting a key). Should we prioritize implementing OAuth PKCE now,
or perhaps add it as an alternative option later?</strike>(PKCE is not
straight forward and complicated so skipping this for now. So that we
can add the support and work on this later.)
* <strike>To visually distinguish models better suited for programming,
I've considered adding a marker (e.g., `</>` or `🧠`) next to their
names. Thoughts on this proposal?</strike>. (This will require a changes
and discussion across model provider. This doesn't fall under the scope
of current PR).
* OpenRouter offers 300+ models. The current implementation loads all of
them. **Feedback Needed:** Should we refine this list or implement more
sophisticated filtering/categorization for better usability?

**Motivation:**

This integration directly addresses one of the most highly upvoted
feature requests/discussions within the Zed community. Adding OpenRouter
support significantly expands the range of AI models accessible to
users.

I welcome feedback from the Zed team on this implementation and the
design choices made. I am eager to refine this feature and make it
available to users.

ISSUES: https://github.com/zed-industries/zed/discussions/16576

Release Notes:

- Added support for OpenRouter as a language model provider.

---------

Signed-off-by: Umesh Yadav <umesh4257@gmail.com>
Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-06-03 15:59:46 +00:00
Shardul Vaidya
e13b494c9e bedrock: Fix cross-region inference (#30659)
Closes #30535

Release Notes:

- AWS Bedrock: Add support for Meta Llama 4 Scout and Maverick models.
- AWS Bedrock: Fixed cross-region inference for all regions.
- AWS Bedrock: Updated all models available through Cross Region
inference.

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-06-03 15:46:35 +00:00
little-dude
c0397727e0 language_models: Sort Ollama models by name (#31620)
Hello,

This is my first contribution so apologies if I'm not following the
proper process (I haven't seen anything special in
https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md). Also,
I have tested my changes manually, but I could not figure out an easy we
to instantiate a `LanguageModelSelector` in the unit tests, so I didn't
write a test. If you can provide some guidance I'd be happy to write a
test.

---

If the user configured the models with custom names via `display_name`,
we want the ollama models to be sorted based on the name that is
actually displayed.

~~The original issue is only about ollama but this change will also
affect the other providers.~~

Closes #30854

Release Notes:

- Ollama: Changed models to be sorted by name.
2025-06-03 15:37:08 +00:00
Marshall Bowers
9c2b90fb8f collab: Return subscription period from GET /billing/subscriptions (#31987)
This PR updates the `GET /billing/subscriptions` endpoint to return the
subscription period on them.

Release Notes:

- N/A
2025-06-03 15:29:08 +00:00
Marshall Bowers
d108e5f53c collab: Fix deserialization of create meter event response (#31982)
This PR fixes the deserialization of the create meter event response
from the Stripe API.

Release Notes:

- N/A
2025-06-03 15:23:38 +00:00
Marshall Bowers
2551bde1d3 collab: Increase number of returned extensions to 1,000 (#31983)
This PR increases the number of returned extensions from the extension
API to 1,000 (up from 500).

We'll need a better solution at some point, but for now we can keep
bumping this number.

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

Release Notes:

- N/A
2025-06-03 15:03:32 +00:00
Peter Tripp
e7de80c6ae ci: Improve Danger and ci.yml explicitness (#31979)
Allow colons after issue links and for them to in ul.
Change ci references from [self-hosted, test] to more explicit
[self-hosted, macOS]

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben.kunkle@gmail.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-06-03 10:54:04 -04:00
Peter Tripp
ae210eced8 Fix aggressive indent in shell scripts (#31973)
Closes: https://github.com/zed-industries/zed/issues/31774

Release Notes:

- N/A

Co-authored-by: Ben Kunkle <ben.kunkle@gmail.com>
2025-06-03 10:50:58 -04:00
Piotr Osiewicz
a9d99d8347 docs: Improve docs for debugger (around breakpoints and doc structure) (#31962)
Closes #ISSUE

Release Notes:

- N/A
2025-06-03 16:35:35 +02:00
Thiago Pacheco
3e6435eddc Fix Python virtual environment detection (#31934)
# Fix Python Virtual Environment Detection in Zed

## Problem

Zed was not properly detecting Python virtual environments when a
project didn't contain a `pyrightconfig.json` file. This caused Pyright
(the Python language server) to report `reportMissingImports` errors for
packages installed in virtual environments, even though the virtual
environment was correctly set up and worked fine in other editors.

The issue was that while Zed's `PythonToolchainProvider` correctly
detected virtual environments, this information wasn't being
communicated to Pyright in a format it could understand.

## Root Cause

The main issue was in how Zed communicated virtual environment
configuration to Pyright through the Language Server Protocol (LSP).
When Pyright requests workspace configuration, it expects virtual
environment settings (`venvPath` and `venv`) at the root level of the
configuration object - the same format used in `pyrightconfig.json`
files. However, Zed was attempting to place these settings in various
nested locations that Pyright wasn't checking.

## Solution

The fix involves several coordinated changes to ensure Pyright receives
virtual environment configuration in all the ways it might expect:

### 1. Enhanced Workspace Configuration (`workspace_configuration`
method)
- When a virtual environment is detected, Zed now sets `venvPath` and
`venv` at the root level of the configuration object, matching the exact
format of a `pyrightconfig.json` file
- Uses relative path `"."` when the virtual environment is located in
the workspace root
- Also sets `python.pythonPath` and `python.defaultInterpreterPath` for
compatibility with different Pyright versions

### 2. Environment Variables for All Language Server Binaries
- Updated `check_if_user_installed`, `fetch_server_binary`,
`check_if_version_installed`, and `cached_server_binary` methods to
include shell environment variables
- This ensures environment variables like `VIRTUAL_ENV` are available to
Pyright, helping with automatic virtual environment detection

### 3. Initialization Options
- Added minimal initialization options to enable Pyright's automatic
path searching and import completion features
- Sets `autoSearchPaths: true` and `useLibraryCodeForTypes: true` to
improve Pyright's ability to find packages

## Key Changes

The workspace configuration now properly formats virtual environment
configuration:
- Root level: `venvPath` and `venv` (matches pyrightconfig.json format)
- Python section: `pythonPath` and `defaultInterpreterPath` for
interpreter paths

## Impact

- Users no longer need to create a `pyrightconfig.json` file for virtual
environment detection
- Python projects with virtual environments in standard locations
(`.venv`, `venv`, etc.) will work out of the box
- Import resolution for packages installed in virtual environments now
works correctly
- Maintains compatibility with manual `pyrightconfig.json` configuration
for complex setups

## Testing

The changes were tested with Python projects using virtual environments
without `pyrightconfig.json` files. Pyright now correctly resolves
imports from packages installed in the virtual environment, eliminating
the `reportMissingImports` errors.

## Release Notes

- Fixed Python virtual environment detection when no
`pyrightconfig.json` is present
- Pyright now correctly resolves imports from packages installed in
virtual environments (`.venv`, `venv`, etc.)
- Python projects with virtual environments no longer show false
`reportMissingImports` errors
- Improved Python development experience with automatic virtual
environment configuration

---------

Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
2025-06-03 16:35:13 +02:00
Danilo Leal
9e75871d48 agent: Make the sound notification play only if Zed is in the background (#31975)
Users were giving feedback about the sound notification being
annoying/unnecessary if Zed is in the foreground, which I agree! So,
this PR changes it so that it only plays if that is not the case.

Release Notes:

- agent: Improved sound notification behavior by making it play only if
Zed is in the background.
2025-06-03 11:14:26 -03:00
105 changed files with 4189 additions and 1594 deletions

View File

@@ -73,7 +73,7 @@ jobs:
timeout-minutes: 60
runs-on:
- self-hosted
- test
- macOS
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -200,7 +200,7 @@ jobs:
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- self-hosted
- test
- macOS
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

View File

@@ -15,7 +15,7 @@ jobs:
if: github.repository_owner == 'zed-industries'
runs-on:
- self-hosted
- test
- macOS
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -33,7 +33,7 @@ jobs:
name: Run tests
runs-on:
- self-hosted
- test
- macOS
needs: style
steps:
- name: Checkout repo

View File

@@ -20,7 +20,7 @@ jobs:
if: github.repository_owner == 'zed-industries'
runs-on:
- self-hosted
- test
- macOS
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -40,7 +40,7 @@ jobs:
if: github.repository_owner == 'zed-industries'
runs-on:
- self-hosted
- test
- macOS
needs: style
steps:
- name: Checkout repo

14
Cargo.lock generated
View File

@@ -8864,6 +8864,7 @@ dependencies = [
"mistral",
"ollama",
"open_ai",
"open_router",
"partial-json-fixer",
"project",
"proto",
@@ -10708,6 +10709,19 @@ dependencies = [
"workspace-hack",
]
[[package]]
name = "open_router"
version = "0.1.0"
dependencies = [
"anyhow",
"futures 0.3.31",
"http_client",
"schemars",
"serde",
"serde_json",
"workspace-hack",
]
[[package]]
name = "opener"
version = "0.7.2"

View File

@@ -100,6 +100,7 @@ members = [
"crates/notifications",
"crates/ollama",
"crates/open_ai",
"crates/open_router",
"crates/outline",
"crates/outline_panel",
"crates/panel",
@@ -307,6 +308,7 @@ node_runtime = { path = "crates/node_runtime" }
notifications = { path = "crates/notifications" }
ollama = { path = "crates/ollama" }
open_ai = { path = "crates/open_ai" }
open_router = { path = "crates/open_router", features = ["schemars"] }
outline = { path = "crates/outline" }
outline_panel = { path = "crates/outline_panel" }
panel = { path = "crates/panel" }

View File

@@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor" stroke="currentColor">
<g clip-path="url(#clip0_205_3)">
<path d="M0.094 7.78c0.469 0 2.281 -0.405 3.219 -0.936s0.938 -0.531 2.875 -1.906c2.453 -1.741 4.188 -1.158 7.031 -1.158" stroke-width="2.8125" />
<path d="m15.969 3.797 -4.805 2.774V1.023z" />
<path d="M0 7.781c0.469 0 2.281 0.405 3.219 0.936s0.938 0.531 2.875 1.906C8.547 12.364 10.281 11.781 13.125 11.781" stroke-width="2.8125" />
<path d="m15.875 11.764 -4.805 -2.774v5.548z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 575 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-todo-icon lucide-list-todo"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg>

After

Width:  |  Height:  |  Size: 373 B

View File

@@ -1 +1,3 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.97942 1.25171L6.9585 1.30199L5.58662 4.60039C5.54342 4.70426 5.44573 4.77523 5.3336 4.78422L1.7727 5.0697L1.71841 5.07405L1.38687 5.10063L1.08608 5.12475C0.820085 5.14607 0.712228 5.47802 0.914889 5.65162L1.14406 5.84793L1.39666 6.06431L1.43802 6.09974L4.15105 8.42374C4.23648 8.49692 4.2738 8.61176 4.24769 8.72118L3.41882 12.196L3.40618 12.249L3.32901 12.5725L3.25899 12.866C3.19708 13.1256 3.47945 13.3308 3.70718 13.1917L3.9647 13.0344L4.24854 12.861L4.29502 12.8326L7.34365 10.9705C7.43965 10.9119 7.5604 10.9119 7.6564 10.9705L10.705 12.8326L10.7515 12.861L11.0354 13.0344L11.2929 13.1917C11.5206 13.3308 11.803 13.1256 11.7411 12.866L11.671 12.5725L11.5939 12.249L11.5812 12.196L10.7524 8.72118C10.7263 8.61176 10.7636 8.49692 10.849 8.42374L13.562 6.09974L13.6034 6.06431L13.856 5.84793L14.0852 5.65162C14.2878 5.47802 14.18 5.14607 13.914 5.12475L13.6132 5.10063L13.2816 5.07405L13.2274 5.0697L9.66645 4.78422C9.55432 4.77523 9.45663 4.70426 9.41343 4.60039L8.04155 1.30199L8.02064 1.25171L7.89291 0.944609L7.77702 0.665992C7.67454 0.419604 7.32551 0.419604 7.22303 0.665992L7.10715 0.944609L6.97942 1.25171ZM7.50003 2.60397L6.50994 4.98442C6.32273 5.43453 5.89944 5.74207 5.41351 5.78103L2.84361 5.98705L4.8016 7.66428C5.17183 7.98142 5.33351 8.47903 5.2204 8.95321L4.62221 11.461L6.8224 10.1171C7.23842 9.86302 7.76164 9.86302 8.17766 10.1171L10.3778 11.461L9.77965 8.95321C9.66654 8.47903 9.82822 7.98142 10.1984 7.66428L12.1564 5.98705L9.58654 5.78103C9.10061 5.74207 8.67732 5.43453 8.49011 4.98442L7.50003 2.60397Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.68323 1.53C7.71245 1.47097 7.75758 1.42129 7.81353 1.38655C7.86949 1.35181 7.93404 1.3334 7.9999 1.3334C8.06576 1.3334 8.13031 1.35181 8.18626 1.38655C8.24222 1.42129 8.28735 1.47097 8.31656 1.53L9.85656 4.64933C9.95802 4.85465 10.1078 5.03227 10.293 5.16697C10.4782 5.30167 10.6933 5.38941 10.9199 5.42267L14.3639 5.92667C14.4292 5.93612 14.4905 5.96365 14.5409 6.00613C14.5913 6.04862 14.6289 6.10437 14.6492 6.16707C14.6696 6.22978 14.6721 6.29694 14.6563 6.36096C14.6405 6.42498 14.6071 6.4833 14.5599 6.52933L12.0692 8.95467C11.905 9.11473 11.7821 9.31232 11.7111 9.53042C11.6402 9.74852 11.6233 9.98059 11.6619 10.2067L12.2499 13.6333C12.2614 13.6986 12.2544 13.7657 12.2296 13.8271C12.2048 13.8885 12.1632 13.9417 12.1096 13.9807C12.056 14.0196 11.9926 14.0427 11.9265 14.0473C11.8604 14.0519 11.7944 14.0378 11.7359 14.0067L8.65723 12.388C8.45438 12.2815 8.22868 12.2258 7.99956 12.2258C7.77044 12.2258 7.54475 12.2815 7.3419 12.388L4.2639 14.0067C4.20545 14.0376 4.1395 14.0515 4.07353 14.0468C4.00757 14.0421 3.94424 14.019 3.89076 13.9801C3.83728 13.9413 3.79579 13.8881 3.771 13.8268C3.74622 13.7655 3.73914 13.6985 3.75056 13.6333L4.3379 10.2073C4.3767 9.98116 4.35989 9.74893 4.28892 9.5307C4.21796 9.31246 4.09497 9.11477 3.93056 8.95467L1.4399 6.53C1.39229 6.48402 1.35856 6.4256 1.34254 6.36138C1.32652 6.29717 1.32886 6.22975 1.34928 6.16679C1.36971 6.10384 1.40741 6.04789 1.45808 6.00532C1.50876 5.96275 1.57037 5.93527 1.6359 5.926L5.07923 5.42267C5.30607 5.38967 5.52149 5.30204 5.70695 5.16733C5.89242 5.03261 6.04237 4.85485 6.1439 4.64933L7.68323 1.53Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +1,3 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7.22303 0.665992C7.32551 0.419604 7.67454 0.419604 7.77702 0.665992L9.41343 4.60039C9.45663 4.70426 9.55432 4.77523 9.66645 4.78422L13.914 5.12475C14.18 5.14607 14.2878 5.47802 14.0852 5.65162L10.849 8.42374C10.7636 8.49692 10.7263 8.61176 10.7524 8.72118L11.7411 12.866C11.803 13.1256 11.5206 13.3308 11.2929 13.1917L7.6564 10.9705C7.5604 10.9119 7.43965 10.9119 7.34365 10.9705L3.70718 13.1917C3.47945 13.3308 3.19708 13.1256 3.25899 12.866L4.24769 8.72118C4.2738 8.61176 4.23648 8.49692 4.15105 8.42374L0.914889 5.65162C0.712228 5.47802 0.820086 5.14607 1.08608 5.12475L5.3336 4.78422C5.44573 4.77523 5.54342 4.70426 5.58662 4.60039L7.22303 0.665992Z" fill="currentColor"></path></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.68323 1.53C7.71245 1.47097 7.75758 1.42129 7.81353 1.38655C7.86949 1.35181 7.93404 1.3334 7.9999 1.3334C8.06576 1.3334 8.13031 1.35181 8.18626 1.38655C8.24222 1.42129 8.28735 1.47097 8.31656 1.53L9.85656 4.64933C9.95802 4.85465 10.1078 5.03227 10.293 5.16697C10.4782 5.30167 10.6933 5.38941 10.9199 5.42267L14.3639 5.92667C14.4292 5.93612 14.4905 5.96365 14.5409 6.00613C14.5913 6.04862 14.6289 6.10437 14.6492 6.16707C14.6696 6.22978 14.6721 6.29694 14.6563 6.36096C14.6405 6.42498 14.6071 6.4833 14.5599 6.52933L12.0692 8.95467C11.905 9.11473 11.7821 9.31232 11.7111 9.53042C11.6402 9.74852 11.6233 9.98059 11.6619 10.2067L12.2499 13.6333C12.2614 13.6986 12.2544 13.7657 12.2296 13.8271C12.2048 13.8885 12.1632 13.9417 12.1096 13.9807C12.056 14.0196 11.9926 14.0427 11.9265 14.0473C11.8604 14.0519 11.7944 14.0378 11.7359 14.0067L8.65723 12.388C8.45438 12.2815 8.22868 12.2258 7.99956 12.2258C7.77044 12.2258 7.54475 12.2815 7.3419 12.388L4.2639 14.0067C4.20545 14.0376 4.1395 14.0515 4.07353 14.0468C4.00757 14.0421 3.94424 14.019 3.89076 13.9801C3.83728 13.9413 3.79579 13.8881 3.771 13.8268C3.74622 13.7655 3.73914 13.6985 3.75056 13.6333L4.3379 10.2073C4.3767 9.98116 4.35989 9.74893 4.28892 9.5307C4.21796 9.31246 4.09497 9.11477 3.93056 8.95467L1.4399 6.53C1.39229 6.48402 1.35856 6.4256 1.34254 6.36138C1.32652 6.29717 1.32886 6.22975 1.34928 6.16679C1.36971 6.10384 1.40741 6.04789 1.45808 6.00532C1.50876 5.96275 1.57037 5.93527 1.6359 5.926L5.07923 5.42267C5.30607 5.38967 5.52149 5.30204 5.70695 5.16733C5.89242 5.03261 6.04237 4.85485 6.1439 4.64933L7.68323 1.53Z" fill="black" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 794 B

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -278,7 +278,9 @@
"enter": "agent::Chat",
"ctrl-enter": "agent::ChatWithFollow",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-ctrl-r": "agent::OpenAgentDiff"
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll"
}
},
{

View File

@@ -315,7 +315,9 @@
"enter": "agent::Chat",
"cmd-enter": "agent::ChatWithFollow",
"cmd-i": "agent::ToggleProfileSelector",
"shift-ctrl-r": "agent::OpenAgentDiff"
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll"
}
},
{

View File

@@ -73,9 +73,6 @@
"unnecessary_code_fade": 0.3,
// Active pane styling settings.
"active_pane_modifiers": {
// The factor to grow the active pane by. Defaults to 1.0
// which gives the same size as all other panes.
"magnification": 1.0,
// Inset border size of the active pane, in pixels.
"border_size": 0.0,
// Opacity of the inactive panes. 0 means transparent, 1 means opaque.
@@ -1500,11 +1497,11 @@
}
},
"LaTeX": {
"format_on_save": "on",
"formatter": "language_server",
"language_servers": ["texlab", "..."],
"prettier": {
"allowed": false
"allowed": true,
"plugins": ["prettier-plugin-latex"]
}
},
"Markdown": {
@@ -1605,6 +1602,9 @@
"version": "1",
"api_url": "https://api.openai.com/v1"
},
"open_router": {
"api_url": "https://openrouter.ai/api/v1"
},
"lmstudio": {
"api_url": "http://localhost:1234/api/v0"
},

View File

@@ -3,7 +3,7 @@ use crate::context::{AgentContextHandle, RULES_ICON};
use crate::context_picker::{ContextPicker, MentionLink};
use crate::context_store::ContextStore;
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
use crate::message_editor::insert_message_creases;
use crate::message_editor::{extract_message_creases, insert_message_creases};
use crate::thread::{
LastRestoreCheckpoint, MessageCrease, MessageId, MessageSegment, Thread, ThreadError,
ThreadEvent, ThreadFeedback, ThreadSummary,
@@ -999,7 +999,7 @@ impl ActiveThread {
ThreadEvent::Stopped(reason) => match reason {
Ok(StopReason::EndTurn | StopReason::MaxTokens) => {
let used_tools = self.thread.read(cx).used_tools_since_last_user_message();
self.play_notification_sound(cx);
self.play_notification_sound(window, cx);
self.show_notification(
if used_tools {
"Finished running tools"
@@ -1014,11 +1014,11 @@ impl ActiveThread {
_ => {}
},
ThreadEvent::ToolConfirmationNeeded => {
self.play_notification_sound(cx);
self.play_notification_sound(window, cx);
self.show_notification("Waiting for tool confirmation", IconName::Info, window, cx);
}
ThreadEvent::ToolUseLimitReached => {
self.play_notification_sound(cx);
self.play_notification_sound(window, cx);
self.show_notification(
"Consecutive tool use limit reached.",
IconName::Warning,
@@ -1160,9 +1160,9 @@ impl ActiveThread {
cx.notify();
}
fn play_notification_sound(&self, cx: &mut App) {
fn play_notification_sound(&self, window: &Window, cx: &mut App) {
let settings = AgentSettings::get_global(cx);
if settings.play_sound_when_agent_done {
if settings.play_sound_when_agent_done && !window.is_window_active() {
Audio::play_sound(Sound::AgentDone, cx);
}
}
@@ -1586,6 +1586,8 @@ impl ActiveThread {
let edited_text = state.editor.read(cx).text(cx);
let creases = state.editor.update(cx, extract_message_creases);
let new_context = self
.context_store
.read(cx)
@@ -1610,6 +1612,7 @@ impl ActiveThread {
message_id,
Role::User,
vec![MessageSegment::Text(edited_text)],
creases,
Some(context.loaded_context),
checkpoint.ok(),
cx,
@@ -3677,10 +3680,13 @@ fn open_editor_at_position(
#[cfg(test)]
mod tests {
use assistant_tool::{ToolRegistry, ToolWorkingSet};
use editor::EditorSettings;
use editor::{EditorSettings, display_map::CreaseMetadata};
use fs::FakeFs;
use gpui::{AppContext, TestAppContext, VisualTestContext};
use language_model::{LanguageModel, fake_provider::FakeLanguageModel};
use language_model::{
ConfiguredModel, LanguageModel, LanguageModelRegistry,
fake_provider::{FakeLanguageModel, FakeLanguageModelProvider},
};
use project::Project;
use prompt_store::PromptBuilder;
use serde_json::json;
@@ -3741,6 +3747,87 @@ mod tests {
assert!(!cx.read(|cx| workspace.read(cx).is_being_followed(CollaboratorId::Agent)));
}
#[gpui::test]
async fn test_reinserting_creases_for_edited_message(cx: &mut TestAppContext) {
init_test_settings(cx);
let project = create_test_project(cx, json!({})).await;
let (cx, active_thread, _, thread, model) =
setup_test_environment(cx, project.clone()).await;
cx.update(|_, cx| {
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry.set_default_model(
Some(ConfiguredModel {
provider: Arc::new(FakeLanguageModelProvider),
model,
}),
cx,
);
});
});
let creases = vec![MessageCrease {
range: 14..22,
metadata: CreaseMetadata {
icon_path: "icon".into(),
label: "foo.txt".into(),
},
context: None,
}];
let message = thread.update(cx, |thread, cx| {
let message_id = thread.insert_user_message(
"Tell me about @foo.txt",
ContextLoadResult::default(),
None,
creases,
cx,
);
thread.message(message_id).cloned().unwrap()
});
active_thread.update_in(cx, |active_thread, window, cx| {
active_thread.start_editing_message(
message.id,
message.segments.as_slice(),
message.creases.as_slice(),
window,
cx,
);
let editor = active_thread
.editing_message
.as_ref()
.unwrap()
.1
.editor
.clone();
editor.update(cx, |editor, cx| editor.edit([(0..13, "modified")], cx));
active_thread.confirm_editing_message(&Default::default(), window, cx);
});
cx.run_until_parked();
let message = thread.update(cx, |thread, _| thread.message(message.id).cloned().unwrap());
active_thread.update_in(cx, |active_thread, window, cx| {
active_thread.start_editing_message(
message.id,
message.segments.as_slice(),
message.creases.as_slice(),
window,
cx,
);
let editor = active_thread
.editing_message
.as_ref()
.unwrap()
.1
.editor
.clone();
let text = editor.update(cx, |editor, cx| editor.text(cx));
assert_eq!(text, "modified @foo.txt");
});
}
fn init_test_settings(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);

View File

@@ -926,8 +926,9 @@ impl CompletionProvider for ContextPickerCompletionProvider {
&self,
buffer: &Entity<language::Buffer>,
position: language::Anchor,
_: &str,
_: bool,
_text: &str,
_trigger_in_words: bool,
_menu_is_open: bool,
cx: &mut Context<Editor>,
) -> bool {
let buffer = buffer.read(cx);

View File

@@ -51,6 +51,10 @@ impl Tool for ContextServerTool {
true
}
fn may_perform_edits(&self) -> bool {
true
}
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
let mut schema = self.tool.input_schema.clone();
assistant_tool::adapt_schema_to_format(&mut schema, format)?;

View File

@@ -6,7 +6,7 @@ use crate::agent_model_selector::{AgentModelSelector, ModelType};
use crate::context::{AgentContextKey, ContextCreasesAddon, ContextLoadResult, load_context};
use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
use crate::ui::{
AnimatedLabel, MaxModeTooltip,
MaxModeTooltip,
preview::{AgentPreview, UsageCallout},
};
use agent_settings::{AgentSettings, CompletionMode};
@@ -27,7 +27,7 @@ use gpui::{
Animation, AnimationExt, App, ClipboardEntry, Entity, EventEmitter, Focusable, Subscription,
Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
};
use language::{Buffer, Language};
use language::{Buffer, Language, Point};
use language_model::{
ConfiguredModel, LanguageModelRequestMessage, MessageContent, RequestUsage,
ZED_CLOUD_PROVIDER_ID,
@@ -51,9 +51,9 @@ use crate::profile_selector::ProfileSelector;
use crate::thread::{MessageCrease, Thread, TokenUsageRatio};
use crate::thread_store::{TextThreadStore, ThreadStore};
use crate::{
ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, NewThread,
OpenAgentDiff, RemoveAllContext, ToggleBurnMode, ToggleContextPicker, ToggleProfileSelector,
register_agent_preview,
ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll,
NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode, ToggleContextPicker,
ToggleProfileSelector, register_agent_preview,
};
#[derive(RegisterComponent)]
@@ -459,11 +459,20 @@ impl MessageEditor {
}
fn handle_review_click(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.thread.read(cx).has_pending_edit_tool_uses() {
return;
}
self.edits_expanded = true;
AgentDiffPane::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
cx.notify();
}
fn handle_edit_bar_expand(&mut self, cx: &mut Context<Self>) {
self.edits_expanded = !self.edits_expanded;
cx.notify();
}
fn handle_file_click(
&self,
buffer: Entity<Buffer>,
@@ -494,6 +503,40 @@ impl MessageEditor {
});
}
fn handle_accept_all(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
if self.thread.read(cx).has_pending_edit_tool_uses() {
return;
}
self.thread.update(cx, |thread, cx| {
thread.keep_all_edits(cx);
});
cx.notify();
}
fn handle_reject_all(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
if self.thread.read(cx).has_pending_edit_tool_uses() {
return;
}
// Since there's no reject_all_edits method in the thread API,
// we need to iterate through all buffers and reject their edits
let action_log = self.thread.read(cx).action_log().clone();
let changed_buffers = action_log.read(cx).changed_buffers(cx);
for (buffer, _) in changed_buffers {
self.thread.update(cx, |thread, cx| {
let buffer_snapshot = buffer.read(cx);
let start = buffer_snapshot.anchor_before(Point::new(0, 0));
let end = buffer_snapshot.anchor_after(buffer_snapshot.max_point());
thread
.reject_edits_in_ranges(buffer, vec![start..end], cx)
.detach();
});
}
cx.notify();
}
fn render_max_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let thread = self.thread.read(cx);
let model = thread.configured_model();
@@ -615,6 +658,12 @@ impl MessageEditor {
.on_action(cx.listener(Self::move_up))
.on_action(cx.listener(Self::expand_message_editor))
.on_action(cx.listener(Self::toggle_burn_mode))
.on_action(
cx.listener(|this, _: &KeepAll, window, cx| this.handle_accept_all(window, cx)),
)
.on_action(
cx.listener(|this, _: &RejectAll, window, cx| this.handle_reject_all(window, cx)),
)
.capture_action(cx.listener(Self::paste))
.gap_2()
.p_2()
@@ -870,7 +919,10 @@ impl MessageEditor {
let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
let is_edit_changes_expanded = self.edits_expanded;
let is_generating = self.thread.read(cx).is_generating();
let thread = self.thread.read(cx);
let pending_edits = thread.has_pending_edit_tool_uses();
const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
v_flex()
.mt_1()
@@ -888,31 +940,28 @@ impl MessageEditor {
}])
.child(
h_flex()
.id("edits-container")
.cursor_pointer()
.p_1p5()
.p_1()
.justify_between()
.when(is_edit_changes_expanded, |this| {
this.border_b_1().border_color(border_color)
})
.on_click(
cx.listener(|this, _, window, cx| this.handle_review_click(window, cx)),
)
.child(
h_flex()
.id("edits-container")
.cursor_pointer()
.w_full()
.gap_1()
.child(
Disclosure::new("edits-disclosure", is_edit_changes_expanded)
.on_click(cx.listener(|this, _ev, _window, cx| {
this.edits_expanded = !this.edits_expanded;
cx.notify();
.on_click(cx.listener(|this, _, _, cx| {
this.handle_edit_bar_expand(cx)
})),
)
.map(|this| {
if is_generating {
if pending_edits {
this.child(
AnimatedLabel::new(format!(
"Editing {} {}",
Label::new(format!(
"Editing {} {}",
changed_buffers.len(),
if changed_buffers.len() == 1 {
"file"
@@ -920,7 +969,15 @@ impl MessageEditor {
"files"
}
))
.size(LabelSize::Small),
.color(Color::Muted)
.size(LabelSize::Small)
.with_animation(
"edit-label",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.3, 0.7)),
|label, delta| label.alpha(delta),
),
)
} else {
this.child(
@@ -945,23 +1002,74 @@ impl MessageEditor {
.color(Color::Muted),
)
}
}),
})
.on_click(
cx.listener(|this, _, _, cx| this.handle_edit_bar_expand(cx)),
),
)
.child(
Button::new("review", "Review Changes")
.label_size(LabelSize::Small)
.key_binding(
KeyBinding::for_action_in(
&OpenAgentDiff,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
h_flex()
.gap_1()
.child(
IconButton::new("review-changes", IconName::ListTodo)
.icon_size(IconSize::Small)
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Review Changes",
&OpenAgentDiff,
&focus_handle,
window,
cx,
)
}
})
.on_click(cx.listener(|this, _, window, cx| {
this.handle_review_click(window, cx)
})),
)
.on_click(cx.listener(|this, _, window, cx| {
this.handle_review_click(window, cx)
})),
.child(ui::Divider::vertical().color(ui::DividerColor::Border))
.child(
Button::new("reject-all-changes", "Reject All")
.label_size(LabelSize::Small)
.disabled(pending_edits)
.when(pending_edits, |this| {
this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
})
.key_binding(
KeyBinding::for_action_in(
&RejectAll,
&focus_handle.clone(),
window,
cx,
)
.map(|kb| kb.size(rems_from_px(10.))),
)
.on_click(cx.listener(|this, _, window, cx| {
this.handle_reject_all(window, cx)
})),
)
.child(
Button::new("accept-all-changes", "Accept All")
.label_size(LabelSize::Small)
.disabled(pending_edits)
.when(pending_edits, |this| {
this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
})
.key_binding(
KeyBinding::for_action_in(
&KeepAll,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(10.))),
)
.on_click(cx.listener(|this, _, window, cx| {
this.handle_accept_all(window, cx)
})),
),
),
)
.when(is_edit_changes_expanded, |parent| {

View File

@@ -871,7 +871,16 @@ impl Thread {
self.tool_use
.pending_tool_uses()
.iter()
.all(|tool_use| tool_use.status.is_error())
.all(|pending_tool_use| pending_tool_use.status.is_error())
}
/// Returns whether any pending tool uses may perform edits
pub fn has_pending_edit_tool_uses(&self) -> bool {
self.tool_use
.pending_tool_uses()
.iter()
.filter(|pending_tool_use| !pending_tool_use.status.is_error())
.any(|pending_tool_use| pending_tool_use.may_perform_edits)
}
pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec<ToolUse> {
@@ -1023,6 +1032,7 @@ impl Thread {
id: MessageId,
new_role: Role,
new_segments: Vec<MessageSegment>,
creases: Vec<MessageCrease>,
loaded_context: Option<LoadedContext>,
checkpoint: Option<GitStoreCheckpoint>,
cx: &mut Context<Self>,
@@ -1032,6 +1042,7 @@ impl Thread {
};
message.role = new_role;
message.segments = new_segments;
message.creases = creases;
if let Some(context) = loaded_context {
message.loaded_context = context;
}

View File

@@ -70,13 +70,15 @@ impl Column for DataType {
}
}
const RULES_FILE_NAMES: [&'static str; 6] = [
const RULES_FILE_NAMES: [&'static str; 8] = [
".rules",
".cursorrules",
".windsurfrules",
".clinerules",
".github/copilot-instructions.md",
"CLAUDE.md",
"AGENT.md",
"AGENTS.md",
];
pub fn init(cx: &mut App) {

View File

@@ -337,6 +337,12 @@ impl ToolUseState {
)
.into();
let may_perform_edits = self
.tools
.read(cx)
.tool(&tool_use.name, cx)
.is_some_and(|tool| tool.may_perform_edits());
self.pending_tool_uses_by_id.insert(
tool_use.id.clone(),
PendingToolUse {
@@ -345,6 +351,7 @@ impl ToolUseState {
name: tool_use.name.clone(),
ui_text: ui_text.clone(),
input: tool_use.input,
may_perform_edits,
status,
},
);
@@ -518,6 +525,7 @@ pub struct PendingToolUse {
pub ui_text: Arc<str>,
pub input: serde_json::Value,
pub status: PendingToolUseStatus,
pub may_perform_edits: bool,
}
#[derive(Debug, Clone)]

View File

@@ -730,6 +730,7 @@ impl JsonSchema for LanguageModelProviderSetting {
"zed.dev".into(),
"copilot_chat".into(),
"deepseek".into(),
"openrouter".into(),
"mistral".into(),
]),
..Default::default()

View File

@@ -342,6 +342,7 @@ impl CompletionProvider for SlashCommandCompletionProvider {
position: language::Anchor,
_text: &str,
_trigger_in_words: bool,
_menu_is_open: bool,
cx: &mut Context<Editor>,
) -> bool {
let buffer = buffer.read(cx);

View File

@@ -218,6 +218,9 @@ pub trait Tool: 'static + Send + Sync {
/// before having permission to run.
fn needs_confirmation(&self, input: &serde_json::Value, cx: &App) -> bool;
/// Returns true if the tool may perform edits.
fn may_perform_edits(&self) -> bool;
/// Returns the JSON schema that describes the tool's input.
fn input_schema(&self, _: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
Ok(serde_json::Value::Object(serde_json::Map::default()))

View File

@@ -48,6 +48,10 @@ impl Tool for CopyPathTool {
false
}
fn may_perform_edits(&self) -> bool {
true
}
fn description(&self) -> String {
include_str!("./copy_path_tool/description.md").into()
}

View File

@@ -33,12 +33,16 @@ impl Tool for CreateDirectoryTool {
"create_directory".into()
}
fn description(&self) -> String {
include_str!("./create_directory_tool/description.md").into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
false
}
fn description(&self) -> String {
include_str!("./create_directory_tool/description.md").into()
fn may_perform_edits(&self) -> bool {
false
}
fn icon(&self) -> IconName {

View File

@@ -37,6 +37,10 @@ impl Tool for DeletePathTool {
false
}
fn may_perform_edits(&self) -> bool {
true
}
fn description(&self) -> String {
include_str!("./delete_path_tool/description.md").into()
}

View File

@@ -50,6 +50,10 @@ impl Tool for DiagnosticsTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./diagnostics_tool/description.md").into()
}

View File

@@ -2,6 +2,7 @@ use crate::{
Templates,
edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent},
schema::json_schema_for,
ui::{COLLAPSED_LINES, ToolOutputPreview},
};
use anyhow::{Context as _, Result, anyhow};
use assistant_tool::{
@@ -13,7 +14,7 @@ use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey};
use futures::StreamExt;
use gpui::{
Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
TextStyleRefinement, WeakEntity, pulsating_between,
TextStyleRefinement, WeakEntity, pulsating_between, px,
};
use indoc::formatdoc;
use language::{
@@ -128,6 +129,10 @@ impl Tool for EditFileTool {
false
}
fn may_perform_edits(&self) -> bool {
true
}
fn description(&self) -> String {
include_str!("edit_file_tool/description.md").to_string()
}
@@ -884,30 +889,8 @@ impl ToolCard for EditFileToolCard {
(element.into_any_element(), line_height)
});
let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
(IconName::ChevronUp, "Collapse Code Block")
} else {
(IconName::ChevronDown, "Expand Code Block")
};
let gradient_overlay =
div()
.absolute()
.bottom_0()
.left_0()
.w_full()
.h_2_5()
.bg(gpui::linear_gradient(
0.,
gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
));
let border_color = cx.theme().colors().border.opacity(0.6);
const DEFAULT_COLLAPSED_LINES: u32 = 10;
let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
let waiting_for_diff = {
let styles = [
("w_4_5", (0.1, 0.85), 2000),
@@ -992,48 +975,34 @@ impl ToolCard for EditFileToolCard {
card.child(waiting_for_diff)
})
.when(self.preview_expanded && !self.is_loading(), |card| {
let editor_view = v_flex()
.relative()
.h_full()
.when(!self.full_height_expanded, |editor_container| {
editor_container.max_h(px(COLLAPSED_LINES as f32 * editor_line_height.0))
})
.overflow_hidden()
.border_t_1()
.border_color(border_color)
.bg(cx.theme().colors().editor_background)
.child(editor);
card.child(
v_flex()
.relative()
.h_full()
.when(!self.full_height_expanded, |editor_container| {
editor_container
.max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
})
.overflow_hidden()
.border_t_1()
.border_color(border_color)
.bg(cx.theme().colors().editor_background)
.child(editor)
.when(
!self.full_height_expanded && is_collapsible,
|editor_container| editor_container.child(gradient_overlay),
),
ToolOutputPreview::new(editor_view.into_any_element(), self.editor.entity_id())
.with_total_lines(self.total_lines.unwrap_or(0) as usize)
.toggle_state(self.full_height_expanded)
.with_collapsed_fade()
.on_toggle({
let this = cx.entity().downgrade();
move |is_expanded, _window, cx| {
if let Some(this) = this.upgrade() {
this.update(cx, |this, _cx| {
this.full_height_expanded = is_expanded;
});
}
}
}),
)
.when(is_collapsible, |card| {
card.child(
h_flex()
.id(("expand-button", self.editor.entity_id()))
.flex_none()
.cursor_pointer()
.h_5()
.justify_center()
.border_t_1()
.rounded_b_md()
.border_color(border_color)
.bg(cx.theme().colors().editor_background)
.hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
.child(
Icon::new(full_height_icon)
.size(IconSize::Small)
.color(Color::Muted),
)
.tooltip(Tooltip::text(full_height_tooltip_label))
.on_click(cx.listener(move |this, _event, _window, _cx| {
this.full_height_expanded = !this.full_height_expanded;
})),
)
})
})
}
}

View File

@@ -118,7 +118,11 @@ impl Tool for FetchTool {
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
true
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {

View File

@@ -59,6 +59,10 @@ impl Tool for FindPathTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./find_path_tool/description.md").into()
}

View File

@@ -60,6 +60,10 @@ impl Tool for GrepTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./grep_tool/description.md").into()
}

View File

@@ -48,6 +48,10 @@ impl Tool for ListDirectoryTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./list_directory_tool/description.md").into()
}

View File

@@ -46,6 +46,10 @@ impl Tool for MovePathTool {
false
}
fn may_perform_edits(&self) -> bool {
true
}
fn description(&self) -> String {
include_str!("./move_path_tool/description.md").into()
}

View File

@@ -37,6 +37,10 @@ impl Tool for NowTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
"Returns the current datetime in RFC 3339 format. Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.".into()
}

View File

@@ -26,7 +26,9 @@ impl Tool for OpenTool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
true
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./open_tool/description.md").to_string()
}

View File

@@ -58,6 +58,10 @@ impl Tool for ReadFileTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./read_file_tool/description.md").into()
}

View File

@@ -1,4 +1,7 @@
use crate::schema::json_schema_for;
use crate::{
schema::json_schema_for,
ui::{COLLAPSED_LINES, ToolOutputPreview},
};
use anyhow::{Context as _, Result, anyhow};
use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
use futures::{FutureExt as _, future::Shared};
@@ -25,7 +28,7 @@ use terminal_view::TerminalView;
use theme::ThemeSettings;
use ui::{Disclosure, Tooltip, prelude::*};
use util::{
get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
ResultExt, get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
time::duration_alt_display,
};
use workspace::Workspace;
@@ -77,6 +80,10 @@ impl Tool for TerminalTool {
true
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./terminal_tool/description.md").to_string()
}
@@ -254,22 +261,24 @@ impl Tool for TerminalTool {
let terminal_view = window.update(cx, |_, window, cx| {
cx.new(|cx| {
TerminalView::new(
let mut view = TerminalView::new(
terminal.clone(),
workspace.downgrade(),
None,
project.downgrade(),
true,
window,
cx,
)
);
view.set_embedded_mode(None, cx);
view
})
})?;
let _ = card.update(cx, |card, _| {
card.update(cx, |card, _| {
card.terminal = Some(terminal_view.clone());
card.start_instant = Instant::now();
});
})
.log_err();
let exit_status = terminal
.update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
@@ -285,7 +294,7 @@ impl Tool for TerminalTool {
exit_status.map(portable_pty::ExitStatus::from),
);
let _ = card.update(cx, |card, _| {
card.update(cx, |card, _| {
card.command_finished = true;
card.exit_status = exit_status;
card.was_content_truncated = processed_content.len() < previous_len;
@@ -293,7 +302,8 @@ impl Tool for TerminalTool {
card.content_line_count = content_line_count;
card.finished_with_empty_output = finished_with_empty_output;
card.elapsed_time = Some(card.start_instant.elapsed());
});
})
.log_err();
Ok(processed_content.into())
}
@@ -473,7 +483,6 @@ impl ToolCard for TerminalToolCard {
let time_elapsed = self
.elapsed_time
.unwrap_or_else(|| self.start_instant.elapsed());
let should_hide_terminal = tool_failed || self.finished_with_empty_output;
let header_bg = cx
.theme()
@@ -574,7 +583,7 @@ impl ToolCard for TerminalToolCard {
),
)
})
.when(!should_hide_terminal, |header| {
.when(!self.finished_with_empty_output, |header| {
header.child(
Disclosure::new(
("terminal-tool-disclosure", self.entity_id),
@@ -618,19 +627,43 @@ impl ToolCard for TerminalToolCard {
),
),
)
.when(self.preview_expanded && !should_hide_terminal, |this| {
this.child(
div()
.pt_2()
.min_h_72()
.border_t_1()
.border_color(border_color)
.bg(cx.theme().colors().editor_background)
.rounded_b_md()
.text_ui_sm(cx)
.child(terminal.clone()),
)
})
.when(
self.preview_expanded && !self.finished_with_empty_output,
|this| {
this.child(
div()
.pt_2()
.border_t_1()
.border_color(border_color)
.bg(cx.theme().colors().editor_background)
.rounded_b_md()
.text_ui_sm(cx)
.child(
ToolOutputPreview::new(
terminal.clone().into_any_element(),
terminal.entity_id(),
)
.with_total_lines(self.content_line_count)
.toggle_state(!terminal.read(cx).is_content_limited(window))
.on_toggle({
let terminal = terminal.clone();
move |is_expanded, _, cx| {
terminal.update(cx, |terminal, cx| {
terminal.set_embedded_mode(
if is_expanded {
None
} else {
Some(COLLAPSED_LINES)
},
cx,
);
});
}
}),
),
)
},
)
.into_any()
}
}

View File

@@ -28,6 +28,10 @@ impl Tool for ThinkingTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./thinking_tool/description.md").to_string()
}

View File

@@ -1,3 +1,5 @@
mod tool_call_card_header;
mod tool_output_preview;
pub use tool_call_card_header::*;
pub use tool_output_preview::*;

View File

@@ -0,0 +1,115 @@
use gpui::{AnyElement, EntityId, prelude::*};
use ui::{Tooltip, prelude::*};
#[derive(IntoElement)]
pub struct ToolOutputPreview<F>
where
F: Fn(bool, &mut Window, &mut App) + 'static,
{
content: AnyElement,
entity_id: EntityId,
full_height: bool,
total_lines: usize,
collapsed_fade: bool,
on_toggle: Option<F>,
}
pub const COLLAPSED_LINES: usize = 10;
impl<F> ToolOutputPreview<F>
where
F: Fn(bool, &mut Window, &mut App) + 'static,
{
pub fn new(content: AnyElement, entity_id: EntityId) -> Self {
Self {
content,
entity_id,
full_height: true,
total_lines: 0,
collapsed_fade: false,
on_toggle: None,
}
}
pub fn with_total_lines(mut self, total_lines: usize) -> Self {
self.total_lines = total_lines;
self
}
pub fn toggle_state(mut self, full_height: bool) -> Self {
self.full_height = full_height;
self
}
pub fn with_collapsed_fade(mut self) -> Self {
self.collapsed_fade = true;
self
}
pub fn on_toggle(mut self, listener: F) -> Self {
self.on_toggle = Some(listener);
self
}
}
impl<F> RenderOnce for ToolOutputPreview<F>
where
F: Fn(bool, &mut Window, &mut App) + 'static,
{
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
if self.total_lines <= COLLAPSED_LINES {
return self.content;
}
let border_color = cx.theme().colors().border.opacity(0.6);
let (icon, tooltip_label) = if self.full_height {
(IconName::ChevronUp, "Collapse")
} else {
(IconName::ChevronDown, "Expand")
};
let gradient_overlay =
if self.collapsed_fade && !self.full_height {
Some(div().absolute().bottom_5().left_0().w_full().h_2_5().bg(
gpui::linear_gradient(
0.,
gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
gpui::linear_color_stop(
cx.theme().colors().editor_background.opacity(0.),
1.,
),
),
))
} else {
None
};
v_flex()
.relative()
.child(self.content)
.children(gradient_overlay)
.child(
h_flex()
.id(("expand-button", self.entity_id))
.flex_none()
.cursor_pointer()
.h_5()
.justify_center()
.border_t_1()
.rounded_b_md()
.border_color(border_color)
.bg(cx.theme().colors().editor_background)
.hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
.child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
.tooltip(Tooltip::text(tooltip_label))
.when_some(self.on_toggle, |this, on_toggle| {
this.on_click({
move |_, window, cx| {
on_toggle(!self.full_height, window, cx);
}
})
}),
)
.into_any()
}
}

View File

@@ -36,6 +36,10 @@ impl Tool for WebSearchTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
"Search the web for information using your query. Use this when you need real-time information, facts, or data that might not be in your training. Results will include snippets and links from relevant web pages.".into()
}

View File

@@ -71,16 +71,20 @@ pub enum Model {
// DeepSeek
DeepSeekR1,
// Meta models
MetaLlama38BInstructV1,
MetaLlama370BInstructV1,
MetaLlama318BInstructV1_128k,
MetaLlama318BInstructV1,
MetaLlama3170BInstructV1_128k,
MetaLlama3170BInstructV1,
MetaLlama3211BInstructV1,
MetaLlama3290BInstructV1,
MetaLlama321BInstructV1,
MetaLlama323BInstructV1,
MetaLlama3_8BInstruct,
MetaLlama3_70BInstruct,
MetaLlama31_8BInstruct,
MetaLlama31_70BInstruct,
MetaLlama31_405BInstruct,
MetaLlama32_1BInstruct,
MetaLlama32_3BInstruct,
MetaLlama32_11BMultiModal,
MetaLlama32_90BMultiModal,
MetaLlama33_70BInstruct,
#[allow(non_camel_case_types)]
MetaLlama4Scout_17BInstruct,
#[allow(non_camel_case_types)]
MetaLlama4Maverick_17BInstruct,
// Mistral models
MistralMistral7BInstructV0,
MistralMixtral8x7BInstructV0,
@@ -145,7 +149,7 @@ impl Model {
Model::AmazonNovaMicro => "amazon.nova-micro-v1:0",
Model::AmazonNovaPro => "amazon.nova-pro-v1:0",
Model::AmazonNovaPremier => "amazon.nova-premier-v1:0",
Model::DeepSeekR1 => "us.deepseek.r1-v1:0",
Model::DeepSeekR1 => "deepseek.r1-v1:0",
Model::AI21J2GrandeInstruct => "ai21.j2-grande-instruct",
Model::AI21J2JumboInstruct => "ai21.j2-jumbo-instruct",
Model::AI21J2Mid => "ai21.j2-mid",
@@ -160,16 +164,18 @@ impl Model {
Model::CohereCommandRV1 => "cohere.command-r-v1:0",
Model::CohereCommandRPlusV1 => "cohere.command-r-plus-v1:0",
Model::CohereCommandLightTextV14_4k => "cohere.command-light-text-v14:7:4k",
Model::MetaLlama38BInstructV1 => "meta.llama3-8b-instruct-v1:0",
Model::MetaLlama370BInstructV1 => "meta.llama3-70b-instruct-v1:0",
Model::MetaLlama318BInstructV1_128k => "meta.llama3-1-8b-instruct-v1:0:128k",
Model::MetaLlama318BInstructV1 => "meta.llama3-1-8b-instruct-v1:0",
Model::MetaLlama3170BInstructV1_128k => "meta.llama3-1-70b-instruct-v1:0:128k",
Model::MetaLlama3170BInstructV1 => "meta.llama3-1-70b-instruct-v1:0",
Model::MetaLlama3211BInstructV1 => "meta.llama3-2-11b-instruct-v1:0",
Model::MetaLlama3290BInstructV1 => "meta.llama3-2-90b-instruct-v1:0",
Model::MetaLlama321BInstructV1 => "meta.llama3-2-1b-instruct-v1:0",
Model::MetaLlama323BInstructV1 => "meta.llama3-2-3b-instruct-v1:0",
Model::MetaLlama3_8BInstruct => "meta.llama3-8b-instruct-v1:0",
Model::MetaLlama3_70BInstruct => "meta.llama3-70b-instruct-v1:0",
Model::MetaLlama31_8BInstruct => "meta.llama3-1-8b-instruct-v1:0",
Model::MetaLlama31_70BInstruct => "meta.llama3-1-70b-instruct-v1:0",
Model::MetaLlama31_405BInstruct => "meta.llama3-1-405b-instruct-v1:0",
Model::MetaLlama32_11BMultiModal => "meta.llama3-2-11b-instruct-v1:0",
Model::MetaLlama32_90BMultiModal => "meta.llama3-2-90b-instruct-v1:0",
Model::MetaLlama32_1BInstruct => "meta.llama3-2-1b-instruct-v1:0",
Model::MetaLlama32_3BInstruct => "meta.llama3-2-3b-instruct-v1:0",
Model::MetaLlama33_70BInstruct => "meta.llama3-3-70b-instruct-v1:0",
Model::MetaLlama4Scout_17BInstruct => "meta.llama4-scout-17b-instruct-v1:0",
Model::MetaLlama4Maverick_17BInstruct => "meta.llama4-maverick-17b-instruct-v1:0",
Model::MistralMistral7BInstructV0 => "mistral.mistral-7b-instruct-v0:2",
Model::MistralMixtral8x7BInstructV0 => "mistral.mixtral-8x7b-instruct-v0:1",
Model::MistralMistralLarge2402V1 => "mistral.mistral-large-2402-v1:0",
@@ -214,16 +220,18 @@ impl Model {
Self::CohereCommandRV1 => "Cohere Command R V1",
Self::CohereCommandRPlusV1 => "Cohere Command R Plus V1",
Self::CohereCommandLightTextV14_4k => "Cohere Command Light Text V14 4K",
Self::MetaLlama38BInstructV1 => "Meta Llama 3 8B Instruct V1",
Self::MetaLlama370BInstructV1 => "Meta Llama 3 70B Instruct V1",
Self::MetaLlama318BInstructV1_128k => "Meta Llama 3 1.8B Instruct V1 128K",
Self::MetaLlama318BInstructV1 => "Meta Llama 3 1.8B Instruct V1",
Self::MetaLlama3170BInstructV1_128k => "Meta Llama 3 1 70B Instruct V1 128K",
Self::MetaLlama3170BInstructV1 => "Meta Llama 3 1 70B Instruct V1",
Self::MetaLlama3211BInstructV1 => "Meta Llama 3 2 11B Instruct V1",
Self::MetaLlama3290BInstructV1 => "Meta Llama 3 2 90B Instruct V1",
Self::MetaLlama321BInstructV1 => "Meta Llama 3 2 1B Instruct V1",
Self::MetaLlama323BInstructV1 => "Meta Llama 3 2 3B Instruct V1",
Self::MetaLlama3_8BInstruct => "Meta Llama 3 8B Instruct",
Self::MetaLlama3_70BInstruct => "Meta Llama 3 70B Instruct",
Self::MetaLlama31_8BInstruct => "Meta Llama 3.1 8B Instruct",
Self::MetaLlama31_70BInstruct => "Meta Llama 3.1 70B Instruct",
Self::MetaLlama31_405BInstruct => "Meta Llama 3.1 405B Instruct",
Self::MetaLlama32_11BMultiModal => "Meta Llama 3.2 11B Vision Instruct",
Self::MetaLlama32_90BMultiModal => "Meta Llama 3.2 90B Vision Instruct",
Self::MetaLlama32_1BInstruct => "Meta Llama 3.2 1B Instruct",
Self::MetaLlama32_3BInstruct => "Meta Llama 3.2 3B Instruct",
Self::MetaLlama33_70BInstruct => "Meta Llama 3.3 70B Instruct",
Self::MetaLlama4Scout_17BInstruct => "Meta Llama 4 Scout 17B Instruct",
Self::MetaLlama4Maverick_17BInstruct => "Meta Llama 4 Maverick 17B Instruct",
Self::MistralMistral7BInstructV0 => "Mistral 7B Instruct V0",
Self::MistralMixtral8x7BInstructV0 => "Mistral Mixtral 8x7B Instruct V0",
Self::MistralMistralLarge2402V1 => "Mistral Large 2402 V1",
@@ -365,55 +373,60 @@ impl Model {
Ok(format!("{}.{}", region_group, model_id))
}
// Models available only in US
(Model::Claude3Opus, "us")
| (Model::Claude3_5Haiku, "us")
| (Model::Claude3_7Sonnet, "us")
| (Model::ClaudeSonnet4, "us")
| (Model::ClaudeOpus4, "us")
| (Model::ClaudeSonnet4Thinking, "us")
| (Model::ClaudeOpus4Thinking, "us")
| (Model::Claude3_7SonnetThinking, "us")
| (Model::AmazonNovaPremier, "us")
| (Model::MistralPixtralLarge2502V1, "us") => {
// Available everywhere
(Model::AmazonNovaLite | Model::AmazonNovaMicro | Model::AmazonNovaPro, _) => {
Ok(format!("{}.{}", region_group, model_id))
}
// Models available in US, EU, and APAC
(Model::Claude3_5SonnetV2, "us")
| (Model::Claude3_5SonnetV2, "apac")
| (Model::Claude3_5Sonnet, _)
| (Model::Claude3Haiku, _)
| (Model::Claude3Sonnet, _)
| (Model::AmazonNovaLite, _)
| (Model::AmazonNovaMicro, _)
| (Model::AmazonNovaPro, _) => Ok(format!("{}.{}", region_group, model_id)),
// Models in US
(
Model::AmazonNovaPremier
| Model::Claude3_5Haiku
| Model::Claude3_5Sonnet
| Model::Claude3_5SonnetV2
| Model::Claude3_7Sonnet
| Model::Claude3_7SonnetThinking
| Model::Claude3Haiku
| Model::Claude3Opus
| Model::Claude3Sonnet
| Model::DeepSeekR1
| Model::MetaLlama31_405BInstruct
| Model::MetaLlama31_70BInstruct
| Model::MetaLlama31_8BInstruct
| Model::MetaLlama32_11BMultiModal
| Model::MetaLlama32_1BInstruct
| Model::MetaLlama32_3BInstruct
| Model::MetaLlama32_90BMultiModal
| Model::MetaLlama33_70BInstruct
| Model::MetaLlama4Maverick_17BInstruct
| Model::MetaLlama4Scout_17BInstruct
| Model::MistralPixtralLarge2502V1
| Model::PalmyraWriterX4
| Model::PalmyraWriterX5,
"us",
) => Ok(format!("{}.{}", region_group, model_id)),
// Models with limited EU availability
(Model::MetaLlama321BInstructV1, "us")
| (Model::MetaLlama321BInstructV1, "eu")
| (Model::MetaLlama323BInstructV1, "us")
| (Model::MetaLlama323BInstructV1, "eu") => {
Ok(format!("{}.{}", region_group, model_id))
}
// Models available in EU
(
Model::Claude3_5Sonnet
| Model::Claude3_7Sonnet
| Model::Claude3_7SonnetThinking
| Model::Claude3Haiku
| Model::Claude3Sonnet
| Model::MetaLlama32_1BInstruct
| Model::MetaLlama32_3BInstruct
| Model::MistralPixtralLarge2502V1,
"eu",
) => Ok(format!("{}.{}", region_group, model_id)),
// US-only models (all remaining Meta models)
(Model::MetaLlama38BInstructV1, "us")
| (Model::MetaLlama370BInstructV1, "us")
| (Model::MetaLlama318BInstructV1, "us")
| (Model::MetaLlama318BInstructV1_128k, "us")
| (Model::MetaLlama3170BInstructV1, "us")
| (Model::MetaLlama3170BInstructV1_128k, "us")
| (Model::MetaLlama3211BInstructV1, "us")
| (Model::MetaLlama3290BInstructV1, "us") => {
Ok(format!("{}.{}", region_group, model_id))
}
// Writer models only available in the US
(Model::PalmyraWriterX4, "us") | (Model::PalmyraWriterX5, "us") => {
// They have some goofiness
Ok(format!("{}.{}", region_group, model_id))
}
// Models available in APAC
(
Model::Claude3_5Sonnet
| Model::Claude3_5SonnetV2
| Model::Claude3Haiku
| Model::Claude3Sonnet,
"apac",
) => Ok(format!("{}.{}", region_group, model_id)),
// Any other combination is not supported
_ => Ok(self.id().into()),
@@ -464,6 +477,10 @@ mod tests {
Model::Claude3_5SonnetV2.cross_region_inference_id("ap-northeast-1")?,
"apac.anthropic.claude-3-5-sonnet-20241022-v2:0"
);
assert_eq!(
Model::Claude3_5SonnetV2.cross_region_inference_id("ap-southeast-2")?,
"apac.anthropic.claude-3-5-sonnet-20241022-v2:0"
);
assert_eq!(
Model::AmazonNovaLite.cross_region_inference_id("ap-south-1")?,
"apac.amazon.nova-lite-v1:0"
@@ -489,11 +506,15 @@ mod tests {
fn test_meta_models_inference_ids() -> anyhow::Result<()> {
// Test Meta models
assert_eq!(
Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1")?,
"us.meta.llama3-70b-instruct-v1:0"
Model::MetaLlama3_70BInstruct.cross_region_inference_id("us-east-1")?,
"meta.llama3-70b-instruct-v1:0"
);
assert_eq!(
Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1")?,
Model::MetaLlama31_70BInstruct.cross_region_inference_id("us-east-1")?,
"us.meta.llama3-1-70b-instruct-v1:0"
);
assert_eq!(
Model::MetaLlama32_1BInstruct.cross_region_inference_id("eu-west-1")?,
"eu.meta.llama3-2-1b-instruct-v1:0"
);
Ok(())

View File

@@ -57,7 +57,7 @@ We run two instances of collab:
Both of these run on the Kubernetes cluster hosted in Digital Ocean.
Deployment is triggered by pushing to the `collab-staging` (or `collab-production`) tag in Github. The best way to do this is:
Deployment is triggered by pushing to the `collab-staging` (or `collab-production`) tag in GitHub. The best way to do this is:
- `./script/deploy-collab staging`
- `./script/deploy-collab production`

View File

@@ -219,12 +219,19 @@ struct BillingSubscriptionJson {
id: BillingSubscriptionId,
name: String,
status: StripeSubscriptionStatus,
period: Option<BillingSubscriptionPeriodJson>,
trial_end_at: Option<String>,
cancel_at: Option<String>,
/// Whether this subscription can be canceled.
is_cancelable: bool,
}
#[derive(Debug, Serialize)]
struct BillingSubscriptionPeriodJson {
start_at: String,
end_at: String,
}
#[derive(Debug, Serialize)]
struct ListBillingSubscriptionsResponse {
subscriptions: Vec<BillingSubscriptionJson>,
@@ -254,6 +261,15 @@ async fn list_billing_subscriptions(
None => "Zed LLM Usage".to_string(),
},
status: subscription.stripe_subscription_status,
period: maybe!({
let start_at = subscription.current_period_start_at()?;
let end_at = subscription.current_period_end_at()?;
Some(BillingSubscriptionPeriodJson {
start_at: start_at.to_rfc3339_opts(SecondsFormat::Millis, true),
end_at: end_at.to_rfc3339_opts(SecondsFormat::Millis, true),
})
}),
trial_end_at: if subscription.kind == Some(SubscriptionKind::ZedProTrial) {
maybe!({
let end_at = subscription.stripe_current_period_end?;

View File

@@ -66,7 +66,7 @@ async fn get_extensions(
params.filter.as_deref(),
provides_filter.as_ref(),
params.max_schema_version,
500,
1_000,
)
.await?;

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use anyhow::{Context as _, Result, anyhow};
use async_trait::async_trait;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use stripe::{
CancellationDetails, CancellationDetailsReason, CheckoutSession, CheckoutSessionMode,
CheckoutSessionPaymentMethodCollection, CreateCheckoutSession, CreateCheckoutSessionLineItems,
@@ -213,9 +213,18 @@ impl StripeClient for RealStripeClient {
}
async fn create_meter_event(&self, params: StripeCreateMeterEventParams<'_>) -> Result<()> {
#[derive(Deserialize)]
struct StripeMeterEvent {
pub identifier: String,
}
let identifier = params.identifier;
match self.client.post_form("/billing/meter_events", params).await {
Ok(event) => Ok(event),
match self
.client
.post_form::<StripeMeterEvent, _>("/billing/meter_events", params)
.await
{
Ok(_event) => Ok(()),
Err(stripe::StripeError::Stripe(error)) => {
if error.http_status == 400
&& error
@@ -228,7 +237,7 @@ impl StripeClient for RealStripeClient {
Err(anyhow!(stripe::StripeError::Stripe(error)))
}
}
Err(error) => Err(anyhow!(error)),
Err(error) => Err(anyhow!("failed to create meter event: {error:?}")),
}
}

View File

@@ -89,6 +89,7 @@ impl CompletionProvider for MessageEditorCompletionProvider {
_position: language::Anchor,
text: &str,
_trigger_in_words: bool,
_menu_is_open: bool,
_cx: &mut Context<Editor>,
) -> bool {
text == "@"

View File

@@ -901,7 +901,6 @@ impl RunningState {
weak_workspace,
None,
weak_project,
false,
window,
cx,
)
@@ -1055,15 +1054,7 @@ impl RunningState {
let terminal = terminal_task.await?;
let terminal_view = cx.new_window_entity(|window, cx| {
TerminalView::new(
terminal.clone(),
workspace,
None,
weak_project,
false,
window,
cx,
)
TerminalView::new(terminal.clone(), workspace, None, weak_project, window, cx)
})?;
running.update_in(cx, |running, window, cx| {

View File

@@ -309,6 +309,7 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider {
_position: language::Anchor,
_text: &str,
_trigger_in_words: bool,
_menu_is_open: bool,
_cx: &mut Context<Editor>,
) -> bool {
true

View File

@@ -194,6 +194,7 @@ pub enum ContextMenuOrigin {
pub struct CompletionsMenu {
pub id: CompletionId,
pub source: CompletionsMenuSource,
sort_completions: bool,
pub initial_position: Anchor,
pub initial_query: Option<Arc<String>>,
@@ -208,7 +209,6 @@ pub struct CompletionsMenu {
scroll_handle: UniformListScrollHandle,
resolve_completions: bool,
show_completion_documentation: bool,
pub(super) ignore_completion_provider: bool,
last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
markdown_cache: Rc<RefCell<VecDeque<(MarkdownCacheKey, Entity<Markdown>)>>>,
language_registry: Option<Arc<LanguageRegistry>>,
@@ -227,6 +227,13 @@ enum MarkdownCacheKey {
},
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum CompletionsMenuSource {
Normal,
SnippetChoices,
Words,
}
// TODO: There should really be a wrapper around fuzzy match tasks that does this.
impl Drop for CompletionsMenu {
fn drop(&mut self) {
@@ -237,9 +244,9 @@ impl Drop for CompletionsMenu {
impl CompletionsMenu {
pub fn new(
id: CompletionId,
source: CompletionsMenuSource,
sort_completions: bool,
show_completion_documentation: bool,
ignore_completion_provider: bool,
initial_position: Anchor,
initial_query: Option<Arc<String>>,
is_incomplete: bool,
@@ -258,13 +265,13 @@ impl CompletionsMenu {
let completions_menu = Self {
id,
source,
sort_completions,
initial_position,
initial_query,
is_incomplete,
buffer,
show_completion_documentation,
ignore_completion_provider,
completions: RefCell::new(completions).into(),
match_candidates,
entries: Rc::new(RefCell::new(Box::new([]))),
@@ -328,6 +335,7 @@ impl CompletionsMenu {
.collect();
Self {
id,
source: CompletionsMenuSource::SnippetChoices,
sort_completions,
initial_position: selection.start,
initial_query: None,
@@ -342,7 +350,6 @@ impl CompletionsMenu {
scroll_handle: UniformListScrollHandle::new(),
resolve_completions: false,
show_completion_documentation: false,
ignore_completion_provider: false,
last_rendered_range: RefCell::new(None).into(),
markdown_cache: RefCell::new(VecDeque::new()).into(),
language_registry: None,

View File

@@ -2512,7 +2512,9 @@ pub mod tests {
cx.update(|cx| syntax_chunks(DisplayRow(0)..DisplayRow(5), &map, &theme, cx)),
[
("fn \n".to_string(), None),
("oute\nr".to_string(), Some(Hsla::blue())),
("oute".to_string(), Some(Hsla::blue())),
("\n".to_string(), None),
("r".to_string(), Some(Hsla::blue())),
("() \n{}\n\n".to_string(), None),
]
);
@@ -2535,8 +2537,11 @@ pub mod tests {
[
("out".to_string(), Some(Hsla::blue())),
("\n".to_string(), None),
(" \nfn ".to_string(), Some(Hsla::red())),
("i\n".to_string(), Some(Hsla::blue()))
(" ".to_string(), Some(Hsla::red())),
("\n".to_string(), None),
("fn ".to_string(), Some(Hsla::red())),
("i".to_string(), Some(Hsla::blue())),
("\n".to_string(), None)
]
);
}

View File

@@ -933,7 +933,7 @@ impl<'a> Iterator for WrapChunks<'a> {
self.transforms.next(&());
return Some(Chunk {
text: &display_text[start_ix..end_ix],
..self.input_chunk.clone()
..Default::default()
});
}

View File

@@ -211,8 +211,11 @@ use workspace::{
searchable::SearchEvent,
};
use crate::hover_links::{find_url, find_url_from_range};
use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState};
use crate::{
code_context_menus::CompletionsMenuSource,
hover_links::{find_url, find_url_from_range},
};
pub const FILE_HEADER_HEIGHT: u32 = 2;
pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1;
@@ -4510,30 +4513,40 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
let ignore_completion_provider = self
let completions_source = self
.context_menu
.borrow()
.as_ref()
.map(|menu| match menu {
CodeContextMenu::Completions(completions_menu) => {
completions_menu.ignore_completion_provider
}
CodeContextMenu::CodeActions(_) => false,
})
.unwrap_or(false);
.and_then(|menu| match menu {
CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source),
CodeContextMenu::CodeActions(_) => None,
});
if ignore_completion_provider {
self.show_word_completions(&ShowWordCompletions, window, cx);
} else if self.is_completion_trigger(text, trigger_in_words, cx) {
self.show_completions(
&ShowCompletions {
trigger: Some(text.to_owned()).filter(|x| !x.is_empty()),
},
window,
cx,
);
} else {
self.hide_context_menu(window, cx);
match completions_source {
Some(CompletionsMenuSource::Words) => {
self.show_word_completions(&ShowWordCompletions, window, cx)
}
Some(CompletionsMenuSource::Normal)
| Some(CompletionsMenuSource::SnippetChoices)
| None
if self.is_completion_trigger(
text,
trigger_in_words,
completions_source.is_some(),
cx,
) =>
{
self.show_completions(
&ShowCompletions {
trigger: Some(text.to_owned()).filter(|x| !x.is_empty()),
},
window,
cx,
)
}
_ => {
self.hide_context_menu(window, cx);
}
}
}
@@ -4541,6 +4554,7 @@ impl Editor {
&self,
text: &str,
trigger_in_words: bool,
menu_is_open: bool,
cx: &mut Context<Self>,
) -> bool {
let position = self.selections.newest_anchor().head();
@@ -4558,6 +4572,7 @@ impl Editor {
position.text_anchor,
text,
trigger_in_words,
menu_is_open,
cx,
)
} else {
@@ -5008,7 +5023,7 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_or_update_completions_menu(true, None, window, cx);
self.open_or_update_completions_menu(Some(CompletionsMenuSource::Words), None, window, cx);
}
pub fn show_completions(
@@ -5017,12 +5032,12 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_or_update_completions_menu(false, options.trigger.as_deref(), window, cx);
self.open_or_update_completions_menu(None, options.trigger.as_deref(), window, cx);
}
fn open_or_update_completions_menu(
&mut self,
ignore_completion_provider: bool,
requested_source: Option<CompletionsMenuSource>,
trigger: Option<&str>,
window: &mut Window,
cx: &mut Context<Self>,
@@ -5047,10 +5062,13 @@ impl Editor {
Self::completion_query(&self.buffer.read(cx).read(cx), position)
.map(|query| query.into());
let provider = if ignore_completion_provider {
None
} else {
self.completion_provider.clone()
let provider = match requested_source {
Some(CompletionsMenuSource::Normal) | None => self.completion_provider.clone(),
Some(CompletionsMenuSource::Words) => None,
Some(CompletionsMenuSource::SnippetChoices) => {
log::error!("bug: SnippetChoices requested_source is not handled");
None
}
};
let sort_completions = provider
@@ -5106,14 +5124,15 @@ impl Editor {
trigger_kind,
};
let (replace_range, word_kind) = buffer_snapshot.surrounding_word(buffer_position);
let (replace_range, word_to_exclude) = if word_kind == Some(CharKind::Word) {
let (word_replace_range, word_to_exclude) = if let (word_range, Some(CharKind::Word)) =
buffer_snapshot.surrounding_word(buffer_position)
{
let word_to_exclude = buffer_snapshot
.text_for_range(replace_range.clone())
.text_for_range(word_range.clone())
.collect::<String>();
(
buffer_snapshot.anchor_before(replace_range.start)
..buffer_snapshot.anchor_after(replace_range.end),
buffer_snapshot.anchor_before(word_range.start)
..buffer_snapshot.anchor_after(buffer_position),
Some(word_to_exclude),
)
} else {
@@ -5221,7 +5240,7 @@ impl Editor {
words.remove(&lsp_completion.new_text);
}
completions.extend(words.into_iter().map(|(word, word_range)| Completion {
replace_range: replace_range.clone(),
replace_range: word_replace_range.clone(),
new_text: word.clone(),
label: CodeLabel::plain(word, None),
icon_path: None,
@@ -5245,9 +5264,9 @@ impl Editor {
.map(|workspace| workspace.read(cx).app_state().languages.clone());
let menu = CompletionsMenu::new(
id,
requested_source.unwrap_or(CompletionsMenuSource::Normal),
sort_completions,
show_completion_documentation,
ignore_completion_provider,
position,
query.clone(),
is_incomplete,
@@ -5531,14 +5550,12 @@ impl Editor {
}
}
let mut common_prefix_len = 0;
for (a, b) in old_text.chars().zip(new_text.chars()) {
if a == b {
common_prefix_len += a.len_utf8();
} else {
break;
}
}
let common_prefix_len = old_text
.chars()
.zip(new_text.chars())
.take_while(|(a, b)| a == b)
.map(|(a, _)| a.len_utf8())
.sum::<usize>();
cx.emit(EditorEvent::InputHandled {
utf16_range_to_replace: None,
@@ -20294,6 +20311,7 @@ pub trait CompletionProvider {
position: language::Anchor,
text: &str,
trigger_in_words: bool,
menu_is_open: bool,
cx: &mut Context<Editor>,
) -> bool;
@@ -20611,6 +20629,7 @@ impl CompletionProvider for Entity<Project> {
position: language::Anchor,
text: &str,
trigger_in_words: bool,
menu_is_open: bool,
cx: &mut Context<Editor>,
) -> bool {
let mut chars = text.chars();
@@ -20625,7 +20644,7 @@ impl CompletionProvider for Entity<Project> {
let buffer = buffer.read(cx);
let snapshot = buffer.snapshot();
if !snapshot.settings_at(position, cx).show_completions_on_input {
if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input {
return false;
}
let classifier = snapshot.char_classifier_at(position).for_completion(true);

View File

@@ -101,7 +101,10 @@ pub fn init(cx: &mut App) {
directories: true,
multiple: false,
},
DirectoryLister::Local(workspace.app_state().fs.clone()),
DirectoryLister::Local(
workspace.project().clone(),
workspace.app_state().fs.clone(),
),
window,
cx,
);

View File

@@ -4,7 +4,6 @@ mod file_finder_tests;
mod open_path_prompt_tests;
pub mod file_finder_settings;
mod new_path_prompt;
mod open_path_prompt;
use futures::future::join_all;
@@ -20,7 +19,6 @@ use gpui::{
KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
Window, actions,
};
use new_path_prompt::NewPathPrompt;
use open_path_prompt::OpenPathPrompt;
use picker::{Picker, PickerDelegate};
use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
@@ -85,8 +83,8 @@ pub fn init_settings(cx: &mut App) {
pub fn init(cx: &mut App) {
init_settings(cx);
cx.observe_new(FileFinder::register).detach();
cx.observe_new(NewPathPrompt::register).detach();
cx.observe_new(OpenPathPrompt::register).detach();
cx.observe_new(OpenPathPrompt::register_new_path).detach();
}
impl FileFinder {
@@ -332,6 +330,7 @@ impl FileFinder {
worktree_id: WorktreeId::from_usize(m.0.worktree_id),
path: m.0.path.clone(),
},
Match::CreateNew(p) => p.clone(),
};
let open_task = workspace.update(cx, move |workspace, cx| {
workspace.split_path_preview(path, false, Some(split_direction), window, cx)
@@ -456,13 +455,15 @@ enum Match {
panel_match: Option<ProjectPanelOrdMatch>,
},
Search(ProjectPanelOrdMatch),
CreateNew(ProjectPath),
}
impl Match {
fn path(&self) -> &Arc<Path> {
fn path(&self) -> Option<&Arc<Path>> {
match self {
Match::History { path, .. } => &path.project.path,
Match::Search(panel_match) => &panel_match.0.path,
Match::History { path, .. } => Some(&path.project.path),
Match::Search(panel_match) => Some(&panel_match.0.path),
Match::CreateNew(_) => None,
}
}
@@ -470,6 +471,7 @@ impl Match {
match self {
Match::History { panel_match, .. } => panel_match.as_ref(),
Match::Search(panel_match) => Some(&panel_match),
Match::CreateNew(_) => None,
}
}
}
@@ -499,7 +501,10 @@ impl Matches {
// reason for the matches set to change.
self.matches
.iter()
.position(|m| path.project.path == *m.path())
.position(|m| match m.path() {
Some(p) => path.project.path == *p,
None => false,
})
.ok_or(0)
} else {
self.matches.binary_search_by(|m| {
@@ -576,6 +581,12 @@ impl Matches {
a: &Match,
b: &Match,
) -> cmp::Ordering {
// Handle CreateNew variant - always put it at the end
match (a, b) {
(Match::CreateNew(_), _) => return cmp::Ordering::Less,
(_, Match::CreateNew(_)) => return cmp::Ordering::Greater,
_ => {}
}
debug_assert!(a.panel_match().is_some() && b.panel_match().is_some());
match (&a, &b) {
@@ -908,6 +919,23 @@ impl FileFinderDelegate {
matches.into_iter(),
extend_old_matches,
);
let worktree = self.project.read(cx).visible_worktrees(cx).next();
let filename = query.raw_query.to_string();
let path = Path::new(&filename);
// add option of creating new file only if path is relative
if let Some(worktree) = worktree {
let worktree = worktree.read(cx);
if path.is_relative()
&& worktree.entry_for_path(&path).is_none()
&& !filename.ends_with("/")
{
self.matches.matches.push(Match::CreateNew(ProjectPath {
worktree_id: worktree.id(),
path: Arc::from(path),
}));
}
}
self.selected_index = selected_match.map_or_else(
|| self.calculate_selected_index(cx),
@@ -988,6 +1016,12 @@ impl FileFinderDelegate {
}
}
Match::Search(path_match) => self.labels_for_path_match(&path_match.0),
Match::CreateNew(project_path) => (
format!("Create file: {}", project_path.path.display()),
vec![],
String::from(""),
vec![],
),
};
if file_name_positions.is_empty() {
@@ -1372,6 +1406,29 @@ impl PickerDelegate for FileFinderDelegate {
}
};
match &m {
Match::CreateNew(project_path) => {
// Create a new file with the given filename
if secondary {
workspace.split_path_preview(
project_path.clone(),
false,
None,
window,
cx,
)
} else {
workspace.open_path_preview(
project_path.clone(),
None,
true,
false,
true,
window,
cx,
)
}
}
Match::History { path, .. } => {
let worktree_id = path.project.worktree_id;
if workspace
@@ -1502,6 +1559,10 @@ impl PickerDelegate for FileFinderDelegate {
.flex_none()
.size(IconSize::Small.rems())
.into_any_element(),
Match::CreateNew(_) => Icon::new(IconName::Plus)
.color(Color::Muted)
.size(IconSize::Small)
.into_any_element(),
};
let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx, ix);
@@ -1509,7 +1570,7 @@ impl PickerDelegate for FileFinderDelegate {
if !settings.file_icons {
return None;
}
let file_name = path_match.path().file_name()?;
let file_name = path_match.path()?.file_name()?;
let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
Some(Icon::from_path(icon).color(Color::Muted))
});

View File

@@ -196,7 +196,7 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
cx.simulate_input("bna");
picker.update(cx, |picker, _| {
assert_eq!(picker.delegate.matches.len(), 2);
assert_eq!(picker.delegate.matches.len(), 3);
});
cx.dispatch_action(SelectNext);
cx.dispatch_action(Confirm);
@@ -229,7 +229,12 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
picker.update(cx, |picker, _| {
assert_eq!(
picker.delegate.matches.len(),
1,
// existence of CreateNew option depends on whether path already exists
if bandana_query == util::separator!("a/bandana") {
1
} else {
2
},
"Wrong number of matches for bandana query '{bandana_query}'. Matches: {:?}",
picker.delegate.matches
);
@@ -269,9 +274,9 @@ async fn test_unicode_paths(cx: &mut TestAppContext) {
cx.simulate_input("g");
picker.update(cx, |picker, _| {
assert_eq!(picker.delegate.matches.len(), 1);
assert_eq!(picker.delegate.matches.len(), 2);
assert_match_at_position(picker, 1, "g");
});
cx.dispatch_action(SelectNext);
cx.dispatch_action(Confirm);
cx.read(|cx| {
let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
@@ -365,13 +370,12 @@ async fn test_complex_path(cx: &mut TestAppContext) {
cx.simulate_input("t");
picker.update(cx, |picker, _| {
assert_eq!(picker.delegate.matches.len(), 1);
assert_eq!(picker.delegate.matches.len(), 2);
assert_eq!(
collect_search_matches(picker).search_paths_only(),
vec![PathBuf::from("其他/S数据表格/task.xlsx")],
)
});
cx.dispatch_action(SelectNext);
cx.dispatch_action(Confirm);
cx.read(|cx| {
let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
@@ -416,8 +420,9 @@ async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
})
.await;
picker.update(cx, |finder, _| {
assert_match_at_position(finder, 1, &query_inside_file.to_string());
let finder = &finder.delegate;
assert_eq!(finder.matches.len(), 1);
assert_eq!(finder.matches.len(), 2);
let latest_search_query = finder
.latest_search_query
.as_ref()
@@ -431,7 +436,6 @@ async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
);
});
cx.dispatch_action(SelectNext);
cx.dispatch_action(Confirm);
let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
@@ -491,8 +495,9 @@ async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
})
.await;
picker.update(cx, |finder, _| {
assert_match_at_position(finder, 1, &query_outside_file.to_string());
let delegate = &finder.delegate;
assert_eq!(delegate.matches.len(), 1);
assert_eq!(delegate.matches.len(), 2);
let latest_search_query = delegate
.latest_search_query
.as_ref()
@@ -506,7 +511,6 @@ async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
);
});
cx.dispatch_action(SelectNext);
cx.dispatch_action(Confirm);
let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
@@ -561,7 +565,8 @@ async fn test_matching_cancellation(cx: &mut TestAppContext) {
.await;
picker.update(cx, |picker, _cx| {
assert_eq!(picker.delegate.matches.len(), 5)
// CreateNew option not shown in this case since file already exists
assert_eq!(picker.delegate.matches.len(), 5);
});
picker.update_in(cx, |picker, window, cx| {
@@ -959,7 +964,8 @@ async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
.await;
cx.read(|cx| {
let finder = picker.read(cx);
assert_eq!(finder.delegate.matches.len(), 0);
assert_eq!(finder.delegate.matches.len(), 1);
assert_match_at_position(finder, 0, "dir");
});
}
@@ -1518,12 +1524,13 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
})
.await;
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 5);
assert_eq!(finder.delegate.matches.len(), 6);
assert_match_at_position(finder, 0, "main.rs");
assert_match_selection(finder, 1, "bar.rs");
assert_match_at_position(finder, 2, "lib.rs");
assert_match_at_position(finder, 3, "moo.rs");
assert_match_at_position(finder, 4, "maaa.rs");
assert_match_at_position(finder, 5, ".rs");
});
// main.rs is not among matches, select top item
@@ -1533,9 +1540,10 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
})
.await;
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 2);
assert_eq!(finder.delegate.matches.len(), 3);
assert_match_at_position(finder, 0, "bar.rs");
assert_match_at_position(finder, 1, "lib.rs");
assert_match_at_position(finder, 2, "b");
});
// main.rs is back, put it on top and select next item
@@ -1545,10 +1553,11 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
})
.await;
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 3);
assert_eq!(finder.delegate.matches.len(), 4);
assert_match_at_position(finder, 0, "main.rs");
assert_match_selection(finder, 1, "moo.rs");
assert_match_at_position(finder, 2, "maaa.rs");
assert_match_at_position(finder, 3, "m");
});
// get back to the initial state
@@ -1623,12 +1632,13 @@ async fn test_setting_auto_select_first_and_select_active_file(cx: &mut TestAppC
})
.await;
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 5);
assert_eq!(finder.delegate.matches.len(), 6);
assert_match_selection(finder, 0, "main.rs");
assert_match_at_position(finder, 1, "bar.rs");
assert_match_at_position(finder, 2, "lib.rs");
assert_match_at_position(finder, 3, "moo.rs");
assert_match_at_position(finder, 4, "maaa.rs");
assert_match_at_position(finder, 5, ".rs");
});
}
@@ -1679,12 +1689,13 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) {
})
.await;
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 5);
assert_eq!(finder.delegate.matches.len(), 6);
assert_match_at_position(finder, 0, "main.rs");
assert_match_selection(finder, 1, "moo.rs");
assert_match_at_position(finder, 2, "bar.rs");
assert_match_at_position(finder, 3, "lib.rs");
assert_match_at_position(finder, 4, "maaa.rs");
assert_match_at_position(finder, 5, ".rs");
});
// main.rs is not among matches, select top item
@@ -1694,9 +1705,10 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) {
})
.await;
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 2);
assert_eq!(finder.delegate.matches.len(), 3);
assert_match_at_position(finder, 0, "bar.rs");
assert_match_at_position(finder, 1, "lib.rs");
assert_match_at_position(finder, 2, "b");
});
// main.rs is back, put it on top and select next item
@@ -1706,10 +1718,11 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) {
})
.await;
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 3);
assert_eq!(finder.delegate.matches.len(), 4);
assert_match_at_position(finder, 0, "main.rs");
assert_match_selection(finder, 1, "moo.rs");
assert_match_at_position(finder, 2, "maaa.rs");
assert_match_at_position(finder, 3, "m");
});
// get back to the initial state
@@ -1965,9 +1978,10 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp
let picker = open_file_picker(&workspace, cx);
cx.simulate_input("rs");
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 2);
assert_eq!(finder.delegate.matches.len(), 3);
assert_match_at_position(finder, 0, "lib.rs");
assert_match_at_position(finder, 1, "main.rs");
assert_match_at_position(finder, 2, "rs");
});
// Delete main.rs
@@ -1980,8 +1994,9 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp
// main.rs is in not among search results anymore
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 1);
assert_eq!(finder.delegate.matches.len(), 2);
assert_match_at_position(finder, 0, "lib.rs");
assert_match_at_position(finder, 1, "rs");
});
// Create util.rs
@@ -1994,9 +2009,10 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp
// util.rs is among search results
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 2);
assert_eq!(finder.delegate.matches.len(), 3);
assert_match_at_position(finder, 0, "lib.rs");
assert_match_at_position(finder, 1, "util.rs");
assert_match_at_position(finder, 2, "rs");
});
}
@@ -2036,9 +2052,10 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
let picker = open_file_picker(&workspace, cx);
cx.simulate_input("rs");
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 2);
assert_eq!(finder.delegate.matches.len(), 3);
assert_match_at_position(finder, 0, "bar.rs");
assert_match_at_position(finder, 1, "lib.rs");
assert_match_at_position(finder, 2, "rs");
});
// Add new worktree
@@ -2054,10 +2071,11 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
// main.rs is among search results
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 3);
assert_eq!(finder.delegate.matches.len(), 4);
assert_match_at_position(finder, 0, "bar.rs");
assert_match_at_position(finder, 1, "lib.rs");
assert_match_at_position(finder, 2, "main.rs");
assert_match_at_position(finder, 3, "rs");
});
// Remove the first worktree
@@ -2068,8 +2086,9 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
// Files from the first worktree are not in the search results anymore
picker.update(cx, |finder, _| {
assert_eq!(finder.delegate.matches.len(), 1);
assert_eq!(finder.delegate.matches.len(), 2);
assert_match_at_position(finder, 0, "main.rs");
assert_match_at_position(finder, 1, "rs");
});
}
@@ -2414,7 +2433,7 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) {
cx.run_until_parked();
picker.update(cx, |picker, _| {
assert_eq!(picker.delegate.matches.len(), 6);
assert_eq!(picker.delegate.matches.len(), 7);
assert_eq!(picker.delegate.selected_index, 0);
});
@@ -2426,7 +2445,7 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) {
cx.run_until_parked();
picker.update(cx, |picker, _| {
assert_eq!(picker.delegate.matches.len(), 6);
assert_eq!(picker.delegate.matches.len(), 7);
assert_eq!(picker.delegate.selected_index, 3);
});
}
@@ -2468,7 +2487,7 @@ async fn open_queried_buffer(
let history_items = picker.update(cx, |finder, _| {
assert_eq!(
finder.delegate.matches.len(),
expected_matches,
expected_matches + 1, // +1 from CreateNew option
"Unexpected number of matches found for query `{input}`, matches: {:?}",
finder.delegate.matches
);
@@ -2617,6 +2636,7 @@ fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries
.push(Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path));
search_entries.search_matches.push(path_match.0.clone());
}
Match::CreateNew(_) => {}
}
}
search_entries
@@ -2650,6 +2670,7 @@ fn assert_match_at_position(
let match_file_name = match &match_item {
Match::History { path, .. } => path.absolute.as_deref().unwrap().file_name(),
Match::Search(path_match) => path_match.0.path.file_name(),
Match::CreateNew(project_path) => project_path.path.file_name(),
}
.unwrap()
.to_string_lossy();

View File

@@ -1,526 +0,0 @@
use futures::channel::oneshot;
use fuzzy::PathMatch;
use gpui::{Entity, HighlightStyle, StyledText};
use picker::{Picker, PickerDelegate};
use project::{Entry, PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
use std::{
path::{Path, PathBuf},
sync::{
Arc,
atomic::{self, AtomicBool},
},
};
use ui::{Context, ListItem, Window};
use ui::{LabelLike, ListItemSpacing, highlight_ranges, prelude::*};
use util::ResultExt;
use workspace::Workspace;
pub(crate) struct NewPathPrompt;
#[derive(Debug, Clone)]
struct Match {
path_match: Option<PathMatch>,
suffix: Option<String>,
}
impl Match {
fn entry<'a>(&'a self, project: &'a Project, cx: &'a App) -> Option<&'a Entry> {
if let Some(suffix) = &self.suffix {
let (worktree, path) = if let Some(path_match) = &self.path_match {
(
project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx),
path_match.path.join(suffix),
)
} else {
(project.worktrees(cx).next(), PathBuf::from(suffix))
};
worktree.and_then(|worktree| worktree.read(cx).entry_for_path(path))
} else if let Some(path_match) = &self.path_match {
let worktree =
project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx)?;
worktree.read(cx).entry_for_path(path_match.path.as_ref())
} else {
None
}
}
fn is_dir(&self, project: &Project, cx: &App) -> bool {
self.entry(project, cx).is_some_and(|e| e.is_dir())
|| self.suffix.as_ref().is_some_and(|s| s.ends_with('/'))
}
fn relative_path(&self) -> String {
if let Some(path_match) = &self.path_match {
if let Some(suffix) = &self.suffix {
format!(
"{}/{}",
path_match.path.to_string_lossy(),
suffix.trim_end_matches('/')
)
} else {
path_match.path.to_string_lossy().to_string()
}
} else if let Some(suffix) = &self.suffix {
suffix.trim_end_matches('/').to_string()
} else {
"".to_string()
}
}
fn project_path(&self, project: &Project, cx: &App) -> Option<ProjectPath> {
let worktree_id = if let Some(path_match) = &self.path_match {
WorktreeId::from_usize(path_match.worktree_id)
} else if let Some(worktree) = project.visible_worktrees(cx).find(|worktree| {
worktree
.read(cx)
.root_entry()
.is_some_and(|entry| entry.is_dir())
}) {
worktree.read(cx).id()
} else {
// todo(): we should find_or_create a workspace.
return None;
};
let path = PathBuf::from(self.relative_path());
Some(ProjectPath {
worktree_id,
path: Arc::from(path),
})
}
fn existing_prefix(&self, project: &Project, cx: &App) -> Option<PathBuf> {
let worktree = project.worktrees(cx).next()?.read(cx);
let mut prefix = PathBuf::new();
let parts = self.suffix.as_ref()?.split('/');
for part in parts {
if worktree.entry_for_path(prefix.join(&part)).is_none() {
return Some(prefix);
}
prefix = prefix.join(part);
}
None
}
fn styled_text(&self, project: &Project, window: &Window, cx: &App) -> StyledText {
let mut text = "./".to_string();
let mut highlights = Vec::new();
let mut offset = text.len();
let separator = '/';
let dir_indicator = "[…]";
if let Some(path_match) = &self.path_match {
text.push_str(&path_match.path.to_string_lossy());
let mut whole_path = PathBuf::from(path_match.path_prefix.to_string());
whole_path = whole_path.join(path_match.path.clone());
for (range, style) in highlight_ranges(
&whole_path.to_string_lossy(),
&path_match.positions,
gpui::HighlightStyle::color(Color::Accent.color(cx)),
) {
highlights.push((range.start + offset..range.end + offset, style))
}
text.push(separator);
offset = text.len();
if let Some(suffix) = &self.suffix {
text.push_str(suffix);
let entry = self.entry(project, cx);
let color = if let Some(entry) = entry {
if entry.is_dir() {
Color::Accent
} else {
Color::Conflict
}
} else {
Color::Created
};
highlights.push((
offset..offset + suffix.len(),
HighlightStyle::color(color.color(cx)),
));
offset += suffix.len();
if entry.is_some_and(|e| e.is_dir()) {
text.push(separator);
offset += separator.len_utf8();
text.push_str(dir_indicator);
highlights.push((
offset..offset + dir_indicator.len(),
HighlightStyle::color(Color::Muted.color(cx)),
));
}
} else {
text.push_str(dir_indicator);
highlights.push((
offset..offset + dir_indicator.len(),
HighlightStyle::color(Color::Muted.color(cx)),
))
}
} else if let Some(suffix) = &self.suffix {
text.push_str(suffix);
let existing_prefix_len = self
.existing_prefix(project, cx)
.map(|prefix| prefix.to_string_lossy().len())
.unwrap_or(0);
if existing_prefix_len > 0 {
highlights.push((
offset..offset + existing_prefix_len,
HighlightStyle::color(Color::Accent.color(cx)),
));
}
highlights.push((
offset + existing_prefix_len..offset + suffix.len(),
HighlightStyle::color(if self.entry(project, cx).is_some() {
Color::Conflict.color(cx)
} else {
Color::Created.color(cx)
}),
));
offset += suffix.len();
if suffix.ends_with('/') {
text.push_str(dir_indicator);
highlights.push((
offset..offset + dir_indicator.len(),
HighlightStyle::color(Color::Muted.color(cx)),
));
}
}
StyledText::new(text).with_default_highlights(&window.text_style().clone(), highlights)
}
}
pub struct NewPathDelegate {
project: Entity<Project>,
tx: Option<oneshot::Sender<Option<ProjectPath>>>,
selected_index: usize,
matches: Vec<Match>,
last_selected_dir: Option<String>,
cancel_flag: Arc<AtomicBool>,
should_dismiss: bool,
}
impl NewPathPrompt {
pub(crate) fn register(
workspace: &mut Workspace,
_window: Option<&mut Window>,
_cx: &mut Context<Workspace>,
) {
workspace.set_prompt_for_new_path(Box::new(|workspace, window, cx| {
let (tx, rx) = futures::channel::oneshot::channel();
Self::prompt_for_new_path(workspace, tx, window, cx);
rx
}));
}
fn prompt_for_new_path(
workspace: &mut Workspace,
tx: oneshot::Sender<Option<ProjectPath>>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let project = workspace.project().clone();
workspace.toggle_modal(window, cx, |window, cx| {
let delegate = NewPathDelegate {
project,
tx: Some(tx),
selected_index: 0,
matches: vec![],
cancel_flag: Arc::new(AtomicBool::new(false)),
last_selected_dir: None,
should_dismiss: true,
};
Picker::uniform_list(delegate, window, cx).width(rems(34.))
});
}
}
impl PickerDelegate for NewPathDelegate {
type ListItem = ui::ListItem;
fn match_count(&self) -> usize {
self.matches.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_: &mut Window,
cx: &mut Context<picker::Picker<Self>>,
) {
self.selected_index = ix;
cx.notify();
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<picker::Picker<Self>>,
) -> gpui::Task<()> {
let query = query
.trim()
.trim_start_matches("./")
.trim_start_matches('/');
let (dir, suffix) = if let Some(index) = query.rfind('/') {
let suffix = if index + 1 < query.len() {
Some(query[index + 1..].to_string())
} else {
None
};
(query[0..index].to_string(), suffix)
} else {
(query.to_string(), None)
};
let worktrees = self
.project
.read(cx)
.visible_worktrees(cx)
.collect::<Vec<_>>();
let include_root_name = worktrees.len() > 1;
let candidate_sets = worktrees
.into_iter()
.map(|worktree| {
let worktree = worktree.read(cx);
PathMatchCandidateSet {
snapshot: worktree.snapshot(),
include_ignored: worktree
.root_entry()
.map_or(false, |entry| entry.is_ignored),
include_root_name,
candidates: project::Candidates::Directories,
}
})
.collect::<Vec<_>>();
self.cancel_flag.store(true, atomic::Ordering::Relaxed);
self.cancel_flag = Arc::new(AtomicBool::new(false));
let cancel_flag = self.cancel_flag.clone();
let query = query.to_string();
let prefix = dir.clone();
cx.spawn_in(window, async move |picker, cx| {
let matches = fuzzy::match_path_sets(
candidate_sets.as_slice(),
&dir,
None,
false,
100,
&cancel_flag,
cx.background_executor().clone(),
)
.await;
let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
if did_cancel {
return;
}
picker
.update(cx, |picker, cx| {
picker
.delegate
.set_search_matches(query, prefix, suffix, matches, cx)
})
.log_err();
})
}
fn confirm_completion(
&mut self,
_: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<String> {
self.confirm_update_query(window, cx)
}
fn confirm_update_query(
&mut self,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<String> {
let m = self.matches.get(self.selected_index)?;
if m.is_dir(self.project.read(cx), cx) {
let path = m.relative_path();
let result = format!("{}/", path);
self.last_selected_dir = Some(path);
Some(result)
} else {
None
}
}
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
let Some(m) = self.matches.get(self.selected_index) else {
return;
};
let exists = m.entry(self.project.read(cx), cx).is_some();
if exists {
self.should_dismiss = false;
let answer = window.prompt(
gpui::PromptLevel::Critical,
&format!("{} already exists. Do you want to replace it?", m.relative_path()),
Some(
"A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
),
&["Replace", "Cancel"],
cx);
let m = m.clone();
cx.spawn_in(window, async move |picker, cx| {
let answer = answer.await.ok();
picker
.update(cx, |picker, cx| {
picker.delegate.should_dismiss = true;
if answer != Some(0) {
return;
}
if let Some(path) = m.project_path(picker.delegate.project.read(cx), cx) {
if let Some(tx) = picker.delegate.tx.take() {
tx.send(Some(path)).ok();
}
}
cx.emit(gpui::DismissEvent);
})
.ok();
})
.detach();
return;
}
if let Some(path) = m.project_path(self.project.read(cx), cx) {
if let Some(tx) = self.tx.take() {
tx.send(Some(path)).ok();
}
}
cx.emit(gpui::DismissEvent);
}
fn should_dismiss(&self) -> bool {
self.should_dismiss
}
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
if let Some(tx) = self.tx.take() {
tx.send(None).ok();
}
cx.emit(gpui::DismissEvent)
}
fn render_match(
&self,
ix: usize,
selected: bool,
window: &mut Window,
cx: &mut Context<picker::Picker<Self>>,
) -> Option<Self::ListItem> {
let m = self.matches.get(ix)?;
Some(
ListItem::new(ix)
.spacing(ListItemSpacing::Sparse)
.inset(true)
.toggle_state(selected)
.child(LabelLike::new().child(m.styled_text(self.project.read(cx), window, cx))),
)
}
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
Some("Type a path...".into())
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
Arc::from("[directory/]filename.ext")
}
}
impl NewPathDelegate {
fn set_search_matches(
&mut self,
query: String,
prefix: String,
suffix: Option<String>,
matches: Vec<PathMatch>,
cx: &mut Context<Picker<Self>>,
) {
cx.notify();
if query.is_empty() {
self.matches = self
.project
.read(cx)
.worktrees(cx)
.flat_map(|worktree| {
let worktree_id = worktree.read(cx).id();
worktree
.read(cx)
.child_entries(Path::new(""))
.filter_map(move |entry| {
entry.is_dir().then(|| Match {
path_match: Some(PathMatch {
score: 1.0,
positions: Default::default(),
worktree_id: worktree_id.to_usize(),
path: entry.path.clone(),
path_prefix: "".into(),
is_dir: entry.is_dir(),
distance_to_relative_ancestor: 0,
}),
suffix: None,
})
})
})
.collect();
return;
}
let mut directory_exists = false;
self.matches = matches
.into_iter()
.map(|m| {
if m.path.as_ref().to_string_lossy() == prefix {
directory_exists = true
}
Match {
path_match: Some(m),
suffix: suffix.clone(),
}
})
.collect();
if !directory_exists {
if suffix.is_none()
|| self
.last_selected_dir
.as_ref()
.is_some_and(|d| query.starts_with(d))
{
self.matches.insert(
0,
Match {
path_match: None,
suffix: Some(query.clone()),
},
)
} else {
self.matches.push(Match {
path_match: None,
suffix: Some(query.clone()),
})
}
}
}
}

View File

@@ -2,6 +2,7 @@ use crate::file_finder_settings::FileFinderSettings;
use file_icons::FileIcons;
use futures::channel::oneshot;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{HighlightStyle, StyledText, Task};
use picker::{Picker, PickerDelegate};
use project::{DirectoryItem, DirectoryLister};
use settings::Settings;
@@ -12,61 +13,136 @@ use std::{
atomic::{self, AtomicBool},
},
};
use ui::{Context, ListItem, Window};
use ui::{Context, LabelLike, ListItem, Window};
use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
use util::{maybe, paths::compare_paths};
use workspace::Workspace;
pub(crate) struct OpenPathPrompt;
#[cfg(target_os = "windows")]
const PROMPT_ROOT: &str = "C:\\";
#[cfg(not(target_os = "windows"))]
const PROMPT_ROOT: &str = "/";
#[derive(Debug)]
pub struct OpenPathDelegate {
tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
lister: DirectoryLister,
selected_index: usize,
directory_state: Option<DirectoryState>,
matches: Vec<usize>,
directory_state: DirectoryState,
string_matches: Vec<StringMatch>,
cancel_flag: Arc<AtomicBool>,
should_dismiss: bool,
replace_prompt: Task<()>,
}
impl OpenPathDelegate {
pub fn new(tx: oneshot::Sender<Option<Vec<PathBuf>>>, lister: DirectoryLister) -> Self {
pub fn new(
tx: oneshot::Sender<Option<Vec<PathBuf>>>,
lister: DirectoryLister,
creating_path: bool,
) -> Self {
Self {
tx: Some(tx),
lister,
selected_index: 0,
directory_state: None,
matches: Vec::new(),
directory_state: DirectoryState::None {
create: creating_path,
},
string_matches: Vec::new(),
cancel_flag: Arc::new(AtomicBool::new(false)),
should_dismiss: true,
replace_prompt: Task::ready(()),
}
}
fn get_entry(&self, selected_match_index: usize) -> Option<CandidateInfo> {
match &self.directory_state {
DirectoryState::List { entries, .. } => {
let id = self.string_matches.get(selected_match_index)?.candidate_id;
entries.iter().find(|entry| entry.path.id == id).cloned()
}
DirectoryState::Create {
user_input,
entries,
..
} => {
let mut i = selected_match_index;
if let Some(user_input) = user_input {
if !user_input.exists || !user_input.is_dir {
if i == 0 {
return Some(CandidateInfo {
path: user_input.file.clone(),
is_dir: false,
});
} else {
i -= 1;
}
}
}
let id = self.string_matches.get(i)?.candidate_id;
entries.iter().find(|entry| entry.path.id == id).cloned()
}
DirectoryState::None { .. } => None,
}
}
#[cfg(any(test, feature = "test-support"))]
pub fn collect_match_candidates(&self) -> Vec<String> {
if let Some(state) = self.directory_state.as_ref() {
self.matches
match &self.directory_state {
DirectoryState::List { entries, .. } => self
.string_matches
.iter()
.filter_map(|&index| {
state
.match_candidates
.get(index)
.filter_map(|string_match| {
entries
.iter()
.find(|entry| entry.path.id == string_match.candidate_id)
.map(|candidate| candidate.path.string.clone())
})
.collect()
} else {
Vec::new()
.collect(),
DirectoryState::Create {
user_input,
entries,
..
} => user_input
.into_iter()
.filter(|user_input| !user_input.exists || !user_input.is_dir)
.map(|user_input| user_input.file.string.clone())
.chain(self.string_matches.iter().filter_map(|string_match| {
entries
.iter()
.find(|entry| entry.path.id == string_match.candidate_id)
.map(|candidate| candidate.path.string.clone())
}))
.collect(),
DirectoryState::None { .. } => Vec::new(),
}
}
}
#[derive(Debug)]
struct DirectoryState {
path: String,
match_candidates: Vec<CandidateInfo>,
error: Option<SharedString>,
enum DirectoryState {
List {
parent_path: String,
entries: Vec<CandidateInfo>,
error: Option<SharedString>,
},
Create {
parent_path: String,
user_input: Option<UserInput>,
entries: Vec<CandidateInfo>,
},
None {
create: bool,
},
}
#[derive(Debug, Clone)]
struct UserInput {
file: StringMatchCandidate,
exists: bool,
is_dir: bool,
}
#[derive(Debug, Clone)]
@@ -83,7 +159,19 @@ impl OpenPathPrompt {
) {
workspace.set_prompt_for_open_path(Box::new(|workspace, lister, window, cx| {
let (tx, rx) = futures::channel::oneshot::channel();
Self::prompt_for_open_path(workspace, lister, tx, window, cx);
Self::prompt_for_open_path(workspace, lister, false, tx, window, cx);
rx
}));
}
pub(crate) fn register_new_path(
workspace: &mut Workspace,
_window: Option<&mut Window>,
_: &mut Context<Workspace>,
) {
workspace.set_prompt_for_new_path(Box::new(|workspace, lister, window, cx| {
let (tx, rx) = futures::channel::oneshot::channel();
Self::prompt_for_open_path(workspace, lister, true, tx, window, cx);
rx
}));
}
@@ -91,13 +179,13 @@ impl OpenPathPrompt {
fn prompt_for_open_path(
workspace: &mut Workspace,
lister: DirectoryLister,
creating_path: bool,
tx: oneshot::Sender<Option<Vec<PathBuf>>>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
workspace.toggle_modal(window, cx, |window, cx| {
let delegate = OpenPathDelegate::new(tx, lister.clone());
let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
let query = lister.default_query(cx);
picker.set_query(query, window, cx);
@@ -110,7 +198,16 @@ impl PickerDelegate for OpenPathDelegate {
type ListItem = ui::ListItem;
fn match_count(&self) -> usize {
self.matches.len()
let user_input = if let DirectoryState::Create { user_input, .. } = &self.directory_state {
user_input
.as_ref()
.filter(|input| !input.exists || !input.is_dir)
.into_iter()
.count()
} else {
0
};
self.string_matches.len() + user_input
}
fn selected_index(&self) -> usize {
@@ -127,127 +224,196 @@ impl PickerDelegate for OpenPathDelegate {
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> gpui::Task<()> {
let lister = self.lister.clone();
let query_path = Path::new(&query);
let last_item = query_path
) -> Task<()> {
let lister = &self.lister;
let last_item = Path::new(&query)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(&last_item) {
(dir.to_string(), last_item)
.to_string_lossy();
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
(dir.to_string(), last_item.into_owned())
} else {
(query, String::new())
};
if dir == "" {
#[cfg(not(target_os = "windows"))]
{
dir = "/".to_string();
}
#[cfg(target_os = "windows")]
{
dir = "C:\\".to_string();
}
dir = PROMPT_ROOT.to_string();
}
let query = if self
.directory_state
.as_ref()
.map_or(false, |s| s.path == dir)
{
None
} else {
Some(lister.list_directory(dir.clone(), cx))
let query = match &self.directory_state {
DirectoryState::List { parent_path, .. } => {
if parent_path == &dir {
None
} else {
Some(lister.list_directory(dir.clone(), cx))
}
}
DirectoryState::Create {
parent_path,
user_input,
..
} => {
if parent_path == &dir
&& user_input.as_ref().map(|input| &input.file.string) == Some(&suffix)
{
None
} else {
Some(lister.list_directory(dir.clone(), cx))
}
}
DirectoryState::None { .. } => Some(lister.list_directory(dir.clone(), cx)),
};
self.cancel_flag.store(true, atomic::Ordering::Relaxed);
self.cancel_flag.store(true, atomic::Ordering::Release);
self.cancel_flag = Arc::new(AtomicBool::new(false));
let cancel_flag = self.cancel_flag.clone();
cx.spawn_in(window, async move |this, cx| {
if let Some(query) = query {
let paths = query.await;
if cancel_flag.load(atomic::Ordering::Relaxed) {
if cancel_flag.load(atomic::Ordering::Acquire) {
return;
}
this.update(cx, |this, _| {
this.delegate.directory_state = Some(match paths {
Ok(mut paths) => {
if dir == "/" {
paths.push(DirectoryItem {
is_dir: true,
path: Default::default(),
});
}
if this
.update(cx, |this, _| {
let new_state = match &this.delegate.directory_state {
DirectoryState::None { create: false }
| DirectoryState::List { .. } => match paths {
Ok(paths) => DirectoryState::List {
entries: path_candidates(&dir, paths),
parent_path: dir.clone(),
error: None,
},
Err(e) => DirectoryState::List {
entries: Vec::new(),
parent_path: dir.clone(),
error: Some(SharedString::from(e.to_string())),
},
},
DirectoryState::None { create: true }
| DirectoryState::Create { .. } => match paths {
Ok(paths) => {
let mut entries = path_candidates(&dir, paths);
let mut exists = false;
let mut is_dir = false;
let mut new_id = None;
entries.retain(|entry| {
new_id = new_id.max(Some(entry.path.id));
if entry.path.string == suffix {
exists = true;
is_dir = entry.is_dir;
}
!exists || is_dir
});
paths.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
let match_candidates = paths
.iter()
.enumerate()
.map(|(ix, item)| CandidateInfo {
path: StringMatchCandidate::new(
ix,
&item.path.to_string_lossy(),
),
is_dir: item.is_dir,
})
.collect::<Vec<_>>();
DirectoryState {
match_candidates,
path: dir,
error: None,
}
}
Err(err) => DirectoryState {
match_candidates: vec![],
path: dir,
error: Some(err.to_string().into()),
},
});
})
.ok();
let new_id = new_id.map(|id| id + 1).unwrap_or(0);
let user_input = if suffix.is_empty() {
None
} else {
Some(UserInput {
file: StringMatchCandidate::new(new_id, &suffix),
exists,
is_dir,
})
};
DirectoryState::Create {
entries,
parent_path: dir.clone(),
user_input,
}
}
Err(_) => DirectoryState::Create {
entries: Vec::new(),
parent_path: dir.clone(),
user_input: Some(UserInput {
exists: false,
is_dir: false,
file: StringMatchCandidate::new(0, &suffix),
}),
},
},
};
this.delegate.directory_state = new_state;
})
.is_err()
{
return;
}
}
let match_candidates = this
.update(cx, |this, cx| {
let directory_state = this.delegate.directory_state.as_ref()?;
if directory_state.error.is_some() {
this.delegate.matches.clear();
this.delegate.selected_index = 0;
cx.notify();
return None;
let Ok(mut new_entries) =
this.update(cx, |this, _| match &this.delegate.directory_state {
DirectoryState::List {
entries,
error: None,
..
}
| DirectoryState::Create { entries, .. } => entries.clone(),
DirectoryState::List { error: Some(_), .. } | DirectoryState::None { .. } => {
Vec::new()
}
Some(directory_state.match_candidates.clone())
})
.unwrap_or(None);
let Some(mut match_candidates) = match_candidates else {
else {
return;
};
if !suffix.starts_with('.') {
match_candidates.retain(|m| !m.path.string.starts_with('.'));
new_entries.retain(|entry| !entry.path.string.starts_with('.'));
}
if suffix == "" {
if suffix.is_empty() {
this.update(cx, |this, cx| {
this.delegate.matches.clear();
this.delegate.string_matches.clear();
this.delegate
.matches
.extend(match_candidates.iter().map(|m| m.path.id));
this.delegate.selected_index = 0;
this.delegate.string_matches = new_entries
.iter()
.map(|m| StringMatch {
candidate_id: m.path.id,
score: 0.0,
positions: Vec::new(),
string: m.path.string.clone(),
})
.collect();
this.delegate.directory_state =
match &this.delegate.directory_state {
DirectoryState::None { create: false }
| DirectoryState::List { .. } => DirectoryState::List {
parent_path: dir.clone(),
entries: new_entries,
error: None,
},
DirectoryState::None { create: true }
| DirectoryState::Create { .. } => DirectoryState::Create {
parent_path: dir.clone(),
user_input: None,
entries: new_entries,
},
};
cx.notify();
})
.ok();
return;
}
let candidates = match_candidates.iter().map(|m| &m.path).collect::<Vec<_>>();
let Ok(is_create_state) =
this.update(cx, |this, _| match &this.delegate.directory_state {
DirectoryState::Create { .. } => true,
DirectoryState::List { .. } => false,
DirectoryState::None { create } => *create,
})
else {
return;
};
let candidates = new_entries
.iter()
.filter_map(|entry| {
if is_create_state && !entry.is_dir && Some(&suffix) == Some(&entry.path.string)
{
None
} else {
Some(&entry.path)
}
})
.collect::<Vec<_>>();
let matches = fuzzy::match_strings(
candidates.as_slice(),
&suffix,
@@ -257,27 +423,57 @@ impl PickerDelegate for OpenPathDelegate {
cx.background_executor().clone(),
)
.await;
if cancel_flag.load(atomic::Ordering::Relaxed) {
if cancel_flag.load(atomic::Ordering::Acquire) {
return;
}
this.update(cx, |this, cx| {
this.delegate.matches.clear();
this.delegate.selected_index = 0;
this.delegate.string_matches = matches.clone();
this.delegate
.matches
.extend(matches.into_iter().map(|m| m.candidate_id));
this.delegate.matches.sort_by_key(|m| {
this.delegate.string_matches.sort_by_key(|m| {
(
this.delegate.directory_state.as_ref().and_then(|d| {
d.match_candidates
.get(*m)
.map(|c| !c.path.string.starts_with(&suffix))
}),
*m,
new_entries
.iter()
.find(|entry| entry.path.id == m.candidate_id)
.map(|entry| &entry.path)
.map(|candidate| !candidate.string.starts_with(&suffix)),
m.candidate_id,
)
});
this.delegate.selected_index = 0;
this.delegate.directory_state = match &this.delegate.directory_state {
DirectoryState::None { create: false } | DirectoryState::List { .. } => {
DirectoryState::List {
entries: new_entries,
parent_path: dir.clone(),
error: None,
}
}
DirectoryState::None { create: true } => DirectoryState::Create {
entries: new_entries,
parent_path: dir.clone(),
user_input: Some(UserInput {
file: StringMatchCandidate::new(0, &suffix),
exists: false,
is_dir: false,
}),
},
DirectoryState::Create { user_input, .. } => {
let (new_id, exists, is_dir) = user_input
.as_ref()
.map(|input| (input.file.id, input.exists, input.is_dir))
.unwrap_or_else(|| (0, false, false));
DirectoryState::Create {
entries: new_entries,
parent_path: dir.clone(),
user_input: Some(UserInput {
file: StringMatchCandidate::new(new_id, &suffix),
exists,
is_dir,
}),
}
}
};
cx.notify();
})
.ok();
@@ -290,49 +486,107 @@ impl PickerDelegate for OpenPathDelegate {
_window: &mut Window,
_: &mut Context<Picker<Self>>,
) -> Option<String> {
let candidate = self.get_entry(self.selected_index)?;
Some(
maybe!({
let m = self.matches.get(self.selected_index)?;
let directory_state = self.directory_state.as_ref()?;
let candidate = directory_state.match_candidates.get(*m)?;
Some(format!(
"{}{}{}",
directory_state.path,
candidate.path.string,
if candidate.is_dir {
MAIN_SEPARATOR_STR
} else {
""
}
))
match &self.directory_state {
DirectoryState::Create { parent_path, .. } => Some(format!(
"{}{}{}",
parent_path,
candidate.path.string,
if candidate.is_dir {
MAIN_SEPARATOR_STR
} else {
""
}
)),
DirectoryState::List { parent_path, .. } => Some(format!(
"{}{}{}",
parent_path,
candidate.path.string,
if candidate.is_dir {
MAIN_SEPARATOR_STR
} else {
""
}
)),
DirectoryState::None { .. } => return None,
}
})
.unwrap_or(query),
)
}
fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(m) = self.matches.get(self.selected_index) else {
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(candidate) = self.get_entry(self.selected_index) else {
return;
};
let Some(directory_state) = self.directory_state.as_ref() else {
return;
};
let Some(candidate) = directory_state.match_candidates.get(*m) else {
return;
};
let result = if directory_state.path == "/" && candidate.path.string.is_empty() {
PathBuf::from("/")
} else {
Path::new(
self.lister
.resolve_tilde(&directory_state.path, cx)
.as_ref(),
)
.join(&candidate.path.string)
};
if let Some(tx) = self.tx.take() {
tx.send(Some(vec![result])).ok();
match &self.directory_state {
DirectoryState::None { .. } => return,
DirectoryState::List { parent_path, .. } => {
let confirmed_path =
if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() {
PathBuf::from(PROMPT_ROOT)
} else {
Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
.join(&candidate.path.string)
};
if let Some(tx) = self.tx.take() {
tx.send(Some(vec![confirmed_path])).ok();
}
}
DirectoryState::Create {
parent_path,
user_input,
..
} => match user_input {
None => return,
Some(user_input) => {
if user_input.is_dir {
return;
}
let prompted_path =
if parent_path == PROMPT_ROOT && user_input.file.string.is_empty() {
PathBuf::from(PROMPT_ROOT)
} else {
Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
.join(&user_input.file.string)
};
if user_input.exists {
self.should_dismiss = false;
let answer = window.prompt(
gpui::PromptLevel::Critical,
&format!("{prompted_path:?} already exists. Do you want to replace it?"),
Some(
"A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
),
&["Replace", "Cancel"],
cx
);
self.replace_prompt = cx.spawn_in(window, async move |picker, cx| {
let answer = answer.await.ok();
picker
.update(cx, |picker, cx| {
picker.delegate.should_dismiss = true;
if answer != Some(0) {
return;
}
if let Some(tx) = picker.delegate.tx.take() {
tx.send(Some(vec![prompted_path])).ok();
}
cx.emit(gpui::DismissEvent);
})
.ok();
});
return;
} else if let Some(tx) = self.tx.take() {
tx.send(Some(vec![prompted_path])).ok();
}
}
},
}
cx.emit(gpui::DismissEvent);
}
@@ -351,19 +605,30 @@ impl PickerDelegate for OpenPathDelegate {
&self,
ix: usize,
selected: bool,
_window: &mut Window,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let settings = FileFinderSettings::get_global(cx);
let m = self.matches.get(ix)?;
let directory_state = self.directory_state.as_ref()?;
let candidate = directory_state.match_candidates.get(*m)?;
let highlight_positions = self
.string_matches
.iter()
.find(|string_match| string_match.candidate_id == *m)
.map(|string_match| string_match.positions.clone())
.unwrap_or_default();
let candidate = self.get_entry(ix)?;
let match_positions = match &self.directory_state {
DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(),
DirectoryState::Create { user_input, .. } => {
if let Some(user_input) = user_input {
if !user_input.exists || !user_input.is_dir {
if ix == 0 {
Vec::new()
} else {
self.string_matches.get(ix - 1)?.positions.clone()
}
} else {
self.string_matches.get(ix)?.positions.clone()
}
} else {
self.string_matches.get(ix)?.positions.clone()
}
}
DirectoryState::None { .. } => Vec::new(),
};
let file_icon = maybe!({
if !settings.file_icons {
@@ -378,34 +643,128 @@ impl PickerDelegate for OpenPathDelegate {
Some(Icon::from_path(icon).color(Color::Muted))
});
Some(
ListItem::new(ix)
.spacing(ListItemSpacing::Sparse)
.start_slot::<Icon>(file_icon)
.inset(true)
.toggle_state(selected)
.child(HighlightedLabel::new(
if directory_state.path == "/" {
format!("/{}", candidate.path.string)
} else {
candidate.path.string.clone()
},
highlight_positions,
)),
)
match &self.directory_state {
DirectoryState::List { parent_path, .. } => Some(
ListItem::new(ix)
.spacing(ListItemSpacing::Sparse)
.start_slot::<Icon>(file_icon)
.inset(true)
.toggle_state(selected)
.child(HighlightedLabel::new(
if parent_path == PROMPT_ROOT {
format!("{}{}", PROMPT_ROOT, candidate.path.string)
} else {
candidate.path.string.clone()
},
match_positions,
)),
),
DirectoryState::Create {
parent_path,
user_input,
..
} => {
let (label, delta) = if parent_path == PROMPT_ROOT {
(
format!("{}{}", PROMPT_ROOT, candidate.path.string),
PROMPT_ROOT.len(),
)
} else {
(candidate.path.string.clone(), 0)
};
let label_len = label.len();
let label_with_highlights = match user_input {
Some(user_input) => {
if user_input.file.string == candidate.path.string {
if user_input.exists {
let label = if user_input.is_dir {
label
} else {
format!("{label} (replace)")
};
StyledText::new(label)
.with_default_highlights(
&window.text_style().clone(),
vec![(
delta..delta + label_len,
HighlightStyle::color(Color::Conflict.color(cx)),
)],
)
.into_any_element()
} else {
StyledText::new(format!("{label} (create)"))
.with_default_highlights(
&window.text_style().clone(),
vec![(
delta..delta + label_len,
HighlightStyle::color(Color::Created.color(cx)),
)],
)
.into_any_element()
}
} else {
let mut highlight_positions = match_positions;
highlight_positions.iter_mut().for_each(|position| {
*position += delta;
});
HighlightedLabel::new(label, highlight_positions).into_any_element()
}
}
None => {
let mut highlight_positions = match_positions;
highlight_positions.iter_mut().for_each(|position| {
*position += delta;
});
HighlightedLabel::new(label, highlight_positions).into_any_element()
}
};
Some(
ListItem::new(ix)
.spacing(ListItemSpacing::Sparse)
.start_slot::<Icon>(file_icon)
.inset(true)
.toggle_state(selected)
.child(LabelLike::new().child(label_with_highlights)),
)
}
DirectoryState::None { .. } => return None,
}
}
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
let text = if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone())
{
error
} else {
"No such file or directory".into()
};
Some(text)
Some(match &self.directory_state {
DirectoryState::Create { .. } => SharedString::from("Type a path…"),
DirectoryState::List {
error: Some(error), ..
} => error.clone(),
DirectoryState::List { .. } | DirectoryState::None { .. } => {
SharedString::from("No such file or directory")
}
})
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
}
}
fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Vec<CandidateInfo> {
if *parent_path == PROMPT_ROOT {
children.push(DirectoryItem {
is_dir: true,
path: PathBuf::default(),
});
}
children.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
children
.iter()
.enumerate()
.map(|(ix, item)| CandidateInfo {
path: StringMatchCandidate::new(ix, &item.path.to_string_lossy()),
is_dir: item.is_dir,
})
.collect()
}

View File

@@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) {
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, cx);
let (picker, cx) = build_open_path_prompt(project, false, cx);
let query = path!("/root");
insert_query(query, &picker, cx).await;
@@ -111,7 +111,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) {
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, cx);
let (picker, cx) = build_open_path_prompt(project, false, cx);
// Confirm completion for the query "/root", since it's a directory, it should add a trailing slash.
let query = path!("/root");
@@ -204,7 +204,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, cx);
let (picker, cx) = build_open_path_prompt(project, false, cx);
// Support both forward and backward slashes.
let query = "C:/root/";
@@ -251,6 +251,54 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_new_path_prompt(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/root"),
json!({
"a1": "A1",
"a2": "A2",
"a3": "A3",
"dir1": {},
"dir2": {
"c": "C",
"d1": "D1",
"d2": "D2",
"d3": "D3",
"dir3": {},
"dir4": {}
}
}),
)
.await;
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, true, cx);
insert_query(path!("/root"), &picker, cx).await;
assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]);
insert_query(path!("/root/d"), &picker, cx).await;
assert_eq!(
collect_match_candidates(&picker, cx),
vec!["d", "dir1", "dir2"]
);
insert_query(path!("/root/dir1"), &picker, cx).await;
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1"]);
insert_query(path!("/root/dir12"), &picker, cx).await;
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir12"]);
insert_query(path!("/root/dir1"), &picker, cx).await;
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1"]);
}
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
cx.update(|cx| {
let state = AppState::test(cx);
@@ -266,11 +314,12 @@ fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
fn build_open_path_prompt(
project: Entity<Project>,
creating_path: bool,
cx: &mut TestAppContext,
) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
let (tx, _) = futures::channel::oneshot::channel();
let lister = project::DirectoryLister::Project(project.clone());
let delegate = OpenPathDelegate::new(tx, lister.clone());
let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
(

View File

@@ -2,7 +2,6 @@ use std::{ops::Range, sync::Arc};
use anyhow::Result;
use async_trait::async_trait;
use collections::BTreeMap;
use derive_more::{Deref, DerefMut};
use gpui::{App, Global, SharedString};
use http_client::HttpClient;
@@ -130,7 +129,8 @@ impl Global for GlobalGitHostingProviderRegistry {}
#[derive(Default)]
struct GitHostingProviderRegistryState {
providers: BTreeMap<String, Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
default_providers: Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
setting_providers: Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
}
#[derive(Default)]
@@ -140,6 +140,7 @@ pub struct GitHostingProviderRegistry {
impl GitHostingProviderRegistry {
/// Returns the global [`GitHostingProviderRegistry`].
#[track_caller]
pub fn global(cx: &App) -> Arc<Self> {
cx.global::<GlobalGitHostingProviderRegistry>().0.clone()
}
@@ -168,7 +169,8 @@ impl GitHostingProviderRegistry {
pub fn new() -> Self {
Self {
state: RwLock::new(GitHostingProviderRegistryState {
providers: BTreeMap::default(),
setting_providers: Vec::default(),
default_providers: Vec::default(),
}),
}
}
@@ -177,7 +179,22 @@ impl GitHostingProviderRegistry {
pub fn list_hosting_providers(
&self,
) -> Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>> {
self.state.read().providers.values().cloned().collect()
let state = self.state.read();
state
.default_providers
.iter()
.cloned()
.chain(state.setting_providers.iter().cloned())
.collect()
}
pub fn set_setting_providers(
&self,
providers: impl IntoIterator<Item = Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
) {
let mut state = self.state.write();
state.setting_providers.clear();
state.setting_providers.extend(providers);
}
/// Adds the provided [`GitHostingProvider`] to the registry.
@@ -185,10 +202,7 @@ impl GitHostingProviderRegistry {
&self,
provider: Arc<dyn GitHostingProvider + Send + Sync + 'static>,
) {
self.state
.write()
.providers
.insert(provider.name(), provider);
self.state.write().default_providers.push(provider);
}
}

View File

@@ -25,22 +25,34 @@ fn init_git_hosting_provider_settings(cx: &mut App) {
}
fn update_git_hosting_providers_from_settings(cx: &mut App) {
let settings_store = cx.global::<SettingsStore>();
let settings = GitHostingProviderSettings::get_global(cx);
let provider_registry = GitHostingProviderRegistry::global(cx);
for provider in settings.git_hosting_providers.iter() {
let Some(url) = Url::parse(&provider.base_url).log_err() else {
continue;
};
let local_values: Vec<GitHostingProviderConfig> = settings_store
.get_all_locals::<GitHostingProviderSettings>()
.into_iter()
.flat_map(|(_, _, providers)| providers.git_hosting_providers.clone())
.collect();
let provider = match provider.provider {
GitHostingProviderKind::Bitbucket => Arc::new(Bitbucket::new(&provider.name, url)) as _,
GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _,
GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _,
};
let iter = settings
.git_hosting_providers
.clone()
.into_iter()
.chain(local_values)
.filter_map(|provider| {
let url = Url::parse(&provider.base_url).log_err()?;
provider_registry.register_hosting_provider(provider);
}
Some(match provider.provider {
GitHostingProviderKind::Bitbucket => {
Arc::new(Bitbucket::new(&provider.name, url)) as _
}
GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _,
GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _,
})
});
provider_registry.set_setting_providers(iter);
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -66,7 +78,7 @@ pub struct GitHostingProviderConfig {
pub name: String,
}
#[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GitHostingProviderSettings {
/// The list of custom Git hosting providers.
#[serde(default)]

View File

@@ -18,6 +18,7 @@ pub enum IconName {
AiMistral,
AiOllama,
AiOpenAi,
AiOpenRouter,
AiZed,
ArrowCircle,
ArrowDown,
@@ -154,6 +155,7 @@ pub enum IconName {
LineHeight,
Link,
ListCollapse,
ListTodo,
ListTree,
ListX,
LoadCircle,

View File

@@ -685,8 +685,9 @@ impl CompletionProvider for RustStyleCompletionProvider {
&self,
buffer: &Entity<language::Buffer>,
position: language::Anchor,
_: &str,
_: bool,
_text: &str,
_trigger_in_words: bool,
_menu_is_open: bool,
cx: &mut Context<Editor>,
) -> bool {
completion_replace_range(&buffer.read(cx).snapshot(), &position).is_some()

View File

@@ -3283,8 +3283,8 @@ impl BufferSnapshot {
pub fn surrounding_word<T: ToOffset>(&self, start: T) -> (Range<usize>, Option<CharKind>) {
let mut start = start.to_offset(self);
let mut end = start;
let mut next_chars = self.chars_at(start).peekable();
let mut prev_chars = self.reversed_chars_at(start).peekable();
let mut next_chars = self.chars_at(start).take(128).peekable();
let mut prev_chars = self.reversed_chars_at(start).take(128).peekable();
let classifier = self.char_classifier_at(start);
let word_kind = cmp::max(

View File

@@ -39,6 +39,7 @@ menu.workspace = true
mistral = { workspace = true, features = ["schemars"] }
ollama = { workspace = true, features = ["schemars"] }
open_ai = { workspace = true, features = ["schemars"] }
open_router = { workspace = true, features = ["schemars"] }
partial-json-fixer.workspace = true
project.workspace = true
proto.workspace = true

View File

@@ -19,6 +19,7 @@ use crate::provider::lmstudio::LmStudioLanguageModelProvider;
use crate::provider::mistral::MistralLanguageModelProvider;
use crate::provider::ollama::OllamaLanguageModelProvider;
use crate::provider::open_ai::OpenAiLanguageModelProvider;
use crate::provider::open_router::OpenRouterLanguageModelProvider;
pub use crate::settings::*;
pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, fs: Arc<dyn Fs>, cx: &mut App) {
@@ -72,5 +73,9 @@ fn register_language_model_providers(
BedrockLanguageModelProvider::new(client.http_client(), cx),
cx,
);
registry.register_provider(
OpenRouterLanguageModelProvider::new(client.http_client(), cx),
cx,
);
registry.register_provider(CopilotChatLanguageModelProvider::new(cx), cx);
}

View File

@@ -8,3 +8,4 @@ pub mod lmstudio;
pub mod mistral;
pub mod ollama;
pub mod open_ai;
pub mod open_router;

View File

@@ -531,13 +531,13 @@ impl LanguageModel for BedrockModel {
> {
let Ok(region) = cx.read_entity(&self.state, |state, _cx| {
// Get region - from credentials or directly from settings
let region = state
.credentials
.as_ref()
.map(|s| s.region.clone())
.unwrap_or(String::from("us-east-1"));
let credentials_region = state.credentials.as_ref().map(|s| s.region.clone());
let settings_region = state.settings.as_ref().and_then(|s| s.region.clone());
region
// Use credentials region if available, otherwise use settings region, finally fall back to default
credentials_region
.or(settings_region)
.unwrap_or(String::from("us-east-1"))
}) else {
return async move {
anyhow::bail!("App State Dropped");

View File

@@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore};
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::{collections::BTreeMap, sync::Arc};
use std::{collections::HashMap, sync::Arc};
use ui::{ButtonLike, Indicator, List, prelude::*};
use util::ResultExt;
@@ -201,7 +201,7 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
}
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let mut models: BTreeMap<String, ollama::Model> = BTreeMap::default();
let mut models: HashMap<String, ollama::Model> = HashMap::new();
// Add models from the Ollama API
for model in self.state.read(cx).available_models.iter() {
@@ -228,7 +228,7 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
);
}
models
let mut models = models
.into_values()
.map(|model| {
Arc::new(OllamaLanguageModel {
@@ -238,7 +238,9 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
request_limiter: RateLimiter::new(4),
}) as Arc<dyn LanguageModel>
})
.collect()
.collect::<Vec<_>>();
models.sort_by_key(|model| model.name());
models
}
fn load_model(&self, model: Arc<dyn LanguageModel>, cx: &App) {

View File

@@ -0,0 +1,788 @@
use anyhow::{Context as _, Result, anyhow};
use collections::HashMap;
use credentials_provider::CredentialsProvider;
use editor::{Editor, EditorElement, EditorStyle};
use futures::{FutureExt, Stream, StreamExt, future::BoxFuture};
use gpui::{
AnyView, App, AsyncApp, Context, Entity, FontStyle, Subscription, Task, TextStyle, WhiteSpace,
};
use http_client::HttpClient;
use language_model::{
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
RateLimiter, Role, StopReason,
};
use open_router::{Model, ResponseStreamEvent, list_models, stream_completion};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore};
use std::pin::Pin;
use std::str::FromStr as _;
use std::sync::Arc;
use theme::ThemeSettings;
use ui::{Icon, IconName, List, Tooltip, prelude::*};
use util::ResultExt;
use crate::{AllLanguageModelSettings, ui::InstructionListItem};
const PROVIDER_ID: &str = "openrouter";
const PROVIDER_NAME: &str = "OpenRouter";
#[derive(Default, Clone, Debug, PartialEq)]
pub struct OpenRouterSettings {
pub api_url: String,
pub available_models: Vec<AvailableModel>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct AvailableModel {
pub name: String,
pub display_name: Option<String>,
pub max_tokens: usize,
pub max_output_tokens: Option<u32>,
pub max_completion_tokens: Option<u32>,
}
pub struct OpenRouterLanguageModelProvider {
http_client: Arc<dyn HttpClient>,
state: gpui::Entity<State>,
}
pub struct State {
api_key: Option<String>,
api_key_from_env: bool,
http_client: Arc<dyn HttpClient>,
available_models: Vec<open_router::Model>,
fetch_models_task: Option<Task<Result<()>>>,
_subscription: Subscription,
}
const OPENROUTER_API_KEY_VAR: &str = "OPENROUTER_API_KEY";
impl State {
fn is_authenticated(&self) -> bool {
self.api_key.is_some()
}
fn reset_api_key(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
let credentials_provider = <dyn CredentialsProvider>::global(cx);
let api_url = AllLanguageModelSettings::get_global(cx)
.open_router
.api_url
.clone();
cx.spawn(async move |this, cx| {
credentials_provider
.delete_credentials(&api_url, &cx)
.await
.log_err();
this.update(cx, |this, cx| {
this.api_key = None;
this.api_key_from_env = false;
cx.notify();
})
})
}
fn set_api_key(&mut self, api_key: String, cx: &mut Context<Self>) -> Task<Result<()>> {
let credentials_provider = <dyn CredentialsProvider>::global(cx);
let api_url = AllLanguageModelSettings::get_global(cx)
.open_router
.api_url
.clone();
cx.spawn(async move |this, cx| {
credentials_provider
.write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx)
.await
.log_err();
this.update(cx, |this, cx| {
this.api_key = Some(api_key);
cx.notify();
})
})
}
fn authenticate(&self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
if self.is_authenticated() {
return Task::ready(Ok(()));
}
let credentials_provider = <dyn CredentialsProvider>::global(cx);
let api_url = AllLanguageModelSettings::get_global(cx)
.open_router
.api_url
.clone();
cx.spawn(async move |this, cx| {
let (api_key, from_env) = if let Ok(api_key) = std::env::var(OPENROUTER_API_KEY_VAR) {
(api_key, true)
} else {
let (_, api_key) = credentials_provider
.read_credentials(&api_url, &cx)
.await?
.ok_or(AuthenticateError::CredentialsNotFound)?;
(
String::from_utf8(api_key)
.context(format!("invalid {} API key", PROVIDER_NAME))?,
false,
)
};
this.update(cx, |this, cx| {
this.api_key = Some(api_key);
this.api_key_from_env = from_env;
cx.notify();
})?;
Ok(())
})
}
fn fetch_models(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
let settings = &AllLanguageModelSettings::get_global(cx).open_router;
let http_client = self.http_client.clone();
let api_url = settings.api_url.clone();
cx.spawn(async move |this, cx| {
let models = list_models(http_client.as_ref(), &api_url).await?;
this.update(cx, |this, cx| {
this.available_models = models;
cx.notify();
})
})
}
fn restart_fetch_models_task(&mut self, cx: &mut Context<Self>) {
let task = self.fetch_models(cx);
self.fetch_models_task.replace(task);
}
}
impl OpenRouterLanguageModelProvider {
pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
let state = cx.new(|cx| State {
api_key: None,
api_key_from_env: false,
http_client: http_client.clone(),
available_models: Vec::new(),
fetch_models_task: None,
_subscription: cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
this.restart_fetch_models_task(cx);
cx.notify();
}),
});
Self { http_client, state }
}
fn create_language_model(&self, model: open_router::Model) -> Arc<dyn LanguageModel> {
Arc::new(OpenRouterLanguageModel {
id: LanguageModelId::from(model.id().to_string()),
model,
state: self.state.clone(),
http_client: self.http_client.clone(),
request_limiter: RateLimiter::new(4),
})
}
}
impl LanguageModelProviderState for OpenRouterLanguageModelProvider {
type ObservableEntity = State;
fn observable_entity(&self) -> Option<gpui::Entity<Self::ObservableEntity>> {
Some(self.state.clone())
}
}
impl LanguageModelProvider for OpenRouterLanguageModelProvider {
fn id(&self) -> LanguageModelProviderId {
LanguageModelProviderId(PROVIDER_ID.into())
}
fn name(&self) -> LanguageModelProviderName {
LanguageModelProviderName(PROVIDER_NAME.into())
}
fn icon(&self) -> IconName {
IconName::AiOpenRouter
}
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
Some(self.create_language_model(open_router::Model::default()))
}
fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
Some(self.create_language_model(open_router::Model::default_fast()))
}
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let mut models_from_api = self.state.read(cx).available_models.clone();
let mut settings_models = Vec::new();
for model in &AllLanguageModelSettings::get_global(cx)
.open_router
.available_models
{
settings_models.push(open_router::Model {
name: model.name.clone(),
display_name: model.display_name.clone(),
max_tokens: model.max_tokens,
supports_tools: Some(false),
});
}
for settings_model in &settings_models {
if let Some(pos) = models_from_api
.iter()
.position(|m| m.name == settings_model.name)
{
models_from_api[pos] = settings_model.clone();
} else {
models_from_api.push(settings_model.clone());
}
}
models_from_api
.into_iter()
.map(|model| self.create_language_model(model))
.collect()
}
fn is_authenticated(&self, cx: &App) -> bool {
self.state.read(cx).is_authenticated()
}
fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
self.state.update(cx, |state, cx| state.authenticate(cx))
}
fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView {
cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
.into()
}
fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
self.state.update(cx, |state, cx| state.reset_api_key(cx))
}
}
pub struct OpenRouterLanguageModel {
id: LanguageModelId,
model: open_router::Model,
state: gpui::Entity<State>,
http_client: Arc<dyn HttpClient>,
request_limiter: RateLimiter,
}
impl OpenRouterLanguageModel {
fn stream_completion(
&self,
request: open_router::Request,
cx: &AsyncApp,
) -> BoxFuture<'static, Result<futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>>>
{
let http_client = self.http_client.clone();
let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| {
let settings = &AllLanguageModelSettings::get_global(cx).open_router;
(state.api_key.clone(), settings.api_url.clone())
}) else {
return futures::future::ready(Err(anyhow!(
"App state dropped: Unable to read API key or API URL from the application state"
)))
.boxed();
};
let future = self.request_limiter.stream(async move {
let api_key = api_key.ok_or_else(|| anyhow!("Missing OpenRouter API Key"))?;
let request = stream_completion(http_client.as_ref(), &api_url, &api_key, request);
let response = request.await?;
Ok(response)
});
async move { Ok(future.await?.boxed()) }.boxed()
}
}
impl LanguageModel for OpenRouterLanguageModel {
fn id(&self) -> LanguageModelId {
self.id.clone()
}
fn name(&self) -> LanguageModelName {
LanguageModelName::from(self.model.display_name().to_string())
}
fn provider_id(&self) -> LanguageModelProviderId {
LanguageModelProviderId(PROVIDER_ID.into())
}
fn provider_name(&self) -> LanguageModelProviderName {
LanguageModelProviderName(PROVIDER_NAME.into())
}
fn supports_tools(&self) -> bool {
self.model.supports_tool_calls()
}
fn telemetry_id(&self) -> String {
format!("openrouter/{}", self.model.id())
}
fn max_token_count(&self) -> usize {
self.model.max_token_count()
}
fn max_output_tokens(&self) -> Option<u32> {
self.model.max_output_tokens()
}
fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
match choice {
LanguageModelToolChoice::Auto => true,
LanguageModelToolChoice::Any => true,
LanguageModelToolChoice::None => true,
}
}
fn supports_images(&self) -> bool {
false
}
fn count_tokens(
&self,
request: LanguageModelRequest,
cx: &App,
) -> BoxFuture<'static, Result<usize>> {
count_open_router_tokens(request, self.model.clone(), cx)
}
fn stream_completion(
&self,
request: LanguageModelRequest,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
futures::stream::BoxStream<
'static,
Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
>,
>,
> {
let request = into_open_router(request, &self.model, self.max_output_tokens());
let completions = self.stream_completion(request, cx);
async move {
let mapper = OpenRouterEventMapper::new();
Ok(mapper.map_stream(completions.await?).boxed())
}
.boxed()
}
}
pub fn into_open_router(
request: LanguageModelRequest,
model: &Model,
max_output_tokens: Option<u32>,
) -> open_router::Request {
let mut messages = Vec::new();
for req_message in request.messages {
for content in req_message.content {
match content {
MessageContent::Text(text) | MessageContent::Thinking { text, .. } => messages
.push(match req_message.role {
Role::User => open_router::RequestMessage::User { content: text },
Role::Assistant => open_router::RequestMessage::Assistant {
content: Some(text),
tool_calls: Vec::new(),
},
Role::System => open_router::RequestMessage::System { content: text },
}),
MessageContent::RedactedThinking(_) => {}
MessageContent::Image(_) => {}
MessageContent::ToolUse(tool_use) => {
let tool_call = open_router::ToolCall {
id: tool_use.id.to_string(),
content: open_router::ToolCallContent::Function {
function: open_router::FunctionContent {
name: tool_use.name.to_string(),
arguments: serde_json::to_string(&tool_use.input)
.unwrap_or_default(),
},
},
};
if let Some(open_router::RequestMessage::Assistant { tool_calls, .. }) =
messages.last_mut()
{
tool_calls.push(tool_call);
} else {
messages.push(open_router::RequestMessage::Assistant {
content: None,
tool_calls: vec![tool_call],
});
}
}
MessageContent::ToolResult(tool_result) => {
let content = match &tool_result.content {
LanguageModelToolResultContent::Text(text) => {
text.to_string()
}
LanguageModelToolResultContent::Image(_) => {
"[Tool responded with an image, but Zed doesn't support these in Open AI models yet]".to_string()
}
};
messages.push(open_router::RequestMessage::Tool {
content: content,
tool_call_id: tool_result.tool_use_id.to_string(),
});
}
}
}
}
open_router::Request {
model: model.id().into(),
messages,
stream: true,
stop: request.stop,
temperature: request.temperature.unwrap_or(0.4),
max_tokens: max_output_tokens,
parallel_tool_calls: if model.supports_parallel_tool_calls() && !request.tools.is_empty() {
Some(false)
} else {
None
},
tools: request
.tools
.into_iter()
.map(|tool| open_router::ToolDefinition::Function {
function: open_router::FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(tool.input_schema),
},
})
.collect(),
tool_choice: request.tool_choice.map(|choice| match choice {
LanguageModelToolChoice::Auto => open_router::ToolChoice::Auto,
LanguageModelToolChoice::Any => open_router::ToolChoice::Required,
LanguageModelToolChoice::None => open_router::ToolChoice::None,
}),
}
}
pub struct OpenRouterEventMapper {
tool_calls_by_index: HashMap<usize, RawToolCall>,
}
impl OpenRouterEventMapper {
pub fn new() -> Self {
Self {
tool_calls_by_index: HashMap::default(),
}
}
pub fn map_stream(
mut self,
events: Pin<Box<dyn Send + Stream<Item = Result<ResponseStreamEvent>>>>,
) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
{
events.flat_map(move |event| {
futures::stream::iter(match event {
Ok(event) => self.map_event(event),
Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))],
})
})
}
pub fn map_event(
&mut self,
event: ResponseStreamEvent,
) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
let Some(choice) = event.choices.first() else {
return vec![Err(LanguageModelCompletionError::Other(anyhow!(
"Response contained no choices"
)))];
};
let mut events = Vec::new();
if let Some(content) = choice.delta.content.clone() {
events.push(Ok(LanguageModelCompletionEvent::Text(content)));
}
if let Some(tool_calls) = choice.delta.tool_calls.as_ref() {
for tool_call in tool_calls {
let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
if let Some(tool_id) = tool_call.id.clone() {
entry.id = tool_id;
}
if let Some(function) = tool_call.function.as_ref() {
if let Some(name) = function.name.clone() {
entry.name = name;
}
if let Some(arguments) = function.arguments.clone() {
entry.arguments.push_str(&arguments);
}
}
}
}
match choice.finish_reason.as_deref() {
Some("stop") => {
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
}
Some("tool_calls") => {
events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
match serde_json::Value::from_str(&tool_call.arguments) {
Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: tool_call.id.clone().into(),
name: tool_call.name.as_str().into(),
is_input_complete: true,
input,
raw_input: tool_call.arguments.clone(),
},
)),
Err(error) => Err(LanguageModelCompletionError::BadInputJson {
id: tool_call.id.into(),
tool_name: tool_call.name.as_str().into(),
raw_input: tool_call.arguments.into(),
json_parse_error: error.to_string(),
}),
}
}));
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
}
Some(stop_reason) => {
log::error!("Unexpected OpenAI stop_reason: {stop_reason:?}",);
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
}
None => {}
}
events
}
}
#[derive(Default)]
struct RawToolCall {
id: String,
name: String,
arguments: String,
}
pub fn count_open_router_tokens(
request: LanguageModelRequest,
_model: open_router::Model,
cx: &App,
) -> BoxFuture<'static, Result<usize>> {
cx.background_spawn(async move {
let messages = request
.messages
.into_iter()
.map(|message| tiktoken_rs::ChatCompletionRequestMessage {
role: match message.role {
Role::User => "user".into(),
Role::Assistant => "assistant".into(),
Role::System => "system".into(),
},
content: Some(message.string_contents()),
name: None,
function_call: None,
})
.collect::<Vec<_>>();
tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages)
})
.boxed()
}
struct ConfigurationView {
api_key_editor: Entity<Editor>,
state: gpui::Entity<State>,
load_credentials_task: Option<Task<()>>,
}
impl ConfigurationView {
fn new(state: gpui::Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let api_key_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor
.set_placeholder_text("sk_or_000000000000000000000000000000000000000000000000", cx);
editor
});
cx.observe(&state, |_, _, cx| {
cx.notify();
})
.detach();
let load_credentials_task = Some(cx.spawn_in(window, {
let state = state.clone();
async move |this, cx| {
if let Some(task) = state
.update(cx, |state, cx| state.authenticate(cx))
.log_err()
{
let _ = task.await;
}
this.update(cx, |this, cx| {
this.load_credentials_task = None;
cx.notify();
})
.log_err();
}
}));
Self {
api_key_editor,
state,
load_credentials_task,
}
}
fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
let api_key = self.api_key_editor.read(cx).text(cx);
if api_key.is_empty() {
return;
}
let state = self.state.clone();
cx.spawn_in(window, async move |_, cx| {
state
.update(cx, |state, cx| state.set_api_key(api_key, cx))?
.await
})
.detach_and_log_err(cx);
cx.notify();
}
fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.api_key_editor
.update(cx, |editor, cx| editor.set_text("", window, cx));
let state = self.state.clone();
cx.spawn_in(window, async move |_, cx| {
state.update(cx, |state, cx| state.reset_api_key(cx))?.await
})
.detach_and_log_err(cx);
cx.notify();
}
fn render_api_key_editor(&self, cx: &mut Context<Self>) -> impl IntoElement {
let settings = ThemeSettings::get_global(cx);
let text_style = TextStyle {
color: cx.theme().colors().text,
font_family: settings.ui_font.family.clone(),
font_features: settings.ui_font.features.clone(),
font_fallbacks: settings.ui_font.fallbacks.clone(),
font_size: rems(0.875).into(),
font_weight: settings.ui_font.weight,
font_style: FontStyle::Normal,
line_height: relative(1.3),
white_space: WhiteSpace::Normal,
..Default::default()
};
EditorElement::new(
&self.api_key_editor,
EditorStyle {
background: cx.theme().colors().editor_background,
local_player: cx.theme().players().local(),
text: text_style,
..Default::default()
},
)
}
fn should_render_editor(&self, cx: &mut Context<Self>) -> bool {
!self.state.read(cx).is_authenticated()
}
}
impl Render for ConfigurationView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let env_var_set = self.state.read(cx).api_key_from_env;
if self.load_credentials_task.is_some() {
div().child(Label::new("Loading credentials...")).into_any()
} else if self.should_render_editor(cx) {
v_flex()
.size_full()
.on_action(cx.listener(Self::save_api_key))
.child(Label::new("To use Zed's assistant with OpenRouter, you need to add an API key. Follow these steps:"))
.child(
List::new()
.child(InstructionListItem::new(
"Create an API key by visiting",
Some("OpenRouter's console"),
Some("https://openrouter.ai/keys"),
))
.child(InstructionListItem::text_only(
"Ensure your OpenRouter account has credits",
))
.child(InstructionListItem::text_only(
"Paste your API key below and hit enter to start using the assistant",
)),
)
.child(
h_flex()
.w_full()
.my_2()
.px_2()
.py_1()
.bg(cx.theme().colors().editor_background)
.border_1()
.border_color(cx.theme().colors().border)
.rounded_sm()
.child(self.render_api_key_editor(cx)),
)
.child(
Label::new(
format!("You can also assign the {OPENROUTER_API_KEY_VAR} environment variable and restart Zed."),
)
.size(LabelSize::Small).color(Color::Muted),
)
.into_any()
} else {
h_flex()
.mt_1()
.p_1()
.justify_between()
.rounded_md()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().background)
.child(
h_flex()
.gap_1()
.child(Icon::new(IconName::Check).color(Color::Success))
.child(Label::new(if env_var_set {
format!("API key set in {OPENROUTER_API_KEY_VAR} environment variable.")
} else {
"API key configured.".to_string()
})),
)
.child(
Button::new("reset-key", "Reset Key")
.label_size(LabelSize::Small)
.icon(Some(IconName::Trash))
.icon_size(IconSize::Small)
.icon_position(IconPosition::Start)
.disabled(env_var_set)
.when(env_var_set, |this| {
this.tooltip(Tooltip::text(format!("To reset your API key, unset the {OPENROUTER_API_KEY_VAR} environment variable.")))
})
.on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))),
)
.into_any()
}
}
}

View File

@@ -20,6 +20,7 @@ use crate::provider::{
mistral::MistralSettings,
ollama::OllamaSettings,
open_ai::OpenAiSettings,
open_router::OpenRouterSettings,
};
/// Initializes the language model settings.
@@ -61,6 +62,7 @@ pub struct AllLanguageModelSettings {
pub bedrock: AmazonBedrockSettings,
pub ollama: OllamaSettings,
pub openai: OpenAiSettings,
pub open_router: OpenRouterSettings,
pub zed_dot_dev: ZedDotDevSettings,
pub google: GoogleSettings,
pub copilot_chat: CopilotChatSettings,
@@ -76,6 +78,7 @@ pub struct AllLanguageModelSettingsContent {
pub ollama: Option<OllamaSettingsContent>,
pub lmstudio: Option<LmStudioSettingsContent>,
pub openai: Option<OpenAiSettingsContent>,
pub open_router: Option<OpenRouterSettingsContent>,
#[serde(rename = "zed.dev")]
pub zed_dot_dev: Option<ZedDotDevSettingsContent>,
pub google: Option<GoogleSettingsContent>,
@@ -271,6 +274,12 @@ pub struct ZedDotDevSettingsContent {
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
pub struct CopilotChatSettingsContent {}
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
pub struct OpenRouterSettingsContent {
pub api_url: Option<String>,
pub available_models: Option<Vec<provider::open_router::AvailableModel>>,
}
impl settings::Settings for AllLanguageModelSettings {
const KEY: Option<&'static str> = Some("language_models");
@@ -409,6 +418,19 @@ impl settings::Settings for AllLanguageModelSettings {
&mut settings.mistral.available_models,
mistral.as_ref().and_then(|s| s.available_models.clone()),
);
// OpenRouter
let open_router = value.open_router.clone();
merge(
&mut settings.open_router.api_url,
open_router.as_ref().and_then(|s| s.api_url.clone()),
);
merge(
&mut settings.open_router.available_models,
open_router
.as_ref()
.and_then(|s| s.available_models.clone()),
);
}
Ok(settings)

View File

@@ -49,6 +49,14 @@ mod tests {
assert_eq!(buffer.text(), expected);
};
// Do not indent after shebang
expect_indents_to(
&mut buffer,
cx,
"#!/usr/bin/env bash\n#",
"#!/usr/bin/env bash\n#",
);
// indent function correctly
expect_indents_to(
&mut buffer,

View File

@@ -29,6 +29,6 @@ brackets = [
### bar
### fi
### ```
increase_indent_pattern = "(\\s*|;)(do|then|in|else|elif)\\b.*$"
decrease_indent_pattern = "(\\s*|;)\\b(fi|done|esac|else|elif)\\b.*$"
increase_indent_pattern = "(^|\\s+|;)(do|then|in|else|elif)\\b.*$"
decrease_indent_pattern = "(^|\\s+|;)(fi|done|esac|else|elif)\\b.*$"
# make sure to test each line mode & block mode

View File

@@ -106,6 +106,24 @@ impl LspAdapter for PythonLspAdapter {
Self::SERVER_NAME.clone()
}
async fn initialization_options(
self: Arc<Self>,
_: &dyn Fs,
_: &Arc<dyn LspAdapterDelegate>,
) -> Result<Option<Value>> {
// Provide minimal initialization options
// Virtual environment configuration will be handled through workspace configuration
Ok(Some(json!({
"python": {
"analysis": {
"autoSearchPaths": true,
"useLibraryCodeForTypes": true,
"autoImportCompletions": true
}
}
})))
}
async fn check_if_user_installed(
&self,
delegate: &dyn LspAdapterDelegate,
@@ -128,9 +146,10 @@ impl LspAdapter for PythonLspAdapter {
let path = node_modules_path.join(NODE_MODULE_RELATIVE_SERVER_PATH);
let env = delegate.shell_env().await;
Some(LanguageServerBinary {
path: node,
env: None,
env: Some(env),
arguments: server_binary_arguments(&path),
})
}
@@ -151,7 +170,7 @@ impl LspAdapter for PythonLspAdapter {
&self,
latest_version: Box<dyn 'static + Send + Any>,
container_dir: PathBuf,
_: &dyn LspAdapterDelegate,
delegate: &dyn LspAdapterDelegate,
) -> Result<LanguageServerBinary> {
let latest_version = latest_version.downcast::<String>().unwrap();
let server_path = container_dir.join(SERVER_PATH);
@@ -163,9 +182,10 @@ impl LspAdapter for PythonLspAdapter {
)
.await?;
let env = delegate.shell_env().await;
Ok(LanguageServerBinary {
path: self.node.binary_path().await?,
env: None,
env: Some(env),
arguments: server_binary_arguments(&server_path),
})
}
@@ -174,7 +194,7 @@ impl LspAdapter for PythonLspAdapter {
&self,
version: &(dyn 'static + Send + Any),
container_dir: &PathBuf,
_: &dyn LspAdapterDelegate,
delegate: &dyn LspAdapterDelegate,
) -> Option<LanguageServerBinary> {
let version = version.downcast_ref::<String>().unwrap();
let server_path = container_dir.join(SERVER_PATH);
@@ -192,9 +212,10 @@ impl LspAdapter for PythonLspAdapter {
if should_install_language_server {
None
} else {
let env = delegate.shell_env().await;
Some(LanguageServerBinary {
path: self.node.binary_path().await.ok()?,
env: None,
env: Some(env),
arguments: server_binary_arguments(&server_path),
})
}
@@ -203,9 +224,11 @@ impl LspAdapter for PythonLspAdapter {
async fn cached_server_binary(
&self,
container_dir: PathBuf,
_: &dyn LspAdapterDelegate,
delegate: &dyn LspAdapterDelegate,
) -> Option<LanguageServerBinary> {
get_cached_server_binary(container_dir, &self.node).await
let mut binary = get_cached_server_binary(container_dir, &self.node).await?;
binary.env = Some(delegate.shell_env().await);
Some(binary)
}
async fn process_completions(&self, items: &mut [lsp::CompletionItem]) {
@@ -308,22 +331,64 @@ impl LspAdapter for PythonLspAdapter {
.and_then(|s| s.settings.clone())
.unwrap_or_default();
// If python.pythonPath is not set in user config, do so using our toolchain picker.
// If we have a detected toolchain, configure Pyright to use it
if let Some(toolchain) = toolchain {
if user_settings.is_null() {
user_settings = Value::Object(serde_json::Map::default());
}
let object = user_settings.as_object_mut().unwrap();
if let Some(python) = object
let interpreter_path = toolchain.path.to_string();
// Detect if this is a virtual environment
if let Some(interpreter_dir) = Path::new(&interpreter_path).parent() {
if let Some(venv_dir) = interpreter_dir.parent() {
// Check if this looks like a virtual environment
if venv_dir.join("pyvenv.cfg").exists()
|| venv_dir.join("bin/activate").exists()
|| venv_dir.join("Scripts/activate.bat").exists()
{
// Set venvPath and venv at the root level
// This matches the format of a pyrightconfig.json file
if let Some(parent) = venv_dir.parent() {
// Use relative path if the venv is inside the workspace
let venv_path = if parent == adapter.worktree_root_path() {
".".to_string()
} else {
parent.to_string_lossy().into_owned()
};
object.insert("venvPath".to_string(), Value::String(venv_path));
}
if let Some(venv_name) = venv_dir.file_name() {
object.insert(
"venv".to_owned(),
Value::String(venv_name.to_string_lossy().into_owned()),
);
}
}
}
}
// Always set the python interpreter path
// Get or create the python section
let python = object
.entry("python")
.or_insert(Value::Object(serde_json::Map::default()))
.as_object_mut()
{
python
.entry("pythonPath")
.or_insert(Value::String(toolchain.path.into()));
}
.unwrap();
// Set both pythonPath and defaultInterpreterPath for compatibility
python.insert(
"pythonPath".to_owned(),
Value::String(interpreter_path.clone()),
);
python.insert(
"defaultInterpreterPath".to_owned(),
Value::String(interpreter_path),
);
}
user_settings
})
}

View File

@@ -31,7 +31,9 @@ use std::{
any::type_name,
borrow::Cow,
cell::{Cell, Ref, RefCell},
cmp, fmt,
cmp,
f32::consts::E,
fmt,
future::Future,
io,
iter::{self, FromIterator},
@@ -43,7 +45,7 @@ use std::{
sync::Arc,
time::{Duration, Instant},
};
use sum_tree::{Bias, Cursor, SumTree, TreeMap};
use sum_tree::{Bias, Cursor, Dimension, SumTree, TreeMap};
use text::{
BufferId, Edit, LineIndent, TextSummary,
locator::Locator,
@@ -281,6 +283,8 @@ impl DiffState {
}
}
// extend
/// The contents of a [`MultiBuffer`] at a single point in time.
#[derive(Clone, Default)]
pub struct MultiBufferSnapshot {
@@ -461,10 +465,15 @@ pub struct ExcerptSummary {
#[derive(Debug, Clone)]
pub struct DiffTransformSummary {
/// Carries summary of all the BufferContent transforms
input: TextSummary,
/// Carries summary of all the BufferContent transforms plus all the DeletedHunk transforms
output: TextSummary,
/// Secret third thing
original: TextSummary,
}
// position in a buffer -> position in the buffer in terms of last git state
#[derive(Clone)]
pub struct MultiBufferRows<'a> {
point: Point,
@@ -506,10 +515,45 @@ pub struct ReversedMultiBufferBytes<'a> {
chunk: &'a [u8],
}
#[derive(Clone)]
struct DiffDimension<D> {
output: OutputDimension<D>,
input: ExcerptDimension<D>,
}
impl<'a, D: TextDimension> sum_tree::Dimension<'a, DiffTransformSummary> for DiffDimension<D> {
fn zero(_: &()) -> Self {
Self {
output: OutputDimension::zero(&()),
input: <ExcerptDimension<D> as Dimension<'_, DiffTransformSummary>>::zero(&()),
}
}
fn add_summary(&mut self, summary: &'a DiffTransformSummary, cx: &()) {
self.output.add_summary(summary, cx);
self.input.add_summary(summary, cx);
}
}
// FIXME
// impl<'a, D: TextDimension> sum_tree::Dimension<'a, ExcerptSummary> for DiffDimension<D> {
// fn zero(cx: &()) -> Self {
// Self {
// output: OutputDimension::zero(cx),
// input: <ExcerptDimension<D> as Dimension<'_, ExcerptSummary>>::zero(&()),
// }
// }
// fn add_summary(&mut self, summary: &'a ExcerptSummary, cx: &()) {
// self.output.add_summary(summary, cx);
// self.input.add_summary(summary, cx);
// }
// }
#[derive(Clone)]
struct MultiBufferCursor<'a, D: TextDimension> {
excerpts: Cursor<'a, Excerpt, ExcerptDimension<D>>,
diff_transforms: Cursor<'a, DiffTransform, (OutputDimension<D>, ExcerptDimension<D>)>,
diff_transforms: Cursor<'a, DiffTransform, DiffDimension<D>>,
diffs: &'a TreeMap<BufferId, BufferDiffSnapshot>,
cached_region: Option<MultiBufferRegion<'a, D>>,
}
@@ -6362,6 +6406,16 @@ impl MultiBufferSnapshot {
prev_transform = Some(item);
}
}
/// translates an anchor in this multibuffer into a corresponding anchor in the diff base buffer
/// for unchanged regions and deleted regions, this is exact (as they exist in the diff base)
/// for added regions, this snaps the position to the start of the addition
pub fn foo(&self, row: MultiBufferRow) -> BufferRow {
// construct a cursor
// seek the cursor to MultiBufferPoint(row, 0)
// read off the `OriginalDimension<Point>` from the cursor
todo!()
}
}
impl<'a, D> MultiBufferCursor<'a, D>
@@ -7131,13 +7185,22 @@ impl sum_tree::Item for DiffTransform {
fn summary(&self, _: &<Self::Summary as sum_tree::Summary>::Context) -> Self::Summary {
match self {
DiffTransform::BufferContent { summary, .. } => DiffTransformSummary {
DiffTransform::BufferContent {
summary,
inserted_hunk_info,
} => DiffTransformSummary {
input: *summary,
output: *summary,
original: if inserted_hunk_info.is_some() {
TextSummary::default()
} else {
*summary
},
},
DiffTransform::DeletedHunk { summary, .. } => DiffTransformSummary {
input: TextSummary::default(),
output: *summary,
original: *summary,
},
}
}
@@ -7156,6 +7219,7 @@ impl sum_tree::Summary for DiffTransformSummary {
DiffTransformSummary {
input: TextSummary::default(),
output: TextSummary::default(),
original: TextSummary::default(),
}
}
@@ -7268,6 +7332,9 @@ struct ExcerptDimension<T>(T);
#[derive(Clone, PartialOrd, Ord, Eq, PartialEq, Debug)]
struct OutputDimension<T>(T);
#[derive(Clone, PartialOrd, Ord, Eq, PartialEq, Debug)]
struct OriginalDimension<T>(T);
impl<'a> sum_tree::Dimension<'a, DiffTransformSummary> for ExcerptOffset {
fn zero(_: &()) -> Self {
ExcerptOffset::new(0)
@@ -7329,6 +7396,20 @@ impl<'a, D: TextDimension> sum_tree::Dimension<'a, DiffTransformSummary> for Out
}
}
impl<'a, D: TextDimension> sum_tree::Dimension<'a, DiffTransformSummary> for OriginalDimension<D> {
fn zero(_: &<DiffTransformSummary as sum_tree::Summary>::Context) -> Self {
OriginalDimension(D::default())
}
fn add_summary(
&mut self,
summary: &'a DiffTransformSummary,
cx: &<DiffTransformSummary as sum_tree::Summary>::Context,
) {
self.0.add_assign(&D::from_text_summary(&summary.original))
}
}
impl<'a> sum_tree::Dimension<'a, DiffTransformSummary> for TextSummary {
fn zero(_: &()) -> Self {
TextSummary::default()

View File

@@ -0,0 +1,25 @@
[package]
name = "open_router"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/open_router.rs"
[features]
default = []
schemars = ["dep:schemars"]
[dependencies]
anyhow.workspace = true
futures.workspace = true
http_client.workspace = true
schemars = { workspace = true, optional = true }
serde.workspace = true
serde_json.workspace = true
workspace-hack.workspace = true

View File

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

View File

@@ -0,0 +1,484 @@
use anyhow::{Context, Result, anyhow};
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::convert::TryFrom;
pub const OPEN_ROUTER_API_URL: &str = "https://openrouter.ai/api/v1";
fn is_none_or_empty<T: AsRef<[U]>, U>(opt: &Option<T>) -> bool {
opt.as_ref().map_or(true, |v| v.as_ref().is_empty())
}
#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
System,
Tool,
}
impl TryFrom<String> for Role {
type Error = anyhow::Error;
fn try_from(value: String) -> Result<Self> {
match value.as_str() {
"user" => Ok(Self::User),
"assistant" => Ok(Self::Assistant),
"system" => Ok(Self::System),
"tool" => Ok(Self::Tool),
_ => Err(anyhow!("invalid role '{value}'")),
}
}
}
impl From<Role> for String {
fn from(val: Role) -> Self {
match val {
Role::User => "user".to_owned(),
Role::Assistant => "assistant".to_owned(),
Role::System => "system".to_owned(),
Role::Tool => "tool".to_owned(),
}
}
}
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Model {
pub name: String,
pub display_name: Option<String>,
pub max_tokens: usize,
pub supports_tools: Option<bool>,
}
impl Model {
pub fn default_fast() -> Self {
Self::new(
"openrouter/auto",
Some("Auto Router"),
Some(2000000),
Some(true),
)
}
pub fn default() -> Self {
Self::default_fast()
}
pub fn new(
name: &str,
display_name: Option<&str>,
max_tokens: Option<usize>,
supports_tools: Option<bool>,
) -> Self {
Self {
name: name.to_owned(),
display_name: display_name.map(|s| s.to_owned()),
max_tokens: max_tokens.unwrap_or(2000000),
supports_tools,
}
}
pub fn id(&self) -> &str {
&self.name
}
pub fn display_name(&self) -> &str {
self.display_name.as_ref().unwrap_or(&self.name)
}
pub fn max_token_count(&self) -> usize {
self.max_tokens
}
pub fn max_output_tokens(&self) -> Option<u32> {
None
}
pub fn supports_tool_calls(&self) -> bool {
self.supports_tools.unwrap_or(false)
}
pub fn supports_parallel_tool_calls(&self) -> bool {
false
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Request {
pub model: String,
pub messages: Vec<RequestMessage>,
pub stream: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stop: Vec<String>,
pub temperature: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ToolDefinition>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ToolChoice {
Auto,
Required,
None,
Other(ToolDefinition),
}
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Deserialize, Serialize, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolDefinition {
#[allow(dead_code)]
Function { function: FunctionDefinition },
}
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FunctionDefinition {
pub name: String,
pub description: Option<String>,
pub parameters: Option<Value>,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
#[serde(tag = "role", rename_all = "lowercase")]
pub enum RequestMessage {
Assistant {
content: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tool_calls: Vec<ToolCall>,
},
User {
content: String,
},
System {
content: String,
},
Tool {
content: String,
tool_call_id: String,
},
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct ToolCall {
pub id: String,
#[serde(flatten)]
pub content: ToolCallContent,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ToolCallContent {
Function { function: FunctionContent },
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct FunctionContent {
pub name: String,
pub arguments: String,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct ResponseMessageDelta {
pub role: Option<Role>,
pub content: Option<String>,
#[serde(default, skip_serializing_if = "is_none_or_empty")]
pub tool_calls: Option<Vec<ToolCallChunk>>,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct ToolCallChunk {
pub index: usize,
pub id: Option<String>,
pub function: Option<FunctionChunk>,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct FunctionChunk {
pub name: Option<String>,
pub arguments: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ChoiceDelta {
pub index: u32,
pub delta: ResponseMessageDelta,
pub finish_reason: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ResponseStreamEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub created: u32,
pub model: String,
pub choices: Vec<ChoiceDelta>,
pub usage: Option<Usage>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Response {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<Choice>,
pub usage: Usage,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Choice {
pub index: u32,
pub message: RequestMessage,
pub finish_reason: Option<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
pub struct ListModelsResponse {
pub data: Vec<ModelEntry>,
}
#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
pub struct ModelEntry {
pub id: String,
pub name: String,
pub created: usize,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_length: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub supported_parameters: Vec<String>,
}
pub async fn complete(
client: &dyn HttpClient,
api_url: &str,
api_key: &str,
request: Request,
) -> Result<Response> {
let uri = format!("{api_url}/chat/completions");
let request_builder = HttpRequest::builder()
.method(Method::POST)
.uri(uri)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", api_key))
.header("HTTP-Referer", "https://zed.dev")
.header("X-Title", "Zed Editor");
let mut request_body = request;
request_body.stream = false;
let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request_body)?))?;
let mut response = client.send(request).await?;
if response.status().is_success() {
let mut body = String::new();
response.body_mut().read_to_string(&mut body).await?;
let response: Response = serde_json::from_str(&body)?;
Ok(response)
} else {
let mut body = String::new();
response.body_mut().read_to_string(&mut body).await?;
#[derive(Deserialize)]
struct OpenRouterResponse {
error: OpenRouterError,
}
#[derive(Deserialize)]
struct OpenRouterError {
message: String,
#[serde(default)]
code: String,
}
match serde_json::from_str::<OpenRouterResponse>(&body) {
Ok(response) if !response.error.message.is_empty() => {
let error_message = if !response.error.code.is_empty() {
format!("{}: {}", response.error.code, response.error.message)
} else {
response.error.message
};
Err(anyhow!(
"Failed to connect to OpenRouter API: {}",
error_message
))
}
_ => Err(anyhow!(
"Failed to connect to OpenRouter API: {} {}",
response.status(),
body,
)),
}
}
}
pub async fn stream_completion(
client: &dyn HttpClient,
api_url: &str,
api_key: &str,
request: Request,
) -> Result<BoxStream<'static, Result<ResponseStreamEvent>>> {
let uri = format!("{api_url}/chat/completions");
let request_builder = HttpRequest::builder()
.method(Method::POST)
.uri(uri)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", api_key))
.header("HTTP-Referer", "https://zed.dev")
.header("X-Title", "Zed Editor");
let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?;
let mut response = client.send(request).await?;
if response.status().is_success() {
let reader = BufReader::new(response.into_body());
Ok(reader
.lines()
.filter_map(|line| async move {
match line {
Ok(line) => {
if line.starts_with(':') {
return None;
}
let line = line.strip_prefix("data: ")?;
if line == "[DONE]" {
None
} else {
match serde_json::from_str::<ResponseStreamEvent>(line) {
Ok(response) => Some(Ok(response)),
Err(error) => {
#[derive(Deserialize)]
struct ErrorResponse {
error: String,
}
match serde_json::from_str::<ErrorResponse>(line) {
Ok(err_response) => Some(Err(anyhow!(err_response.error))),
Err(_) => {
if line.trim().is_empty() {
None
} else {
Some(Err(anyhow!(
"Failed to parse response: {}. Original content: '{}'",
error, line
)))
}
}
}
}
}
}
}
Err(error) => Some(Err(anyhow!(error))),
}
})
.boxed())
} else {
let mut body = String::new();
response.body_mut().read_to_string(&mut body).await?;
#[derive(Deserialize)]
struct OpenRouterResponse {
error: OpenRouterError,
}
#[derive(Deserialize)]
struct OpenRouterError {
message: String,
#[serde(default)]
code: String,
}
match serde_json::from_str::<OpenRouterResponse>(&body) {
Ok(response) if !response.error.message.is_empty() => {
let error_message = if !response.error.code.is_empty() {
format!("{}: {}", response.error.code, response.error.message)
} else {
response.error.message
};
Err(anyhow!(
"Failed to connect to OpenRouter API: {}",
error_message
))
}
_ => Err(anyhow!(
"Failed to connect to OpenRouter API: {} {}",
response.status(),
body,
)),
}
}
}
pub async fn list_models(client: &dyn HttpClient, api_url: &str) -> Result<Vec<Model>> {
let uri = format!("{api_url}/models");
let request_builder = HttpRequest::builder()
.method(Method::GET)
.uri(uri)
.header("Accept", "application/json");
let request = request_builder.body(AsyncBody::default())?;
let mut response = client.send(request).await?;
let mut body = String::new();
response.body_mut().read_to_string(&mut body).await?;
if response.status().is_success() {
let response: ListModelsResponse =
serde_json::from_str(&body).context("Unable to parse OpenRouter models response")?;
let models = response
.data
.into_iter()
.map(|entry| Model {
name: entry.id,
// OpenRouter returns display names in the format "provider_name: model_name".
// When displayed in the UI, these names can get truncated from the right.
// Since users typically already know the provider, we extract just the model name
// portion (after the colon) to create a more concise and user-friendly label
// for the model dropdown in the agent panel.
display_name: Some(
entry
.name
.split(':')
.next_back()
.unwrap_or(&entry.name)
.trim()
.to_string(),
),
max_tokens: entry.context_length.unwrap_or(2000000),
supports_tools: Some(entry.supported_parameters.contains(&"tools".to_string())),
})
.collect();
Ok(models)
} else {
Err(anyhow!(
"Failed to connect to OpenRouter API: {} {}",
response.status(),
body,
))
}
}

View File

@@ -343,6 +343,8 @@ impl Prettier {
prettier_plugin_dir.join("plugin.js"),
// this one is for @prettier/plugin-php
prettier_plugin_dir.join("standalone.js"),
// this one is for prettier-plugin-latex
prettier_plugin_dir.join("dist").join("prettier-plugin-latex.js"),
prettier_plugin_dir,
]
.into_iter()

View File

@@ -770,13 +770,26 @@ pub struct DirectoryItem {
#[derive(Clone)]
pub enum DirectoryLister {
Project(Entity<Project>),
Local(Arc<dyn Fs>),
Local(Entity<Project>, Arc<dyn Fs>),
}
impl std::fmt::Debug for DirectoryLister {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DirectoryLister::Project(project) => {
write!(f, "DirectoryLister::Project({project:?})")
}
DirectoryLister::Local(project, _) => {
write!(f, "DirectoryLister::Local({project:?})")
}
}
}
}
impl DirectoryLister {
pub fn is_local(&self, cx: &App) -> bool {
match self {
DirectoryLister::Local(_) => true,
DirectoryLister::Local(..) => true,
DirectoryLister::Project(project) => project.read(cx).is_local(),
}
}
@@ -790,12 +803,28 @@ impl DirectoryLister {
}
pub fn default_query(&self, cx: &mut App) -> String {
if let DirectoryLister::Project(project) = self {
if let Some(worktree) = project.read(cx).visible_worktrees(cx).next() {
return worktree.read(cx).abs_path().to_string_lossy().to_string();
let separator = std::path::MAIN_SEPARATOR_STR;
match self {
DirectoryLister::Project(project) => project,
DirectoryLister::Local(project, _) => project,
}
.read(cx)
.visible_worktrees(cx)
.next()
.map(|worktree| worktree.read(cx).abs_path())
.map(|dir| dir.to_string_lossy().to_string())
.or_else(|| std::env::home_dir().map(|dir| dir.to_string_lossy().to_string()))
.map(|mut s| {
s.push_str(separator);
s
})
.unwrap_or_else(|| {
if cfg!(target_os = "windows") {
format!("C:{separator}")
} else {
format!("~{separator}")
}
};
format!("~{}", std::path::MAIN_SEPARATOR_STR)
})
}
pub fn list_directory(&self, path: String, cx: &mut App) -> Task<Result<Vec<DirectoryItem>>> {
@@ -803,7 +832,7 @@ impl DirectoryLister {
DirectoryLister::Project(project) => {
project.update(cx, |project, cx| project.list_directory(path, cx))
}
DirectoryLister::Local(fs) => {
DirectoryLister::Local(_, fs) => {
let fs = fs.clone();
cx.background_spawn(async move {
let mut results = vec![];
@@ -4049,7 +4078,7 @@ impl Project {
cx: &mut Context<Self>,
) -> Task<Result<Vec<DirectoryItem>>> {
if self.is_local() {
DirectoryLister::Local(self.fs.clone()).list_directory(query, cx)
DirectoryLister::Local(cx.entity(), self.fs.clone()).list_directory(query, cx)
} else if let Some(session) = self.ssh_client.as_ref() {
let path_buf = PathBuf::from(query);
let request = proto::ListRemoteDirectory {

View File

@@ -11,6 +11,7 @@ use buffer_diff::{
use fs::FakeFs;
use futures::{StreamExt, future};
use git::{
GitHostingProviderRegistry,
repository::RepoPath,
status::{StatusCode, TrackedStatus},
};
@@ -216,6 +217,71 @@ async fn test_editorconfig_support(cx: &mut gpui::TestAppContext) {
});
}
#[gpui::test]
async fn test_git_provider_project_setting(cx: &mut gpui::TestAppContext) {
init_test(cx);
cx.update(|cx| {
GitHostingProviderRegistry::default_global(cx);
git_hosting_providers::init(cx);
});
let fs = FakeFs::new(cx.executor());
let str_path = path!("/dir");
let path = Path::new(str_path);
fs.insert_tree(
path!("/dir"),
json!({
".zed": {
"settings.json": r#"{
"git_hosting_providers": [
{
"provider": "gitlab",
"base_url": "https://google.com",
"name": "foo"
}
]
}"#
},
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let (_worktree, _) =
project.read_with(cx, |project, cx| project.find_worktree(path, cx).unwrap());
cx.executor().run_until_parked();
cx.update(|cx| {
let provider = GitHostingProviderRegistry::global(cx);
assert!(
provider
.list_hosting_providers()
.into_iter()
.any(|provider| provider.name() == "foo")
);
});
fs.atomic_write(
Path::new(path!("/dir/.zed/settings.json")).to_owned(),
"{}".into(),
)
.await
.unwrap();
cx.run_until_parked();
cx.update(|cx| {
let provider = GitHostingProviderRegistry::global(cx);
assert!(
!provider
.list_hosting_providers()
.into_iter()
.any(|provider| provider.name() == "foo")
);
});
}
#[gpui::test]
async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) {
init_test(cx);

View File

@@ -22,7 +22,7 @@ use gpui::{
Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy,
Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions,
anchored, deferred, div, impl_actions, point, px, size, uniform_list,
anchored, deferred, div, impl_actions, point, px, size, transparent_white, uniform_list,
};
use indexmap::IndexMap;
use language::DiagnosticSeverity;
@@ -85,8 +85,7 @@ pub struct ProjectPanel {
ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
last_worktree_root_id: Option<ProjectEntryId>,
last_selection_drag_over_entry: Option<ProjectEntryId>,
last_external_paths_drag_over_entry: Option<ProjectEntryId>,
drag_target_entry: Option<DragTargetEntry>,
expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
unfolded_dir_ids: HashSet<ProjectEntryId>,
// Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
@@ -112,6 +111,13 @@ pub struct ProjectPanel {
hover_expand_task: Option<Task<()>>,
}
struct DragTargetEntry {
/// The entry currently under the mouse cursor during a drag operation
entry_id: ProjectEntryId,
/// Highlight this entry along with all of its children
highlight_entry_id: Option<ProjectEntryId>,
}
#[derive(Copy, Clone, Debug)]
struct FoldedDirectoryDragTarget {
entry_id: ProjectEntryId,
@@ -472,9 +478,8 @@ impl ProjectPanel {
visible_entries: Default::default(),
ancestors: Default::default(),
folded_directory_drag_target: None,
drag_target_entry: None,
last_worktree_root_id: Default::default(),
last_external_paths_drag_over_entry: None,
last_selection_drag_over_entry: None,
expanded_dir_ids: Default::default(),
unfolded_dir_ids: Default::default(),
selection: None,
@@ -3703,6 +3708,67 @@ impl ProjectPanel {
(depth, difference)
}
fn highlight_entry_for_external_drag(
&self,
target_entry: &Entry,
target_worktree: &Worktree,
) -> Option<ProjectEntryId> {
// Always highlight directory or parent directory if it's file
if target_entry.is_dir() {
Some(target_entry.id)
} else if let Some(parent_entry) = target_entry
.path
.parent()
.and_then(|parent_path| target_worktree.entry_for_path(parent_path))
{
Some(parent_entry.id)
} else {
None
}
}
fn highlight_entry_for_selection_drag(
&self,
target_entry: &Entry,
target_worktree: &Worktree,
dragged_selection: &DraggedSelection,
cx: &Context<Self>,
) -> Option<ProjectEntryId> {
let target_parent_path = target_entry.path.parent();
// In case of single item drag, we do not highlight existing
// directory which item belongs too
if dragged_selection.items().count() == 1 {
let active_entry_path = self
.project
.read(cx)
.path_for_entry(dragged_selection.active_selection.entry_id, cx)?;
if let Some(active_parent_path) = active_entry_path.path.parent() {
// Do not highlight active entry parent
if active_parent_path == target_entry.path.as_ref() {
return None;
}
// Do not highlight active entry sibling files
if Some(active_parent_path) == target_parent_path && target_entry.is_file() {
return None;
}
}
}
// Always highlight directory or parent directory if it's file
if target_entry.is_dir() {
Some(target_entry.id)
} else if let Some(parent_entry) =
target_parent_path.and_then(|parent_path| target_worktree.entry_for_path(parent_path))
{
Some(parent_entry.id)
} else {
None
}
}
fn render_entry(
&self,
entry_id: ProjectEntryId,
@@ -3745,6 +3811,8 @@ impl ProjectPanel {
.as_ref()
.map(|f| f.to_string_lossy().to_string());
let path = details.path.clone();
let path_for_external_paths = path.clone();
let path_for_dragged_selection = path.clone();
let depth = details.depth;
let worktree_id = details.worktree_id;
@@ -3802,6 +3870,27 @@ impl ProjectPanel {
};
let folded_directory_drag_target = self.folded_directory_drag_target;
let is_highlighted = {
if let Some(highlight_entry_id) = self
.drag_target_entry
.as_ref()
.and_then(|drag_target| drag_target.highlight_entry_id)
{
// Highlight if same entry or it's children
if entry_id == highlight_entry_id {
true
} else {
maybe!({
let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
let highlight_entry = worktree.read(cx).entry_for_id(highlight_entry_id)?;
Some(path.starts_with(&highlight_entry.path))
})
.unwrap_or(false)
}
} else {
false
}
};
div()
.id(entry_id.to_proto() as usize)
@@ -3815,95 +3904,111 @@ impl ProjectPanel {
.hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
.on_drag_move::<ExternalPaths>(cx.listener(
move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
if event.bounds.contains(&event.event.position) {
if this.last_external_paths_drag_over_entry == Some(entry_id) {
return;
let is_current_target = this.drag_target_entry.as_ref()
.map(|entry| entry.entry_id) == Some(entry_id);
if !event.bounds.contains(&event.event.position) {
// Entry responsible for setting drag target is also responsible to
// clear it up after drag is out of bounds
if is_current_target {
this.drag_target_entry = None;
}
this.last_external_paths_drag_over_entry = Some(entry_id);
this.marked_entries.clear();
let Some((worktree, path, entry)) = maybe!({
let worktree = this
.project
.read(cx)
.worktree_for_id(selection.worktree_id, cx)?;
let worktree = worktree.read(cx);
let entry = worktree.entry_for_path(&path)?;
let path = if entry.is_dir() {
path.as_ref()
} else {
path.parent()?
};
Some((worktree, path, entry))
}) else {
return;
};
this.marked_entries.insert(SelectedEntry {
entry_id: entry.id,
worktree_id: worktree.id(),
});
for entry in worktree.child_entries(path) {
this.marked_entries.insert(SelectedEntry {
entry_id: entry.id,
worktree_id: worktree.id(),
});
}
cx.notify();
return;
}
if is_current_target {
return;
}
let Some((entry_id, highlight_entry_id)) = maybe!({
let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?;
let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree);
Some((target_entry.id, highlight_entry_id))
}) else {
return;
};
this.drag_target_entry = Some(DragTargetEntry {
entry_id,
highlight_entry_id,
});
this.marked_entries.clear();
},
))
.on_drop(cx.listener(
move |this, external_paths: &ExternalPaths, window, cx| {
this.drag_target_entry = None;
this.hover_scroll_task.take();
this.last_external_paths_drag_over_entry = None;
this.marked_entries.clear();
this.drop_external_files(external_paths.paths(), entry_id, window, cx);
cx.stop_propagation();
},
))
.on_drag_move::<DraggedSelection>(cx.listener(
move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
if event.bounds.contains(&event.event.position) {
if this.last_selection_drag_over_entry == Some(entry_id) {
return;
}
this.last_selection_drag_over_entry = Some(entry_id);
this.hover_expand_task.take();
let is_current_target = this.drag_target_entry.as_ref()
.map(|entry| entry.entry_id) == Some(entry_id);
if !kind.is_dir()
|| this
.expanded_dir_ids
.get(&details.worktree_id)
.map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
{
return;
if !event.bounds.contains(&event.event.position) {
// Entry responsible for setting drag target is also responsible to
// clear it up after drag is out of bounds
if is_current_target {
this.drag_target_entry = None;
}
let bounds = event.bounds;
this.hover_expand_task =
Some(cx.spawn_in(window, async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(500))
.await;
this.update_in(cx, |this, window, cx| {
this.hover_expand_task.take();
if this.last_selection_drag_over_entry == Some(entry_id)
&& bounds.contains(&window.mouse_position())
{
this.expand_entry(worktree_id, entry_id, cx);
this.update_visible_entries(
Some((worktree_id, entry_id)),
cx,
);
cx.notify();
}
})
.ok();
}));
return;
}
if is_current_target {
return;
}
let Some((entry_id, highlight_entry_id)) = maybe!({
let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?;
let dragged_selection = event.drag(cx);
let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, dragged_selection, cx);
Some((target_entry.id, highlight_entry_id))
}) else {
return;
};
this.drag_target_entry = Some(DragTargetEntry {
entry_id,
highlight_entry_id,
});
this.marked_entries.clear();
this.hover_expand_task.take();
if !kind.is_dir()
|| this
.expanded_dir_ids
.get(&details.worktree_id)
.map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
{
return;
}
let bounds = event.bounds;
this.hover_expand_task =
Some(cx.spawn_in(window, async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(500))
.await;
this.update_in(cx, |this, window, cx| {
this.hover_expand_task.take();
if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id)
&& bounds.contains(&window.mouse_position())
{
this.expand_entry(worktree_id, entry_id, cx);
this.update_visible_entries(
Some((worktree_id, entry_id)),
cx,
);
cx.notify();
}
})
.ok();
}));
},
))
.on_drag(
@@ -3917,14 +4022,10 @@ impl ProjectPanel {
})
},
)
.drag_over::<DraggedSelection>(move |style, _, _, _| {
if folded_directory_drag_target.is_some() {
return style;
}
style.bg(item_colors.drag_over)
})
.when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over))
.on_drop(
cx.listener(move |this, selections: &DraggedSelection, window, cx| {
this.drag_target_entry = None;
this.hover_scroll_task.take();
this.hover_expand_task.take();
if folded_directory_drag_target.is_some() {
@@ -4126,6 +4227,7 @@ impl ProjectPanel {
div()
.on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
this.hover_scroll_task.take();
this.drag_target_entry = None;
this.folded_directory_drag_target = None;
if let Some(target_entry_id) = target_entry_id {
this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
@@ -4208,6 +4310,7 @@ impl ProjectPanel {
))
.on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
this.hover_scroll_task.take();
this.drag_target_entry = None;
this.folded_directory_drag_target = None;
if let Some(target_entry_id) = target_entry_id {
this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
@@ -4573,13 +4676,14 @@ impl Render for ProjectPanel {
.map(|(_, worktree_entries, _)| worktree_entries.len())
.sum();
fn handle_drag_move_scroll<T: 'static>(
fn handle_drag_move<T: 'static>(
this: &mut ProjectPanel,
e: &DragMoveEvent<T>,
window: &mut Window,
cx: &mut Context<ProjectPanel>,
) {
if !e.bounds.contains(&e.event.position) {
this.drag_target_entry = None;
return;
}
this.hover_scroll_task.take();
@@ -4633,8 +4737,8 @@ impl Render for ProjectPanel {
h_flex()
.id("project-panel")
.group("project-panel")
.on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
.on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
.on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
.on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
.size_full()
.relative()
.on_hover(cx.listener(|this, hovered, window, cx| {
@@ -4890,8 +4994,7 @@ impl Render for ProjectPanel {
})
.on_drop(cx.listener(
move |this, external_paths: &ExternalPaths, window, cx| {
this.last_external_paths_drag_over_entry = None;
this.marked_entries.clear();
this.drag_target_entry = None;
this.hover_scroll_task.take();
if let Some(task) = this
.workspace

View File

@@ -5098,6 +5098,205 @@ async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
);
}
#[gpui::test]
async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"dir1": {
"file1.txt": "",
"dir2": {
"file2.txt": ""
}
},
"file3.txt": ""
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
panel.update(cx, |panel, cx| {
let project = panel.project.read(cx);
let worktree = project.visible_worktrees(cx).next().unwrap();
let worktree = worktree.read(cx);
// Test 1: Target is a directory, should highlight the directory itself
let dir_entry = worktree.entry_for_path("dir1").unwrap();
let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
assert_eq!(
result,
Some(dir_entry.id),
"Should highlight directory itself"
);
// Test 2: Target is nested file, should highlight immediate parent
let nested_file = worktree.entry_for_path("dir1/dir2/file2.txt").unwrap();
let nested_parent = worktree.entry_for_path("dir1/dir2").unwrap();
let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
assert_eq!(
result,
Some(nested_parent.id),
"Should highlight immediate parent"
);
// Test 3: Target is root level file, should highlight root
let root_file = worktree.entry_for_path("file3.txt").unwrap();
let result = panel.highlight_entry_for_external_drag(root_file, worktree);
assert_eq!(
result,
Some(worktree.root_entry().unwrap().id),
"Root level file should return None"
);
// Test 4: Target is root itself, should highlight root
let root_entry = worktree.root_entry().unwrap();
let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
assert_eq!(
result,
Some(root_entry.id),
"Root level file should return None"
);
});
}
#[gpui::test]
async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"parent_dir": {
"child_file.txt": "",
"sibling_file.txt": "",
"child_dir": {
"nested_file.txt": ""
}
},
"other_dir": {
"other_file.txt": ""
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
panel.update(cx, |panel, cx| {
let project = panel.project.read(cx);
let worktree = project.visible_worktrees(cx).next().unwrap();
let worktree_id = worktree.read(cx).id();
let worktree = worktree.read(cx);
let parent_dir = worktree.entry_for_path("parent_dir").unwrap();
let child_file = worktree
.entry_for_path("parent_dir/child_file.txt")
.unwrap();
let sibling_file = worktree
.entry_for_path("parent_dir/sibling_file.txt")
.unwrap();
let child_dir = worktree.entry_for_path("parent_dir/child_dir").unwrap();
let other_dir = worktree.entry_for_path("other_dir").unwrap();
let other_file = worktree.entry_for_path("other_dir/other_file.txt").unwrap();
// Test 1: Single item drag, don't highlight parent directory
let dragged_selection = DraggedSelection {
active_selection: SelectedEntry {
worktree_id,
entry_id: child_file.id,
},
marked_selections: Arc::new(BTreeSet::from([SelectedEntry {
worktree_id,
entry_id: child_file.id,
}])),
};
let result =
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
assert_eq!(result, None, "Should not highlight parent of dragged item");
// Test 2: Single item drag, don't highlight sibling files
let result = panel.highlight_entry_for_selection_drag(
sibling_file,
worktree,
&dragged_selection,
cx,
);
assert_eq!(result, None, "Should not highlight sibling files");
// Test 3: Single item drag, highlight unrelated directory
let result =
panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
assert_eq!(
result,
Some(other_dir.id),
"Should highlight unrelated directory"
);
// Test 4: Single item drag, highlight sibling directory
let result =
panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
assert_eq!(
result,
Some(child_dir.id),
"Should highlight sibling directory"
);
// Test 5: Multiple items drag, highlight parent directory
let dragged_selection = DraggedSelection {
active_selection: SelectedEntry {
worktree_id,
entry_id: child_file.id,
},
marked_selections: Arc::new(BTreeSet::from([
SelectedEntry {
worktree_id,
entry_id: child_file.id,
},
SelectedEntry {
worktree_id,
entry_id: sibling_file.id,
},
])),
};
let result =
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
assert_eq!(
result,
Some(parent_dir.id),
"Should highlight parent with multiple items"
);
// Test 6: Target is file in different directory, highlight parent
let result =
panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
assert_eq!(
result,
Some(other_dir.id),
"Should highlight parent of target file"
);
// Test 7: Target is directory, always highlight
let result =
panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
assert_eq!(
result,
Some(child_dir.id),
"Should always highlight directories"
);
});
}
fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
let path = path.as_ref();
panel.update(cx, |panel, cx| {

View File

@@ -147,7 +147,7 @@ impl ProjectPicker {
) -> Entity<Self> {
let (tx, rx) = oneshot::channel();
let lister = project::DirectoryLister::Project(project.clone());
let delegate = file_finder::OpenPathDelegate::new(tx, lister);
let delegate = file_finder::OpenPathDelegate::new(tx, lister, false);
let picker = cx.new(|cx| {
let picker = Picker::uniform_list(delegate, window, cx)

View File

@@ -261,6 +261,7 @@ impl PickerDelegate for RulePickerDelegate {
let rule = self.matches.get(ix)?;
let default = rule.default;
let prompt_id = rule.id;
let element = ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
@@ -272,9 +273,10 @@ impl PickerDelegate for RulePickerDelegate {
.child(Label::new(rule.title.clone().unwrap_or("Untitled".into()))),
)
.end_slot::<IconButton>(default.then(|| {
IconButton::new("toggle-default-rule", IconName::SparkleFilled)
IconButton::new("toggle-default-rule", IconName::StarFilled)
.toggle_state(true)
.icon_color(Color::Accent)
.icon_size(IconSize::Small)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::text("Remove from Default Rules"))
.on_click(cx.listener(move |_, _, _, cx| {
@@ -283,7 +285,7 @@ impl PickerDelegate for RulePickerDelegate {
}))
.end_hover_slot(
h_flex()
.gap_2()
.gap_1()
.child(if prompt_id.is_built_in() {
div()
.id("built-in-rule")
@@ -299,8 +301,9 @@ impl PickerDelegate for RulePickerDelegate {
})
.into_any()
} else {
IconButton::new("delete-rule", IconName::Trash)
IconButton::new("delete-rule", IconName::TrashAlt)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::text("Delete Rule"))
.on_click(cx.listener(move |_, _, _, cx| {
@@ -309,16 +312,27 @@ impl PickerDelegate for RulePickerDelegate {
.into_any_element()
})
.child(
IconButton::new("toggle-default-rule", IconName::Sparkle)
IconButton::new("toggle-default-rule", IconName::Star)
.toggle_state(default)
.selected_icon(IconName::SparkleFilled)
.selected_icon(IconName::StarFilled)
.icon_color(if default { Color::Accent } else { Color::Muted })
.icon_size(IconSize::Small)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::text(if default {
"Remove from Default Rules"
} else {
"Add to Default Rules"
}))
.map(|this| {
if default {
this.tooltip(Tooltip::text("Remove from Default Rules"))
} else {
this.tooltip(move |window, cx| {
Tooltip::with_meta(
"Add to Default Rules",
None,
"Always included in every thread.",
window,
cx,
)
})
}
})
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
})),
@@ -1008,216 +1022,180 @@ impl RulesLibrary {
.size_full()
.relative()
.overflow_hidden()
.pl(DynamicSpacing::Base16.rems(cx))
.pt(DynamicSpacing::Base08.rems(cx))
.on_click(cx.listener(move |_, _, window, _| {
window.focus(&focus_handle);
}))
.child(
h_flex()
.group("active-editor-header")
.pr(DynamicSpacing::Base16.rems(cx))
.pt(DynamicSpacing::Base02.rems(cx))
.pb(DynamicSpacing::Base08.rems(cx))
.pt_2()
.px_2p5()
.gap_2()
.justify_between()
.child(
h_flex().gap_1().child(
div()
.max_w_80()
.on_action(cx.listener(Self::move_down_from_title))
.border_1()
.border_color(transparent_black())
.rounded_sm()
.group_hover("active-editor-header", |this| {
this.border_color(
cx.theme().colors().border_variant,
)
})
.child(EditorElement::new(
&rule_editor.title_editor,
EditorStyle {
background: cx.theme().system().transparent,
local_player: cx.theme().players().local(),
text: TextStyle {
color: cx
.theme()
.colors()
.editor_foreground,
font_family: settings
.ui_font
.family
.clone(),
font_features: settings
.ui_font
.features
.clone(),
font_size: HeadlineSize::Large
.rems()
.into(),
font_weight: settings.ui_font.weight,
line_height: relative(
settings.buffer_line_height.value(),
),
..Default::default()
},
scrollbar_width: Pixels::ZERO,
syntax: cx.theme().syntax().clone(),
status: cx.theme().status().clone(),
inlay_hints_style:
editor::make_inlay_hints_style(cx),
inline_completion_styles:
editor::make_suggestion_styles(cx),
..EditorStyle::default()
div()
.w_full()
.on_action(cx.listener(Self::move_down_from_title))
.border_1()
.border_color(transparent_black())
.rounded_sm()
.group_hover("active-editor-header", |this| {
this.border_color(cx.theme().colors().border_variant)
})
.child(EditorElement::new(
&rule_editor.title_editor,
EditorStyle {
background: cx.theme().system().transparent,
local_player: cx.theme().players().local(),
text: TextStyle {
color: cx.theme().colors().editor_foreground,
font_family: settings.ui_font.family.clone(),
font_features: settings
.ui_font
.features
.clone(),
font_size: HeadlineSize::Large.rems().into(),
font_weight: settings.ui_font.weight,
line_height: relative(
settings.buffer_line_height.value(),
),
..Default::default()
},
)),
),
scrollbar_width: Pixels::ZERO,
syntax: cx.theme().syntax().clone(),
status: cx.theme().status().clone(),
inlay_hints_style: editor::make_inlay_hints_style(
cx,
),
inline_completion_styles:
editor::make_suggestion_styles(cx),
..EditorStyle::default()
},
)),
)
.child(
h_flex()
.h_full()
.child(
h_flex()
.h_full()
.gap(DynamicSpacing::Base16.rems(cx))
.child(div()),
)
.child(
h_flex()
.h_full()
.gap(DynamicSpacing::Base16.rems(cx))
.children(rule_editor.token_count.map(
|token_count| {
let token_count: SharedString =
token_count.to_string().into();
let label_token_count: SharedString =
token_count.to_string().into();
.flex_shrink_0()
.gap(DynamicSpacing::Base04.rems(cx))
.children(rule_editor.token_count.map(|token_count| {
let token_count: SharedString =
token_count.to_string().into();
let label_token_count: SharedString =
token_count.to_string().into();
h_flex()
.id("token_count")
.tooltip(move |window, cx| {
let token_count =
token_count.clone();
Tooltip::with_meta(
format!(
"{} tokens",
token_count.clone()
),
None,
format!(
"Model: {}",
model
.as_ref()
.map(|model| model
.name()
.0)
.unwrap_or_default()
),
window,
cx,
)
})
.child(
Label::new(format!(
"{} tokens",
label_token_count.clone()
))
.color(Color::Muted),
)
},
))
.child(if prompt_id.is_built_in() {
div()
.id("built-in-rule")
.child(
Icon::new(IconName::FileLock)
.color(Color::Muted),
)
.tooltip(move |window, cx| {
Tooltip::with_meta(
"Built-in rule",
None,
BUILT_IN_TOOLTIP_TEXT,
window,
cx,
)
})
.into_any()
} else {
IconButton::new("delete-rule", IconName::Trash)
.size(ButtonSize::Large)
.style(ButtonStyle::Transparent)
.shape(IconButtonShape::Square)
.size(ButtonSize::Large)
.tooltip(move |window, cx| {
Tooltip::for_action(
"Delete Rule",
&DeleteRule,
window,
cx,
)
})
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(DeleteRule),
cx,
);
})
.into_any_element()
div()
.id("token_count")
.mr_1()
.flex_shrink_0()
.tooltip(move |window, cx| {
Tooltip::with_meta(
"Token Estimation",
None,
format!(
"Model: {}",
model
.as_ref()
.map(|model| model.name().0)
.unwrap_or_default()
),
window,
cx,
)
})
.child(
IconButton::new(
"duplicate-rule",
IconName::BookCopy,
)
.size(ButtonSize::Large)
.style(ButtonStyle::Transparent)
.shape(IconButtonShape::Square)
.size(ButtonSize::Large)
.tooltip(move |window, cx| {
Tooltip::for_action(
"Duplicate Rule",
&DuplicateRule,
window,
cx,
)
})
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(DuplicateRule),
cx,
);
}),
)
.child(
IconButton::new(
"toggle-default-rule",
IconName::Sparkle,
)
.style(ButtonStyle::Transparent)
.toggle_state(rule_metadata.default)
.selected_icon(IconName::SparkleFilled)
.icon_color(if rule_metadata.default {
Color::Accent
} else {
Color::Muted
})
.shape(IconButtonShape::Square)
.size(ButtonSize::Large)
.tooltip(Tooltip::text(
if rule_metadata.default {
"Remove from Default Rules"
} else {
"Add to Default Rules"
},
Label::new(format!(
"{} tokens",
label_token_count.clone()
))
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(ToggleDefaultRule),
cx,
);
}),
),
.color(Color::Muted),
)
}))
.child(if prompt_id.is_built_in() {
div()
.id("built-in-rule")
.child(
Icon::new(IconName::FileLock)
.color(Color::Muted),
)
.tooltip(move |window, cx| {
Tooltip::with_meta(
"Built-in rule",
None,
BUILT_IN_TOOLTIP_TEXT,
window,
cx,
)
})
.into_any()
} else {
IconButton::new("delete-rule", IconName::TrashAlt)
.icon_size(IconSize::Small)
.tooltip(move |window, cx| {
Tooltip::for_action(
"Delete Rule",
&DeleteRule,
window,
cx,
)
})
.on_click(|_, window, cx| {
window
.dispatch_action(Box::new(DeleteRule), cx);
})
.into_any_element()
})
.child(
IconButton::new("duplicate-rule", IconName::BookCopy)
.icon_size(IconSize::Small)
.tooltip(move |window, cx| {
Tooltip::for_action(
"Duplicate Rule",
&DuplicateRule,
window,
cx,
)
})
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(DuplicateRule),
cx,
);
}),
)
.child(
IconButton::new("toggle-default-rule", IconName::Star)
.icon_size(IconSize::Small)
.toggle_state(rule_metadata.default)
.selected_icon(IconName::StarFilled)
.icon_color(if rule_metadata.default {
Color::Accent
} else {
Color::Muted
})
.map(|this| {
if rule_metadata.default {
this.tooltip(Tooltip::text(
"Remove from Default Rules",
))
} else {
this.tooltip(move |window, cx| {
Tooltip::with_meta(
"Add to Default Rules",
None,
"Always included in every thread.",
window,
cx,
)
})
}
})
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(ToggleDefaultRule),
cx,
);
}),
),
),
)
@@ -1228,7 +1206,14 @@ impl RulesLibrary {
.on_action(cx.listener(Self::move_up_from_body))
.flex_grow()
.h_full()
.child(rule_editor.body_editor.clone()),
.child(
h_flex()
.py_2()
.pl_2p5()
.h_full()
.flex_1()
.child(rule_editor.body_editor.clone()),
),
),
)
}))

View File

@@ -250,6 +250,7 @@ trait AnySettingValue: 'static + Send + Sync {
cx: &mut App,
) -> Result<Box<dyn Any>>;
fn value_for_path(&self, path: Option<SettingsLocation>) -> &dyn Any;
fn all_local_values(&self) -> Vec<(WorktreeId, Arc<Path>, &dyn Any)>;
fn set_global_value(&mut self, value: Box<dyn Any>);
fn set_local_value(&mut self, root_id: WorktreeId, path: Arc<Path>, value: Box<dyn Any>);
fn json_schema(
@@ -376,6 +377,24 @@ impl SettingsStore {
.expect("no default value for setting type")
}
/// Get all values from project specific settings
pub fn get_all_locals<T: Settings>(&self) -> Vec<(WorktreeId, Arc<Path>, &T)> {
self.setting_values
.get(&TypeId::of::<T>())
.unwrap_or_else(|| panic!("unregistered setting type {}", type_name::<T>()))
.all_local_values()
.into_iter()
.map(|(id, path, any)| {
(
id,
path,
any.downcast_ref::<T>()
.expect("wrong value type for setting"),
)
})
.collect()
}
/// Override the global value for a setting.
///
/// The given value will be overwritten if the user settings file changes.
@@ -1235,6 +1254,13 @@ impl<T: Settings> AnySettingValue for SettingValue<T> {
(key, value)
}
fn all_local_values(&self) -> Vec<(WorktreeId, Arc<Path>, &dyn Any)> {
self.local_values
.iter()
.map(|(id, path, value)| (*id, path.clone(), value as _))
.collect()
}
fn value_for_path(&self, path: Option<SettingsLocation>) -> &dyn Any {
if let Some(SettingsLocation { worktree_id, path }) = path {
for (settings_root_id, settings_path, value) in self.local_values.iter().rev() {

View File

@@ -264,7 +264,6 @@ async fn deserialize_pane_group(
workspace.clone(),
Some(workspace_id),
project.downgrade(),
false,
window,
cx,
)

View File

@@ -1,9 +1,9 @@
use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine};
use gpui::{
AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, Element,
ElementId, Entity, FocusHandle, Focusable, Font, FontStyle, FontWeight, GlobalElementId,
HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement,
LayoutId, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine,
ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, HighlightStyle,
Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement, LayoutId,
ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine,
StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, UTF16Selection,
UnderlineStyle, WeakEntity, WhiteSpace, Window, WindowTextSystem, div, fill, point, px,
relative, size,
@@ -32,7 +32,7 @@ use workspace::Workspace;
use std::mem;
use std::{fmt::Debug, ops::RangeInclusive, rc::Rc};
use crate::{BlockContext, BlockProperties, TerminalView};
use crate::{BlockContext, BlockProperties, TerminalMode, TerminalView};
/// The information generated during layout that is necessary for painting.
pub struct LayoutState {
@@ -160,7 +160,7 @@ pub struct TerminalElement {
focused: bool,
cursor_visible: bool,
interactivity: Interactivity,
embedded: bool,
mode: TerminalMode,
block_below_cursor: Option<Rc<BlockProperties>>,
}
@@ -181,7 +181,7 @@ impl TerminalElement {
focused: bool,
cursor_visible: bool,
block_below_cursor: Option<Rc<BlockProperties>>,
embedded: bool,
mode: TerminalMode,
) -> TerminalElement {
TerminalElement {
terminal,
@@ -191,7 +191,7 @@ impl TerminalElement {
focus: focus.clone(),
cursor_visible,
block_below_cursor,
embedded,
mode,
interactivity: Default::default(),
}
.track_focus(&focus)
@@ -511,21 +511,20 @@ impl TerminalElement {
},
),
);
self.interactivity.on_scroll_wheel({
let terminal_view = self.terminal_view.downgrade();
move |e, window, cx| {
terminal_view
.update(cx, |terminal_view, cx| {
if !terminal_view.embedded
|| terminal_view.focus_handle(cx).is_focused(window)
{
if !matches!(self.mode, TerminalMode::Embedded { .. }) {
self.interactivity.on_scroll_wheel({
let terminal_view = self.terminal_view.downgrade();
move |e, _window, cx| {
terminal_view
.update(cx, |terminal_view, cx| {
terminal_view.scroll_wheel(e, cx);
cx.notify();
}
})
.ok();
}
});
})
.ok();
}
});
}
// Mouse mode handlers:
// All mouse modes need the extra click handlers
@@ -606,16 +605,6 @@ impl Element for TerminalElement {
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
if self.embedded {
let scrollable = {
let term = self.terminal.read(cx);
!term.scrolled_to_top() && !term.scrolled_to_bottom() && self.focused
};
if scrollable {
self.interactivity.occlude_mouse();
}
}
let layout_id = self.interactivity.request_layout(
global_id,
inspector_id,
@@ -623,8 +612,29 @@ impl Element for TerminalElement {
cx,
|mut style, window, cx| {
style.size.width = relative(1.).into();
style.size.height = relative(1.).into();
// style.overflow = point(Overflow::Hidden, Overflow::Hidden);
match &self.mode {
TerminalMode::Scrollable => {
style.size.height = relative(1.).into();
}
TerminalMode::Embedded { max_lines } => {
let rem_size = window.rem_size();
let line_height = window.text_style().font_size.to_pixels(rem_size)
* TerminalSettings::get_global(cx)
.line_height
.value()
.to_pixels(rem_size)
.0;
let mut line_count = self.terminal.read(cx).total_lines();
if !self.focused {
if let Some(max_lines) = max_lines {
line_count = line_count.min(*max_lines);
}
}
style.size.height = (line_count * line_height).into();
}
}
window.request_layout(style, None, cx)
},
@@ -679,12 +689,13 @@ impl Element for TerminalElement {
let line_height = terminal_settings.line_height.value();
let font_size = if self.embedded {
window.text_style().font_size.to_pixels(window.rem_size())
} else {
terminal_settings
let font_size = match &self.mode {
TerminalMode::Embedded { .. } => {
window.text_style().font_size.to_pixels(window.rem_size())
}
TerminalMode::Scrollable => terminal_settings
.font_size
.map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx))
.map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx)),
};
let theme = cx.theme().clone();

View File

@@ -439,7 +439,6 @@ impl TerminalPanel {
weak_workspace.clone(),
database_id,
project.downgrade(),
false,
window,
cx,
)
@@ -677,7 +676,6 @@ impl TerminalPanel {
workspace.weak_handle(),
workspace.database_id(),
workspace.project().downgrade(),
false,
window,
cx,
)
@@ -718,7 +716,6 @@ impl TerminalPanel {
workspace.weak_handle(),
workspace.database_id(),
workspace.project().downgrade(),
false,
window,
cx,
)

View File

@@ -116,7 +116,7 @@ pub struct TerminalView {
context_menu: Option<(Entity<ContextMenu>, gpui::Point<Pixels>, Subscription)>,
cursor_shape: CursorShape,
blink_state: bool,
embedded: bool,
mode: TerminalMode,
blinking_terminal_enabled: bool,
cwd_serialized: bool,
blinking_paused: bool,
@@ -137,6 +137,15 @@ pub struct TerminalView {
_terminal_subscriptions: Vec<Subscription>,
}
#[derive(Default, Clone)]
pub enum TerminalMode {
#[default]
Scrollable,
Embedded {
max_lines: Option<usize>,
},
}
#[derive(Debug)]
struct HoverTarget {
tooltip: String,
@@ -176,7 +185,6 @@ impl TerminalView {
workspace: WeakEntity<Workspace>,
workspace_id: Option<WorkspaceId>,
project: WeakEntity<Project>,
embedded: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -215,7 +223,7 @@ impl TerminalView {
blink_epoch: 0,
hover: None,
hover_tooltip_update: Task::ready(()),
embedded,
mode: TerminalMode::Scrollable,
workspace_id,
show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
block_below_cursor: None,
@@ -236,6 +244,21 @@ impl TerminalView {
}
}
/// Enable 'embedded' mode where the terminal displays the full content with an optional limit of lines.
pub fn set_embedded_mode(&mut self, max_lines: Option<usize>, cx: &mut Context<Self>) {
self.mode = TerminalMode::Embedded { max_lines };
cx.notify();
}
pub fn is_content_limited(&self, window: &Window) -> bool {
match &self.mode {
TerminalMode::Scrollable => false,
TerminalMode::Embedded { max_lines } => {
!self.focus_handle.is_focused(window) && max_lines.is_some()
}
}
}
/// Sets the marked (pre-edit) text from the IME.
pub(crate) fn set_marked_text(
&mut self,
@@ -820,6 +843,7 @@ impl TerminalView {
fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
if !Self::should_show_scrollbar(cx)
|| !(self.show_scrollbar || self.scrollbar_state.is_dragging())
|| matches!(self.mode, TerminalMode::Embedded { .. })
{
return None;
}
@@ -1467,7 +1491,7 @@ impl Render for TerminalView {
focused,
self.should_show_cursor(focused, cx),
self.block_below_cursor.clone(),
self.embedded,
self.mode.clone(),
))
.when_some(self.render_scrollbar(cx), |div, scrollbar| {
div.child(scrollbar)
@@ -1593,7 +1617,6 @@ impl Item for TerminalView {
self.workspace.clone(),
workspace_id,
self.project.clone(),
false,
window,
cx,
)
@@ -1751,7 +1774,6 @@ impl SerializableItem for TerminalView {
workspace,
Some(workspace_id),
project.downgrade(),
false,
window,
cx,
)

View File

@@ -25,7 +25,7 @@ use gpui::{
use itertools::Itertools;
use language::DiagnosticSeverity;
use parking_lot::Mutex;
use project::{Project, ProjectEntryId, ProjectPath, WorktreeId};
use project::{DirectoryLister, Project, ProjectEntryId, ProjectPath, WorktreeId};
use schemars::JsonSchema;
use serde::Deserialize;
use settings::{Settings, SettingsStore};
@@ -1921,24 +1921,56 @@ impl Pane {
})?
.await?;
} else if can_save_as && is_singleton {
let abs_path = pane.update_in(cx, |pane, window, cx| {
let new_path = pane.update_in(cx, |pane, window, cx| {
pane.activate_item(item_ix, true, true, window, cx);
pane.workspace.update(cx, |workspace, cx| {
workspace.prompt_for_new_path(window, cx)
let lister = if workspace.project().read(cx).is_local() {
DirectoryLister::Local(
workspace.project().clone(),
workspace.app_state().fs.clone(),
)
} else {
DirectoryLister::Project(workspace.project().clone())
};
workspace.prompt_for_new_path(lister, window, cx)
})
})??;
if let Some(abs_path) = abs_path.await.ok().flatten() {
let Some(new_path) = new_path.await.ok().flatten().into_iter().flatten().next()
else {
return Ok(false);
};
let project_path = pane
.update(cx, |pane, cx| {
pane.project
.update(cx, |project, cx| {
project.find_or_create_worktree(new_path, true, cx)
})
.ok()
})
.ok()
.flatten();
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.into(),
};
pane.update_in(cx, |pane, window, cx| {
if let Some(item) = pane.item_for_path(abs_path.clone(), cx) {
if let Some(item) = pane.item_for_path(new_path.clone(), cx) {
pane.remove_item(item.item_id(), false, false, window, cx);
}
item.save_as(project, abs_path, window, cx)
item.save_as(project, new_path, window, cx)
})?
.await?;
} else {
return Ok(false);
}
};
save_task.await?;
return Ok(true);
}
}

View File

@@ -1155,16 +1155,7 @@ mod element {
debug_assert!(flexes.len() == len);
debug_assert!(flex_values_in_bounds(flexes.as_slice()));
let active_pane_magnification = WorkspaceSettings::get(None, cx)
.active_pane_modifiers
.magnification
.and_then(|val| if val == 1.0 { None } else { Some(val) });
let total_flex = if let Some(flex) = active_pane_magnification {
self.children.len() as f32 - 1. + flex
} else {
len as f32
};
let total_flex = len as f32;
let mut origin = bounds.origin;
let space_per_flex = bounds.size.along(self.axis) / total_flex;
@@ -1177,15 +1168,7 @@ mod element {
children: Vec::new(),
};
for (ix, mut child) in mem::take(&mut self.children).into_iter().enumerate() {
let child_flex = active_pane_magnification
.map(|magnification| {
if self.active_pane_ix == Some(ix) {
magnification
} else {
1.
}
})
.unwrap_or_else(|| flexes[ix]);
let child_flex = flexes[ix];
let child_size = bounds
.size
@@ -1214,7 +1197,7 @@ mod element {
}
for (ix, child_layout) in layout.children.iter_mut().enumerate() {
if active_pane_magnification.is_none() && ix < len - 1 {
if ix < len - 1 {
child_layout.handle = Some(Self::layout_handle(
self.axis,
child_layout.bounds,

View File

@@ -899,9 +899,10 @@ pub enum OpenVisible {
type PromptForNewPath = Box<
dyn Fn(
&mut Workspace,
DirectoryLister,
&mut Window,
&mut Context<Workspace>,
) -> oneshot::Receiver<Option<ProjectPath>>,
) -> oneshot::Receiver<Option<Vec<PathBuf>>>,
>;
type PromptForOpenPath = Box<
@@ -1874,25 +1875,25 @@ impl Workspace {
let (tx, rx) = oneshot::channel();
let abs_path = cx.prompt_for_paths(path_prompt_options);
cx.spawn_in(window, async move |this, cx| {
cx.spawn_in(window, async move |workspace, cx| {
let Ok(result) = abs_path.await else {
return Ok(());
};
match result {
Ok(result) => {
tx.send(result).log_err();
tx.send(result).ok();
}
Err(err) => {
let rx = this.update_in(cx, |this, window, cx| {
this.show_portal_error(err.to_string(), cx);
let prompt = this.on_prompt_for_open_path.take().unwrap();
let rx = prompt(this, lister, window, cx);
this.on_prompt_for_open_path = Some(prompt);
let rx = workspace.update_in(cx, |workspace, window, cx| {
workspace.show_portal_error(err.to_string(), cx);
let prompt = workspace.on_prompt_for_open_path.take().unwrap();
let rx = prompt(workspace, lister, window, cx);
workspace.on_prompt_for_open_path = Some(prompt);
rx
})?;
if let Ok(path) = rx.await {
tx.send(path).log_err();
tx.send(path).ok();
}
}
};
@@ -1906,77 +1907,58 @@ impl Workspace {
pub fn prompt_for_new_path(
&mut self,
lister: DirectoryLister,
window: &mut Window,
cx: &mut Context<Self>,
) -> oneshot::Receiver<Option<ProjectPath>> {
if (self.project.read(cx).is_via_collab() || self.project.read(cx).is_via_ssh())
) -> oneshot::Receiver<Option<Vec<PathBuf>>> {
if self.project.read(cx).is_via_collab()
|| self.project.read(cx).is_via_ssh()
|| !WorkspaceSettings::get_global(cx).use_system_path_prompts
{
let prompt = self.on_prompt_for_new_path.take().unwrap();
let rx = prompt(self, window, cx);
let rx = prompt(self, lister, window, cx);
self.on_prompt_for_new_path = Some(prompt);
return rx;
}
let (tx, rx) = oneshot::channel();
cx.spawn_in(window, async move |this, cx| {
let abs_path = this.update(cx, |this, cx| {
let mut relative_to = this
cx.spawn_in(window, async move |workspace, cx| {
let abs_path = workspace.update(cx, |workspace, cx| {
let relative_to = workspace
.most_recent_active_path(cx)
.and_then(|p| p.parent().map(|p| p.to_path_buf()));
if relative_to.is_none() {
let project = this.project.read(cx);
relative_to = project
.visible_worktrees(cx)
.filter_map(|worktree| {
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.or_else(|| {
let project = workspace.project.read(cx);
project.visible_worktrees(cx).find_map(|worktree| {
Some(worktree.read(cx).as_local()?.abs_path().to_path_buf())
})
.next()
};
cx.prompt_for_new_path(&relative_to.unwrap_or_else(|| PathBuf::from("")))
})
.or_else(std::env::home_dir)
.unwrap_or_else(|| PathBuf::from(""));
cx.prompt_for_new_path(&relative_to)
})?;
let abs_path = match abs_path.await? {
Ok(path) => path,
Err(err) => {
let rx = this.update_in(cx, |this, window, cx| {
this.show_portal_error(err.to_string(), cx);
let rx = workspace.update_in(cx, |workspace, window, cx| {
workspace.show_portal_error(err.to_string(), cx);
let prompt = this.on_prompt_for_new_path.take().unwrap();
let rx = prompt(this, window, cx);
this.on_prompt_for_new_path = Some(prompt);
let prompt = workspace.on_prompt_for_new_path.take().unwrap();
let rx = prompt(workspace, lister, window, cx);
workspace.on_prompt_for_new_path = Some(prompt);
rx
})?;
if let Ok(path) = rx.await {
tx.send(path).log_err();
tx.send(path).ok();
}
return anyhow::Ok(());
}
};
let project_path = abs_path.and_then(|abs_path| {
this.update(cx, |this, cx| {
this.project.update(cx, |project, cx| {
project.find_or_create_worktree(abs_path, true, cx)
})
})
.ok()
});
if let Some(project_path) = project_path {
let (worktree, path) = project_path.await?;
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id())?;
tx.send(Some(ProjectPath {
worktree_id,
path: path.into(),
}))
.ok();
} else {
tx.send(None).ok();
}
tx.send(abs_path.map(|path| vec![path])).ok();
anyhow::Ok(())
})
.detach_and_log_err(cx);
.detach();
rx
}

View File

@@ -51,12 +51,6 @@ impl OnLastWindowClosed {
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct ActivePanelModifiers {
/// Scale by which to zoom the active pane.
/// When set to 1.0, the active pane has the same size as others,
/// but when set to a larger value, the active pane takes up more space.
///
/// Default: `1.0`
pub magnification: Option<f32>,
/// Size of the border surrounding the active pane.
/// When set to 0, the active pane doesn't have any border.
/// The border is drawn inset.

View File

@@ -503,7 +503,10 @@ fn register_actions(
directories: true,
multiple: true,
},
DirectoryLister::Local(workspace.app_state().fs.clone()),
DirectoryLister::Local(
workspace.project().clone(),
workspace.app_state().fs.clone(),
),
window,
cx,
);

View File

@@ -126,6 +126,7 @@
- [Scala](./languages/scala.md)
- [Scheme](./languages/scheme.md)
- [Shell Script](./languages/sh.md)
- [SQL](./languages/sql.md)
- [Svelte](./languages/svelte.md)
- [Swift](./languages/swift.md)
- [Tailwind CSS](./languages/tailwindcss.md)

View File

@@ -27,6 +27,6 @@ To sign out of Zed, you can use either of these methods:
## Email
Note that Zed associates your Github _profile email_ with your Zed account, not your _primary email_. We're unable to change the email associated with your Zed account without you changing your profile email.
Note that Zed associates your GitHub _profile email_ with your Zed account, not your _primary email_. We're unable to change the email associated with your Zed account without you changing your profile email.
We _are_ able to update the billing email on your account, if you're a Zed Pro user. See [Updating Billing Information](./ai/billing.md#updating-billing-info) for more

View File

@@ -226,7 +226,9 @@ Zed will also use the `GOOGLE_AI_API_KEY` environment variable if it's defined.
#### Custom Models {#google-ai-custom-models}
By default, Zed will use `stable` versions of models, but you can use specific versions of models, including [experimental models](https://ai.google.dev/gemini-api/docs/models/experimental-models), with the Google AI provider by adding the following to your Zed `settings.json`:
By default, Zed will use `stable` versions of models, but you can use specific versions of models, including [experimental models](https://ai.google.dev/gemini-api/docs/models/experimental-models). You can configure a model to use [thinking mode](https://ai.google.dev/gemini-api/docs/thinking) (if it supports it) by adding a `mode` configuration to your model. This is useful for controlling reasoning token usage and response speed. If not specified, Gemini will automatically choose the thinking budget.
Here is an example of a custom Google AI model you could add to your Zed `settings.json`:
```json
{
@@ -234,9 +236,13 @@ By default, Zed will use `stable` versions of models, but you can use specific v
"google": {
"available_models": [
{
"name": "gemini-1.5-flash-latest",
"display_name": "Gemini 1.5 Flash (Latest)",
"max_tokens": 1000000
"name": "gemini-2.5-flash-preview-05-20",
"display_name": "Gemini 2.5 Flash (Thinking)",
"max_tokens": 1000000,
"mode": {
"type": "thinking",
"budget_tokens": 24000
}
}
]
}

View File

@@ -5,8 +5,17 @@ Currently, Zed supports `.rules` files at the directory's root and the Rules Lib
## `.rules` files
Zed supports including `.rules` files at the top level of worktrees, and act as project-level instructions you'd like to have included in all of your interactions with the Agent Panel.
Other names for this file are also supportedthe first file which matches in this list will be used: `.rules`, `.cursorrules`, `.windsurfrules`, `.clinerules`, `.github/copilot-instructions.md`, or `CLAUDE.md`.
Zed supports including `.rules` files at the top level of worktrees, and act as project-level instructions that are included in all of your interactions with the Agent Panel.
Other names for this file are also supported for compatibility with other agents, but note that the first file which matches in this list will be used:
- `.rules`
- `.cursorrules`
- `.windsurfrules`
- `.clinerules`
- `.github/copilot-instructions.md`
- `AGENT.md`
- `AGENTS.md`
- `CLAUDE.md`
## Rules Library {#rules-library}

View File

@@ -38,23 +38,12 @@ Extensions that provide language servers may also provide default settings for t
```json
{
"active_pane_modifiers": {
"magnification": 1.0,
"border_size": 0.0,
"inactive_opacity": 1.0
}
}
```
### Magnification
- Description: Scale by which to zoom the active pane. When set to `1.0`, the active pane has the same size as others, but when set to a larger value, the active pane takes up more space.
- Setting: `magnification`
- Default: `1.0`
**Options**
`float` values
### Border size
- Description: Size of the border surrounding the active pane. When set to 0, the active pane doesn't have any border. The border is drawn inset.

View File

@@ -22,10 +22,10 @@ Zed supports a variety of debug adapters for different programming languages:
- PHP (xdebug): Provides debugging and profiling capabilities for PHP applications, including remote debugging and code coverage analysis.
- Ruby (rdbg): Provides debugging capabilities for Ruby applications
These adapters enable Zed to provide a consistent debugging experience across multiple languages while leveraging the specific features and capabilities of each debugger.
Additionally, Ruby support (via rdbg) is being actively worked on.
## Getting Started
For basic debugging, you can set up a new configuration by opening the `New Session Modal` either via the `debugger: start` (default: f4) or by clicking the plus icon at the top right of the debug panel.
@@ -34,7 +34,16 @@ For more advanced use cases, you can create debug configurations by directly edi
You can then use the `New Session Modal` to select a configuration and start debugging.
### Configuration
### Launching & Attaching
Zed debugger offers two ways to debug your program; you can either _launch_ a new instance of your program or _attach_ to an existing process.
Which one you choose depends on what you are trying to achieve.
When launching a new instance, Zed (and the underlying debug adapter) can often do a better job at picking up the debug information compared to attaching to an existing process, since it controls the lifetime of a whole program. Running unit tests or a debug build of your application is a good use case for launching.
Compared to launching, attaching to an existing process might seem inferior, but that's far from truth; there are cases where you cannot afford to restart your program, because e.g. the bug is not reproducible outside of a production environment or some other circumstances.
## Configuration
While configuration fields are debug adapter-dependent, most adapters support the following fields:
@@ -58,22 +67,91 @@ While configuration fields are debug adapter-dependent, most adapters support th
]
```
#### Tasks
All configuration fields support task variables. See [Tasks Variables](./tasks.md#variables)
Zed also allows embedding a task that is run before the debugger starts. This is useful for setting up the environment or running any necessary setup steps before the debugger starts.
### Build tasks
See an example [here](#build-binary-then-debug)
#### Python Examples
##### Python Active File
Zed also allows embedding a Zed task in a `build` field that is run before the debugger starts. This is useful for setting up the environment or running any necessary setup steps before the debugger starts.
```json
[
{
"label": "Active File",
"label": "Build Binary",
"adapter": "CodeLLDB",
"program": "path_to_program",
"request": "launch",
"build": {
"command": "make",
"args": ["build", "-j8"]
}
}
]
```
Build tasks can also refer to the existing tasks by unsubstituted label:
```json
[
{
"label": "Build Binary",
"adapter": "CodeLLDB",
"program": "path_to_program",
"request": "launch",
"build": "my build task" // Or "my build task for $ZED_FILE"
}
]
```
### Automatic scenario creation
Given a Zed task, Zed can automatically create a scenario for you. Automatic scenario creation also powers our scenario creation from gutter.
Automatic scenario creation is currently supported for Rust, Go and Python. Javascript/TypeScript support being worked on.
### Example Configurations
#### JavaScript
##### Debug Active File
```json
[
{
"label": "Debug with node",
"adapter": "JavaScript",
"program": "$ZED_FILE",
"request": "launch",
"console": "integratedTerminal",
"type": "pwa-node"
}
]
```
##### Attach debugger to a server running in web browser (`npx serve`)
Given an externally-ran web server (e.g. with `npx serve` or `npx live-server`) one can attach to it and open it with a browser.
```json
[
{
"label": "Inspect ",
"adapter": "JavaScript",
"type": "pwa-chrome",
"request": "launch",
"url": "http://localhost:5500", // Fill your URL here.
"program": "$ZED_FILE",
"webRoot": "${ZED_WORKTREE_ROOT}"
}
]
```
#### Python
##### Debug Active File
```json
[
{
"label": "Python Active File",
"adapter": "Debugpy",
"program": "$ZED_FILE",
"request": "launch"
@@ -85,16 +163,20 @@ See an example [here](#build-binary-then-debug)
For a common Flask Application with a file structure similar to the following:
- .venv/
- app/
- **init**.py
- **main**.py
- routes.py
- templates/
- index.html
- static/
- style.css
- requirements.txt
```
.venv/
app/
init.py
main.py
routes.py
templates/
index.html
static/
style.css
requirements.txt
```
the following configuration can be used:
```json
[
@@ -154,18 +236,46 @@ For a common Flask Application with a file structure similar to the following:
]
```
#### TypeScript
##### Attach debugger to a server running in web browser (`npx serve`)
Given an externally-ran web server (e.g. with `npx serve` or `npx live-server`) one can attach to it and open it with a browser.
```json
[
{
"label": "Launch Chromee (TypeScript)",
"adapter": "JavaScript",
"type": "pwa-chrome",
"request": "launch",
"url": "http://localhost:5500",
"program": "$ZED_FILE",
"webRoot": "${ZED_WORKTREE_ROOT}",
"sourceMaps": true,
"build": {
"command": "npx",
"args": ["tsc"]
}
}
]
```
## Breakpoints
Zed currently supports these types of breakpoints:
To set a breakpoint, simply click next to the line number in the editor gutter.
Breakpoints can be tweaked depending on your needs; to access additional options of a given breakpoint, right-click on the breakpoint icon in the gutter and select the desired option.
At present, you can:
- Standard Breakpoints: Stop at the breakpoint when it's hit
- Log Breakpoints: Output a log message instead of stopping at the breakpoint when it's hit
- Conditional Breakpoints: Stop at the breakpoint when it's hit if the condition is met
- Hit Breakpoints: Stop at the breakpoint when it's hit a certain number of times
- Add a log to a breakpoint, which will output a log message whenever that breakpoint is hit.
- Make the breakpoint conditional, which will only stop at the breakpoint when the condition is met. The syntax for conditions is adapter-specific.
- Add a hit count to a breakpoint, which will only stop at the breakpoint after it's hit a certain number of times.
- Disable a breakpoint, which will prevent it from being hit while leaving it visible in the gutter.
Standard breakpoints can be toggled by left-clicking on the editor gutter or using the Toggle Breakpoint action. Right-clicking on a breakpoint or on a code runner symbol brings up the breakpoint context menu. This has options for toggling breakpoints and editing log breakpoints.
Some debug adapters (e.g. CodeLLDB and JavaScript) will also _verify_ whether your breakpoints can be hit; breakpoints that cannot be hit are surfaced more prominently in the UI.
Other kinds of breakpoints can be toggled/edited by right-clicking on the breakpoint icon in the gutter and selecting the desired option.
All breakpoints enabled for a given project are also listed in "Breakpoints" item in your debugging session UI. From "Breakpoints" item in your UI you can also manage exception breakpoints.
The debug adapter will then stop whenever an exception of a given kind occurs. Which exception types are supported depends on the debug adapter.
## Settings

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