Compare commits

...

26 commits

Author SHA1 Message Date
Leastrio
ea2d9bcd02
Merge da634338c8 into 09165c15dc 2026-05-30 23:37:01 -05:00
Nathan Sobo
09165c15dc
gpui: Support prompt_for_paths in TestPlatform (#58139)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Has been skipped
Implements the previously-`unimplemented!()`
`TestPlatform::prompt_for_paths` so tests can drive the platform Open
dialog deterministically.

Adds `TestAppContext::simulate_path_prompt_response` and
`did_prompt_for_paths`, mirroring the existing `prompt_for_new_path`
test helpers (`simulate_new_path_selection`). The simulated response
validates that callers don't return multiple paths when
`PathPromptOptions::multiple` is false.

Release Notes:

- N/A
2026-05-30 20:37:39 +00:00
renovate[bot]
e2e7a6769e
Update dependency requests to v2.33.0 [SECURITY] (#58093)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Has been skipped
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [requests](https://redirect.github.com/psf/requests)
([changelog](https://redirect.github.com/psf/requests/blob/master/HISTORY.md))
| `2.32.3` → `2.33.0` |
![age](https://developer.mend.io/api/mc/badges/age/pypi/requests/2.33.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/requests/2.32.3/2.33.0?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/15138) for more information.

---

### Requests vulnerable to .netrc credentials leak via malicious URLs
[CVE-2024-47081](https://nvd.nist.gov/vuln/detail/CVE-2024-47081) /
[GHSA-9hjg-9r4m-mvj7](https://redirect.github.com/advisories/GHSA-9hjg-9r4m-mvj7)

<details>
<summary>More information</summary>

#### Details
##### Impact

Due to a URL parsing issue, Requests releases prior to 2.32.4 may leak
.netrc credentials to third parties for specific maliciously-crafted
URLs.

##### Workarounds
For older versions of Requests, use of the .netrc file can be disabled
with `trust_env=False` on your Requests Session
([docs](https://requests.readthedocs.io/en/latest/api/#requests.Session.trust_env)).

##### References

[https://github.com/psf/requests/pull/6965](https://redirect.github.com/psf/requests/pull/6965)
https://seclists.org/fulldisclosure/2025/Jun/2

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N`

#### References
-
[https://github.com/psf/requests/security/advisories/GHSA-9hjg-9r4m-mvj7](https://redirect.github.com/psf/requests/security/advisories/GHSA-9hjg-9r4m-mvj7)
-
[https://nvd.nist.gov/vuln/detail/CVE-2024-47081](https://nvd.nist.gov/vuln/detail/CVE-2024-47081)
-
[https://github.com/psf/requests/pull/6965](https://redirect.github.com/psf/requests/pull/6965)
-
[96ba401c12)
-
[https://requests.readthedocs.io/en/latest/api/#requests.Session.trust_env](https://requests.readthedocs.io/en/latest/api/#requests.Session.trust_env)
-
[https://seclists.org/fulldisclosure/2025/Jun/2](https://seclists.org/fulldisclosure/2025/Jun/2)
-
[http://seclists.org/fulldisclosure/2025/Jun/2](http://seclists.org/fulldisclosure/2025/Jun/2)
-
[http://www.openwall.com/lists/oss-security/2025/06/03/11](http://www.openwall.com/lists/oss-security/2025/06/03/11)
-
[http://www.openwall.com/lists/oss-security/2025/06/03/9](http://www.openwall.com/lists/oss-security/2025/06/03/9)
-
[http://www.openwall.com/lists/oss-security/2025/06/04/1](http://www.openwall.com/lists/oss-security/2025/06/04/1)
-
[http://www.openwall.com/lists/oss-security/2025/06/04/6](http://www.openwall.com/lists/oss-security/2025/06/04/6)
-
[https://github.com/advisories/GHSA-9hjg-9r4m-mvj7](https://redirect.github.com/advisories/GHSA-9hjg-9r4m-mvj7)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-9hjg-9r4m-mvj7)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Requests has Insecure Temp File Reuse in its extract_zipped_paths()
utility function
[CVE-2026-25645](https://nvd.nist.gov/vuln/detail/CVE-2026-25645) /
[GHSA-gc5v-m9x4-r6x2](https://redirect.github.com/advisories/GHSA-gc5v-m9x4-r6x2)

<details>
<summary>More information</summary>

#### Details
##### Impact
The `requests.utils.extract_zipped_paths()` utility function uses a
predictable filename when extracting files from zip archives into the
system temporary directory. If the target file already exists, it is
reused without validation. A local attacker with write access to the
temp directory could pre-create a malicious file that would be loaded in
place of the legitimate one.

##### Affected usages
**Standard usage of the Requests library is not affected by this
vulnerability.** Only applications that call `extract_zipped_paths()`
directly are impacted.

##### Remediation
Upgrade to at least Requests 2.33.0, where the library now extracts
files to a non-deterministic location.

If developers are unable to upgrade, they can set `TMPDIR` in their
environment to a directory with restricted write access.

#### Severity
- CVSS Score: 4.4 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:N/I:H/A:N`

#### References
-
[https://github.com/psf/requests/security/advisories/GHSA-gc5v-m9x4-r6x2](https://redirect.github.com/psf/requests/security/advisories/GHSA-gc5v-m9x4-r6x2)
-
[66d21cb07b)
-
[https://github.com/psf/requests/releases/tag/v2.33.0](https://redirect.github.com/psf/requests/releases/tag/v2.33.0)
-
[https://nvd.nist.gov/vuln/detail/CVE-2026-25645](https://nvd.nist.gov/vuln/detail/CVE-2026-25645)
-
[https://github.com/advisories/GHSA-gc5v-m9x4-r6x2](https://redirect.github.com/advisories/GHSA-gc5v-m9x4-r6x2)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-gc5v-m9x4-r6x2)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>psf/requests (requests)</summary>

###
[`v2.33.0`](https://redirect.github.com/psf/requests/blob/HEAD/HISTORY.md#2330-2026-03-25)

[Compare
Source](https://redirect.github.com/psf/requests/compare/v2.32.5...v2.33.0)

**Announcements**

- 📣 Requests is adding inline types. If you have a typed code base that
uses Requests, please take a look at
[#&#8203;7271](https://redirect.github.com/psf/requests/issues/7271).
Give it a try, and report
  any gaps or feedback you may have in the issue. 📣

**Security**

- CVE-2026-25645 `requests.utils.extract_zipped_paths` now extracts
  contents to a non-deterministic location to prevent malicious file
  replacement. This does not affect default usage of Requests, only
  applications calling the utility function directly.

**Improvements**

- Migrated to a PEP 517 build system using setuptools.
([#&#8203;7012](https://redirect.github.com/psf/requests/issues/7012))

**Bugfixes**

- Fixed an issue where an empty netrc entry could cause
  malformed authentication to be applied to Requests on
Python 3.11+.
([#&#8203;7205](https://redirect.github.com/psf/requests/issues/7205))

**Deprecations**

- Dropped support for Python 3.9 following its end of support.
([#&#8203;7196](https://redirect.github.com/psf/requests/issues/7196))

**Documentation**

- Various typo fixes and doc improvements.

###
[`v2.32.5`](https://redirect.github.com/psf/requests/blob/HEAD/HISTORY.md#2325-2025-08-18)

[Compare
Source](https://redirect.github.com/psf/requests/compare/v2.32.4...v2.32.5)

**Bugfixes**

- The SSLContext caching feature originally introduced in 2.32.0 has
created
a new class of issues in Requests that have had negative impact across a
number
of use cases. The Requests team has decided to revert this feature as
long term
maintenance of it is proving to be unsustainable in its current
iteration.

**Deprecations**

- Added support for Python 3.14.
- Dropped support for Python 3.8 following its end of support.

###
[`v2.32.4`](https://redirect.github.com/psf/requests/blob/HEAD/HISTORY.md#2324-2025-06-10)

[Compare
Source](https://redirect.github.com/psf/requests/compare/v2.32.3...v2.32.4)

**Security**

- CVE-2024-47081 Fixed an issue where a maliciously crafted URL and
trusted
environment will retrieve credentials for the wrong hostname/machine
from a
  netrc file.

**Improvements**

- Numerous documentation improvements

**Deprecations**

- Added support for pypy 3.11 for Linux and macOS.
- Dropped support for pypy 3.9 following its end of support.

</details>

---

### Configuration

📅 **Schedule**: (in timezone America/New_York)

- Branch creation
  - ""
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

Release Notes:

- N/A

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMDIuMSIsInVwZGF0ZWRJblZlciI6IjQzLjIwMi4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-29 20:29:38 +00:00
Sathwik Chirivelli
5d3b9e467e
git_ui: Open file diffs from git panel (#56152)
Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Addresses https://github.com/zed-industries/zed/discussions/33773.

This changes git panel file activation so double-clicking or
secondary-opening a changed file opens a dedicated full-file diff tab
backed by a `SplittableEditor`.

The per-file diff reuses the project diff staging and restore controls,
respects the configured diff view style, and focuses an existing
per-file diff tab when one is already open instead of creating
duplicates.

Verified with `cargo run`.

Release Notes:

- Improved git panel file diff opening.

---------

Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
2026-05-29 19:43:26 +00:00
Joseph T. Lyons
06826ef10f
Bump urllib3 to v2.7.0 (#58092)
Self-Review Checklist:

- [X] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [ ] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [ ] Performance impact has been considered and is acceptable

Release Notes:

- N/A
2026-05-29 17:55:04 +00:00
Anthony Eid
7f4a99aa95
Fix task modal fallback when LSP tasks are empty (#58090)
References
[FR-28](https://linear.app/zed-industries/issue/FR-28/task-modal-does-not-show-runnable-rust-test).

The bug this PR aims to fix is

> I can click the play button beside a rust test function, but it does
not show up in the task modal.

I wasn't able to reproduce it, but I suspect this was caused by the task
system preferring LSP code actions by default. It checked that an LSP
was queried for a task instead of checking if the queried LSP actually
returned any tasks. So the fix was just adding the below if statement as
a check.

```rust
if !new_lsp_tasks.is_empty() {
    lsp_tasks
        .entry(source_kind)
        .or_insert_with(Vec::new)
        .append(&mut new_lsp_tasks);
}
```

I also added a regression test for this

Self-Review Checklist:

- [x] I have reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the UI/UX checklist
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable


Release Notes:

- Fixed task modal failing to show language tasks in some cases
2026-05-29 17:49:11 +00:00
Bennet Bo Fenner
122619624d
x_ai: Add support for specifying reasoning effort (#58078)
See
https://docs.x.ai/developers/model-capabilities/text/reasoning#the-reasoning_effort-parameter

Closes #58056

Release Notes:

- agent: Added support for specifying reasoning effort for Grok 4.3
(xAI)
2026-05-29 16:28:27 +00:00
Smit Barmase
2ea99a81f1
Add new area labels to track mapping (#58083)
| Label | Description |
|---|---|
| `area:preview/csv` | Feedback for Zed's CSV support |
| `area:fs` | Related to the fs crate. |
| `area:scanning` | Worktree scanning related PRs. |
| `area:editor/bookmarks` | Feedback for the editor bookmarks |
| `area:ai/agent thread/skills` | Feedback for Zed's AI Skills feature |
| `area:ai/terminal threads` | Feedback for Zed's Terminal Threads |
| `area:crashes` | PR related to crashes crate. |
| `area:scripts` | Changes in "script" directory |


Release Notes:

- N/A
2026-05-29 16:08:53 +00:00
Nathan Sobo
c30d18b10d
scheduler: Add spawn_dedicated for single-threaded actors with !Send state (#57609)
Adds `scheduler::spawn_dedicated_thread` (and inherent `spawn_dedicated`
methods on `PlatformScheduler` and `TestScheduler`) so single-threaded
actors that own `!Send` state can run on their own OS thread and freely
do blocking I/O without disturbing any other executor.

### Why

A single-threaded actor that needs to do blocking syscalls is currently
stuck: it can't run on the shared foreground executor (blocking would
stall every other foreground session), and it can't move to the
background pool because its state isn't `Send`. `spawn_dedicated` gives
each such actor its own thread and its own `LocalExecutor`, while still
participating in the same testable scheduler infrastructure as
everything else.

### Shape

- `pub fn spawn_dedicated_thread(session_id, scheduler, f) -> Task<_>`
in `scheduler`. Owns the OS thread, the per-session runnable channel,
and the `LocalExecutor` setup.
- Inherent `spawn_dedicated` on `PlatformScheduler` (allocates its own
`SessionId`, delegates to the free function).
- Inherent `spawn_dedicated` on `TestScheduler` (no real thread — runs
as a fresh local session driven by the test scheduler's run loop, so
determinism under `many` is preserved).
- Renames `Scheduler::schedule_foreground` → `schedule_local` and
`scheduler::ForegroundExecutor` → `scheduler::LocalExecutor` to reflect
that these are session-pinned queues rather than "the main thread" (a
dedicated session runs on its own thread). GPUI's wrapper
`gpui::ForegroundExecutor` and the `foreground_executor` field/method
names are unchanged to keep blast radius small.
- `LocalExecutor::new` now takes an explicit dispatch closure, so the
routing decision (default session, dedicated thread, or something else)
lives at the construction site.

### Tests

- `TestScheduler` side: round-trip, `!Send` future, `Send` closure
capturing shared state, inner `executor.spawn`, determinism under `many`
seeds, drop-cancels-future, detached child runs after root completes.
- `PlatformScheduler` side: real separate thread (blocking syscalls
don't stall the test), `!Send` future output, drop-cancels-future,
thread tears down after work completes, detached child outlives root.

cc @as-cii

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2026-05-29 15:58:02 +00:00
Bennet Bo Fenner
18051ab399
agent_ui: Remove unused rule APIs (#58080)
Removes unused `@rule` mentions and unused APIs from `prompt_store`

Follow up to #58067

Release Notes:

- N/A
2026-05-29 15:27:28 +00:00
Bennet Bo Fenner
654a864b3a
git_ui: Do not include git commit prompt twice (#58062)
Now that customisation of this prompt was moved to `AGENTS.md`, we don't
want to load the customised prompt, and instead just use the default
prompt, so that we don't include the same instructions twice

Release Notes:

- git: Improve performance when generating git commit message with LLM
2026-05-29 15:27:21 +00:00
Conrad Irwin
5fba9b0cba
Enable gain normalization on collab (#58036)
This updates our WebRTC configuration to enable gain normalization in
the
recording flow, which should help normalize the effective volume of
participants
in calls.

Release Notes:

- Added volume equalizations to participants in collab calls
2026-05-29 15:21:53 +00:00
Smit Barmase
906bff792c
agent_ui: Show permission popover when inline prompt is above the viewport (#58081)
Follow up to https://github.com/zed-industries/zed/pull/57632, uses
changes from https://github.com/zed-industries/zed/pull/58061

Previously the floating permission popover only appeared when the inline
permission prompt was scrolled below the viewport. It now also appears
when the prompt is scrolled above the viewport, with the scroll button
pointing in the right direction.

Release Notes:

- Fixed the agent permission popover not appearing when the inline
prompt was scrolled above the viewport.
2026-05-29 15:11:25 +00:00
Agus Zubiaga
c029cc4354
Bump convert_case to v0.11.0 (#58000)
Bumps the workspace `convert_case` dependency from 0.8 to 0.11 (already
in the tree) so the `zed` binary includes only one copy.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
2026-05-29 15:00:32 +00:00
Richard Feldman
0bafd1938c
Add handoff feature flag (#58024)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Has been skipped
Adds the handoff feature flag with staff disabled by default, giving the
rest of the auto-compaction stack a rollout gate without changing
behavior for users outside the flag.

Release Notes:

- N/A
2026-05-29 14:10:02 +00:00
Lena
b7b1d1a2c7
Duplicate Bot: Reduce noise (#58074)
Release Notes:

- N/A
2026-05-29 13:28:35 +00:00
Smit Barmase
a1d2ef6514
gpui: Add item_is_above_viewport and item_is_below_viewport APIs to ListState (#58061)
In prep for handling the above-viewport case in
https://github.com/zed-industries/zed/pull/57632, which currently only
handles below case.

This PR adds `ListState::item_is_above_viewport` and
`ListState::item_is_below_viewport` methods, which report whether a
given list item is entirely outside the current viewport. Both return
`None` when the list has not measured enough layout to answer.

Release Notes:

- N/A
2026-05-29 13:24:59 +00:00
Jakub Konka
81f818aa86
nix: Go around a linker issue on Darwin (#58070)
Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [ ] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [ ] Performance impact has been considered and is acceptable

Release Notes:

- N/A
2026-05-29 12:22:37 +00:00
Bennet Bo Fenner
f49be143f3
agent: Do not render diagnostics in diff (#58052)
Release Notes:

- agent: Fixed an issue where diagnostics would show up in agent panel
diffs
2026-05-29 12:18:57 +00:00
Bennet Bo Fenner
3eab273b85
Remove rules library (#58067)
Rules library has been replaced by skills, so we can safely remove it

Release Notes:

- N/A
2026-05-29 11:59:18 +00:00
Mikhail Pertsev
85f8bf7393
editor: Extract header and mouse out of element.rs (#57472)
cc @SomeoneToIgnore

## Summary

Follow-up to [this
comment](https://github.com/zed-industries/zed/discussions/55352#discussioncomment-16961889).
This extracts the buffer header and breadcrumb rendering helpers out of
`element.rs` into a `header.rs` and mouse related things to `mouse.rs`

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
2026-05-29 11:51:17 +00:00
Jakub Konka
da87558cc2
Revert "Improve ChatGPT subscription response resilience" (#58035)
Reverts zed-industries/zed#57891
2026-05-29 11:23:56 +00:00
Anthony Eid
6bca2136a1
agent: Batch streaming edit operations (#58037)
## Summary

While profiling agent sessions that make a lot of `edit_file`
operations, I noticed the LSP `textDocument/didChange` handler firing
excessively. Looking into this, I found out that the streaming edit
pipeline was applying each `CharOperation` from `StreamingDiff` as its
own `buffer.edit` transaction, and every transaction emits a
`BufferEvent::Edited` event. Each event can trigger several other
expensive events depending on whether the buffer is being rendered in an
editor or is registered with a language.

For example, there are `didChange` LSP events, the editor's on edit work
(matching brackets, bracket colorization, code actions, outline), and
more. A single `edit_file` could trigger hundreds of these at the higher
end in a single synchronous app update, which would block the foreground
thread for a bit and cause Zed to drop frames.

I fixed this by collecting all of a chunk's `CharOperation`s and
applying them in one `buffer.edit` call, so only a single
`BufferEvent::Edited` event gets emitted. This is safe because
operations are non overlapping by design of streaming diff (the edit
cursor only advances).

## Why this wasn't caught earlier

The cost only fully appears when a buffer is both registered with a
language server and rendered in an editor. Without that, most of the per
transaction observers never run, so the existing `edit_file_tool`
benchmark (which ran the tool against a bare buffer) didn't surface it.
I reworked the benchmark to open the edited buffer in an editor view,
register a fake language server with per edit diagnostics, and lay out a
frame, so it exercises the same cascade as the real editor. I also added
a larger fixture.

## Results

Measured with the `release-fast` profile on the reworked benchmark:

| Fixture | Initial file | Before | After | Improvement |
| --- | --- | --- | --- | --- |
| `tiny_function_rewrite` | 1.4 KB | 31.1 ms | 12.1 ms | −61% |
| `small_function_rewrite` | 3.0 KB | 42.4 ms | 19.3 ms | −55% |
| `medium_many_small_changes` | 4.6 KB | 309.2 ms | 151.5 ms | −51% |
| `medium_insertions` | 4.6 KB | 171.8 ms | 126.1 ms | −27% |
| `large_multi_edit` | 44 KB | 9,549 ms | 919 ms | −90% |

Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the UI/UX checklist
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:
- Improved agent's edit file tool performance
2026-05-29 09:49:55 +00:00
Agus Zubiaga
ef5da3ccc2
Remove unused --nc flag and nc crate (#55962)
The `nc` crate and `zed --nc <socket>` flag were added in #34577 to let
the Claude Code integration spawn the running zed binary as a
netcat-style bridge between stdio and a Unix socket for its MCP server.

That integration was removed in #37120 in favor of the external
`@agentclientprotocol/claude-agent-acp` npm package, which dropped the
only caller of `--nc`. The flag, its dispatch in `main.rs`, and the `nc`
crate itself were left behind and have been unused since.

Nothing in the Zed codebase spawns `zed --nc` anymore, so remove the
flag and delete the crate. The unrelated `--askpass` netcat bridge (in
the `askpass` crate) is unaffected.

Release Notes:

- N/A
2026-05-29 08:16:05 +00:00
Jakub Konka
a3669a29e6
nix: Fix dev shell on Darwin (#58032)
Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [ ] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [ ] Performance impact has been considered and is acceptable

Release Notes:

- N/A
2026-05-29 08:10:32 +00:00
Shuhei Kadowaki
3d9852ae04
lsp: Fix duplicate workspace/didChangeConfiguration notifications (#56853)
Editing the `lsp` section of `.zed/settings.json` caused two identical
`workspace/didChangeConfiguration` notifications to be sent to each
language server, e.g.:

    // Send:

{"jsonrpc":"2.0","method":"workspace/didChangeConfiguration","params":{"settings":{"jetls":{"code_lens":{"references":true},"completion":{"method_signature":{"prepend_inference_result":true}},"full_analysis":{"debounce":2},"inlay_hint":{"block_end":{"min_lines":25}}}}}}

    // Send:

{"jsonrpc":"2.0","method":"workspace/didChangeConfiguration","params":{"settings":{"jetls":{"code_lens":{"references":true},"completion":{"method_signature":{"prepend_inference_result":true}},"full_analysis":{"debounce":2},"inlay_hint":{"block_end":{"min_lines":25}}}}}}

`maintain_workspace_config` observed `SettingsStore` directly while
`on_settings_changed` also fed the same loop through
`request_workspace_config_refresh`, so every settings change drove the
refresh loop twice and sent two identical
`workspace/didChangeConfiguration` notifications to each language
server.

Drop the in-loop observer and drive the loop from
`external_refresh_requests` alone. Settings changes still arrive via
`on_settings_changed -> request_workspace_config_refresh`, and toolchain
activation continues to use the same channel.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #ISSUE

Release Notes:

- Fixed language servers receiving duplicate
`workspace/didChangeConfiguration` notifications on every settings
change.
2026-05-29 07:31:00 +00:00
75 changed files with 5381 additions and 5482 deletions

View file

@ -55,7 +55,6 @@
/crates/open_ai/ @zed-industries/ai-team
/crates/open_router/ @zed-industries/ai-team
/crates/prompt_store/ @zed-industries/ai-team
/crates/rules_library/ @zed-industries/ai-team
# SUGGESTED: Review needed - based on Richard Feldman (2 commits)
/crates/shell_command_parser/ @zed-industries/ai-team
/crates/vercel/ @zed-industries/ai-team
@ -181,7 +180,6 @@
/crates/fs_benchmarks/ @zed-industries/infrastructure-team
/crates/http_client/ @zed-industries/infrastructure-team
/crates/http_client_tls/ @zed-industries/infrastructure-team
/crates/nc/ @zed-industries/infrastructure-team
/crates/net/ @zed-industries/infrastructure-team
/crates/paths/ @zed-industries/infrastructure-team
/crates/release_channel/ @zed-industries/infrastructure-team

104
Cargo.lock generated
View file

@ -109,7 +109,6 @@ dependencies = [
"parking_lot",
"portable-pty",
"project",
"prompt_store",
"rand 0.9.4",
"sandbox",
"serde",
@ -234,6 +233,7 @@ dependencies = [
"agent_settings",
"agent_skills",
"anyhow",
"assets",
"async-channel 2.5.0",
"async-io",
"chrono",
@ -290,6 +290,7 @@ dependencies = [
"tempfile",
"text",
"theme",
"theme_settings",
"thiserror 2.0.17",
"ui",
"unindent",
@ -404,7 +405,7 @@ dependencies = [
"agent-client-protocol",
"anyhow",
"collections",
"convert_case 0.8.0",
"convert_case 0.11.0",
"fs",
"futures 0.3.32",
"gpui",
@ -514,7 +515,6 @@ dependencies = [
"remote_server",
"reqwest_client",
"rope",
"rules_library",
"schemars 1.0.4",
"search",
"semver",
@ -2162,7 +2162,7 @@ dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools 0.11.0",
"itertools 0.10.5",
"log",
"prettyplease",
"proc-macro2",
@ -2182,7 +2182,7 @@ dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools 0.11.0",
"itertools 0.10.5",
"proc-macro2",
"quote",
"regex",
@ -5312,7 +5312,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -5782,7 +5782,7 @@ dependencies = [
"client",
"clock",
"collections",
"convert_case 0.8.0",
"convert_case 0.11.0",
"criterion",
"ctor",
"dap",
@ -6147,7 +6147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -7603,7 +7603,7 @@ dependencies = [
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -9064,7 +9064,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@ -9082,7 +9082,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.56.0",
]
[[package]]
@ -9336,7 +9336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"hashbrown 0.15.5",
"serde",
"serde_core",
]
@ -10146,7 +10146,7 @@ dependencies = [
"cloud_api_types",
"collections",
"component",
"convert_case 0.8.0",
"convert_case 0.11.0",
"copilot",
"copilot_chat",
"copilot_ui",
@ -10479,7 +10479,7 @@ dependencies = [
[[package]]
name = "libwebrtc"
version = "0.3.26"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
dependencies = [
"cxx",
"glib",
@ -10589,7 +10589,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "livekit"
version = "0.7.32"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
dependencies = [
"base64 0.22.1",
"bmrng",
@ -10615,7 +10615,7 @@ dependencies = [
[[package]]
name = "livekit-api"
version = "0.4.14"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
dependencies = [
"base64 0.21.7",
"futures-util",
@ -10642,7 +10642,7 @@ dependencies = [
[[package]]
name = "livekit-protocol"
version = "0.7.1"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
dependencies = [
"futures-util",
"livekit-runtime",
@ -10658,7 +10658,7 @@ dependencies = [
[[package]]
name = "livekit-runtime"
version = "0.4.0"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
dependencies = [
"tokio",
"tokio-stream",
@ -11364,7 +11364,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"collections",
"convert_case 0.8.0",
"convert_case 0.11.0",
"log",
"pretty_assertions",
"serde_json",
@ -11725,16 +11725,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "nc"
version = "0.1.0"
dependencies = [
"anyhow",
"futures 0.3.32",
"net",
"smol",
]
[[package]]
name = "ndk"
version = "0.9.0"
@ -11960,7 +11950,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -14492,7 +14482,6 @@ dependencies = [
"db",
"fs",
"futures 0.3.32",
"fuzzy",
"gpui",
"handlebars 4.5.0",
"heed",
@ -14500,7 +14489,6 @@ dependencies = [
"log",
"parking_lot",
"paths",
"rope",
"serde",
"serde_json",
"strum 0.27.2",
@ -14599,7 +14587,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4"
dependencies = [
"bytes 1.11.1",
"heck 0.5.0",
"itertools 0.11.0",
"itertools 0.10.5",
"log",
"multimap",
"once_cell",
@ -14632,7 +14620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1"
dependencies = [
"anyhow",
"itertools 0.11.0",
"itertools 0.10.5",
"proc-macro2",
"quote",
"syn 2.0.117",
@ -14894,7 +14882,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.40",
"socket2 0.6.3",
"socket2 0.5.10",
"thiserror 2.0.17",
"tokio",
"tracing",
@ -14931,9 +14919,9 @@ dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.5.10",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@ -16017,33 +16005,6 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8388ea1a9e0ea807e442e8263a699e7edcb320ecbcd21b4fa8ff859acce3ba"
[[package]]
name = "rules_library"
version = "0.1.0"
dependencies = [
"anyhow",
"collections",
"editor",
"gpui",
"language",
"language_model",
"log",
"menu",
"picker",
"platform_title_bar",
"prompt_store",
"release_channel",
"rope",
"serde",
"settings",
"theme_settings",
"ui",
"ui_input",
"util",
"workspace",
"zed_actions",
]
[[package]]
name = "runtimelib"
version = "1.4.0"
@ -16185,7 +16146,7 @@ dependencies = [
"errno 0.3.14",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -18807,7 +18768,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -19524,7 +19485,7 @@ name = "toolchain_selector"
version = "0.1.0"
dependencies = [
"anyhow",
"convert_case 0.8.0",
"convert_case 0.11.0",
"editor",
"futures 0.3.32",
"fuzzy",
@ -19732,7 +19693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f"
dependencies = [
"cc",
"windows-targets 0.52.6",
"windows-targets 0.48.5",
]
[[package]]
@ -21525,7 +21486,7 @@ dependencies = [
[[package]]
name = "webrtc-sys"
version = "0.3.23"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
dependencies = [
"cc",
"cxx",
@ -21539,7 +21500,7 @@ dependencies = [
[[package]]
name = "webrtc-sys-build"
version = "0.3.13"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
dependencies = [
"anyhow",
"fs2",
@ -21837,7 +21798,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@ -23598,7 +23559,6 @@ dependencies = [
"migrator",
"mimalloc",
"miniprofiler_ui",
"nc",
"node_runtime",
"notifications",
"onboarding",

View file

@ -137,7 +137,6 @@ members = [
"crates/miniprofiler_ui",
"crates/mistral",
"crates/multi_buffer",
"crates/nc",
"crates/net",
"crates/node_runtime",
"crates/notifications",
@ -172,7 +171,6 @@ members = [
"crates/reqwest_client",
"crates/rope",
"crates/rpc",
"crates/rules_library",
"crates/sandbox",
"crates/skill_creator",
"crates/scheduler",
@ -399,7 +397,6 @@ migrator = { path = "crates/migrator" }
mistral = { path = "crates/mistral" }
multi_buffer = { path = "crates/multi_buffer" }
miniprofiler_ui = { path = "crates/miniprofiler_ui" }
nc = { path = "crates/nc" }
net = { path = "crates/net" }
node_runtime = { path = "crates/node_runtime" }
notifications = { path = "crates/notifications" }
@ -434,7 +431,6 @@ reqwest_client = { path = "crates/reqwest_client" }
rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", features = ["wav", "playback", "wav_output", "recording"] }
rope = { path = "crates/rope" }
rpc = { path = "crates/rpc" }
rules_library = { path = "crates/rules_library" }
skill_creator = { path = "crates/skill_creator" }
scheduler = { path = "crates/scheduler" }
sandbox = { path = "crates/sandbox" }
@ -563,7 +559,7 @@ clap = { version = "4.4", features = ["derive", "wrap_help"] }
cocoa = "=0.26.0"
cocoa-foundation = "=0.2.0"
const_format = "0.2"
convert_case = "0.8.0"
convert_case = "0.11.0"
core-foundation = "=0.10.0"
core-foundation-sys = "0.8.6"
core-video = { version = "0.5.2", features = ["metal"] }
@ -895,9 +891,9 @@ notify = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24c
notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24cad542c28e04ced02e20325a4ec28a31d" }
windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" }
calloop = { git = "https://github.com/zed-industries/calloop" }
livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" }
libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" }
webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" }
livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" }
libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" }
webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" }
[profile.dev]
split-debuginfo = "unpacked"

View file

@ -380,15 +380,7 @@
"shift-backspace": "agent::ArchiveSelectedThread",
},
},
{
"context": "RulesLibrary",
"bindings": {
"new": "rules_library::NewRule",
"ctrl-n": "rules_library::NewRule",
"ctrl-shift-s": "rules_library::ToggleDefaultRule",
"ctrl-w": "workspace::CloseWindow",
},
},
{
"context": "BufferSearchBar",
"bindings": {

View file

@ -427,15 +427,6 @@
"backspace": "agent::ArchiveSelectedThread",
},
},
{
"context": "RulesLibrary",
"use_key_equivalents": true,
"bindings": {
"cmd-n": "rules_library::NewRule",
"cmd-shift-s": "rules_library::ToggleDefaultRule",
"cmd-w": "workspace::CloseWindow",
},
},
{
"context": "BufferSearchBar",
"use_key_equivalents": true,

View file

@ -383,15 +383,6 @@
"shift-backspace": "agent::ArchiveSelectedThread",
},
},
{
"context": "RulesLibrary",
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "rules_library::NewRule",
"ctrl-shift-s": "rules_library::ToggleDefaultRule",
"ctrl-w": "workspace::CloseWindow",
},
},
{
"context": "BufferSearchBar",
"use_key_equivalents": true,

View file

@ -38,7 +38,6 @@ parking_lot = { workspace = true, optional = true }
image.workspace = true
portable-pty.workspace = true
project.workspace = true
prompt_store.workspace = true
sandbox.workspace = true
serde.workspace = true
serde_json.workspace = true

View file

@ -1,7 +1,6 @@
use agent_client_protocol::schema as acp;
use anyhow::{Context as _, Result, bail};
use file_icons::FileIcons;
use prompt_store::{PromptId, UserPromptId};
use serde::{Deserialize, Serialize};
use std::{
borrow::Cow,
@ -37,10 +36,6 @@ pub enum MentionUri {
id: acp::SessionId,
name: String,
},
Rule {
id: PromptId,
name: String,
},
Diagnostics {
#[serde(default = "default_include_errors")]
include_errors: bool,
@ -205,13 +200,6 @@ impl MentionUri {
id: acp::SessionId::new(thread_id),
name,
})
} else if let Some(rule_id) = path.strip_prefix("/agent/rule/") {
let name = single_query_param(&url, "name")?.context("Missing rule name")?;
let rule_id = UserPromptId(rule_id.parse()?);
Ok(Self::Rule {
id: rule_id.into(),
name,
})
} else if path == "/agent/diagnostics" {
let mut include_errors = default_include_errors();
let mut include_warnings = false;
@ -342,7 +330,6 @@ impl MentionUri {
MentionUri::PastedImage { name } => name.clone(),
MentionUri::Symbol { name, .. } => name.clone(),
MentionUri::Thread { name, .. } => name.clone(),
MentionUri::Rule { name, .. } => name.clone(),
MentionUri::Diagnostics { .. } => "Diagnostics".to_string(),
MentionUri::TerminalSelection { line_count } => {
if *line_count == 1 {
@ -443,7 +430,6 @@ impl MentionUri {
.unwrap_or_else(|| IconName::Folder.path().into()),
MentionUri::Symbol { .. } => IconName::Code.path().into(),
MentionUri::Thread { .. } => IconName::Thread.path().into(),
MentionUri::Rule { .. } => IconName::Reader.path().into(),
MentionUri::Diagnostics { .. } => IconName::Warning.path().into(),
MentionUri::TerminalSelection { .. } => IconName::Terminal.path().into(),
MentionUri::Selection { .. } => IconName::Reader.path().into(),
@ -526,12 +512,6 @@ impl MentionUri {
url.query_pairs_mut().append_pair("name", name);
url
}
MentionUri::Rule { name, id } => {
let mut url = Url::parse("zed:///").unwrap();
url.set_path(&format!("/agent/rule/{id}"));
url.query_pairs_mut().append_pair("name", name);
url
}
MentionUri::Diagnostics {
include_errors,
include_warnings,
@ -811,20 +791,6 @@ mod tests {
assert_eq!(parsed.to_uri().to_string(), thread_uri);
}
#[test]
fn test_parse_rule_uri() {
let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Rule { id, name } => {
assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52");
assert_eq!(name, "Some rule");
}
_ => panic!("Expected Rule variant"),
}
assert_eq!(parsed.to_uri().to_string(), rule_uri);
}
#[test]
fn test_parse_skill_uri_round_trip() {
let skill_uri = MentionUri::Skill {

View file

@ -78,6 +78,7 @@ zed_env_vars.workspace = true
zstd.workspace = true
[dev-dependencies]
assets.workspace = true
async-io.workspace = true
agent_servers = { workspace = true, "features" = ["test-support"] }
client = { workspace = true, "features" = ["test-support"] }
@ -103,6 +104,7 @@ reqwest_client.workspace = true
settings = { workspace = true, "features" = ["test-support"] }
theme = { workspace = true, "features" = ["test-support"] }
theme_settings.workspace = true
unindent = { workspace = true }

View file

@ -1,4 +1,5 @@
use std::{
any::Any,
future::Future,
path::Path,
sync::Arc,
@ -14,26 +15,40 @@ use agent_settings::{AgentSettings, ToolRules};
use criterion::{
BatchSize, BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main,
};
use futures::{pin_mut, task::noop_waker};
use gpui::{AppContext as _, BackgroundExecutor, Entity, TestAppContext, UpdateGlobal as _};
use editor::{Editor, EditorStyle};
use futures::{StreamExt as _, pin_mut, task::noop_waker};
use gpui::{
AnyWindowHandle, AppContext as _, BackgroundExecutor, Entity, Focusable as _, TestAppContext,
UpdateGlobal as _,
};
use language::{FakeLspAdapter, rust_lang};
use language_model::fake_provider::FakeLanguageModel;
use project::{FakeFs, Project};
use prompt_store::ProjectContext;
use rand::{Rng as _, SeedableRng as _, rngs::StdRng};
use serde_json::{Value, json};
use settings::{Settings as _, SettingsStore};
use ui::IntoElement as _;
const SEED: u64 = 0x5EED_5EED;
const OLD_TEXT_CHUNK_SIZE: usize = 512;
const NEW_TEXT_CHUNK_SIZE: usize = 512;
const FILE_PROJECT_PATH: &str = "root/src/workspace_snapshot.rs";
const FILE_ABS_PATH: &str = "/root/src/workspace_snapshot.rs";
#[derive(Clone)]
struct EditOp {
old_text: String,
new_text: String,
}
#[derive(Clone)]
struct EditFixture {
name: &'static str,
old_file_text: String,
expected_file_text: String,
old_text: String,
new_text: String,
edits: Vec<EditOp>,
}
struct BenchmarkHarness {
@ -43,6 +58,12 @@ struct BenchmarkHarness {
partial_payloads: Vec<Value>,
final_payload: Value,
expected_file_text: String,
editor: Option<Entity<Editor>>,
window: Option<AnyWindowHandle>,
// Keeps the LSP buffer-registration handle and the fake language server alive
// for the lifetime of the benchmark so `didChange`/diagnostics keep flowing
// while edits are applied.
keep_alive: Vec<Box<dyn Any>>,
}
impl Drop for BenchmarkHarness {
@ -50,19 +71,18 @@ impl Drop for BenchmarkHarness {
// Release our handles to the entities first.
self.edit_tool.take();
self.thread.take();
self.editor.take();
self.keep_alive.clear();
if let Some(cx) = self.cx.take() {
// `ActionLog` holds buffers strongly via `tracked_buffers`, and spawns a background
// diff-maintenance task that also captures a strong `Entity<Buffer>`. Releasing the
// last handle to the action log only marks its entity for deferred release; the
// entity's value (and the buffer handles inside) is not actually dropped until
// `flush_effects` runs `release_dropped_entities`. Even then, the cancelled task's
// captured handle does not drop until the executor pumps the cancellation through.
//
// Without this two-step teardown, GPUI's test leak detector panics on
// `TestAppContext` drop because the buffer still appears alive. See
// `ActionLog::track_buffer_internal` and `LeakDetector::drop` in
// `crates/gpui/src/app/entity_map.rs`.
if let Some(mut cx) = self.cx.take() {
// Close the editor window so the editor entity and the buffer handles
// it holds are released, then pump the executor so cancelled editor /
// action-log background tasks drop their captured handles before the
// leak detector runs on `TestAppContext` drop.
if let Some(window) = self.window.take() {
cx.update_window(window, |_, window, _| window.remove_window())
.ok();
}
cx.update(|_| {});
cx.executor().run_until_parked();
cx.quit();
@ -76,9 +96,10 @@ fn edit_file_tool_streaming(c: &mut Criterion) {
group.sample_size(10);
for fixture in fixtures {
group.throughput(Throughput::Bytes(fixture.new_text.len() as u64));
let new_bytes: usize = fixture.edits.iter().map(|edit| edit.new_text.len()).sum();
group.throughput(Throughput::Bytes(new_bytes as u64));
group.bench_with_input(
BenchmarkId::new(fixture.name, fixture.old_text.len()),
BenchmarkId::new(fixture.name, fixture.old_file_text.len()),
&fixture,
|bench, fixture| {
bench.iter_batched(
@ -107,26 +128,168 @@ fn edit_file_tool_streaming(c: &mut Criterion) {
fn setup_harness(fixture: EditFixture) -> BenchmarkHarness {
let mut cx = init_context();
let executor = cx.executor();
let (edit_tool, thread) = block_on_executor(
let parts = block_on_executor(
&executor,
setup_edit_tool(&mut cx, fixture.old_file_text.clone()),
setup_editor_and_tool(&mut cx, fixture.old_file_text.clone()),
);
let partial_payloads = streamed_partial_payloads(&fixture.old_text, &fixture.new_text);
// Let the LSP handshake, initial parse, and first layout settle before timing.
cx.executor().run_until_parked();
let partial_payloads = streamed_partial_payloads(&fixture.edits);
let final_payload = json!({
"path": "root/src/workspace_snapshot.rs",
"edits": [{
"old_text": fixture.old_text,
"new_text": fixture.new_text,
}],
"path": FILE_PROJECT_PATH,
"edits": fixture
.edits
.iter()
.map(|edit| json!({ "old_text": edit.old_text, "new_text": edit.new_text }))
.collect::<Vec<_>>(),
});
BenchmarkHarness {
cx: Some(cx),
edit_tool: Some(edit_tool),
thread: Some(thread),
edit_tool: Some(parts.edit_tool),
thread: Some(parts.thread),
partial_payloads,
final_payload,
expected_file_text: fixture.expected_file_text,
editor: Some(parts.editor),
window: Some(parts.window),
keep_alive: parts.keep_alive,
}
}
struct HarnessParts {
edit_tool: Arc<EditFileTool>,
thread: Entity<Thread>,
editor: Entity<Editor>,
window: AnyWindowHandle,
keep_alive: Vec<Box<dyn Any>>,
}
/// Builds a project + edit tool, opens the target buffer in an editor view inside
/// a window, and attaches a fake Rust language server. This mirrors the real app:
/// the edited file is open in a pane with a language server, so each buffer edit
/// drives the editor's observer cascade (matching brackets, code actions, outline,
/// bracket colorization), a tree-sitter reparse, and an LSP `didChange` +
/// diagnostics round-trip — the costs that dominate a real agent edit.
async fn setup_editor_and_tool(cx: &mut TestAppContext, file_text: String) -> HarnessParts {
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"src": {
"workspace_snapshot.rs": file_text,
},
}),
)
.await;
let project = Project::test(fs, [Path::new("/root")], cx).await;
let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
language_registry.add(rust_lang());
let mut fake_servers = language_registry.register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
text_document_sync: Some(lsp::TextDocumentSyncCapability::Kind(
lsp::TextDocumentSyncKind::INCREMENTAL,
)),
..Default::default()
},
..Default::default()
},
);
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
let model = Arc::new(FakeLanguageModel::default());
let thread = cx.new(|cx| {
Thread::new(
project.clone(),
cx.new(|_cx| ProjectContext::default()),
context_server_registry,
Templates::new(),
Some(model),
cx,
)
});
let action_log: Entity<ActionLog> =
thread.read_with(cx, |thread, _cx| thread.action_log().clone());
let edit_tool = Arc::new(EditFileTool::new(
project.clone(),
thread.downgrade(),
action_log,
language_registry,
));
// Open the same buffer the tool will edit and register it with the language
// servers so edits produce `didChange` notifications.
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(FILE_ABS_PATH, cx)
})
.await
.expect("failed to open buffer");
let lsp_handle = project.update(cx, |project, cx| {
project.register_buffer_with_language_servers(&buffer, cx)
});
let fake_server = fake_servers
.next()
.await
.expect("fake language server should start");
// Publish diagnostics on every edit, mirroring a real server reacting to
// `didChange`, so the editor's diagnostics path runs per edit.
let server = fake_server.clone();
fake_server.handle_notification::<lsp::notification::DidChangeTextDocument, _>(
move |params, _cx| {
server.notify::<lsp::notification::PublishDiagnostics>(lsp::PublishDiagnosticsParams {
uri: params.text_document.uri.clone(),
version: Some(params.text_document.version),
diagnostics: vec![lsp::Diagnostic {
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)),
severity: Some(lsp::DiagnosticSeverity::WARNING),
message: "bench diagnostic".to_string(),
..Default::default()
}],
});
},
);
// Attach an editor view in a window and lay it out once so the viewport-gated
// observers (bracket colorization, selection highlights) have a visible range.
let window = cx.add_window(|window, cx| {
let mut editor = Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
editor.set_style(EditorStyle::default(), window, cx);
window.focus(&editor.focus_handle(cx), cx);
editor
});
let editor = window.root(cx).expect("window should have an editor root");
let window: AnyWindowHandle = window.into();
// Lay out and paint a real frame so the editor establishes a viewport (this
// is what makes the viewport-gated observers like bracket colorization run).
{
let mut visual_cx = gpui::VisualTestContext::from_window(window, &*cx);
visual_cx.draw(
gpui::point(gpui::px(0.0), gpui::px(0.0)),
gpui::size(gpui::px(1024.0), gpui::px(768.0)),
|_, _| editor.clone().into_any_element(),
);
}
let keep_alive: Vec<Box<dyn Any>> = vec![
Box::new(lsp_handle),
Box::new(fake_server),
Box::new(fake_servers),
Box::new(buffer),
];
HarnessParts {
edit_tool,
thread,
editor,
window,
keep_alive,
}
}
@ -135,6 +298,9 @@ fn init_context() -> TestAppContext {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
assets::Assets.load_test_fonts(cx);
theme_settings::init(theme::LoadThemes::JustBase, cx);
editor::init(cx);
SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
store.update_user_settings(cx, |settings| {
settings
@ -142,6 +308,7 @@ fn init_context() -> TestAppContext {
.all_languages
.defaults
.ensure_final_newline_on_save = Some(false);
settings.project.all_languages.defaults.colorize_brackets = Some(true);
});
});
@ -161,48 +328,6 @@ fn init_context() -> TestAppContext {
cx
}
async fn setup_edit_tool(
cx: &mut TestAppContext,
file_text: String,
) -> (Arc<EditFileTool>, Entity<Thread>) {
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"src": {
"workspace_snapshot.rs": file_text,
},
}),
)
.await;
let project = Project::test(fs, [Path::new("/root")], cx).await;
let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
let model = Arc::new(FakeLanguageModel::default());
let thread = cx.new(|cx| {
Thread::new(
project.clone(),
cx.new(|_cx| ProjectContext::default()),
context_server_registry,
Templates::new(),
Some(model),
cx,
)
});
let action_log: Entity<ActionLog> =
thread.read_with(cx, |thread, _cx| thread.action_log().clone());
let edit_tool = Arc::new(EditFileTool::new(
project,
thread.downgrade(),
action_log,
language_registry,
));
(edit_tool, thread)
}
fn run_streamed_edit(harness: &mut BenchmarkHarness) -> EditFileToolOutput {
let (mut sender, input): (_, ToolInput<EditFileToolInput>) = ToolInput::test();
for payload in &harness.partial_payloads {
@ -247,33 +372,36 @@ fn block_on_executor<R>(executor: &BackgroundExecutor, future: impl Future<Outpu
panic!("future did not complete while running edit_file_tool benchmark");
}
fn streamed_partial_payloads(old_text: &str, new_text: &str) -> Vec<Value> {
let path = "root/src/workspace_snapshot.rs";
let mut payloads = Vec::new();
/// Builds the streamed partial payloads for a (possibly multi-edit) session,
/// mirroring how the agent reveals one edit at a time: earlier edits stay
/// complete in the array while the current edit streams its `old_text` then its
/// `new_text` in chunks.
fn streamed_partial_payloads(edits: &[EditOp]) -> Vec<Value> {
let path = FILE_PROJECT_PATH;
let mut payloads = vec![json!({ "path": path }), json!({ "path": path })];
payloads.push(json!({ "path": path }));
payloads.push(json!({ "path": path }));
for index in 0..edits.len() {
let completed: Vec<Value> = edits[..index]
.iter()
.map(|edit| json!({ "old_text": edit.old_text, "new_text": edit.new_text }))
.collect();
let edit = &edits[index];
for old_end in chunk_ends(old_text, OLD_TEXT_CHUNK_SIZE) {
payloads.push(json!({
"path": path,
"edits": [{ "old_text": &old_text[..old_end] }],
}));
}
for old_end in chunk_ends(&edit.old_text, OLD_TEXT_CHUNK_SIZE) {
let mut arr = completed.clone();
arr.push(json!({ "old_text": &edit.old_text[..old_end] }));
payloads.push(json!({ "path": path, "edits": arr }));
}
payloads.push(json!({
"path": path,
"edits": [{ "old_text": old_text, "new_text": "" }],
}));
let mut arr = completed.clone();
arr.push(json!({ "old_text": edit.old_text, "new_text": "" }));
payloads.push(json!({ "path": path, "edits": arr }));
for new_end in chunk_ends(new_text, NEW_TEXT_CHUNK_SIZE) {
payloads.push(json!({
"path": path,
"edits": [{
"old_text": old_text,
"new_text": &new_text[..new_end],
}],
}));
for new_end in chunk_ends(&edit.new_text, NEW_TEXT_CHUNK_SIZE) {
let mut arr = completed.clone();
arr.push(json!({ "old_text": edit.old_text, "new_text": &edit.new_text[..new_end] }));
payloads.push(json!({ "path": path, "edits": arr }));
}
}
payloads
@ -326,6 +454,7 @@ fn fixtures() -> Vec<EditFixture> {
EditPattern::InsertHelperBlocks { every_nth_line: 9 },
SEED + 3,
),
make_large_multi_edit_fixture("large_multi_edit", 80, 16, SEED + 4),
]
}
@ -375,11 +504,106 @@ fn make_fixture(
name,
old_file_text,
expected_file_text,
old_text,
new_text,
edits: vec![EditOp { old_text, new_text }],
}
}
fn make_large_multi_edit_fixture(
name: &'static str,
function_count: usize,
edit_count: usize,
seed: u64,
) -> EditFixture {
const HEADER_LINES: usize = 10;
const FUNCTION_LINES: usize = 12;
const FUNCTION_BODY_LINES: usize = 11;
let mut rng = StdRng::seed_from_u64(seed);
let old_lines = random_rust_module(&mut rng, function_count);
let old_file_text = old_lines.join("\n");
let step = (function_count / edit_count).max(1);
let mut picks: Vec<usize> = (0..edit_count)
.map(|k| (k * step).min(function_count - 1))
.collect();
picks.dedup();
let replacements: Vec<(usize, Vec<String>)> = picks
.iter()
.map(|&function_index| {
(
function_index,
large_function_lines(&mut rng, function_index),
)
})
.collect();
let edits = replacements
.iter()
.map(|(function_index, new_function)| {
let start = HEADER_LINES + function_index * FUNCTION_LINES;
let end = start + FUNCTION_BODY_LINES;
EditOp {
old_text: old_lines[start..end].join("\n"),
new_text: new_function.join("\n"),
}
})
.collect();
let mut new_lines = old_lines;
for (function_index, new_function) in replacements.iter().rev() {
let start = HEADER_LINES + function_index * FUNCTION_LINES;
let end = start + FUNCTION_BODY_LINES;
new_lines.splice(start..end, new_function.iter().cloned());
}
let expected_file_text = new_lines.join("\n");
EditFixture {
name,
old_file_text,
expected_file_text,
edits,
}
}
fn large_function_lines(rng: &mut StdRng, index: usize) -> Vec<String> {
let function_name = identifier(rng, index + 40_000);
let argument_name = identifier(rng, index + 41_000);
let mut lines = vec![
format!(
" pub fn {function_name}(&mut self, {argument_name}: usize) -> Result<usize> {{"
),
format!(" let mut accumulator = {argument_name};"),
];
let body_lines = rng.random_range(30..42);
for body_index in 0..body_lines {
let local_name = identifier(rng, index + 50_000 + body_index);
let multiplier = rng.random_range(2..19);
let offset = rng.random_range(1..256);
match body_index % 4 {
0 => lines.push(format!(
" let {local_name} = accumulator.saturating_mul({multiplier}).saturating_add({offset});"
)),
1 => lines.push(format!(
" accumulator = {local_name}.saturating_sub(self.version % {offset}.max(1));"
)),
2 => lines.push(format!(
" if {local_name} % {multiplier} == 0 {{ accumulator = accumulator.saturating_add({local_name}); }}"
)),
_ => lines.push(format!(
" self.buffers.insert(\"{local_name}\".to_string(), accumulator);"
)),
}
}
lines.push(" self.version = self.version.saturating_add(accumulator);".to_string());
lines.push(" Ok(accumulator)".to_string());
lines.push(" }".to_string());
lines
}
fn edit_range(lines: &[String], pattern: &EditPattern) -> std::ops::Range<usize> {
let mut range = match pattern {
EditPattern::LocalizedRewrite {

View file

@ -4069,63 +4069,6 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) {
});
}
#[gpui::test]
async fn test_send_retry_on_http_send_error(cx: &mut TestAppContext) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
let mut events = thread
.update(cx, |thread, cx| {
thread.send(UserMessageId::new(), ["Hello!"], cx)
})
.expect("thread send should start");
cx.run_until_parked();
fake_model.send_last_completion_stream_error(LanguageModelCompletionError::HttpSend {
provider: LanguageModelProviderName::new("OpenAI"),
error: anyhow::anyhow!("response headers timed out after 10s"),
});
fake_model.end_last_completion_stream();
cx.executor().advance_clock(BASE_RETRY_DELAY);
cx.run_until_parked();
fake_model.send_last_completion_stream_text_chunk("Recovered!");
fake_model.end_last_completion_stream();
cx.run_until_parked();
let mut retry_events = Vec::new();
while let Some(Ok(event)) = events.next().await {
match event {
ThreadEvent::Retry(retry_status) => {
retry_events.push(retry_status);
}
ThreadEvent::Stop(..) => break,
_ => {}
}
}
assert_eq!(retry_events.len(), 1);
assert!(matches!(
retry_events[0],
acp_thread::RetryStatus { attempt: 1, .. }
));
thread.read_with(cx, |thread, _cx| {
assert_eq!(
thread.to_markdown(),
indoc! {"
## User
Hello!
## Assistant
Recovered!
"}
)
});
}
#[gpui::test]
async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;

View file

@ -316,17 +316,6 @@ impl UserMessage {
MentionUri::Thread { .. } => {
write!(&mut thread_context, "\n{}\n", content).ok();
}
MentionUri::Rule { .. } => {
write!(
&mut rules_context,
"\n{}",
MarkdownCodeBlock {
tag: "",
text: content
}
)
.ok();
}
MentionUri::Fetch { url } => {
write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok();
}

View file

@ -620,21 +620,14 @@ impl EditPipeline {
log::debug!("new_text_chunk: done=true, final_text='{}'", final_text);
if !final_text.is_empty() {
let char_ops = streaming_diff.push_new(&final_text);
apply_char_operations(
&char_ops,
buffer,
&original_snapshot,
&mut edit_cursor,
&context.action_log,
cx,
);
}
let remaining_ops = streaming_diff.finish();
let mut char_ops = if final_text.is_empty() {
Vec::new()
} else {
streaming_diff.push_new(&final_text)
};
char_ops.extend(streaming_diff.finish());
apply_char_operations(
&remaining_ops,
&char_ops,
buffer,
&original_snapshot,
&mut edit_cursor,
@ -902,16 +895,17 @@ fn apply_char_operations(
action_log: &Entity<ActionLog>,
cx: &mut AsyncApp,
) {
let mut edits: Vec<_> = Vec::new();
for op in ops {
match op {
CharOperation::Insert { text } => {
let anchor = snapshot.anchor_after(*edit_cursor);
agent_edit_buffer(&buffer, [(anchor..anchor, text.as_str())], action_log, cx);
edits.push((anchor..anchor, text.as_str().into()));
}
CharOperation::Delete { bytes } => {
let delete_end = *edit_cursor + bytes;
let anchor_range = snapshot.anchor_range_inside(*edit_cursor..delete_end);
agent_edit_buffer(&buffer, [(anchor_range, "")], action_log, cx);
edits.push((anchor_range, Arc::<str>::from("")));
*edit_cursor = delete_end;
}
CharOperation::Keep { bytes } => {
@ -919,6 +913,9 @@ fn apply_char_operations(
}
}
}
if !edits.is_empty() {
agent_edit_buffer(buffer, edits, action_log, cx);
}
}
fn extract_match(

View file

@ -89,7 +89,6 @@ release_channel.workspace = true
remote.workspace = true
remote_connection.workspace = true
rope.workspace = true
rules_library.workspace = true
skill_creator.workspace = true
schemars.workspace = true
serde.workspace = true

View file

@ -78,7 +78,6 @@ use gpui::{
use language::LanguageRegistry;
use language_model::LanguageModelRegistry;
use project::{Project, ProjectPath, Worktree};
use prompt_store::PromptStore;
use settings::TerminalDockPosition;
use settings::{NotifyWhenAgentWaiting, Settings, update_settings_file};
use skill_creator::{SkillCreatorOpenMode, is_supported_skill_url, open_skill_creator};
@ -1049,7 +1048,6 @@ pub struct AgentPanel {
fs: Arc<dyn Fs>,
language_registry: Arc<LanguageRegistry>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
connection_store: Entity<AgentConnectionStore>,
context_server_registry: Entity<ContextServerRegistry>,
configuration: Option<Entity<AgentConfiguration>>,
@ -1170,13 +1168,8 @@ impl AgentPanel {
workspace: WeakEntity<Workspace>,
mut cx: AsyncWindowContext,
) -> Task<Result<Entity<Self>>> {
let prompt_store = cx.update(|_window, cx| PromptStore::global(cx));
let kvp = cx.update(|_window, cx| KeyValueStore::global(cx)).ok();
cx.spawn(async move |cx| {
let prompt_store = match prompt_store {
Ok(prompt_store) => prompt_store.await.ok(),
Err(_) => None,
};
let workspace_id = workspace
.read_with(cx, |workspace, _| workspace.database_id())
.ok()
@ -1301,7 +1294,7 @@ impl AgentPanel {
};
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| Self::new(workspace, prompt_store, window, cx));
let panel = cx.new(|cx| Self::new(workspace, window, cx));
panel.update(cx, |panel, cx| {
let is_via_collab = panel.project.read(cx).is_via_collab();
@ -1381,12 +1374,7 @@ impl AgentPanel {
})
}
pub(crate) fn new(
workspace: &Workspace,
prompt_store: Option<Entity<PromptStore>>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
pub(crate) fn new(workspace: &Workspace, _window: &mut Window, cx: &mut Context<Self>) -> Self {
let fs = workspace.app_state().fs.clone();
let user_store = workspace.app_state().user_store.clone();
let project = workspace.project();
@ -1468,7 +1456,6 @@ impl AgentPanel {
project: project.clone(),
fs: fs.clone(),
language_registry,
prompt_store,
connection_store,
configuration: None,
configuration_subscription: None,
@ -1547,10 +1534,6 @@ impl AgentPanel {
}
}
pub(crate) fn prompt_store(&self) -> &Option<Entity<PromptStore>> {
&self.prompt_store
}
pub fn thread_store(&self) -> &Entity<ThreadStore> {
&self.thread_store
}
@ -4395,7 +4378,6 @@ impl AgentPanel {
workspace.clone(),
project,
thread_store,
self.prompt_store.clone(),
source,
window,
cx,
@ -6087,7 +6069,7 @@ impl Dismissable for TrialEndUpsell {
#[cfg(any(test, feature = "test-support"))]
impl AgentPanel {
pub fn test_new(workspace: &Workspace, window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new(workspace, None, window, cx)
Self::new(workspace, window, cx)
}
/// Drops a thread's `ConversationView` from `retained_threads` without
@ -6594,7 +6576,7 @@ mod tests {
// Set up workspace A: with an active thread.
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
cx.new(|cx| AgentPanel::new(workspace, window, cx))
});
panel_a.update_in(cx, |panel, window, cx| {
@ -6620,7 +6602,7 @@ mod tests {
// Set up workspace B: ClaudeCode, no active thread.
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
cx.new(|cx| AgentPanel::new(workspace, window, cx))
});
panel_b.update(cx, |panel, _cx| {
@ -6723,7 +6705,7 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
cx.new(|cx| AgentPanel::new(workspace, window, cx))
});
panel.update_in(cx, |panel, window, cx| {
@ -6918,7 +6900,7 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
cx.new(|cx| AgentPanel::new(workspace, window, cx))
});
panel_a
.update_in(cx, |panel, window, cx| {
@ -6995,7 +6977,7 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
cx.new(|cx| AgentPanel::new(workspace, window, cx))
});
panel.update_in(cx, |panel, window, cx| {
@ -7087,7 +7069,7 @@ mod tests {
});
let panel = workspace.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
cx.new(|cx| AgentPanel::new(workspace, window, cx))
});
// Open a restored thread using a flaky server so the initial connect
@ -7186,7 +7168,7 @@ mod tests {
.unwrap();
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -7286,12 +7268,12 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -7666,7 +7648,7 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -7853,7 +7835,7 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -8082,7 +8064,7 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -8168,7 +8150,7 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -8258,7 +8240,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
cx.new(|cx| AgentPanel::new(workspace, window, cx))
});
(panel, cx)
@ -8305,7 +8287,7 @@ mod tests {
register_test_sidebar(threads_list_active, &mut cx);
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
workspace.focus_panel::<AgentPanel>(window, cx);
panel
@ -8435,7 +8417,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -8548,7 +8530,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -9791,7 +9773,7 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -10383,7 +10365,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
cx.new(|cx| AgentPanel::new(workspace, window, cx))
});
// Open thread A and send a message. With empty next_prompt_updates it
@ -10652,7 +10634,7 @@ mod tests {
// Set up workspace A with agent_a
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
cx.new(|cx| AgentPanel::new(workspace, window, cx))
});
panel_a.update(cx, |panel, _cx| {
panel.selected_agent = agent_a.clone();
@ -10660,7 +10642,7 @@ mod tests {
// Set up workspace B with agent_b
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
cx.new(|cx| AgentPanel::new(workspace, window, cx))
});
panel_b.update(cx, |panel, _cx| {
panel.selected_agent = agent_b.clone();
@ -10731,7 +10713,7 @@ mod tests {
};
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -10788,7 +10770,7 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -10878,7 +10860,7 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -10966,7 +10948,7 @@ mod tests {
workspace.update(cx, |workspace, _cx| workspace.set_random_database_id());
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -11076,7 +11058,7 @@ mod tests {
workspace.update(cx, |workspace, _cx| workspace.set_random_database_id());
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -11182,7 +11164,7 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -11681,7 +11663,7 @@ mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
cx.new(|cx| AgentPanel::new(workspace, window, cx))
});
cx.run_until_parked();
@ -11782,7 +11764,7 @@ mod tests {
// Create the agent panel and add it to the workspace.
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -11992,7 +11974,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -12229,7 +12211,7 @@ mod tests {
// Set up panel_a with an active thread and type draft text.
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -12253,7 +12235,7 @@ mod tests {
// Set up panel_b on workspace_b — starts as a fresh, empty panel.
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -12323,7 +12305,7 @@ mod tests {
// Set up panel_a with draft text.
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -12347,7 +12329,7 @@ mod tests {
// Set up panel_b with its OWN content — this is a non-fresh panel.
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
@ -12391,42 +12373,4 @@ mod tests {
);
});
}
/// Regression test: NewThread must produce a connected thread even when
/// the PromptStore fails to initialize (e.g. LMDB permission error).
/// Before the fix, `NativeAgentServer::connect` propagated the
/// PromptStore error with `?`, which put every new ConversationView
/// into LoadError and made it impossible to start any native-agent
/// thread.
#[gpui::test]
async fn test_new_thread_with_prompt_store_error(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
// NativeAgentServer::connect needs a global Fs.
let fs = FakeFs::new(cx.executor());
cx.update(|_, cx| {
<dyn fs::Fs>::set_global(fs.clone(), cx);
});
cx.run_until_parked();
// Dispatch NewThread, which goes through the real NativeAgentServer
// path. In tests the PromptStore LMDB open fails with
// "Permission denied"; the fix (.log_err() instead of ?) lets
// the connection succeed anyway.
panel.update_in(&mut cx, |panel, window, cx| {
panel.new_thread(&NewThread, window, cx);
});
cx.run_until_parked();
panel.read_with(&cx, |panel, cx| {
assert!(
panel.active_conversation_view().is_some(),
"panel should have a conversation view after NewThread"
);
assert!(
panel.active_agent_thread(cx).is_some(),
"panel should have an active, connected agent thread"
);
});
}
}

View file

@ -58,7 +58,7 @@ use language_model::{
ConfiguredModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
};
use project::{AgentId, DisableAiSettings};
use prompt_store::{PromptBuilder, rules_to_skills_migration};
use prompt_store::{self, PromptBuilder, rules_to_skills_migration};
use rope::Point;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@ -550,7 +550,7 @@ pub fn init(
cx: &mut App,
) {
agent::ThreadStore::init_global(cx);
rules_library::init(cx);
prompt_store::init(cx);
skill_creator::init(cx);
if !is_eval {
// Initializing the language model from the user settings messes with the eval, so we only initialize them when

View file

@ -42,7 +42,6 @@ use markdown::{
};
use parking_lot::{Mutex, RwLock};
use project::{AgentId, AgentServerStore, Project, ProjectEntryId, ProjectPath};
use prompt_store::{PromptId, PromptStore};
use crate::message_editor::SessionCapabilities;
use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image};
@ -75,7 +74,6 @@ use workspace::{
path_link::sanitize_path_text,
};
use zed_actions::agent::{Chat, ToggleModelSelector};
use zed_actions::assistant::OpenRulesLibrary;
use super::config_options::ConfigOptionsView;
use super::entry_view_state::EntryViewState;
@ -531,7 +529,6 @@ pub struct ConversationView {
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
pub(crate) thread_id: ThreadId,
pub(crate) root_session_id: Option<acp::SessionId>,
server_state: ServerState,
@ -738,7 +735,6 @@ impl ConversationView {
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
source: AgentThreadSource,
window: &mut Window,
cx: &mut Context<Self>,
@ -795,7 +791,6 @@ impl ConversationView {
workspace,
project: project.clone(),
thread_store,
prompt_store,
thread_id,
root_session_id: resume_session_id.clone(),
server_state: Self::initial_state(
@ -1104,7 +1099,6 @@ impl ConversationView {
self.workspace.clone(),
self.project.downgrade(),
self.thread_store.clone(),
self.prompt_store.clone(),
session_capabilities.clone(),
self.agent.agent_id(),
)
@ -1273,7 +1267,6 @@ impl ConversationView {
self.project.downgrade(),
self.code_span_resolver.clone(),
self.thread_store.clone(),
self.prompt_store.clone(),
initial_content,
subscriptions,
window,
@ -2492,7 +2485,6 @@ impl ConversationView {
workspace.clone(),
project.clone(),
None,
None,
session_capabilities.clone(),
agent_name.clone(),
"",
@ -3721,7 +3713,6 @@ pub(crate) mod tests {
workspace.downgrade(),
project,
Some(thread_store),
None,
AgentThreadSource::AgentPanel,
window,
cx,
@ -3858,7 +3849,6 @@ pub(crate) mod tests {
workspace.downgrade(),
project,
Some(thread_store),
None,
AgentThreadSource::AgentPanel,
window,
cx,
@ -3940,7 +3930,6 @@ pub(crate) mod tests {
workspace.downgrade(),
project,
Some(thread_store),
None,
AgentThreadSource::AgentPanel,
window,
cx,
@ -4079,7 +4068,6 @@ pub(crate) mod tests {
workspace.downgrade(),
project.clone(),
Some(thread_store),
None,
AgentThreadSource::AgentPanel,
window,
cx,
@ -4364,7 +4352,7 @@ pub(crate) mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| crate::AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
workspace.focus_panel::<crate::AgentPanel>(window, cx);
panel
@ -4405,7 +4393,6 @@ pub(crate) mod tests {
workspace.downgrade(),
project.clone(),
Some(thread_store),
None,
AgentThreadSource::AgentPanel,
window,
cx,
@ -4504,7 +4491,6 @@ pub(crate) mod tests {
workspace.downgrade(),
project.clone(),
Some(thread_store),
None,
AgentThreadSource::AgentPanel,
window,
cx,
@ -4580,7 +4566,6 @@ pub(crate) mod tests {
workspace.downgrade(),
project.clone(),
Some(thread_store),
None,
AgentThreadSource::AgentPanel,
window,
cx,
@ -4648,7 +4633,6 @@ pub(crate) mod tests {
workspace.downgrade(),
project.clone(),
Some(thread_store),
None,
AgentThreadSource::AgentPanel,
window,
cx,
@ -4724,7 +4708,7 @@ pub(crate) mod tests {
let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
let panel = workspace1.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx));
let panel = cx.new(|cx| crate::AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
// Open the dock and activate the agent panel so it's visible
@ -4770,7 +4754,6 @@ pub(crate) mod tests {
workspace1.downgrade(),
project1.clone(),
Some(thread_store),
None,
AgentThreadSource::AgentPanel,
window,
cx,
@ -4992,7 +4975,6 @@ pub(crate) mod tests {
workspace.downgrade(),
project,
Some(thread_store),
None,
AgentThreadSource::AgentPanel,
window,
cx,
@ -5651,7 +5633,6 @@ pub(crate) mod tests {
workspace.downgrade(),
project.clone(),
Some(thread_store.clone()),
None,
AgentThreadSource::AgentPanel,
window,
cx,
@ -8113,9 +8094,17 @@ pub(crate) mod tests {
async fn test_permission_row_hidden_when_inline_bounds_unavailable(cx: &mut TestAppContext) {
init_test(cx);
let (_view, thread_view, _entry_ix, cx) =
let (_view, thread_view, entry_ix, cx) =
setup_pending_permission_thread("perm-no-bounds", cx).await;
// Pin the scroll top to the entry so it isn't treated as above the
// viewport, forcing the unmeasured-bounds path we want to exercise.
thread_view.read_with(cx, |view, _cx| {
view.list_state.scroll_to(ListOffset {
item_ix: entry_ix,
offset_in_item: px(0.0),
});
});
thread_view.update_in(cx, |view, window, cx| {
assert!(
view.render_main_agent_awaiting_permission(window, cx)
@ -8176,8 +8165,8 @@ pub(crate) mod tests {
let (_view, thread_view, entry_ix, cx) =
setup_pending_permission_thread("perm-scroll", cx).await;
// Start off-screen below the viewport — row visible because the item
// has bounds that do not intersect the viewport.
// Start off-screen below the viewport. The row is visible because the
// item has bounds that do not intersect the viewport.
draw_thread_list_at(
&thread_view,
ListOffset {
@ -8221,6 +8210,69 @@ pub(crate) mod tests {
});
}
#[gpui::test]
async fn test_permission_row_shown_when_inline_prompt_is_above_viewport(
cx: &mut TestAppContext,
) {
init_test(cx);
let (_view, thread_view, entry_ix, cx) =
setup_pending_permission_thread("perm-above", cx).await;
let thread = thread_view.read_with(cx, |view, _cx| view.thread.clone());
thread.update(cx, |thread, cx| {
let result = thread.handle_session_update(
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
"More content".into(),
)),
cx,
);
assert!(
result.is_ok(),
"following assistant message should be accepted"
);
});
draw_thread_list_at(
&thread_view,
ListOffset {
item_ix: entry_ix + 1,
offset_in_item: px(0.0),
},
cx,
);
thread_view.read_with(cx, |view, _cx| {
assert!(
entry_ix < view.list_state.logical_scroll_top().item_ix,
"The tool call entry should be above the logical scroll top"
);
});
thread_view.update_in(cx, |view, window, cx| {
assert!(
view.render_main_agent_awaiting_permission(window, cx)
.is_some(),
"Floating row should be visible when the inline prompt is above the viewport"
);
});
// Scrolling up to the entry brings it back into view.
draw_thread_list_at(
&thread_view,
ListOffset {
item_ix: entry_ix,
offset_in_item: px(0.0),
},
cx,
);
thread_view.update_in(cx, |view, window, cx| {
assert!(
view.render_main_agent_awaiting_permission(window, cx)
.is_none(),
"Floating row should disappear after scrolling brings the inline prompt into view"
);
});
}
#[gpui::test]
async fn test_permission_row_disappears_when_authorized(cx: &mut TestAppContext) {
init_test(cx);
@ -8556,7 +8608,6 @@ pub(crate) mod tests {
workspace.downgrade(),
project,
Some(thread_store),
None,
AgentThreadSource::AgentPanel,
window,
cx,

View file

@ -683,7 +683,6 @@ impl ThreadView {
project: WeakEntity<Project>,
code_span_resolver: AgentCodeSpanResolver,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
initial_content: Option<AgentInitialContent>,
mut subscriptions: Vec<Subscription>,
window: &mut Window,
@ -703,7 +702,6 @@ impl ThreadView {
workspace.clone(),
project.clone(),
thread_store,
prompt_store,
session_capabilities.clone(),
agent_id.clone(),
&placeholder,
@ -3047,15 +3045,6 @@ impl ThreadView {
)
}
/// Returns true when the entry has been measured and sits entirely below
/// the current viewport.
fn entry_is_below_viewport(&self, entry_ix: usize) -> bool {
let viewport_bounds = self.list_state.viewport_bounds();
self.list_state
.bounds_for_item(entry_ix)
.is_some_and(|entry_bounds| entry_bounds.top() >= viewport_bounds.bottom())
}
pub(crate) fn render_main_agent_awaiting_permission(
&self,
window: &Window,
@ -3073,9 +3062,13 @@ impl ThreadView {
let thread = self.thread.read(cx);
let (entry_ix, tool_call) = thread.tool_call(&tool_call_id)?;
if !self.entry_is_below_viewport(entry_ix) {
let scroll_icon = if self.list_state.item_is_above_viewport(entry_ix)? {
IconName::ArrowUp
} else if self.list_state.item_is_below_viewport(entry_ix)? {
IconName::ArrowDown
} else {
return None;
}
};
let focus_handle = self.focus_handle(cx);
@ -3118,7 +3111,7 @@ impl ThreadView {
Button::new("main-agent-permission-scroll-to", "Scroll")
.label_size(LabelSize::Small)
.end_icon(
Icon::new(IconName::ArrowDown)
Icon::new(scroll_icon)
.size(IconSize::XSmall)
.color(Color::Default),
)
@ -10014,17 +10007,6 @@ pub(crate) fn open_link(
});
}
}
MentionUri::Rule { id, .. } => {
let PromptId::User { uuid } = id else {
return;
};
window.dispatch_action(
Box::new(OpenRulesLibrary {
prompt_to_select: Some(uuid.0),
}),
cx,
)
}
MentionUri::Fetch { url } => {
cx.open_url(url.as_str());
}

View file

@ -10,8 +10,7 @@ use gpui::{
ScrollHandle, TextStyleRefinement, WeakEntity, Window,
};
use language::language_settings::SoftWrap;
use project::{AgentId, Project};
use prompt_store::PromptStore;
use project::{AgentId, Project, project_settings::DiagnosticSeverity};
use rope::Point;
use settings::Settings as _;
use terminal_view::TerminalView;
@ -25,7 +24,6 @@ pub struct EntryViewState {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
entries: Vec<Entry>,
session_capabilities: SharedSessionCapabilities,
agent_id: AgentId,
@ -36,7 +34,6 @@ impl EntryViewState {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
session_capabilities: SharedSessionCapabilities,
agent_id: AgentId,
) -> Self {
@ -44,7 +41,6 @@ impl EntryViewState {
workspace,
project,
thread_store,
prompt_store,
entries: Vec::new(),
session_capabilities,
agent_id,
@ -86,7 +82,6 @@ impl EntryViewState {
self.workspace.clone(),
self.project.clone(),
self.thread_store.clone(),
self.prompt_store.clone(),
self.session_capabilities.clone(),
self.agent_id.clone(),
"Edit message @ to include context",
@ -444,7 +439,8 @@ fn create_editor_diff(
cx,
);
editor.set_show_gutter(false, cx);
editor.disable_inline_diagnostics();
editor.disable_diagnostics(cx);
editor.set_max_diagnostics_severity(DiagnosticSeverity::Off, cx);
editor.disable_expand_excerpt_buttons(cx);
editor.set_show_vertical_scrollbar(false, cx);
editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
@ -545,7 +541,6 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store,
None,
Arc::new(RwLock::new(SessionCapabilities::default())),
"Test Agent".into(),
)

View file

@ -43,7 +43,7 @@ use language_model::{ConfigurationError, ConfiguredModel, LanguageModelRegistry}
use multi_buffer::MultiBufferRow;
use parking_lot::Mutex;
use project::{DisableAiSettings, Project};
use prompt_store::{PromptBuilder, PromptStore};
use prompt_store::PromptBuilder;
use settings::{Settings, SettingsStore};
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
@ -228,7 +228,6 @@ impl InlineAssistant {
};
let agent_panel = agent_panel.read(cx);
let prompt_store = agent_panel.prompt_store().as_ref().cloned();
let thread_store = agent_panel.thread_store().clone();
let handle_assist =
@ -240,7 +239,6 @@ impl InlineAssistant {
cx.entity().downgrade(),
workspace.project().downgrade(),
thread_store,
prompt_store,
action.prompt.clone(),
window,
cx,
@ -254,7 +252,6 @@ impl InlineAssistant {
cx.entity().downgrade(),
workspace.project().downgrade(),
thread_store,
prompt_store,
action.prompt.clone(),
window,
cx,
@ -437,7 +434,6 @@ impl InlineAssistant {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
initial_prompt: Option<String>,
window: &mut Window,
codegen_ranges: &[Range<Anchor>],
@ -483,7 +479,6 @@ impl InlineAssistant {
session_id,
self.fs.clone(),
thread_store.clone(),
prompt_store.clone(),
project.clone(),
workspace.clone(),
window,
@ -574,7 +569,6 @@ impl InlineAssistant {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
initial_prompt: Option<String>,
window: &mut Window,
cx: &mut App,
@ -592,7 +586,6 @@ impl InlineAssistant {
workspace,
project,
thread_store,
prompt_store,
initial_prompt,
window,
&codegen_ranges,
@ -1915,7 +1908,6 @@ pub mod evals {
workspace.downgrade(),
project.downgrade(),
thread_store,
None,
Some(prompt),
window,
cx,

View file

@ -17,7 +17,6 @@ use language_model::{LanguageModel, LanguageModelRegistry};
use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
use parking_lot::Mutex;
use project::Project;
use prompt_store::PromptStore;
use settings::Settings;
use std::cmp;
use std::ops::Range;
@ -1237,7 +1236,6 @@ impl PromptEditor<BufferCodegen> {
session_id: Uuid,
fs: Arc<dyn Fs>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
project: WeakEntity<Project>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
@ -1276,8 +1274,7 @@ impl PromptEditor<BufferCodegen> {
editor
});
let mention_set = cx
.new(|_cx| MentionSet::new(project, Some(thread_store.clone()), prompt_store.clone()));
let mention_set = cx.new(|_cx| MentionSet::new(project, Some(thread_store.clone())));
let model_selector_menu_handle = PopoverMenuHandle::default();
@ -1393,7 +1390,6 @@ impl PromptEditor<TerminalCodegen> {
session_id: Uuid,
fs: Arc<dyn Fs>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
project: WeakEntity<Project>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
@ -1427,8 +1423,7 @@ impl PromptEditor<TerminalCodegen> {
editor
});
let mention_set = cx
.new(|_cx| MentionSet::new(project, Some(thread_store.clone()), prompt_store.clone()));
let mention_set = cx.new(|_cx| MentionSet::new(project, Some(thread_store.clone())));
let model_selector_menu_handle = PopoverMenuHandle::default();
@ -1705,7 +1700,6 @@ mod tests {
session_id,
fs,
thread_store,
None,
project,
workspace.downgrade(),
window,

View file

@ -22,7 +22,6 @@ use language_model::{LanguageModelImage, LanguageModelImageExt};
use multi_buffer::MultiBufferRow;
use postage::stream::Stream as _;
use project::{Project, ProjectItem, ProjectPath, Worktree};
use prompt_store::{PromptId, PromptStore};
use rope::Point;
use std::{
cell::RefCell,
@ -61,21 +60,15 @@ pub struct MentionImage {
pub struct MentionSet {
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
mentions: HashMap<CreaseId, (MentionUri, MentionTask)>,
crease_entities: HashMap<CreaseId, Entity<LoadingContext>>,
}
impl MentionSet {
pub fn new(
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
) -> Self {
pub fn new(project: WeakEntity<Project>, thread_store: Option<Entity<ThreadStore>>) -> Self {
Self {
project,
thread_store,
prompt_store,
mentions: HashMap::default(),
crease_entities: HashMap::default(),
}
@ -153,7 +146,6 @@ impl MentionSet {
line_range,
..
} => self.confirm_mention_for_symbol(abs_path, line_range, cx),
MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
MentionUri::Skill {
skill_file_path, ..
} => self.confirm_mention_for_skill(skill_file_path, cx),
@ -327,7 +319,6 @@ impl MentionSet {
line_range,
..
} => self.confirm_mention_for_symbol(abs_path, line_range, cx),
MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
MentionUri::Skill {
skill_file_path, ..
} => self.confirm_mention_for_skill(skill_file_path, cx),
@ -515,24 +506,6 @@ impl MentionSet {
})
}
fn confirm_mention_for_rule(
&mut self,
id: PromptId,
cx: &mut Context<Self>,
) -> Task<Result<Mention>> {
let Some(prompt_store) = self.prompt_store.as_ref() else {
return Task::ready(Err(anyhow!("Missing prompt store")));
};
let prompt = prompt_store.read(cx).load(id, cx);
cx.spawn(async move |_, _| {
let prompt = prompt.await?;
Ok(Mention::Text {
content: prompt,
tracked_buffers: Vec::new(),
})
})
}
pub fn confirm_mention_for_selection(
&mut self,
source_range: Range<text::Anchor>,
@ -773,7 +746,7 @@ mod tests {
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let thread_store = None;
let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store, None));
let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store));
let task = mention_set.update(cx, |mention_set, cx| {
mention_set.confirm_mention_for_thread(acp::SessionId::new("thread-1"), cx)
@ -799,7 +772,7 @@ mod tests {
)
.await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), None, None));
let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), None));
let mention_task = mention_set.update(cx, |mention_set, cx| {
let http_client = project.read(cx).client().http_client();

View file

@ -33,7 +33,6 @@ use project::AgentId;
use project::{
CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, ProjectPath, Worktree,
};
use prompt_store::PromptStore;
use rope::Point;
use settings::Settings;
use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc};
@ -453,7 +452,6 @@ impl MessageEditor {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
session_capabilities: SharedSessionCapabilities,
agent_id: AgentId,
placeholder: &str,
@ -506,8 +504,7 @@ impl MessageEditor {
editor
});
let mention_set =
cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone()));
let mention_set = cx.new(|_cx| MentionSet::new(project, thread_store.clone()));
let completion_provider = Rc::new(PromptCompletionProvider::new(
MessageEditorCompletionDelegate {
session_capabilities: session_capabilities.clone(),
@ -2475,7 +2472,6 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -2576,7 +2572,6 @@ mod tests {
workspace_handle.clone(),
project.downgrade(),
thread_store.clone(),
None,
session_capabilities.clone(),
"Claude Agent".into(),
"Test",
@ -2742,7 +2737,6 @@ mod tests {
workspace_handle,
project.downgrade(),
thread_store.clone(),
None,
session_capabilities.clone(),
"Test Agent".into(),
"Test",
@ -2915,7 +2909,6 @@ mod tests {
workspace_handle,
project.downgrade(),
None,
None,
session_capabilities.clone(),
"Test Agent".into(),
"Test",
@ -3064,7 +3057,6 @@ mod tests {
workspace_handle,
project.downgrade(),
Some(thread_store),
None,
session_capabilities.clone(),
"Test Agent".into(),
"Test",
@ -3556,7 +3548,6 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -3657,7 +3648,6 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -3726,7 +3716,6 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -3779,7 +3768,6 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -3836,7 +3824,6 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -3894,7 +3881,6 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -3956,7 +3942,6 @@ mod tests {
workspace_handle,
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -4116,7 +4101,6 @@ mod tests {
workspace_handle,
project.downgrade(),
thread_store.clone(),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -4236,7 +4220,6 @@ mod tests {
workspace_handle,
project.downgrade(),
Some(thread_store.clone()),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -4315,7 +4298,6 @@ mod tests {
workspace_handle,
project.downgrade(),
Some(thread_store),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -4493,7 +4475,6 @@ mod tests {
workspace_handle,
project.downgrade(),
Some(thread_store),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -4905,7 +4886,6 @@ mod tests {
workspace_handle,
project.downgrade(),
Some(thread_store),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -5160,7 +5140,6 @@ mod tests {
workspace_handle,
project.downgrade(),
Some(thread_store),
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -5253,7 +5232,6 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
None,
None,
Default::default(),
"Test Agent".into(),
"Test",
@ -5402,7 +5380,6 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
None,
None,
Default::default(),
"Test Agent".into(),
"Test",

View file

@ -22,7 +22,7 @@ use language_models::provider::anthropic::telemetry::{
AnthropicCompletionType, AnthropicEventData, AnthropicEventType, report_anthropic_event,
};
use project::Project;
use prompt_store::{PromptBuilder, PromptStore};
use prompt_store::PromptBuilder;
use std::sync::Arc;
use terminal_view::TerminalView;
use ui::prelude::*;
@ -64,7 +64,6 @@ impl TerminalInlineAssistant {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
initial_prompt: Option<String>,
window: &mut Window,
cx: &mut App,
@ -89,7 +88,6 @@ impl TerminalInlineAssistant {
session_id,
self.fs.clone(),
thread_store.clone(),
prompt_store.clone(),
project.clone(),
workspace.clone(),
window,

View file

@ -1888,7 +1888,7 @@ mod tests {
.unwrap();
let mut vcx = VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace_entity.update_in(&mut vcx, |workspace, window, cx| {
cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx))
cx.new(|cx| crate::AgentPanel::new(workspace, window, cx))
});
(panel, vcx)
}

View file

@ -8,7 +8,6 @@ use gpui::{
pulsating_between,
};
use language::Buffer;
use prompt_store::PromptId;
use rope::Point;
use settings::Settings;
use theme_settings::ThemeSettings;
@ -195,9 +194,6 @@ fn open_mention_uri(
MentionUri::Thread { id, name } => {
open_thread(workspace, id, name, window, cx);
}
MentionUri::Rule { id, .. } => {
open_rule(workspace, id, window, cx);
}
MentionUri::Skill {
skill_file_path, ..
} => {
@ -360,23 +356,3 @@ fn open_thread(
}
});
}
fn open_rule(
_workspace: &mut Workspace,
id: PromptId,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
use zed_actions::assistant::OpenRulesLibrary;
let PromptId::User { uuid } = id else {
return;
};
window.dispatch_action(
Box::new(OpenRulesLibrary {
prompt_to_select: Some(uuid.0),
}),
cx,
);
}

View file

@ -12,9 +12,12 @@ mod real_implementation {
impl Default for EchoCanceller {
fn default() -> Self {
Self(Arc::new(Mutex::new(apm::AudioProcessingModule::new(
true, false, false, false,
))))
// Sound-effect playback only feeds this APM through `process_reverse_stream`
// for AEC reference; gain/HPF/NS would be no-ops here, so we keep the
// original (echo only) configuration via the legacy flag form.
Self(Arc::new(Mutex::new(
apm::AudioProcessingModule::from_flags(true, false, false, false),
)))
}
}

View file

@ -143,7 +143,7 @@ pub enum ChannelEvent {
impl EventEmitter<ChannelEvent> for ChannelStore {}
enum OpenEntityHandle<E> {
enum OpenEntityHandle<E: 'static> {
Open(WeakEntity<E>),
Loading(Shared<Task<Result<Entity<E>, Arc<anyhow::Error>>>>),
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -164,10 +164,12 @@ pub fn lsp_tasks(
},
));
}
lsp_tasks
.entry(source_kind)
.or_insert_with(Vec::new)
.append(&mut new_lsp_tasks);
if !new_lsp_tasks.is_empty() {
lsp_tasks
.entry(source_kind)
.or_insert_with(Vec::new)
.append(&mut new_lsp_tasks);
}
}
}
lsp_tasks.into_iter().collect()

View file

@ -35,6 +35,18 @@ impl FeatureFlag for AgentSharingFeatureFlag {
}
register_feature_flag!(AgentSharingFeatureFlag);
pub struct HandoffFeatureFlag;
impl FeatureFlag for HandoffFeatureFlag {
const NAME: &'static str = "handoff";
type Value = PresenceFlag;
fn enabled_for_staff() -> bool {
false
}
}
register_feature_flag!(HandoffFeatureFlag);
pub struct DiffReviewFeatureFlag;
impl FeatureFlag for DiffReviewFeatureFlag {

View file

@ -5,6 +5,7 @@ use crate::commit_view::CommitView;
use crate::git_panel_settings::GitPanelScrollbarAccessor;
use crate::project_diff::{self, BranchDiff, Diff, ProjectDiff};
use crate::remote_output::{self, RemoteAction, SuccessMessage};
use crate::solo_diff_view::SoloDiffView;
use crate::{branch_picker, picker_prompt, render_remote_button};
use crate::{
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
@ -15,10 +16,7 @@ use anyhow::Context as _;
use askpass::AskPassDelegate;
use collections::{BTreeMap, HashMap, HashSet};
use db::kvp::KeyValueStore;
use editor::{
Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior,
actions::ExpandAllDiffHunks,
};
use editor::{Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior};
use editor::{EditorStyle, RewrapOptions};
use file_icons::FileIcons;
use futures::StreamExt as _;
@ -62,7 +60,7 @@ use project::{
},
project_settings::{GitPathStyle, ProjectSettings},
};
use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES};
use prompt_store::RULES_FILE_NAMES;
use proto::RpcError;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore, StatusStyle, update_settings_file};
@ -85,7 +83,7 @@ use workspace::SERIALIZATION_THROTTLE_TIME;
use workspace::{
Item, Workspace,
dock::{DockPosition, Panel, PanelEvent},
notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyResultExt},
notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyTaskExt},
};
use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize};
@ -1385,63 +1383,22 @@ impl GitPanel {
});
}
fn open_file(
fn open_solo_diff(
&mut self,
_: &menu::SecondaryConfirm,
window: &mut Window,
cx: &mut Context<Self>,
) {
maybe!({
let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
let active_repo = self.active_repository.as_ref()?;
let path = active_repo
.read(cx)
.repo_path_to_project_path(&entry.repo_path, cx)?;
if entry.status.is_deleted() {
return None;
}
let entry = self
.entries
.get(self.selected_entry?)?
.status_entry()?
.clone();
let repository = self.active_repository.clone()?;
let open_task = self
.workspace
.update(cx, |workspace, cx| {
workspace.open_path_preview(path, None, false, false, true, window, cx)
})
.ok()?;
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |_, mut cx| {
let item = open_task
.await
.notify_workspace_async_err(workspace, &mut cx)
.ok_or_else(|| anyhow::anyhow!("Failed to open file"))?;
if let Some(active_editor) = item.downcast::<Editor>() {
if let Some(diff_task) =
active_editor.update(cx, |editor, _cx| editor.wait_for_diff_to_load())
{
diff_task.await;
}
cx.update(|window, cx| {
active_editor.update(cx, |editor, cx| {
editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx);
let snapshot = editor.snapshot(window, cx);
editor.go_to_hunk_before_or_after_position(
&snapshot,
language::Point::new(0, 0),
Direction::Next,
true,
window,
cx,
);
})
})
.log_err();
}
anyhow::Ok(())
})
.detach();
SoloDiffView::open_or_focus(entry, repository, self.workspace.clone(), window, cx)
.detach_and_notify_err(self.workspace.clone(), window, cx);
Some(())
});
@ -2685,20 +2642,6 @@ impl GitPanel {
}
}
async fn load_commit_message_prompt(cx: &mut AsyncApp) -> String {
let load = async {
let store = cx.update(|cx| PromptStore::global(cx)).await.ok()?;
store
.update(cx, |s, cx| {
s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx)
})
.await
.ok()
};
load.await
.unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string())
}
fn build_commit_message_prompt(
prompt: &str,
user_agents_md: Option<&str>,
@ -2803,7 +2746,7 @@ impl GitPanel {
.and_then(|user_agents_md| user_agents_md.content().cloned())
});
let prompt = Self::load_commit_message_prompt(&mut cx).await;
let prompt = include_str!("../src/commit_message_prompt.txt");
let subject = this.update(cx, |this, cx| {
this.commit_editor
@ -5984,7 +5927,7 @@ impl GitPanel {
)
.separator()
.action("Open Diff", menu::Confirm.boxed_clone())
.action("Open File", menu::SecondaryConfirm.boxed_clone())
.action("Open Diff (File)", menu::SecondaryConfirm.boxed_clone())
.when(!is_created, |context_menu| {
context_menu
.separator()
@ -6263,7 +6206,7 @@ impl GitPanel {
this.selected_entry = Some(ix);
cx.notify();
if event.click_count() > 1 || event.modifiers().secondary() {
this.open_file(&Default::default(), window, cx)
this.open_solo_diff(&Default::default(), window, cx)
} else {
this.open_diff(&Default::default(), window, cx);
this.focus_handle.focus(window, cx);
@ -6713,7 +6656,7 @@ impl Render for GitPanel {
.on_action(cx.listener(Self::last_entry))
.on_action(cx.listener(Self::close_panel))
.on_action(cx.listener(Self::open_diff))
.on_action(cx.listener(Self::open_file))
.on_action(cx.listener(Self::open_solo_diff))
.on_action(cx.listener(Self::focus_changes_list))
.on_action(cx.listener(Self::focus_editor))
.on_action(cx.listener(Self::expand_commit_editor))

View file

@ -45,6 +45,7 @@ pub mod picker_prompt;
pub mod project_diff;
pub(crate) mod remote_output;
pub mod repository_selector;
pub mod solo_diff_view;
pub mod stash_picker;
pub mod text_diff_view;
pub mod worktree_names;

View file

@ -0,0 +1,787 @@
use crate::{git_panel::GitStatusEntry, git_status_icon};
use anyhow::{Context as _, Result};
use buffer_diff::DiffHunkSecondaryStatus;
use editor::{
Direction, Editor, EditorEvent, EditorSettings, SplittableEditor, ToggleSplitDiff,
actions::{GoToHunk, GoToPreviousHunk},
};
use fs::Fs;
use git::{
Commit, Restore, StageAndNext, StageFile, ToggleStaged, UnstageAndNext, UnstageFile,
repository::RepoPath, status::StageStatus,
};
use gpui::{
Action, AnyElement, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, Render, Subscription, Task, WeakEntity, Window,
};
use language::{Buffer, HighlightedText};
use multi_buffer::MultiBuffer;
use project::{
Project,
git_store::{Repository, RepositoryId},
};
use settings::{DiffViewStyle, Settings, SettingsStore, update_settings_file};
use std::{
any::{Any, TypeId},
sync::Arc,
};
use ui::{
Color, DiffStat, Divider, Icon, IconButton, IconButtonShape, IconName, Label, LabelCommon as _,
SharedString, Tooltip, prelude::*, vertical_divider,
};
use util::paths::{PathExt as _, PathStyle};
use workspace::{
Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
Workspace,
item::{ItemEvent, SaveOptions, TabContentParams},
notifications::NotifyTaskExt,
searchable::SearchableItemHandle,
};
pub struct SoloDiffView {
repository: Entity<Repository>,
repository_id: RepositoryId,
repo_path: RepoPath,
buffer: Entity<Buffer>,
editor: Entity<SplittableEditor>,
workspace: WeakEntity<Workspace>,
_settings_subscription: Subscription,
}
impl SoloDiffView {
pub fn open_or_focus(
entry: GitStatusEntry,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
let Some(workspace_entity) = workspace.upgrade() else {
return Task::ready(Err(anyhow::anyhow!("workspace was dropped")));
};
let existing = workspace_entity
.read(cx)
.items_of_type::<SoloDiffView>(cx)
.find(|item| item.read(cx).matches(&repository, &entry.repo_path, cx));
if let Some(existing) = existing {
workspace_entity.update(cx, |workspace, cx| {
workspace.activate_item(&existing, true, true, window, cx);
});
existing.focus_handle(cx).focus(window, cx);
return Task::ready(Ok(existing));
}
let Some(project_path) = repository
.read(cx)
.repo_path_to_project_path(&entry.repo_path, cx)
else {
return Task::ready(Err(anyhow::anyhow!(
"could not resolve repository path {:?}",
entry.repo_path
)));
};
let project = workspace_entity.read(cx).project().clone();
let repo_path = entry.repo_path;
window.spawn(cx, async move |cx| {
let buffer = project
.update(cx, |project, cx| {
project.open_buffer(project_path.clone(), cx)
})
.await?;
let diff = project
.update(cx, |project, cx| {
project.open_uncommitted_diff(buffer.clone(), cx)
})
.await?;
workspace_entity.update_in(cx, |workspace, window, cx| {
let workspace_handle = cx.entity();
let view = cx.new(|cx| {
Self::new(
project,
repository,
repo_path,
buffer,
diff,
workspace_handle,
window,
cx,
)
});
workspace.add_item_to_active_pane(Box::new(view.clone()), None, true, window, cx);
view
})
})
}
fn new(
project: Entity<Project>,
repository: Entity<Repository>,
repo_path: RepoPath,
buffer: Entity<Buffer>,
diff: Entity<buffer_diff::BufferDiff>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repository_id = repository.read(cx).id;
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::singleton(buffer.clone(), cx);
multibuffer.add_diff(diff, cx);
multibuffer.set_all_diff_hunks_expanded(cx);
multibuffer
});
let editor = cx.new(|cx| {
let editor = SplittableEditor::new(
EditorSettings::get_global(cx).diff_view_style,
multibuffer,
project.clone(),
workspace.clone(),
window,
cx,
);
editor.rhs_editor().update(cx, |editor, cx| {
editor.set_should_serialize(false, cx);
let snapshot = editor.snapshot(window, cx);
editor.go_to_hunk_before_or_after_position(
&snapshot,
language::Point::new(0, 0),
Direction::Next,
true,
window,
cx,
);
});
editor
});
let mut previous_diff_view_style = EditorSettings::get_global(cx).diff_view_style;
let settings_subscription =
cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
let diff_view_style = EditorSettings::get_global(cx).diff_view_style;
if diff_view_style != previous_diff_view_style {
this.editor.update(cx, |editor, cx| {
if editor.diff_view_style() != diff_view_style {
editor.toggle_split(&ToggleSplitDiff, window, cx);
}
});
previous_diff_view_style = diff_view_style;
cx.notify();
}
});
Self {
repository,
repository_id,
repo_path,
buffer,
editor,
workspace: workspace.downgrade(),
_settings_subscription: settings_subscription,
}
}
fn matches(&self, repository: &Entity<Repository>, repo_path: &RepoPath, cx: &App) -> bool {
self.repository_id == repository.read(cx).id && &self.repo_path == repo_path
}
fn button_states(&self, cx: &App) -> SoloDiffButtonStates {
let editor = self.editor.read(cx).rhs_editor().read(cx);
let multibuffer = editor.buffer().read(cx);
let snapshot = multibuffer.snapshot(cx);
let prev_next = snapshot.diff_hunks().nth(1).is_some();
let mut selection = true;
let mut ranges = editor
.selections
.disjoint_anchor_ranges()
.collect::<Vec<_>>();
if !ranges.iter().any(|range| range.start != range.end) {
selection = false;
let anchor = editor.selections.newest_anchor().head();
if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor)
&& let Some(range) = snapshot
.anchor_in_buffer(excerpt_range.context.start)
.zip(snapshot.anchor_in_buffer(excerpt_range.context.end))
.map(|(start, end)| start..end)
{
ranges = vec![range];
} else {
ranges = Vec::new();
}
}
let mut stage = false;
let mut unstage = false;
for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
match hunk.status.secondary {
DiffHunkSecondaryStatus::HasSecondaryHunk
| DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
stage = true;
}
DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
stage = true;
unstage = true;
}
DiffHunkSecondaryStatus::NoSecondaryHunk
| DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
unstage = true;
}
}
}
let stage_status = self
.repository
.read(cx)
.status_for_path(&self.repo_path)
.map(|entry| entry.status.staging())
.unwrap_or(StageStatus::Unstaged);
SoloDiffButtonStates {
stage,
unstage,
restore: stage || unstage,
prev_next,
selection,
stage_file: stage_status.has_unstaged(),
unstage_file: stage_status.has_staged(),
}
}
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut App) {
self.focus_handle(cx).focus(window, cx);
let action = action.boxed_clone();
cx.defer(move |cx| {
cx.dispatch_action(action.as_ref());
});
}
fn change_file_stage(&self, stage: bool, window: &mut Window, cx: &mut Context<Self>) {
let repository = self.repository.clone();
let repo_path = self.repo_path.clone();
let workspace = self.workspace.clone();
let task = cx.spawn(async move |_, cx| {
repository
.update(cx, |repository, cx| {
if stage {
repository.stage_entries(vec![repo_path], cx)
} else {
repository.unstage_entries(vec![repo_path], cx)
}
})
.await
.with_context(|| {
if stage {
"failed to stage file"
} else {
"failed to unstage file"
}
})
});
task.detach_and_notify_err(workspace, window, cx);
}
}
impl EventEmitter<EditorEvent> for SoloDiffView {}
impl Focusable for SoloDiffView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.editor.focus_handle(cx)
}
}
impl Item for SoloDiffView {
type Event = EditorEvent;
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::Diff).color(Color::Muted))
}
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
.color(if params.selected {
Color::Default
} else {
Color::Muted
})
.into_any_element()
}
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
self.buffer
.read(cx)
.file()
.and_then(|file| {
Some(
file.full_path(cx)
.file_name()?
.to_string_lossy()
.to_string(),
)
})
.unwrap_or_else(|| {
self.repo_path
.as_ref()
.display(PathStyle::local())
.into_owned()
})
.into()
}
fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
Some(
self.buffer
.read(cx)
.file()
.map(|file| file.full_path(cx).compact().to_string_lossy().into_owned())
.unwrap_or_else(|| {
self.repo_path
.as_ref()
.display(PathStyle::local())
.into_owned()
})
.into(),
)
}
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
Editor::to_item_events(event, f)
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("Solo Diff View Opened")
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor.deactivated(window, cx);
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
cx: &'a App,
) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.clone().into())
} else {
self.editor.act_as_type(type_id, cx)
}
}
fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
fn for_each_project_item(
&self,
cx: &App,
f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
) {
self.editor.for_each_project_item(cx, f)
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |editor, _| {
editor.set_nav_history(Some(nav_history));
})
});
}
fn navigate(
&mut self,
data: Arc<dyn Any + Send>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.editor.update(cx, |editor, cx| {
editor
.rhs_editor()
.update(cx, |editor, cx| editor.navigate(data, window, cx))
})
}
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
ToolbarItemLocation::PrimaryLeft
}
fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<gpui::Font>)> {
self.editor.breadcrumbs(cx)
}
fn added_to_workspace(
&mut self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |editor, cx| {
editor.added_to_workspace(workspace, window, cx)
})
});
}
fn can_save(&self, cx: &App) -> bool {
self.editor.read(cx).rhs_editor().read(cx).can_save(cx)
}
fn save(
&mut self,
options: SaveOptions,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.editor.save(options, project, window, cx)
}
}
impl Render for SoloDiffView {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
self.editor.clone()
}
}
pub struct SoloDiffStyleToolbar {
solo_diff: Option<WeakEntity<SoloDiffView>>,
}
pub struct SoloDiffGitToolbar {
solo_diff: Option<WeakEntity<SoloDiffView>>,
}
impl SoloDiffStyleToolbar {
pub fn new(_: &mut Context<Self>) -> Self {
Self { solo_diff: None }
}
fn solo_diff(&self) -> Option<Entity<SoloDiffView>> {
self.solo_diff.as_ref()?.upgrade()
}
fn set_diff_view_style(
&mut self,
diff_view_style: DiffViewStyle,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(solo_diff) = self.solo_diff() else {
return;
};
let workspace = solo_diff.read(cx).workspace.clone();
update_settings_file(<dyn Fs>::global(cx), cx, move |settings, _| {
settings.editor.diff_view_style = Some(diff_view_style);
});
if let Some(workspace) = workspace.upgrade() {
let splittable_editors = {
workspace
.read(cx)
.items(cx)
.filter_map(|item| item.act_as_type(TypeId::of::<SplittableEditor>(), cx))
.filter_map(|item| item.downcast::<SplittableEditor>().ok())
.collect::<Vec<_>>()
};
for editor in splittable_editors {
editor.update(cx, |editor, cx| {
if editor.diff_view_style() != diff_view_style {
editor.toggle_split(&ToggleSplitDiff, window, cx);
}
});
}
}
cx.notify();
}
}
impl EventEmitter<ToolbarItemEvent> for SoloDiffStyleToolbar {}
impl ToolbarItemView for SoloDiffStyleToolbar {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
_: &mut Window,
cx: &mut Context<Self>,
) -> ToolbarItemLocation {
self.solo_diff = active_pane_item
.and_then(|item| item.act_as::<SoloDiffView>(cx))
.map(|entity| entity.downgrade());
if self.solo_diff.is_some() {
ToolbarItemLocation::PrimaryLeft
} else {
ToolbarItemLocation::Hidden
}
}
}
impl Render for SoloDiffStyleToolbar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(solo_diff) = self.solo_diff() else {
return div();
};
let editor_entity = solo_diff.read(cx).editor.clone();
let editor = editor_entity.read(cx);
let diff_view_style = editor.diff_view_style();
let is_split_set = diff_view_style == DiffViewStyle::Split;
let split_icon = if is_split_set && !editor.is_split() {
IconName::DiffSplitAuto
} else {
IconName::DiffSplit
};
h_flex()
.h_8()
.items_center()
.gap_1()
.child(
IconButton::new("solo-diff-unified", IconName::DiffUnified)
.icon_size(IconSize::Small)
.toggle_state(diff_view_style == DiffViewStyle::Unified)
.tooltip(Tooltip::text("Unified"))
.on_click(cx.listener(|this, _, window, cx| {
this.set_diff_view_style(DiffViewStyle::Unified, window, cx);
})),
)
.child(
IconButton::new("solo-diff-split", split_icon)
.icon_size(IconSize::Small)
.toggle_state(diff_view_style == DiffViewStyle::Split)
.tooltip(Tooltip::text("Split"))
.on_click(cx.listener(|this, _, window, cx| {
this.set_diff_view_style(DiffViewStyle::Split, window, cx);
})),
)
.child(vertical_divider())
.child(div().w_1())
}
}
impl SoloDiffGitToolbar {
pub fn new(_: &mut Context<Self>) -> Self {
Self { solo_diff: None }
}
fn solo_diff(&self) -> Option<Entity<SoloDiffView>> {
self.solo_diff.as_ref()?.upgrade()
}
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
if let Some(solo_diff) = self.solo_diff() {
solo_diff.update(cx, |solo_diff, cx| {
solo_diff.dispatch_action(action, window, cx);
});
}
}
fn stage_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(solo_diff) = self.solo_diff() {
solo_diff.update(cx, |solo_diff, cx| {
solo_diff.change_file_stage(true, window, cx);
});
}
}
fn unstage_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(solo_diff) = self.solo_diff() {
solo_diff.update(cx, |solo_diff, cx| {
solo_diff.change_file_stage(false, window, cx);
});
}
}
}
impl EventEmitter<ToolbarItemEvent> for SoloDiffGitToolbar {}
impl ToolbarItemView for SoloDiffGitToolbar {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
_: &mut Window,
cx: &mut Context<Self>,
) -> ToolbarItemLocation {
self.solo_diff = active_pane_item
.and_then(|item| item.act_as::<SoloDiffView>(cx))
.map(|entity| entity.downgrade());
if self.solo_diff.is_some() {
ToolbarItemLocation::PrimaryRight
} else {
ToolbarItemLocation::Hidden
}
}
}
struct SoloDiffButtonStates {
stage: bool,
unstage: bool,
restore: bool,
prev_next: bool,
selection: bool,
stage_file: bool,
unstage_file: bool,
}
impl Render for SoloDiffGitToolbar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(solo_diff) = self.solo_diff() else {
return div();
};
let focus_handle = solo_diff.focus_handle(cx);
let solo_diff = solo_diff.read(cx);
let button_states = solo_diff.button_states(cx);
let status_entry = solo_diff
.repository
.read(cx)
.status_for_path(&solo_diff.repo_path);
let status = status_entry.as_ref().map(|entry| entry.status);
let diff_stat = status_entry.and_then(|entry| entry.diff_stat);
h_group_xl()
.my_neg_1()
.py_1()
.items_center()
.flex_wrap()
.justify_between()
.children(status.map(|status| git_status_icon(status).into_any_element()))
.children(diff_stat.map(|stat| {
DiffStat::new("solo-diff-stat", stat.added as usize, stat.deleted as usize)
.into_any_element()
}))
.child(
h_group_sm()
.when(button_states.selection, |el| {
el.child(
Button::new("stage", "Toggle Staged")
.tooltip(Tooltip::for_action_title_in(
"Toggle Staged",
&ToggleStaged,
&focus_handle,
))
.disabled(!button_states.stage && !button_states.unstage)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&ToggleStaged, window, cx)
})),
)
})
.when(!button_states.selection, |el| {
el.child(
Button::new("stage", "Stage")
.tooltip(Tooltip::for_action_title_in(
"Stage and go to next hunk",
&StageAndNext,
&focus_handle,
))
.disabled(!button_states.stage)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&StageAndNext, window, cx)
})),
)
.child(
Button::new("unstage", "Unstage")
.tooltip(Tooltip::for_action_title_in(
"Unstage and go to next hunk",
&UnstageAndNext,
&focus_handle,
))
.disabled(!button_states.unstage)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&UnstageAndNext, window, cx)
})),
)
})
.child(
Button::new("restore", "Restore")
.tooltip(Tooltip::for_action_title_in(
"Restore selected hunk",
&Restore,
&focus_handle,
))
.disabled(!button_states.restore)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&Restore, window, cx)
})),
),
)
.child(
h_group_sm()
.child(
IconButton::new("up", IconName::ArrowUp)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::for_action_title_in(
"Go to previous hunk",
&GoToPreviousHunk,
&focus_handle,
))
.disabled(!button_states.prev_next)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&GoToPreviousHunk, window, cx)
})),
)
.child(
IconButton::new("down", IconName::ArrowDown)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::for_action_title_in(
"Go to next hunk",
&GoToHunk,
&focus_handle,
))
.disabled(!button_states.prev_next)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&GoToHunk, window, cx)
})),
),
)
.child(vertical_divider())
.child(
h_group_sm()
.child(
Button::new("stage-file", "Stage File")
.tooltip(Tooltip::for_action_title_in(
"Stage file",
&StageFile,
&focus_handle,
))
.disabled(!button_states.stage_file)
.on_click(
cx.listener(|this, _, window, cx| this.stage_file(window, cx)),
),
)
.child(
Button::new("unstage-file", "Unstage File")
.tooltip(Tooltip::for_action_title_in(
"Unstage file",
&UnstageFile,
&focus_handle,
))
.disabled(!button_states.unstage_file)
.on_click(
cx.listener(|this, _, window, cx| this.unstage_file(window, cx)),
),
)
.child(Divider::vertical())
.child(
Button::new("commit", "Commit")
.tooltip(Tooltip::for_action_title_in(
"Commit",
&Commit,
&focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&Commit, window, cx);
})),
),
)
}
}

View file

@ -336,6 +336,20 @@ impl TestAppContext {
self.test_platform.simulate_new_path_selection(select_path);
}
/// Simulates responding to a `prompt_for_paths` ("Open") dialog.
pub fn simulate_path_prompt_response(
&self,
select_paths: impl FnOnce(&crate::PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
) {
self.test_platform
.simulate_path_prompt_response(select_paths);
}
/// Returns true if there's a path selection dialog pending.
pub fn did_prompt_for_paths(&self) -> bool {
self.test_platform.did_prompt_for_paths()
}
/// Simulates clicking a button in an platform-level alert dialog.
#[track_caller]
pub fn simulate_prompt_answer(&self, button: &str) {
@ -1098,3 +1112,54 @@ impl AnyWindowHandle {
.unwrap()
}
}
#[cfg(test)]
mod tests {
use crate::{PathPromptOptions, TestAppContext};
use std::path::PathBuf;
#[gpui::test]
async fn test_simulate_path_prompt_response(cx: &mut TestAppContext) {
assert!(!cx.did_prompt_for_paths());
let receiver = cx.update(|cx| {
cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: true,
prompt: None,
})
});
assert!(cx.did_prompt_for_paths());
let selected = vec![PathBuf::from("/a"), PathBuf::from("/b")];
cx.simulate_path_prompt_response({
let selected = selected.clone();
move |options| {
assert!(options.multiple);
Some(selected)
}
});
assert!(!cx.did_prompt_for_paths());
let response = receiver.await.unwrap().unwrap();
assert_eq!(response, Some(selected));
}
#[gpui::test]
async fn test_simulate_path_prompt_cancellation(cx: &mut TestAppContext) {
let receiver = cx.update(|cx| {
cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
})
});
cx.simulate_path_prompt_response(|_options| None);
let response = receiver.await.unwrap().unwrap();
assert_eq!(response, None);
}
}

View file

@ -741,6 +741,44 @@ impl ListState {
pub fn viewport_bounds(&self) -> Bounds<Pixels> {
self.0.borrow().last_layout_bounds.unwrap_or_default()
}
/// Returns whether the item is entirely above the viewport, or `None` if
/// the list has not measured enough layout to know.
pub fn item_is_above_viewport(&self, ix: usize) -> Option<bool> {
let viewport_bounds = self.viewport_bounds();
if viewport_bounds.size.height == px(0.0) {
return None;
}
let scroll_top = self.logical_scroll_top();
if ix < scroll_top.item_ix {
// Rows before the logical scroll top have no item bounds, but
// their position relative to the viewport is known from scroll state.
return Some(true);
}
let item_bounds = self.bounds_for_item(ix)?;
Some(item_bounds.bottom() <= viewport_bounds.top())
}
/// Returns whether the item is entirely below the viewport, or `None` if
/// the list has not measured enough layout to know.
pub fn item_is_below_viewport(&self, ix: usize) -> Option<bool> {
let viewport_bounds = self.viewport_bounds();
if viewport_bounds.size.height == px(0.0) {
return None;
}
let scroll_top = self.logical_scroll_top();
if ix < scroll_top.item_ix {
// Rows before the logical scroll top have no item bounds, but
// their position relative to the viewport is known from scroll state.
return Some(false);
}
let item_bounds = self.bounds_for_item(ix)?;
Some(item_bounds.top() >= viewport_bounds.bottom())
}
}
impl StateInner {
@ -1644,6 +1682,114 @@ mod test {
assert_eq!(offset.offset_in_item, px(0.));
}
struct TestListView(ListState);
impl Render for TestListView {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
list(self.0.clone(), |_, _, _| {
div().h(px(20.)).w_full().into_any()
})
.w_full()
.h_full()
}
}
#[gpui::test]
fn test_item_viewport_queries_return_none_before_layout(_cx: &mut TestAppContext) {
let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
assert_eq!(state.item_is_above_viewport(0), None);
assert_eq!(state.item_is_below_viewport(0), None);
}
#[gpui::test]
fn test_item_viewport_queries_before_logical_scroll_top(cx: &mut TestAppContext) {
let cx = cx.add_empty_window();
let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
state.scroll_to(gpui::ListOffset {
item_ix: 2,
offset_in_item: px(0.),
});
cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
cx.new(|_| TestListView(state.clone())).into_any_element()
});
assert_eq!(state.item_is_above_viewport(1), Some(true));
assert_eq!(state.item_is_below_viewport(1), Some(false));
}
#[gpui::test]
fn test_item_viewport_queries_measured_item_inside_viewport(cx: &mut TestAppContext) {
let cx = cx.add_empty_window();
let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
state.scroll_to(gpui::ListOffset {
item_ix: 2,
offset_in_item: px(0.),
});
cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
cx.new(|_| TestListView(state.clone())).into_any_element()
});
assert_eq!(state.item_is_above_viewport(2), Some(false));
assert_eq!(state.item_is_below_viewport(2), Some(false));
}
#[gpui::test]
fn test_item_viewport_queries_measured_item_above_viewport(cx: &mut TestAppContext) {
let cx = cx.add_empty_window();
let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
state.scroll_to(gpui::ListOffset {
item_ix: 2,
offset_in_item: px(20.),
});
cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
cx.new(|_| TestListView(state.clone())).into_any_element()
});
assert_eq!(state.item_is_above_viewport(2), Some(true));
assert_eq!(state.item_is_below_viewport(2), Some(false));
}
#[gpui::test]
fn test_item_viewport_queries_measured_item_below_viewport(cx: &mut TestAppContext) {
let cx = cx.add_empty_window();
let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
state.scroll_to(gpui::ListOffset {
item_ix: 2,
offset_in_item: px(0.),
});
cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
cx.new(|_| TestListView(state.clone())).into_any_element()
});
assert_eq!(state.item_is_above_viewport(3), Some(false));
assert_eq!(state.item_is_below_viewport(3), Some(true));
}
#[gpui::test]
fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) {
let cx = cx.add_empty_window();
let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
cx.new(|_| TestListView(state.clone())).into_any_element()
});
state.scroll_to_end();
assert_eq!(state.logical_scroll_top().item_ix, state.item_count());
assert_eq!(state.item_is_above_viewport(0), Some(true));
assert_eq!(state.item_is_below_viewport(0), Some(false));
}
#[gpui::test]
fn test_measure_all_after_width_change(cx: &mut TestAppContext) {
let cx = cx.add_empty_window();

View file

@ -6,9 +6,7 @@ use scheduler::Instant;
use scheduler::Scheduler;
use std::{future::Future, marker::PhantomData, mem, pin::Pin, rc::Rc, sync::Arc, time::Duration};
pub use scheduler::{
FallibleTask, ForegroundExecutor as SchedulerForegroundExecutor, Priority, Task,
};
pub use scheduler::{FallibleTask, LocalExecutor as SchedulerLocalExecutor, Priority, Task};
/// A pointer to the executor that is currently running,
/// for spawning background tasks.
@ -22,7 +20,7 @@ pub struct BackgroundExecutor {
/// for spawning tasks on the main thread.
#[derive(Clone)]
pub struct ForegroundExecutor {
inner: scheduler::ForegroundExecutor,
inner: scheduler::LocalExecutor,
dispatcher: Arc<dyn PlatformDispatcher>,
not_send: PhantomData<Rc<()>>,
}
@ -280,18 +278,29 @@ impl ForegroundExecutor {
)
} else {
let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone()));
let session_id = platform_scheduler.allocate_session_id();
(platform_scheduler, session_id)
let inner = platform_scheduler.foreground_executor();
return Self {
inner,
dispatcher,
not_send: PhantomData,
};
};
#[cfg(not(any(test, feature = "test-support")))]
let (scheduler, session_id): (Arc<dyn Scheduler>, _) = {
let inner = {
let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone()));
let session_id = platform_scheduler.allocate_session_id();
(platform_scheduler, session_id)
platform_scheduler.foreground_executor()
};
let inner = scheduler::ForegroundExecutor::new(session_id, scheduler);
#[cfg(any(test, feature = "test-support"))]
let inner = {
let scheduler_for_dispatch = Arc::downgrade(&scheduler);
scheduler::LocalExecutor::new(session_id, scheduler, move |runnable| {
if let Some(scheduler) = scheduler_for_dispatch.upgrade() {
scheduler.schedule_local(session_id, runnable);
}
})
};
Self {
inner,
@ -366,7 +375,7 @@ impl ForegroundExecutor {
}
#[doc(hidden)]
pub fn scheduler_executor(&self) -> SchedulerForegroundExecutor {
pub fn scheduler_executor(&self) -> SchedulerLocalExecutor {
self.inner.clone()
}
}

View file

@ -139,8 +139,7 @@ impl PlatformDispatcher for TestDispatcher {
}
fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) {
self.scheduler
.schedule_foreground(self.session_id, runnable);
self.scheduler.schedule_local(self.session_id, runnable);
}
fn dispatch_after(&self, _duration: Duration, _runnable: RunnableVariant) {

View file

@ -1,9 +1,10 @@
use crate::{
AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay,
PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata,
Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform,
PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper,
PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
SourceMetadata, Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams,
size,
};
use anyhow::Result;
use collections::VecDeque;
@ -85,6 +86,10 @@ struct TestPrompt {
pub(crate) struct TestPrompts {
multiple_choice: VecDeque<TestPrompt>,
new_path: VecDeque<(PathBuf, oneshot::Sender<Result<Option<PathBuf>>>)>,
paths: VecDeque<(
PathPromptOptions,
oneshot::Sender<Result<Option<Vec<PathBuf>>>>,
)>,
}
impl TestPlatform {
@ -147,6 +152,33 @@ impl TestPlatform {
tx.send(Ok(select_path(&path))).ok();
}
pub(crate) fn simulate_path_prompt_response(
&self,
select_paths: impl FnOnce(&PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
) {
let (options, tx) = self
.prompts
.borrow_mut()
.paths
.pop_front()
.expect("no pending paths prompt");
let selection = select_paths(&options);
if let Some(paths) = &selection
&& !options.multiple
&& paths.len() > 1
{
panic!(
"selected {} paths for a prompt that does not allow multiple selection",
paths.len()
);
}
tx.send(Ok(selection)).ok();
}
pub(crate) fn did_prompt_for_paths(&self) -> bool {
!self.prompts.borrow().paths.is_empty()
}
#[track_caller]
pub(crate) fn simulate_prompt_answer(&self, response: &str) {
let prompt = self
@ -348,9 +380,11 @@ impl Platform for TestPlatform {
fn prompt_for_paths(
&self,
_options: crate::PathPromptOptions,
options: crate::PathPromptOptions,
) -> oneshot::Receiver<Result<Option<Vec<std::path::PathBuf>>>> {
unimplemented!()
let (tx, rx) = oneshot::channel();
self.prompts.borrow_mut().paths.push_back((options, tx));
rx
}
fn prompt_for_new_path(

View file

@ -3,10 +3,14 @@ use async_task::Runnable;
use chrono::{DateTime, Utc};
use futures::channel::oneshot;
use scheduler::Instant;
use scheduler::{Clock, Priority, Scheduler, SessionId, TestScheduler, Timer};
use scheduler::{
Clock, LocalExecutor, Priority, Scheduler, SessionId, Task, TestScheduler, Timer,
spawn_dedicated_thread,
};
#[cfg(not(target_family = "wasm"))]
use std::task::{Context, Poll};
use std::{
any::Any,
future::Future,
pin::Pin,
sync::{
@ -35,7 +39,17 @@ impl PlatformScheduler {
}
}
pub fn allocate_session_id(&self) -> SessionId {
pub fn foreground_executor(self: &Arc<Self>) -> LocalExecutor {
let session_id = self.next_session_id();
let scheduler = Arc::downgrade(self);
LocalExecutor::new(session_id, self.clone(), move |runnable| {
if let Some(scheduler) = scheduler.upgrade() {
scheduler.schedule_local(session_id, runnable);
}
})
}
fn next_session_id(&self) -> SessionId {
SessionId::new(self.next_session_id.fetch_add(1, Ordering::SeqCst))
}
}
@ -90,7 +104,7 @@ impl Scheduler for PlatformScheduler {
}
}
fn schedule_foreground(&self, _session_id: SessionId, runnable: Runnable<RunnableMeta>) {
fn schedule_local(&self, _session_id: SessionId, runnable: Runnable<RunnableMeta>) {
self.dispatcher
.dispatch_on_main_thread(runnable, Priority::default());
}
@ -133,6 +147,21 @@ impl Scheduler for PlatformScheduler {
self.clock.clone()
}
fn spawn_dedicated(
self: Arc<Self>,
f: Box<
dyn FnOnce(
LocalExecutor,
)
-> Pin<Box<dyn Future<Output = Box<dyn Any + Send + Sync>> + 'static>>
+ Send
+ 'static,
>,
) -> Task<Box<dyn Any + Send + Sync>> {
let session_id = self.next_session_id();
spawn_dedicated_thread(session_id, self, move |executor| f(executor))
}
fn as_test(&self) -> Option<&TestScheduler> {
None
}
@ -152,3 +181,261 @@ impl Clock for PlatformClock {
self.dispatcher.now()
}
}
#[cfg(all(test, not(target_family = "wasm")))]
mod tests {
use super::*;
use crate::{RunnableVariant, ThreadTaskTimings};
use scheduler::BackgroundExecutor;
use std::time::Instant as StdInstant;
// `spawn_dedicated` shouldn't touch the platform dispatcher at all;
// panicking on every method ensures the test catches it if it does.
struct SmokeDispatcher;
impl PlatformDispatcher for SmokeDispatcher {
fn get_all_timings(&self) -> Vec<ThreadTaskTimings> {
Vec::new()
}
fn get_current_thread_timings(&self) -> ThreadTaskTimings {
ThreadTaskTimings {
thread_name: None,
thread_id: std::thread::current().id(),
timings: Vec::new(),
total_pushed: 0,
}
}
fn is_main_thread(&self) -> bool {
false
}
fn dispatch(&self, _runnable: RunnableVariant, _priority: Priority) {
panic!("SmokeDispatcher should not be asked to dispatch in this test");
}
fn dispatch_on_main_thread(&self, _runnable: RunnableVariant, _priority: Priority) {
panic!("SmokeDispatcher does not implement a main thread");
}
fn dispatch_after(&self, _duration: Duration, _runnable: RunnableVariant) {
panic!("SmokeDispatcher does not implement timers");
}
fn spawn_realtime(&self, _f: Box<dyn FnOnce() + Send>) {
panic!("SmokeDispatcher does not implement realtime");
}
}
#[test]
fn spawn_dedicated_runs_on_a_real_separate_thread() {
let background =
BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher))));
let started = StdInstant::now();
let task = background.spawn_dedicated(|_executor| async move {
// A genuine blocking syscall on the dedicated thread. If
// `spawn_dedicated` were running the future on any shared
// executor, this would stall that executor.
let thread_id_before = std::thread::current().id();
std::thread::sleep(Duration::from_millis(50));
let thread_id_after = std::thread::current().id();
assert_eq!(thread_id_before, thread_id_after);
(thread_id_before, "slept")
});
let (dedicated_thread_id, message) = futures::executor::block_on(task);
let elapsed = started.elapsed();
assert_eq!(message, "slept");
assert_ne!(
dedicated_thread_id,
std::thread::current().id(),
"dedicated future ran on the test thread"
);
assert!(
elapsed >= Duration::from_millis(40),
"expected the dedicated thread to genuinely sleep, elapsed = {:?}",
elapsed
);
}
#[test]
fn spawn_dedicated_returns_not_send_future_output() {
// The whole point of `spawn_dedicated` is that the future can be
// `!Send`. Constructing one with `Rc<RefCell<_>>` ensures the
// signature actually permits it.
use std::cell::RefCell;
use std::rc::Rc;
let background =
BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher))));
let task = background.spawn_dedicated(|_executor| async move {
let state = Rc::new(RefCell::new(0_i32));
for _ in 0..3 {
*state.borrow_mut() += 1;
}
*state.borrow()
});
let output = futures::executor::block_on(task);
assert_eq!(output, 3);
}
#[test]
fn spawn_dedicated_dropping_task_cancels_future() {
use parking_lot::Mutex;
use std::sync::mpsc;
let background =
BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher))));
let (started_tx, started_rx) = mpsc::channel::<()>();
let (after_park_tx, after_park_rx) = mpsc::channel::<()>();
let observed_post_await_write = Arc::new(Mutex::new(false));
let task = {
let observed_post_await_write = observed_post_await_write.clone();
background.spawn_dedicated(move |_executor| async move {
// Announce that the future is live on the dedicated thread.
started_tx
.send(())
.expect("started signal must be received");
// Park forever. Dropping the `Task` must cancel us here so
// the code below this `await` never runs.
futures::future::pending::<()>().await;
*observed_post_await_write.lock() = true;
after_park_tx
.send(())
.expect("after-park signal must be received");
})
};
// Wait until the dedicated future is actually parked at the await.
started_rx
.recv_timeout(Duration::from_secs(2))
.expect("dedicated future failed to start");
// Drop the root Task: this must cancel the future.
drop(task);
// If cancellation works, the future never advances past `pending`,
// so this recv must time out.
assert!(
after_park_rx
.recv_timeout(Duration::from_millis(100))
.is_err(),
"dedicated future advanced past the await after its Task was dropped"
);
assert!(
!*observed_post_await_write.lock(),
"dedicated future ran code past the cancellation point"
);
}
#[test]
fn spawn_dedicated_thread_tears_down_after_work_completes() {
use std::sync::mpsc;
// Fires from `Drop` so we observe teardown of the dedicated future's
// captured state on whichever thread runs its destructor.
struct DropSignal {
tx: Option<mpsc::Sender<std::thread::ThreadId>>,
}
impl Drop for DropSignal {
fn drop(&mut self) {
if let Some(tx) = self.tx.take() {
let _ = tx.send(std::thread::current().id());
}
}
}
let background =
BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher))));
let (started_tx, started_rx) = mpsc::channel::<std::thread::ThreadId>();
let (drop_tx, drop_rx) = mpsc::channel::<std::thread::ThreadId>();
let task = background.spawn_dedicated(move |_executor| async move {
// Captured by the future's state. When the future completes and
// its state is dropped on the dedicated thread, this guard's
// `Drop` fires and reports the thread id it ran on.
let _guard = DropSignal { tx: Some(drop_tx) };
started_tx
.send(std::thread::current().id())
.expect("started signal must be received");
// Future returns immediately. The dedicated thread should then
// drop the future (firing _guard), exit the recv loop, and exit.
});
let dedicated_thread_id = started_rx
.recv_timeout(Duration::from_secs(2))
.expect("dedicated future failed to start");
assert_ne!(
dedicated_thread_id,
std::thread::current().id(),
"dedicated future ran on the test thread"
);
// Drive the root task to completion so its body finishes.
futures::executor::block_on(task);
// The guard's drop runs from the dedicated thread as it tears down
// the future's captured state. If the executor/recv-loop were
// keeping the future alive past task completion, this would hang.
let drop_thread_id = drop_rx
.recv_timeout(Duration::from_secs(2))
.expect("dedicated future's captured state was not dropped after task completion");
assert_eq!(
drop_thread_id, dedicated_thread_id,
"dedicated future's captured state must be dropped on the dedicated thread, not elsewhere"
);
}
#[test]
fn spawn_dedicated_detached_child_outlives_root() {
use std::sync::mpsc;
let background =
BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher))));
// `gate_rx` lets the detached child park until the test explicitly
// releases it — after we've already observed the root completing.
let (gate_tx, gate_rx) = mpsc::channel::<()>();
let (child_done_tx, child_done_rx) = mpsc::channel::<std::thread::ThreadId>();
let task = background.spawn_dedicated(move |executor| async move {
executor
.spawn(async move {
// Blocking on `recv` is normally wrong inside an
// executor, but the dedicated thread is exclusive to
// this session, so blocking the only future on it is
// fine — this is the property `spawn_dedicated` is
// designed to provide.
gate_rx
.recv()
.expect("gate sender dropped before child resumed");
child_done_tx
.send(std::thread::current().id())
.expect("child_done receiver dropped");
})
.detach();
// Root finishes here. The detached child must keep the
// dedicated thread alive until it completes.
});
futures::executor::block_on(task);
// Negative assertion: the child has not finished, because the gate
// hasn't been released yet.
assert!(
child_done_rx
.recv_timeout(Duration::from_millis(50))
.is_err(),
"detached child finished before being released"
);
// Release the gate. The detached child should now complete on the
// dedicated thread.
gate_tx.send(()).expect("gate receiver dropped");
let child_thread_id = child_done_rx
.recv_timeout(Duration::from_secs(2))
.expect("detached child failed to complete after gate was released");
assert_ne!(
child_thread_id,
std::thread::current().id(),
"detached child ran on the test thread instead of the dedicated thread"
);
}
}

View file

@ -12,15 +12,12 @@ use language_model::{
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelToolChoice, RateLimiter,
};
use open_ai::{
ReasoningEffort,
responses::{StreamResponseOptions, stream_response_with_options},
};
use open_ai::{ReasoningEffort, responses::stream_response};
use rand::RngCore as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::{SystemTime, UNIX_EPOCH};
use ui::{ConfiguredApiCard, prelude::*};
use url::form_urlencoded;
use util::ResultExt as _;
@ -38,31 +35,6 @@ const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
const CREDENTIALS_KEY: &str = "https://chatgpt.com/backend-api/codex";
const TOKEN_REFRESH_BUFFER_MS: u64 = 5 * 60 * 1000;
const CODEX_RESPONSE_HEADER_TIMEOUT: Duration = Duration::from_secs(10);
fn codex_extra_headers(
account_id: Option<&str>,
session_id: Option<&str>,
) -> Vec<(String, String)> {
let mut extra_headers: Vec<(String, String)> = vec![
("originator".into(), "zed".into()),
("OpenAI-Beta".into(), "responses=experimental".into()),
];
if let Some(id) = account_id {
if !id.is_empty() {
extra_headers.push(("ChatGPT-Account-Id".into(), id.into()));
}
}
if let Some(id) = session_id {
if !id.is_empty() {
extra_headers.push(("session-id".into(), id.into()));
}
}
extra_headers
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct CodexCredentials {
@ -500,7 +472,6 @@ impl LanguageModel for OpenAiSubscribedLanguageModel {
// The Codex backend rejects `max_output_tokens` (`Unsupported parameter`),
// unlike the public OpenAI Responses API. Pass `None` so the field is
// omitted from the serialized request body entirely.
let session_id = request.thread_id.clone();
let mut responses_request = into_open_ai_response(
request,
self.model.id(),
@ -539,24 +510,26 @@ impl LanguageModel for OpenAiSubscribedLanguageModel {
let future = cx.spawn(async move |cx| {
let creds = get_fresh_credentials(&state, &http_client, cx).await?;
let extra_headers =
codex_extra_headers(creds.account_id.as_deref(), session_id.as_deref());
let mut extra_headers: Vec<(String, String)> = vec![
("originator".into(), "zed".into()),
("OpenAI-Beta".into(), "responses=experimental".into()),
];
if let Some(ref id) = creds.account_id {
if !id.is_empty() {
extra_headers.push(("ChatGPT-Account-Id".into(), id.clone()));
}
}
let access_token = creds.access_token.clone();
let background_executor = cx.background_executor().clone();
request_limiter
.stream(async move {
stream_response_with_options(
stream_response(
http_client.as_ref(),
PROVIDER_NAME.0.as_str(),
CODEX_BASE_URL,
&access_token,
responses_request,
extra_headers,
StreamResponseOptions::response_header_timeout(
CODEX_RESPONSE_HEADER_TIMEOUT,
background_executor.timer(CODEX_RESPONSE_HEADER_TIMEOUT),
),
)
.await
.map_err(LanguageModelCompletionError::from)
@ -1135,7 +1108,6 @@ mod tests {
use super::*;
use gpui::TestAppContext;
use http_client::FakeHttpClient;
use language_model::{LanguageModelRequestMessage, Role};
use parking_lot::Mutex;
use std::future::Future;
use std::pin::Pin;
@ -1185,30 +1157,6 @@ mod tests {
}
}
#[test]
fn test_codex_extra_headers_include_session_id() {
assert_eq!(
codex_extra_headers(Some("account-1"), Some("thread-1")),
vec![
("originator".into(), "zed".into()),
("OpenAI-Beta".into(), "responses=experimental".into()),
("ChatGPT-Account-Id".into(), "account-1".into()),
("session-id".into(), "thread-1".into()),
]
);
}
#[test]
fn test_codex_extra_headers_omit_empty_optional_ids() {
assert_eq!(
codex_extra_headers(Some(""), Some("")),
vec![
("originator".into(), "zed".into()),
("OpenAI-Beta".into(), "responses=experimental".into()),
]
);
}
fn make_expired_credentials() -> CodexCredentials {
CodexCredentials {
access_token: "old_access".to_string(),
@ -1229,13 +1177,6 @@ mod tests {
}
}
fn make_fresh_credentials_with_account() -> CodexCredentials {
CodexCredentials {
account_id: Some("account-1".to_string()),
..make_fresh_credentials()
}
}
fn fake_token_response() -> String {
serde_json::json!({
"access_token": "fresh_access",
@ -1245,127 +1186,6 @@ mod tests {
.to_string()
}
#[gpui::test]
async fn test_stream_completion_sends_codex_session_header(cx: &mut TestAppContext) {
let captured_headers = Arc::new(Mutex::new(None::<http_client::http::HeaderMap>));
let captured_headers_clone = captured_headers.clone();
let http_client = FakeHttpClient::create(move |request| {
*captured_headers_clone.lock() = Some(request.headers().clone());
async move {
let body = r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}"#;
Ok(http_client::Response::builder()
.status(200)
.body(http_client::AsyncBody::from(format!("{body}\n\n")))?)
}
});
let state = cx.new(|_cx| State {
credentials: Some(make_fresh_credentials_with_account()),
sign_in_task: None,
refresh_task: None,
load_task: None,
credentials_provider: Arc::new(FakeCredentialsProvider::new()),
auth_generation: 0,
last_auth_error: None,
});
let model = OpenAiSubscribedLanguageModel {
id: LanguageModelId::from(ChatGptModel::Gpt55.id().to_string()),
model: ChatGptModel::Gpt55,
state,
http_client,
request_limiter: RateLimiter::new(4),
};
let request = LanguageModelRequest {
thread_id: Some("thread-1".to_string()),
prompt_id: Some("prompt-1".to_string()),
messages: vec![LanguageModelRequestMessage {
role: Role::User,
content: vec!["Hello".into()],
cache: false,
reasoning_details: None,
}],
..Default::default()
};
let mut stream = model
.stream_completion(request, &cx.to_async())
.await
.expect("stream should start");
stream
.next()
.await
.expect("stream should emit event")
.expect("event should parse");
let captured_headers = captured_headers
.lock()
.clone()
.expect("request headers should be captured");
assert_eq!(
captured_headers
.get("session-id")
.and_then(|value| value.to_str().ok()),
Some("thread-1")
);
assert_eq!(
captured_headers
.get("ChatGPT-Account-Id")
.and_then(|value| value.to_str().ok()),
Some("account-1")
);
}
#[gpui::test]
async fn test_stream_completion_times_out_before_codex_headers(cx: &mut TestAppContext) {
let http_client = FakeHttpClient::create(|_request| {
futures::future::pending::<anyhow::Result<http_client::Response<AsyncBody>>>()
});
let state = cx.new(|_cx| State {
credentials: Some(make_fresh_credentials()),
sign_in_task: None,
refresh_task: None,
load_task: None,
credentials_provider: Arc::new(FakeCredentialsProvider::new()),
auth_generation: 0,
last_auth_error: None,
});
let model = OpenAiSubscribedLanguageModel {
id: LanguageModelId::from(ChatGptModel::Gpt55.id().to_string()),
model: ChatGptModel::Gpt55,
state,
http_client,
request_limiter: RateLimiter::new(4),
};
let request = LanguageModelRequest {
thread_id: Some("thread-1".to_string()),
prompt_id: Some("prompt-1".to_string()),
messages: vec![LanguageModelRequestMessage {
role: Role::User,
content: vec!["Hello".into()],
cache: false,
reasoning_details: None,
}],
..Default::default()
};
let stream_completion = model.stream_completion(request, &cx.to_async());
cx.run_until_parked();
cx.executor().advance_clock(CODEX_RESPONSE_HEADER_TIMEOUT);
let error = match stream_completion.await {
Ok(_) => panic!("stream should time out before headers arrive"),
Err(error) => error,
};
assert!(matches!(
error,
LanguageModelCompletionError::HttpSend { provider, .. }
if provider == PROVIDER_NAME
));
}
#[gpui::test]
async fn test_concurrent_refresh_deduplicates(cx: &mut TestAppContext) {
let refresh_count = Arc::new(AtomicUsize::new(0));

View file

@ -316,12 +316,6 @@ fn map_open_ai_error(error: open_ai::RequestError) -> LanguageModelCompletionErr
retry_after,
)
}
open_ai::RequestError::ResponseHeaderTimeout { timeout, .. } => {
LanguageModelCompletionError::HttpSend {
provider: PROVIDER_NAME,
error: anyhow::anyhow!("response headers timed out after {timeout:?}"),
}
}
open_ai::RequestError::Other(error) => LanguageModelCompletionError::Other(error),
}
}

View file

@ -6,10 +6,10 @@ use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt, Window};
use http_client::HttpClient;
use language_model::{
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
env_var,
LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
LanguageModelToolSchemaFormat, RateLimiter, env_var,
};
use open_ai::ResponseStreamEvent;
pub use settings::XaiAvailableModel as AvailableModel;
@ -255,6 +255,75 @@ impl XAiLanguageModel {
}
}
fn x_ai_reasoning_efforts(model: &x_ai::Model) -> &'static [open_ai::ReasoningEffort] {
if model.supports_reasoning_effort() {
&[
open_ai::ReasoningEffort::None,
open_ai::ReasoningEffort::Low,
open_ai::ReasoningEffort::Medium,
open_ai::ReasoningEffort::High,
]
} else {
&[]
}
}
fn default_thinking_reasoning_effort(model: &x_ai::Model) -> Option<open_ai::ReasoningEffort> {
if model.supports_reasoning_effort() {
Some(open_ai::ReasoningEffort::Low)
} else {
None
}
}
fn reasoning_effort_for_request(
request: &LanguageModelRequest,
model: &x_ai::Model,
) -> Option<open_ai::ReasoningEffort> {
let supported_efforts = x_ai_reasoning_efforts(model);
if supported_efforts.is_empty() {
return None;
}
if request.thinking_allowed {
request
.thinking_effort
.as_deref()
.and_then(|effort| effort.parse::<open_ai::ReasoningEffort>().ok())
.filter(|effort| supported_efforts.contains(effort))
.filter(|effort| *effort != open_ai::ReasoningEffort::None)
.or_else(|| default_thinking_reasoning_effort(model))
} else if supported_efforts.contains(&open_ai::ReasoningEffort::None) {
Some(open_ai::ReasoningEffort::None)
} else {
None
}
}
fn supported_thinking_effort_levels(model: &x_ai::Model) -> Vec<LanguageModelEffortLevel> {
let default_effort = default_thinking_reasoning_effort(model);
x_ai_reasoning_efforts(model)
.iter()
.copied()
.filter_map(|effort| {
let (name, value) = match effort {
open_ai::ReasoningEffort::None => return None,
open_ai::ReasoningEffort::Minimal => ("Minimal", "minimal"),
open_ai::ReasoningEffort::Low => ("Low", "low"),
open_ai::ReasoningEffort::Medium => ("Medium", "medium"),
open_ai::ReasoningEffort::High => ("High", "high"),
open_ai::ReasoningEffort::XHigh => ("Extra High", "xhigh"),
};
Some(LanguageModelEffortLevel {
name: name.into(),
value: value.into(),
is_default: Some(effort) == default_effort,
})
})
.collect()
}
impl LanguageModel for XAiLanguageModel {
fn id(&self) -> LanguageModelId {
self.id.clone()
@ -291,6 +360,15 @@ impl LanguageModel for XAiLanguageModel {
| LanguageModelToolChoice::None => true,
}
}
fn supports_thinking(&self) -> bool {
self.model.supports_reasoning_effort()
}
fn supported_effort_levels(&self) -> Vec<LanguageModelEffortLevel> {
supported_thinking_effort_levels(&self.model)
}
fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
if self.model.requires_json_schema_subset() {
LanguageModelToolSchemaFormat::JsonSchemaSubset
@ -329,13 +407,14 @@ impl LanguageModel for XAiLanguageModel {
LanguageModelCompletionError,
>,
> {
let reasoning_effort = reasoning_effort_for_request(&request, &self.model);
let request = crate::provider::open_ai::into_open_ai(
request,
self.model.id(),
self.model.supports_parallel_tool_calls(),
self.model.supports_prompt_cache_key(),
self.max_output_tokens(),
None,
reasoning_effort,
false,
);
let completions = self.stream_completion(request, cx);
@ -428,6 +507,56 @@ impl ConfigurationView {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grok_43_supports_selectable_thinking_effort_levels() {
let effort_levels = supported_thinking_effort_levels(&x_ai::Model::Grok43);
let values = effort_levels
.iter()
.map(|level| level.value.as_ref())
.collect::<Vec<_>>();
assert_eq!(values, ["low", "medium", "high"]);
assert_eq!(
effort_levels
.iter()
.find(|level| level.is_default)
.map(|level| level.value.as_ref()),
Some("low")
);
}
#[test]
fn grok_43_request_uses_selected_reasoning_effort() {
let request = LanguageModelRequest {
thinking_allowed: true,
thinking_effort: Some("high".to_string()),
..Default::default()
};
assert_eq!(
reasoning_effort_for_request(&request, &x_ai::Model::Grok43),
Some(open_ai::ReasoningEffort::High)
);
}
#[test]
fn grok_43_request_uses_none_when_thinking_is_disabled() {
let request = LanguageModelRequest {
thinking_allowed: false,
..Default::default()
};
assert_eq!(
reasoning_effort_for_request(&request, &x_ai::Model::Grok43),
Some(open_ai::ReasoningEffort::None)
);
}
}
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_state.is_from_env_var();

View file

@ -49,8 +49,27 @@ pub(crate) struct AudioStack {
impl AudioStack {
pub(crate) fn new(executor: BackgroundExecutor) -> Self {
// AGC2's `adaptive_digital` is what actually levels speech toward a target;
// the `gain_controller2.enabled` master switch alone leaves it off, which
// historically meant capture was effectively unleveled. Defaults match
// what Chrome/Meet ship with -- in particular `max_gain_db = 50` paired
// with `max_output_noise_level_dbfs = -50`, which lets the AGC reach
// very quiet talkers while the noise-level estimator backs off before
// boosting amplifies the noise floor.
let apm = Arc::new(Mutex::new(apm::AudioProcessingModule::new(
true, true, true, true,
apm::AudioProcessingConfig {
echo_canceller_enabled: true,
gain_controller2: apm::GainController2Config {
enabled: true,
adaptive_digital: apm::AdaptiveDigitalConfig {
enabled: true,
..Default::default()
},
..Default::default()
},
high_pass_filter_enabled: true,
noise_suppression_enabled: true,
},
)));
let mixer = Arc::new(Mutex::new(audio_mixer::AudioMixer::new()));
Self {

View file

@ -1,19 +0,0 @@
[package]
name = "nc"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/nc.rs"
doctest = false
[dependencies]
anyhow.workspace = true
futures.workspace = true
net.workspace = true
smol.workspace = true

View file

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

View file

@ -1,51 +0,0 @@
use anyhow::Result;
#[cfg(windows)]
pub fn main(_socket: &str) -> Result<()> {
// It looks like we can't get an async stdio stream on Windows from smol.
panic!("--nc isn't yet supported on Windows");
}
/// The main function for when Zed is running in netcat mode
#[cfg(not(windows))]
pub fn main(socket: &str) -> Result<()> {
use futures::{AsyncReadExt as _, AsyncWriteExt as _, FutureExt as _, io::BufReader, select};
use net::async_net::UnixStream;
use smol::{Unblock, io::AsyncBufReadExt};
smol::block_on(async {
let socket_stream = UnixStream::connect(socket).await?;
let (socket_read, mut socket_write) = socket_stream.split();
let mut socket_reader = BufReader::new(socket_read);
let mut stdout = Unblock::new(std::io::stdout());
let stdin = Unblock::new(std::io::stdin());
let mut stdin_reader = BufReader::new(stdin);
let mut socket_line = Vec::new();
let mut stdin_line = Vec::new();
loop {
select! {
bytes_read = socket_reader.read_until(b'\n', &mut socket_line).fuse() => {
if bytes_read? == 0 {
break
}
stdout.write_all(&socket_line).await?;
stdout.flush().await?;
socket_line.clear();
}
bytes_read = stdin_reader.read_until(b'\n', &mut stdin_line).fuse() => {
if bytes_read? == 0 {
break
}
socket_write.write_all(&stdin_line).await?;
socket_write.flush().await?;
stdin_line.clear();
}
}
}
anyhow::Ok(())
})
}

View file

@ -11,7 +11,7 @@ use http_client::{
pub use language_model_core::ReasoningEffort;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{convert::TryFrom, future::Future, time::Duration};
use std::{convert::TryFrom, future::Future};
use strum::EnumIter;
use thiserror::Error;
@ -684,8 +684,6 @@ pub enum RequestError {
body: String,
headers: HeaderMap<HeaderValue>,
},
#[error("response headers from {provider}'s API timed out after {timeout:?}")]
ResponseHeaderTimeout { provider: String, timeout: Duration },
#[error(transparent)]
Other(#[from] anyhow::Error),
}
@ -905,10 +903,6 @@ impl From<RequestError> for language_model_core::LanguageModelCompletionError {
Self::from_http_status(provider.into(), status_code, body, retry_after)
}
RequestError::ResponseHeaderTimeout { provider, timeout } => Self::HttpSend {
provider: provider.into(),
error: anyhow!("response headers timed out after {timeout:?}"),
},
RequestError::Other(e) => Self::Other(e),
}
}

View file

@ -1,266 +1,11 @@
use anyhow::{Result, anyhow};
use futures::{
AsyncBufReadExt, AsyncReadExt, FutureExt, StreamExt, future::BoxFuture, io::BufReader,
stream::BoxStream,
};
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::{future::Future, time::Duration};
use crate::{ReasoningEffort, RequestError, Role, ServiceTier, ToolChoice};
#[derive(Default)]
pub struct StreamResponseOptions {
response_header_timeout: Option<(Duration, BoxFuture<'static, ()>)>,
}
impl StreamResponseOptions {
pub fn response_header_timeout(
timeout: Duration,
timer: impl Future<Output = ()> + Send + 'static,
) -> Self {
Self {
response_header_timeout: Some((timeout, timer.boxed())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::{FutureExt, StreamExt, future};
use http_client::{
AsyncBody, HttpClient, Request as HttpRequest, Response as HttpResponse, Url,
};
use std::{
io::{Cursor, Read},
pin::Pin,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
task::{Context, Poll, Waker},
};
struct TestHttpClient {
handler: Arc<
dyn Fn(
HttpRequest<AsyncBody>,
) -> BoxFuture<'static, anyhow::Result<HttpResponse<AsyncBody>>>
+ Send
+ Sync,
>,
}
impl TestHttpClient {
fn new<F>(handler: F) -> Self
where
F: Fn(
HttpRequest<AsyncBody>,
) -> BoxFuture<'static, anyhow::Result<HttpResponse<AsyncBody>>>
+ Send
+ Sync
+ 'static,
{
Self {
handler: Arc::new(handler),
}
}
}
impl HttpClient for TestHttpClient {
fn user_agent(&self) -> Option<&http_client::http::HeaderValue> {
None
}
fn proxy(&self) -> Option<&Url> {
None
}
fn send(
&self,
request: HttpRequest<AsyncBody>,
) -> BoxFuture<'static, anyhow::Result<HttpResponse<AsyncBody>>> {
(self.handler)(request)
}
}
struct DelayedBody {
state: Arc<DelayedBodyState>,
bytes: Cursor<Vec<u8>>,
}
struct DelayedBodyState {
released: AtomicBool,
waker: Mutex<Option<Waker>>,
}
struct DelayedBodyHandle {
state: Arc<DelayedBodyState>,
}
impl DelayedBody {
fn new(bytes: Vec<u8>) -> (Self, DelayedBodyHandle) {
let state = Arc::new(DelayedBodyState {
released: AtomicBool::new(false),
waker: Mutex::new(None),
});
(
Self {
state: state.clone(),
bytes: Cursor::new(bytes),
},
DelayedBodyHandle { state },
)
}
}
impl DelayedBodyHandle {
fn release(&self) {
self.state.released.store(true, Ordering::SeqCst);
if let Some(waker) = self.state.waker.lock().expect("lock poisoned").take() {
waker.wake();
}
}
}
impl futures::AsyncRead for DelayedBody {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buffer: &mut [u8],
) -> Poll<std::io::Result<usize>> {
if !self.state.released.load(Ordering::SeqCst) {
self.state
.waker
.lock()
.expect("lock poisoned")
.replace(cx.waker().clone());
return Poll::Pending;
}
Poll::Ready(self.bytes.read(buffer))
}
}
fn test_request() -> Request {
Request {
model: "gpt-test".into(),
instructions: None,
input: Vec::new(),
include: Vec::new(),
stream: true,
temperature: None,
top_p: None,
max_output_tokens: None,
parallel_tool_calls: None,
tool_choice: None,
tools: Vec::new(),
prompt_cache_key: None,
reasoning: None,
store: None,
service_tier: None,
}
}
#[test]
fn stream_response_times_out_before_headers() {
futures::executor::block_on(async {
let client = TestHttpClient::new(|_| {
future::pending::<anyhow::Result<HttpResponse<AsyncBody>>>().boxed()
});
let result = stream_response_with_options(
&client,
"Test Provider",
"https://api.test/v1",
"test-key",
test_request(),
Vec::new(),
StreamResponseOptions::response_header_timeout(
Duration::from_secs(10),
future::ready(()),
),
)
.await;
assert!(matches!(
result,
Err(RequestError::ResponseHeaderTimeout {
provider,
timeout
}) if provider == "Test Provider" && timeout == Duration::from_secs(10)
));
});
}
#[test]
fn stream_response_does_not_timeout_after_headers_arrive() {
futures::executor::block_on(async {
let body = r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}"#;
let (delayed_body, delayed_body_handle) =
DelayedBody::new(format!("{body}\n\n").into_bytes());
let delayed_body = Mutex::new(Some(delayed_body));
let client = TestHttpClient::new(move |_| {
let delayed_body = delayed_body
.lock()
.expect("lock poisoned")
.take()
.expect("test sends only one request");
async {
Ok(HttpResponse::builder()
.status(200)
.body(AsyncBody::from_reader(delayed_body))?)
}
.boxed()
});
let (timeout_tx, timeout_rx) = futures::channel::oneshot::channel::<()>();
let mut stream = stream_response_with_options(
&client,
"Test Provider",
"https://api.test/v1",
"test-key",
test_request(),
Vec::new(),
StreamResponseOptions::response_header_timeout(
Duration::from_secs(10),
async move {
assert!(
timeout_rx.await.is_ok(),
"timer should be dropped after headers arrive"
);
},
),
)
.await
.expect("headers should arrive before timeout");
assert!(
timeout_tx.send(()).is_err(),
"timeout future should be dropped after headers arrive"
);
assert!(
stream.next().now_or_never().is_none(),
"stream should wait for delayed body bytes"
);
delayed_body_handle.release();
let event = stream
.next()
.await
.expect("stream should produce an event")
.expect("event should parse");
assert!(matches!(event, StreamEvent::Completed { .. }));
});
}
}
#[derive(Serialize, Debug)]
pub struct Request {
pub model: String,
@ -695,27 +440,6 @@ pub async fn stream_response(
api_key: &str,
request: Request,
extra_headers: Vec<(String, String)>,
) -> Result<BoxStream<'static, Result<StreamEvent>>, RequestError> {
stream_response_with_options(
client,
provider_name,
api_url,
api_key,
request,
extra_headers,
StreamResponseOptions::default(),
)
.await
}
pub async fn stream_response_with_options(
client: &dyn HttpClient,
provider_name: &str,
api_url: &str,
api_key: &str,
request: Request,
extra_headers: Vec<(String, String)>,
options: StreamResponseOptions,
) -> Result<BoxStream<'static, Result<StreamEvent>>, RequestError> {
let uri = format!("{api_url}/responses");
let mut request_builder = HttpRequest::builder()
@ -734,24 +458,7 @@ pub async fn stream_response_with_options(
))
.map_err(|e| RequestError::Other(e.into()))?;
let mut response = if let Some((timeout, timer)) = options.response_header_timeout {
let send_request = client.send(request).fuse();
let timer = timer.fuse();
futures::pin_mut!(send_request);
futures::pin_mut!(timer);
futures::select! {
response = send_request => response?,
() = timer => {
return Err(RequestError::ResponseHeaderTimeout {
provider: provider_name.to_owned(),
timeout,
});
}
}
} else {
client.send(request).await?
};
let mut response = client.send(request).await?;
if response.status().is_success() {
if is_streaming {
let reader = BufReader::new(response.into_body());

View file

@ -8437,26 +8437,21 @@ impl LspStore {
}
fn maintain_workspace_config(
external_refresh_requests: watch::Receiver<()>,
mut external_refresh_requests: watch::Receiver<()>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let (mut settings_changed_tx, mut settings_changed_rx) = watch::channel();
let _ = postage::stream::Stream::try_recv(&mut settings_changed_rx);
let settings_observation = cx.observe_global::<SettingsStore>(move |_, _| {
*settings_changed_tx.borrow_mut() = ();
});
let mut joint_future =
futures::stream::select(settings_changed_rx, external_refresh_requests);
// Multiple things can happen when a workspace environment (selected toolchain + settings) change:
// - We might shut down a language server if it's no longer enabled for a given language (and there are no buffers using it otherwise).
// - We might also shut it down when the workspace configuration of all of the users of a given language server converges onto that of the other.
// - In the same vein, we might also decide to start a new language server if the workspace configuration *diverges* from the other.
// - In the easiest case (where we're not wrangling the lifetime of a language server anyhow), if none of the roots of a single language server diverge in their configuration,
// but it is still different to what we had before, we're gonna send out a workspace configuration update.
//
// Settings-store changes reach this loop via `on_settings_changed` -> `request_workspace_config_refresh`,
// which writes to `external_refresh_requests`. Observing `SettingsStore` here as well would cause every
// settings change to drive the loop twice and emit duplicate `workspace/didChangeConfiguration` notifications.
cx.spawn(async move |this, cx| {
while let Some(()) = joint_future.next().await {
while let Some(()) = external_refresh_requests.next().await {
this.update(cx, |this, cx| {
this.refresh_server_tree(cx);
})
@ -8465,7 +8460,6 @@ impl LspStore {
Self::refresh_workspace_configurations(&this, cx).await;
}
drop(settings_observation);
anyhow::Ok(())
})
}

View file

@ -3355,6 +3355,68 @@ async fn test_toggling_enable_language_server(cx: &mut gpui::TestAppContext) {
.await;
}
#[gpui::test]
async fn test_updating_lsp_settings_sends_one_did_change_configuration(
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/dir"), json!({ "a.rs": "" })).await;
let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
let mut fake_rust_servers = language_registry.register_fake_lsp(
"Rust",
FakeLspAdapter {
name: "rust-lsp",
..Default::default()
},
);
language_registry.add(rust_lang());
let _rs_buffer = project
.update(cx, |project, cx| {
project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx)
})
.await
.unwrap();
let fake_rust_server = fake_rust_servers.next().await.unwrap();
let did_change_count = Arc::new(atomic::AtomicUsize::new(0));
fake_rust_server.handle_notification::<lsp::notification::DidChangeConfiguration, _>({
let did_change_count = did_change_count.clone();
move |_, _| {
did_change_count.fetch_add(1, atomic::Ordering::SeqCst);
}
});
cx.executor().run_until_parked();
did_change_count.store(0, atomic::Ordering::SeqCst);
cx.update(|cx| {
SettingsStore::update_global(cx, |settings, cx| {
settings.update_user_settings(cx, |settings| {
settings.project.lsp.0.insert(
"rust-lsp".into(),
settings::LspSettings {
settings: Some(json!({ "foo": true })),
..Default::default()
},
);
});
})
});
cx.executor().run_until_parked();
assert_eq!(
did_change_count.load(atomic::Ordering::SeqCst),
1,
"expected exactly one workspace/didChangeConfiguration after a settings change"
);
}
#[gpui::test(iterations = 3)]
async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) {
init_test(cx);

View file

@ -21,7 +21,6 @@ db.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
handlebars.workspace = true
heed.workspace = true
@ -29,7 +28,6 @@ language.workspace = true
log.workspace = true
parking_lot.workspace = true
paths.workspace = true
rope.workspace = true
serde.workspace = true
serde_json.workspace = true
strum.workspace = true

View file

@ -6,24 +6,17 @@ use chrono::{DateTime, Utc};
use collections::HashMap;
use futures::FutureExt as _;
use futures::future::Shared;
use fuzzy::StringMatchCandidate;
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, ReadGlobal, SharedString, Task,
};
use gpui::{App, AppContext, Entity, Global, ReadGlobal, SharedString, Task};
use heed::{
Database, RoTxn,
types::{SerdeBincode, SerdeJson, Str},
};
use parking_lot::RwLock;
pub use prompts::*;
use rope::Rope;
use serde::{Deserialize, Serialize};
use std::{
cmp::Reverse,
future::Future,
path::PathBuf,
sync::{Arc, atomic::AtomicBool},
};
use std::{future::Future, path::PathBuf, sync::Arc};
use strum::{EnumIter, IntoEnumIterator as _};
use text::LineEnding;
use util::ResultExt;
@ -122,15 +115,6 @@ impl PromptId {
pub fn is_built_in(&self) -> bool {
matches!(self, Self::BuiltIn { .. })
}
pub fn can_edit(&self) -> bool {
match self {
Self::User { .. } => true,
Self::BuiltIn(builtin) => match builtin {
BuiltInPrompt::CommitMessage => true,
},
}
}
}
impl From<BuiltInPrompt> for PromptId {
@ -173,14 +157,9 @@ impl std::fmt::Display for PromptId {
pub struct PromptStore {
env: heed::Env,
metadata_cache: RwLock<MetadataCache>,
metadata: Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
bodies: Database<SerdeJson<PromptId>, Str>,
}
pub struct PromptsUpdatedEvent;
impl EventEmitter<PromptsUpdatedEvent> for PromptStore {}
#[derive(Default)]
struct MetadataCache {
metadata: Vec<PromptMetadata>,
@ -220,21 +199,6 @@ impl MetadataCache {
Ok(cache)
}
fn insert(&mut self, metadata: PromptMetadata) {
self.metadata_by_id.insert(metadata.id, metadata.clone());
if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) {
*old_metadata = metadata;
} else {
self.metadata.push(metadata);
}
self.sort();
}
fn remove(&mut self, id: PromptId) {
self.metadata.retain(|metadata| metadata.id != id);
self.metadata_by_id.remove(&id);
}
fn sort(&mut self) {
self.metadata.sort_unstable_by(|a, b| {
a.title
@ -275,7 +239,6 @@ impl PromptStore {
Ok(PromptStore {
env: db_env,
metadata_cache: RwLock::new(metadata_cache),
metadata,
bodies,
})
})
@ -363,219 +326,6 @@ impl PromptStore {
pub fn all_prompt_metadata(&self) -> Vec<PromptMetadata> {
self.metadata_cache.read().metadata.clone()
}
pub fn default_prompt_metadata(&self) -> Vec<PromptMetadata> {
return self
.metadata_cache
.read()
.metadata
.iter()
.filter(|metadata| metadata.default)
.cloned()
.collect::<Vec<_>>();
}
pub fn delete(&self, id: PromptId, cx: &Context<Self>) -> Task<Result<()>> {
self.metadata_cache.write().remove(id);
let db_connection = self.env.clone();
let bodies = self.bodies;
let metadata = self.metadata;
let task = cx.background_spawn(async move {
let mut txn = db_connection.write_txn()?;
metadata.delete(&mut txn, &id)?;
bodies.delete(&mut txn, &id)?;
if let PromptId::User { uuid } = id {
let prompt_id_v1 = PromptIdV1::from(uuid);
if let Some(metadata_v1_db) = db_connection
.open_database::<SerdeBincode<PromptIdV1>, SerdeBincode<()>>(
&txn,
Some("metadata"),
)?
{
metadata_v1_db.delete(&mut txn, &prompt_id_v1)?;
}
if let Some(bodies_v1_db) = db_connection
.open_database::<SerdeBincode<PromptIdV1>, SerdeBincode<()>>(
&txn,
Some("bodies"),
)?
{
bodies_v1_db.delete(&mut txn, &prompt_id_v1)?;
}
}
txn.commit()?;
anyhow::Ok(())
});
cx.spawn(async move |this, cx| {
task.await?;
this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok();
anyhow::Ok(())
})
}
pub fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
self.metadata_cache.read().metadata_by_id.get(&id).cloned()
}
pub fn first(&self) -> Option<PromptMetadata> {
self.metadata_cache.read().metadata.first().cloned()
}
pub fn id_for_title(&self, title: &str) -> Option<PromptId> {
let metadata_cache = self.metadata_cache.read();
let metadata = metadata_cache
.metadata
.iter()
.find(|metadata| metadata.title.as_deref() == Some(title))?;
Some(metadata.id)
}
pub fn search(
&self,
query: String,
cancellation_flag: Arc<AtomicBool>,
cx: &App,
) -> Task<Vec<PromptMetadata>> {
let cached_metadata = self.metadata_cache.read().metadata.clone();
let executor = cx.background_executor().clone();
cx.background_spawn(async move {
let mut matches = if query.is_empty() {
cached_metadata
} else {
let candidates = cached_metadata
.iter()
.enumerate()
.filter_map(|(ix, metadata)| {
Some(StringMatchCandidate::new(ix, metadata.title.as_ref()?))
})
.collect::<Vec<_>>();
let matches = fuzzy::match_strings(
&candidates,
&query,
false,
true,
100,
&cancellation_flag,
executor,
)
.await;
matches
.into_iter()
.map(|mat| cached_metadata[mat.candidate_id].clone())
.collect()
};
matches.sort_by_key(|metadata| Reverse(metadata.default));
matches
})
}
pub fn save(
&self,
id: PromptId,
title: Option<SharedString>,
default: bool,
body: Rope,
cx: &Context<Self>,
) -> Task<Result<()>> {
if !id.can_edit() {
return Task::ready(Err(anyhow!("this prompt cannot be edited")));
}
let body = body.to_string();
let is_default_content = id
.as_built_in()
.is_some_and(|builtin| body.trim() == builtin.default_content().trim());
let metadata = if let Some(builtin) = id.as_built_in() {
PromptMetadata::builtin(builtin)
} else {
PromptMetadata {
id,
title,
default,
saved_at: Utc::now(),
}
};
self.metadata_cache.write().insert(metadata.clone());
let db_connection = self.env.clone();
let bodies = self.bodies;
let metadata_db = self.metadata;
let task = cx.background_spawn(async move {
let mut txn = db_connection.write_txn()?;
if is_default_content {
metadata_db.delete(&mut txn, &id)?;
bodies.delete(&mut txn, &id)?;
} else {
metadata_db.put(&mut txn, &id, &metadata)?;
bodies.put(&mut txn, &id, &body)?;
}
txn.commit()?;
anyhow::Ok(())
});
cx.spawn(async move |this, cx| {
task.await?;
this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok();
anyhow::Ok(())
})
}
pub fn save_metadata(
&self,
id: PromptId,
mut title: Option<SharedString>,
default: bool,
cx: &Context<Self>,
) -> Task<Result<()>> {
let mut cache = self.metadata_cache.write();
if !id.can_edit() {
title = cache
.metadata_by_id
.get(&id)
.and_then(|metadata| metadata.title.clone());
}
let prompt_metadata = PromptMetadata {
id,
title,
default,
saved_at: Utc::now(),
};
cache.insert(prompt_metadata.clone());
let db_connection = self.env.clone();
let metadata = self.metadata;
let task = cx.background_spawn(async move {
let mut txn = db_connection.write_txn()?;
metadata.put(&mut txn, &id, &prompt_metadata)?;
txn.commit()?;
anyhow::Ok(())
});
cx.spawn(async move |this, cx| {
task.await?;
this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok();
anyhow::Ok(())
})
}
}
/// Deprecated: Legacy V1 prompt ID format, used only for migrating data from old databases. Use `PromptId` instead.
@ -608,7 +358,7 @@ mod tests {
use gpui::TestAppContext;
#[gpui::test]
async fn test_built_in_prompt_load_save(cx: &mut TestAppContext) {
async fn test_built_in_prompt_load(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let temp_dir = tempfile::tempdir().unwrap();
@ -632,265 +382,14 @@ mod tests {
"Loading a built-in prompt not in DB should return default content"
);
let metadata = store.read_with(cx, |store, _| store.metadata(commit_message_id));
assert!(
metadata.is_some(),
"Built-in prompt should always have metadata"
);
assert!(
store.read_with(cx, |store, _| {
store
.metadata_cache
.read()
.metadata_by_id
.contains_key(&commit_message_id)
.all_prompt_metadata()
.iter()
.any(|metadata| metadata.id == commit_message_id)
}),
"Built-in prompt should always be in cache"
);
let custom_content = "Custom commit message prompt";
store
.update(cx, |store, cx| {
store.save(
commit_message_id,
Some("Commit message".into()),
false,
Rope::from(custom_content),
cx,
)
})
.await
.unwrap();
let loaded_custom = store
.update(cx, |store, cx| store.load(commit_message_id, cx))
.await
.unwrap();
assert_eq!(
loaded_custom.trim(),
custom_content.trim(),
"Custom content should be loaded after saving"
);
assert!(
store
.read_with(cx, |store, _| store.metadata(commit_message_id))
.is_some(),
"Built-in prompt should have metadata after customization"
);
store
.update(cx, |store, cx| {
store.save(
commit_message_id,
Some("Commit message".into()),
false,
Rope::from(BuiltInPrompt::CommitMessage.default_content()),
cx,
)
})
.await
.unwrap();
let metadata_after_reset =
store.read_with(cx, |store, _| store.metadata(commit_message_id));
assert!(
metadata_after_reset.is_some(),
"Built-in prompt should still have metadata after reset"
);
assert_eq!(
metadata_after_reset
.as_ref()
.and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
Some("Commit message"),
"Built-in prompt should have default title after reset"
);
let loaded_after_reset = store
.update(cx, |store, cx| store.load(commit_message_id, cx))
.await
.unwrap();
let mut expected_content_after_reset =
BuiltInPrompt::CommitMessage.default_content().to_string();
LineEnding::normalize(&mut expected_content_after_reset);
assert_eq!(
loaded_after_reset.trim(),
expected_content_after_reset.trim(),
"Content should be back to default after saving default content"
);
}
/// Test that the prompt store initializes successfully even when the database
/// contains records with incompatible/undecodable PromptId keys (e.g., from
/// a different branch that used a different serialization format).
///
/// This is a regression test for the "fail-open" behavior: we should skip
/// bad records rather than failing the entire store initialization.
#[gpui::test]
async fn test_prompt_store_handles_incompatible_db_records(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("prompts-db-with-bad-records");
std::fs::create_dir_all(&db_path).unwrap();
// First, create the DB and write an incompatible record directly.
// We simulate a record written by a different branch that used
// `{"kind":"CommitMessage"}` instead of `{"kind":"BuiltIn", ...}`.
{
let db_env = unsafe {
heed::EnvOpenOptions::new()
.map_size(1024 * 1024 * 1024)
.max_dbs(4)
.open(&db_path)
.unwrap()
};
let mut txn = db_env.write_txn().unwrap();
// Create the metadata.v2 database with raw bytes so we can write
// an incompatible key format.
let metadata_db: Database<heed::types::Bytes, heed::types::Bytes> = db_env
.create_database(&mut txn, Some("metadata.v2"))
.unwrap();
// Write an incompatible PromptId key: `{"kind":"CommitMessage"}`
// This is the old/branch format that current code can't decode.
let bad_key = br#"{"kind":"CommitMessage"}"#;
let dummy_metadata = br#"{"id":{"kind":"CommitMessage"},"title":"Bad Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#;
metadata_db.put(&mut txn, bad_key, dummy_metadata).unwrap();
// Also write a valid record to ensure we can still read good data.
let good_key = br#"{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"}"#;
let good_metadata = br#"{"id":{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"},"title":"Good Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#;
metadata_db.put(&mut txn, good_key, good_metadata).unwrap();
txn.commit().unwrap();
}
// Now try to create a PromptStore from this DB.
// With fail-open behavior, this should succeed and skip the bad record.
// Without fail-open, this would return an error.
let store_result = cx.update(|cx| PromptStore::new(db_path, cx)).await;
assert!(
store_result.is_ok(),
"PromptStore should initialize successfully even with incompatible DB records. \
Got error: {:?}",
store_result.err()
);
let store = cx.new(|_cx| store_result.unwrap());
// Verify the good record was loaded.
let good_id = PromptId::User {
uuid: UserPromptId("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()),
};
let metadata = store.read_with(cx, |store, _| store.metadata(good_id));
assert!(
metadata.is_some(),
"Valid records should still be loaded after skipping bad ones"
);
assert_eq!(
metadata
.as_ref()
.and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
Some("Good Record"),
"Valid record should have correct title"
);
}
#[gpui::test]
async fn test_deleted_prompt_does_not_reappear_after_migration(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("prompts-db-v1-migration");
std::fs::create_dir_all(&db_path).unwrap();
let prompt_uuid: Uuid = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap();
let prompt_id_v1 = PromptIdV1(prompt_uuid);
let prompt_id_v2 = PromptId::User {
uuid: UserPromptId(prompt_uuid),
};
// Create V1 database with a prompt
{
let db_env = unsafe {
heed::EnvOpenOptions::new()
.map_size(1024 * 1024 * 1024)
.max_dbs(4)
.open(&db_path)
.unwrap()
};
let mut txn = db_env.write_txn().unwrap();
let metadata_v1_db: Database<SerdeBincode<PromptIdV1>, SerdeBincode<PromptMetadataV1>> =
db_env.create_database(&mut txn, Some("metadata")).unwrap();
let bodies_v1_db: Database<SerdeBincode<PromptIdV1>, SerdeBincode<String>> =
db_env.create_database(&mut txn, Some("bodies")).unwrap();
let metadata_v1 = PromptMetadataV1 {
id: prompt_id_v1.clone(),
title: Some("V1 Prompt".into()),
default: false,
saved_at: Utc::now(),
};
metadata_v1_db
.put(&mut txn, &prompt_id_v1, &metadata_v1)
.unwrap();
bodies_v1_db
.put(&mut txn, &prompt_id_v1, &"V1 prompt body".to_string())
.unwrap();
txn.commit().unwrap();
}
// Migrate V1 to V2 by creating PromptStore
let store = cx
.update(|cx| PromptStore::new(db_path.clone(), cx))
.await
.unwrap();
let store = cx.new(|_cx| store);
// Verify the prompt was migrated
let metadata = store.read_with(cx, |store, _| store.metadata(prompt_id_v2));
assert!(metadata.is_some(), "V1 prompt should be migrated to V2");
assert_eq!(
metadata
.as_ref()
.and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
Some("V1 Prompt"),
"Migrated prompt should have correct title"
);
// Delete the prompt
store
.update(cx, |store, cx| store.delete(prompt_id_v2, cx))
.await
.unwrap();
// Verify prompt is deleted
let metadata_after_delete = store.read_with(cx, |store, _| store.metadata(prompt_id_v2));
assert!(
metadata_after_delete.is_none(),
"Prompt should be deleted from V2"
);
drop(store);
// "Restart" by creating a new PromptStore from the same path
let store_after_restart = cx.update(|cx| PromptStore::new(db_path, cx)).await.unwrap();
let store_after_restart = cx.new(|_cx| store_after_restart);
// Test the prompt does not reappear
let metadata_after_restart =
store_after_restart.read_with(cx, |store, _| store.metadata(prompt_id_v2));
assert!(
metadata_after_restart.is_none(),
"Deleted prompt should NOT reappear after restart/migration"
);
}
}

View file

@ -1555,7 +1555,7 @@ type ResponseChannels = Mutex<HashMap<MessageId, oneshot::Sender<(Envelope, ones
type StreamResponseChannels =
Arc<Mutex<HashMap<MessageId, UnboundedSender<(Result<Envelope>, oneshot::Sender<()>)>>>>;
struct Signal<T> {
struct Signal<T: 'static> {
tx: Mutex<Option<oneshot::Sender<T>>>,
rx: Shared<Task<Option<T>>>,
}

View file

@ -1,36 +0,0 @@
[package]
name = "rules_library"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/rules_library.rs"
[dependencies]
anyhow.workspace = true
collections.workspace = true
editor.workspace = true
gpui.workspace = true
language.workspace = true
language_model.workspace = true
log.workspace = true
menu.workspace = true
picker.workspace = true
platform_title_bar.workspace = true
prompt_store.workspace = true
release_channel.workspace = true
rope.workspace = true
serde.workspace = true
settings.workspace = true
theme_settings.workspace = true
ui.workspace = true
ui_input.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,7 @@
use crate::{Instant, Priority, RunnableMeta, Scheduler, SessionId, Timer};
use async_task::Runnable;
use std::{
any::Any,
future::Future,
marker::PhantomData,
mem::ManuallyDrop,
@ -12,18 +14,39 @@ use std::{
time::Duration,
};
/// A `!Send` executor pinned to a single session. Tasks spawned on it run in
/// order on whichever thread drains the dispatch destination supplied at
/// construction time — typically the main thread for the default session, or
/// a dedicated OS thread for sessions created by `spawn_dedicated_thread`.
#[derive(Clone)]
pub struct ForegroundExecutor {
pub struct LocalExecutor {
session_id: SessionId,
scheduler: Arc<dyn Scheduler>,
// Spawned tasks' schedule callbacks each hold an `Arc` clone of this
// closure, so the destination it captures stays alive as long as work
// could still land on it.
dispatch: Arc<dyn Fn(Runnable<RunnableMeta>) + Send + Sync>,
not_send: PhantomData<Rc<()>>,
}
impl ForegroundExecutor {
pub fn new(session_id: SessionId, scheduler: Arc<dyn Scheduler>) -> Self {
impl LocalExecutor {
/// Constructs a local executor that runs spawned tasks by sending their
/// runnables through `dispatch`. The `scheduler` is retained for access to
/// clocks, timers, and other scheduler-level services.
///
/// For the common case of routing runnables through
/// `Scheduler::schedule_local`, callers pass a closure that does exactly
/// that. `spawn_dedicated_thread` instead passes a closure that sends to
/// the dedicated thread's channel.
pub fn new(
session_id: SessionId,
scheduler: Arc<dyn Scheduler>,
dispatch: impl Fn(Runnable<RunnableMeta>) + Send + Sync + 'static,
) -> Self {
Self {
session_id,
scheduler,
dispatch: Arc::new(dispatch),
not_send: PhantomData,
}
}
@ -42,16 +65,11 @@ impl ForegroundExecutor {
F: Future + 'static,
F::Output: 'static,
{
let session_id = self.session_id;
let scheduler = Arc::downgrade(&self.scheduler);
let dispatch = self.dispatch.clone();
let location = Location::caller();
let (runnable, task) = spawn_local_with_source_location(
future,
move |runnable| {
if let Some(scheduler) = scheduler.upgrade() {
scheduler.schedule_foreground(session_id, runnable);
}
},
move |runnable| dispatch(runnable),
RunnableMeta { location },
);
runnable.schedule();
@ -110,6 +128,48 @@ impl ForegroundExecutor {
pub fn now(&self) -> Instant {
self.scheduler.clock().now()
}
/// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`].
/// The closure runs on a new OS thread under `PlatformScheduler`, or on
/// the test scheduler's loop under `TestScheduler`.
///
/// The returned `Task` represents the dedicated work: dropping it cancels
/// the dedicated closure, `.await`ing it yields the closure's return
/// value, `.detach()`ing it lets the dedicated work run independently of
/// the caller.
#[track_caller]
pub fn spawn_dedicated<F, Fut>(&self, f: F) -> Task<Fut::Output>
where
F: FnOnce(LocalExecutor) -> Fut + Send + 'static,
Fut: Future + 'static,
Fut::Output: Send + Sync + 'static,
{
self.scheduler
.clone()
.spawn_dedicated(box_dedicated(f))
.downcast::<Fut::Output>()
}
}
/// Boxes the user-supplied dedicated closure into the type-erased shape
/// expected by [`Scheduler::spawn_dedicated`]. The user's `Fut::Output` is
/// boxed as `Box<dyn Any + Send + Sync>` on the dedicated side and downcast
/// back to `Fut::Output` by [`Task::downcast`] in the wrapper.
fn box_dedicated<F, Fut>(
f: F,
) -> Box<
dyn FnOnce(LocalExecutor) -> Pin<Box<dyn Future<Output = Box<dyn Any + Send + Sync>> + 'static>>
+ Send
+ 'static,
>
where
F: FnOnce(LocalExecutor) -> Fut + Send + 'static,
Fut: Future + 'static,
Fut::Output: Send + Sync + 'static,
{
Box::new(move |executor| {
Box::pin(async move { Box::new(f(executor).await) as Box<dyn Any + Send + Sync> })
})
}
#[derive(Clone)]
@ -193,6 +253,27 @@ impl BackgroundExecutor {
pub fn scheduler(&self) -> &Arc<dyn Scheduler> {
&self.scheduler
}
/// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`].
/// The closure runs on a new OS thread under `PlatformScheduler`, or on
/// the test scheduler's loop under `TestScheduler`.
///
/// The returned `Task` represents the dedicated work: dropping it cancels
/// the dedicated closure, `.await`ing it yields the closure's return
/// value, `.detach()`ing it lets the dedicated work run independently of
/// the caller.
#[track_caller]
pub fn spawn_dedicated<F, Fut>(&self, f: F) -> Task<Fut::Output>
where
F: FnOnce(LocalExecutor) -> Fut + Send + 'static,
Fut: Future + 'static,
Fut::Output: Send + Sync + 'static,
{
self.scheduler
.clone()
.spawn_dedicated(box_dedicated(f))
.downcast::<Fut::Output>()
}
}
/// Task is a primitive that allows work to happen in the background.
@ -202,16 +283,22 @@ impl BackgroundExecutor {
/// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows
/// the task to continue running, but with no way to return a value.
#[must_use]
#[derive(Debug)]
pub struct Task<T>(TaskState<T>);
#[derive(Debug)]
enum TaskState<T> {
/// A task that is ready to return a value
Ready(Option<T>),
/// A task that is currently running.
Spawned(async_task::Task<T, RunnableMeta>),
/// A typed view of a [`Task<Box<dyn Any + Send + Sync>>`] obtained via
/// [`Task::downcast`]. The inner task drives the actual work; the
/// downcast layer just unwraps the `Box<dyn Any + Send + Sync>` on poll.
Downcast {
inner: Box<Task<Box<dyn Any + Send + Sync>>>,
marker: PhantomData<fn() -> T>,
},
}
impl<T> Task<T> {
@ -229,6 +316,7 @@ impl<T> Task<T> {
match &self.0 {
TaskState::Ready(_) => true,
TaskState::Spawned(task) => task.is_finished(),
TaskState::Downcast { inner, .. } => inner.is_ready(),
}
}
@ -237,6 +325,7 @@ impl<T> Task<T> {
match self {
Task(TaskState::Ready(_)) => {}
Task(TaskState::Spawned(task)) => task.detach(),
Task(TaskState::Downcast { inner, .. }) => inner.detach(),
}
}
@ -245,10 +334,43 @@ impl<T> Task<T> {
FallibleTask(match self.0 {
TaskState::Ready(val) => FallibleTaskState::Ready(val),
TaskState::Spawned(task) => FallibleTaskState::Spawned(task.fallible()),
TaskState::Downcast { inner, .. } => FallibleTaskState::Downcast {
inner: Box::new(inner.fallible()),
marker: PhantomData,
},
})
}
}
impl Task<Box<dyn Any + Send + Sync>> {
/// Reinterprets the boxed output as a concrete `T` via downcast on
/// completion. Used by [`LocalExecutor::spawn_dedicated`] and
/// [`BackgroundExecutor::spawn_dedicated`] to recover the user closure's
/// `Fut::Output` from the dyn-safe [`Scheduler::spawn_dedicated`].
///
/// Panics on poll if the inner output is not in fact a `T` -- a logic
/// error in whatever produced the inner task, since the downcast type is
/// chosen by the caller of `downcast`.
pub fn downcast<T: Send + Sync + 'static>(self) -> Task<T> {
Task(TaskState::Downcast {
inner: Box::new(self),
marker: PhantomData,
})
}
}
impl<T> std::fmt::Debug for Task<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.0 {
TaskState::Ready(_) => f.debug_tuple("Task::Ready").finish(),
TaskState::Spawned(task) => f.debug_tuple("Task::Spawned").field(task).finish(),
TaskState::Downcast { inner, .. } => {
f.debug_tuple("Task::Downcast").field(inner).finish()
}
}
}
}
/// A task that returns `Option<T>` instead of panicking when cancelled.
#[must_use]
pub struct FallibleTask<T>(FallibleTaskState<T>);
@ -259,6 +381,12 @@ enum FallibleTaskState<T> {
/// A task that is currently running (wraps async_task::FallibleTask).
Spawned(async_task::FallibleTask<T, RunnableMeta>),
/// Mirror of [`TaskState::Downcast`] for fallible tasks.
Downcast {
inner: Box<FallibleTask<Box<dyn Any + Send + Sync>>>,
marker: PhantomData<fn() -> T>,
},
}
impl<T> FallibleTask<T> {
@ -272,17 +400,29 @@ impl<T> FallibleTask<T> {
match self.0 {
FallibleTaskState::Ready(_) => {}
FallibleTaskState::Spawned(task) => task.detach(),
FallibleTaskState::Downcast { inner, .. } => inner.detach(),
}
}
}
impl<T> Future for FallibleTask<T> {
impl<T: 'static> Future for FallibleTask<T> {
type Output = Option<T>;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
match unsafe { self.get_unchecked_mut() } {
FallibleTask(FallibleTaskState::Ready(val)) => Poll::Ready(val.take()),
FallibleTask(FallibleTaskState::Spawned(task)) => Pin::new(task).poll(cx),
FallibleTask(FallibleTaskState::Downcast { inner, .. }) => {
match Pin::new(inner.as_mut()).poll(cx) {
Poll::Ready(Some(boxed_any)) => Poll::Ready(Some(
*boxed_any
.downcast::<T>()
.expect("FallibleTask::poll: downcast type mismatch"),
)),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
}
}
@ -294,17 +434,29 @@ impl<T> std::fmt::Debug for FallibleTask<T> {
FallibleTaskState::Spawned(task) => {
f.debug_tuple("FallibleTask::Spawned").field(task).finish()
}
FallibleTaskState::Downcast { inner, .. } => f
.debug_tuple("FallibleTask::Downcast")
.field(inner)
.finish(),
}
}
}
impl<T> Future for Task<T> {
impl<T: 'static> Future for Task<T> {
type Output = T;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
match unsafe { self.get_unchecked_mut() } {
Task(TaskState::Ready(val)) => Poll::Ready(val.take().unwrap()),
Task(TaskState::Spawned(task)) => Pin::new(task).poll(cx),
Task(TaskState::Downcast { inner, .. }) => match Pin::new(inner.as_mut()).poll(cx) {
Poll::Ready(boxed_any) => Poll::Ready(
*boxed_any
.downcast::<T>()
.expect("Task::poll: downcast type mismatch"),
),
Poll::Pending => Poll::Pending,
},
}
}
}

View file

@ -11,11 +11,13 @@ pub use test_scheduler::*;
use async_task::Runnable;
use futures::channel::oneshot;
use std::{
any::Any,
future::Future,
panic::Location,
pin::Pin,
sync::Arc,
task::{Context, Poll},
thread,
time::Duration,
};
@ -82,7 +84,11 @@ pub trait Scheduler: Send + Sync {
timeout: Option<Duration>,
) -> bool;
fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable<RunnableMeta>);
/// Schedule a runnable on the local (session-pinned) queue for `session_id`.
/// Runnables scheduled here run in order on whichever thread drains the
/// session — the main thread for ordinary sessions, or a dedicated OS
/// thread for sessions created via `spawn_dedicated_thread`.
fn schedule_local(&self, session_id: SessionId, runnable: Runnable<RunnableMeta>);
/// Schedule a background task with the given priority.
fn schedule_background_with_priority(
@ -103,11 +109,87 @@ pub trait Scheduler: Send + Sync {
fn timer(&self, timeout: Duration) -> Timer;
fn clock(&self) -> Arc<dyn Clock>;
/// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`].
///
/// `PlatformScheduler` runs the closure on a new OS thread (see
/// [`spawn_dedicated_thread`]). `TestScheduler` runs it on the test
/// scheduler's loop alongside everything else so determinism under
/// `TestScheduler::many` is preserved.
///
/// This is the dyn-safe entry point: the closure's output is type-erased
/// as `Box<dyn Any + Send + Sync>` so the trait stays object-safe.
/// Callers typically reach for the type-safe wrappers on
/// [`LocalExecutor::spawn_dedicated`] and
/// [`BackgroundExecutor::spawn_dedicated`], which compose this method
/// with [`Task::downcast`] to recover the closure's concrete return type.
fn spawn_dedicated(
self: Arc<Self>,
f: Box<
dyn FnOnce(
LocalExecutor,
)
-> Pin<Box<dyn Future<Output = Box<dyn Any + Send + Sync>> + 'static>>
+ Send
+ 'static,
>,
) -> Task<Box<dyn Any + Send + Sync>>;
fn as_test(&self) -> Option<&TestScheduler> {
None
}
}
/// Spawn work on a fresh OS thread that's exclusive to the returned task and
/// anything spawned on the executor it provides. Blocking syscalls inside that
/// work don't disturb any other executor in the process.
///
/// `f` is called on the dedicated thread with a [`LocalExecutor`] pinned
/// to it. The future `f` returns may freely be `!Send`. The returned `Task` is
/// that future's task: dropping it cancels the root, but detached children
/// keep running until they finish. The thread shuts down once the executor and
/// every task on it are gone.
///
/// The caller is responsible for supplying a `session_id` that's distinct from
/// every other live session on `scheduler`. Concrete schedulers typically wrap
/// this in an inherent method that allocates the id from their own counter.
pub fn spawn_dedicated_thread<F, Fut>(
session_id: SessionId,
scheduler: Arc<dyn Scheduler>,
f: F,
) -> Task<Fut::Output>
where
F: FnOnce(LocalExecutor) -> Fut + Send + 'static,
Fut: Future + 'static,
Fut::Output: Send + 'static,
{
let (runnable_sender, runnable_receiver) = flume::unbounded::<Runnable<RunnableMeta>>();
let (task_sender, task_receiver) = flume::bounded::<Task<Fut::Output>>(1);
thread::Builder::new()
.name(format!("spawn_dedicated session {:?}", session_id))
.spawn(move || {
let dispatch = move |runnable: Runnable<RunnableMeta>| {
let _ = runnable_sender.send(runnable);
};
let executor = LocalExecutor::new(session_id, scheduler, dispatch);
let root_task = executor.spawn(f(executor.clone()));
let _ = task_sender.send(root_task);
// After this drop, every strong reference to the runnable sender
// lives inside a spawned task or a user-held executor clone. The
// recv loop exits once all of those are gone.
drop(executor);
while let Ok(runnable) = runnable_receiver.recv() {
runnable.run();
}
})
.expect("failed to spawn dedicated thread");
task_receiver
.recv()
.expect("dedicated thread failed to produce root task")
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct SessionId(u16);

View file

@ -1,6 +1,6 @@
use crate::{
BackgroundExecutor, Clock, ForegroundExecutor, Instant, Priority, RunnableMeta, Scheduler,
SessionId, TestClock, Timer,
BackgroundExecutor, Clock, Instant, LocalExecutor, Priority, RunnableMeta, Scheduler,
SessionId, Task, TestClock, Timer,
};
use async_task::Runnable;
use backtrace::{Backtrace, BacktraceFrame};
@ -10,6 +10,7 @@ use rand::{
distr::{StandardUniform, uniform::SampleRange, uniform::SampleUniform},
prelude::*,
};
use std::any::Any;
use std::{
any::type_name_of_val,
collections::{BTreeMap, HashSet, VecDeque},
@ -152,18 +153,21 @@ impl TestScheduler {
self.state.lock().is_main_thread
}
/// Allocate a new session ID for foreground task scheduling.
/// This is used by GPUI's TestDispatcher to map dispatcher instances to sessions.
pub fn allocate_session_id(&self) -> SessionId {
let mut state = self.state.lock();
state.next_session_id.0 += 1;
state.next_session_id
}
/// Create a foreground executor for this scheduler
pub fn foreground(self: &Arc<Self>) -> ForegroundExecutor {
/// Create a local executor for this scheduler.
pub fn foreground(self: &Arc<Self>) -> LocalExecutor {
let session_id = self.allocate_session_id();
ForegroundExecutor::new(session_id, self.clone())
let scheduler = Arc::downgrade(self);
LocalExecutor::new(session_id, self.clone(), move |runnable| {
if let Some(scheduler) = scheduler.upgrade() {
scheduler.schedule_local(session_id, runnable);
}
})
}
/// Create a background executor for this scheduler
@ -585,7 +589,7 @@ impl Scheduler for TestScheduler {
completed
}
fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable<RunnableMeta>) {
fn schedule_local(&self, session_id: SessionId, runnable: Runnable<RunnableMeta>) {
assert_correct_thread(&self.thread, &self.state);
let mut state = self.state.lock();
let ix = if state.randomize_order {
@ -660,6 +664,31 @@ impl Scheduler for TestScheduler {
self.clock.clone()
}
/// In the test world, dedicated work is just a fresh local session driven
/// by the test scheduler's run loop alongside everything else. No real
/// thread is spawned, so determinism under `TestScheduler::many` is
/// preserved.
fn spawn_dedicated(
self: Arc<Self>,
f: Box<
dyn FnOnce(
LocalExecutor,
)
-> Pin<Box<dyn Future<Output = Box<dyn Any + Send + Sync>> + 'static>>
+ Send
+ 'static,
>,
) -> Task<Box<dyn Any + Send + Sync>> {
let session_id = self.allocate_session_id();
let scheduler = Arc::downgrade(&self);
let executor = LocalExecutor::new(session_id, self, move |runnable| {
if let Some(scheduler) = scheduler.upgrade() {
scheduler.schedule_local(session_id, runnable);
}
});
executor.spawn(f(executor.clone()))
}
fn as_test(&self) -> Option<&TestScheduler> {
Some(self)
}

View file

@ -728,3 +728,234 @@ fn test_background_priority_scheduling() {
iterations
);
}
#[test]
fn test_spawn_dedicated_basic_round_trip() {
let result = TestScheduler::once(async |scheduler| {
scheduler
.background()
.spawn_dedicated(|_executor| async { 42 })
.await
});
assert_eq!(result, 42);
}
#[test]
fn test_spawn_dedicated_not_send_future() {
let result = TestScheduler::once(async |scheduler| {
scheduler
.background()
.spawn_dedicated(|_executor| async move {
// `Rc<RefCell<_>>` is `!Send`. If `spawn_dedicated` required
// the returned future to be `Send`, this wouldn't compile.
let state = Rc::new(RefCell::new(0_i32));
for _ in 0..5 {
*state.borrow_mut() += 1;
}
*state.borrow()
})
.await
});
assert_eq!(result, 5);
}
#[test]
fn test_spawn_dedicated_send_closure_captures() {
use parking_lot::Mutex;
let observed = TestScheduler::once(async |scheduler| {
let shared = Arc::new(Mutex::new(0_i32));
let shared_for_closure = shared.clone();
let returned = scheduler
.background()
.spawn_dedicated(move |_executor| {
// `shared_for_closure` crossed the `Send` boundary of the
// closure; we then mutate it from inside the !Send future.
let local = shared_for_closure;
async move {
*local.lock() = 7;
}
})
.await;
let _: () = returned;
*shared.lock()
});
assert_eq!(observed, 7);
}
#[test]
fn test_spawn_dedicated_inner_spawn_local() {
let result = TestScheduler::once(async |scheduler| {
scheduler
.background()
.spawn_dedicated(|executor| async move {
// The provided executor can spawn additional `!Send` work
// onto the same dedicated session.
let inner = Rc::new(RefCell::new(0_i32));
let inner_for_child = inner.clone();
let child = executor.spawn(async move {
*inner_for_child.borrow_mut() = 99;
*inner_for_child.borrow()
});
child.await
})
.await
});
assert_eq!(result, 99);
}
#[test]
fn test_spawn_dedicated_determinism_under_many() {
use parking_lot::Mutex;
let outcomes = TestScheduler::many(if cfg!(miri) { 4 } else { 20 }, async |scheduler| {
let trace = Arc::new(Mutex::new(Vec::<u32>::new()));
let background = scheduler.background();
let mut tasks = Vec::new();
for id in 0..4_u32 {
let trace = trace.clone();
let task = background.spawn_dedicated(move |executor| async move {
for step in 0..3 {
trace.lock().push(id * 100 + step);
executor.spawn(async {}).await;
}
id
});
tasks.push(task);
}
let mut outputs = Vec::new();
for task in tasks {
outputs.push(task.await);
}
(trace.lock().clone(), outputs)
});
// Re-running with the same seed should produce the same trace. Run a
// second pass with identical seeds and compare to the first.
let outcomes_replay = TestScheduler::many(if cfg!(miri) { 4 } else { 20 }, async |scheduler| {
let trace = Arc::new(Mutex::new(Vec::<u32>::new()));
let background = scheduler.background();
let mut tasks = Vec::new();
for id in 0..4_u32 {
let trace = trace.clone();
let task = background.spawn_dedicated(move |executor| async move {
for step in 0..3 {
trace.lock().push(id * 100 + step);
executor.spawn(async {}).await;
}
id
});
tasks.push(task);
}
let mut outputs = Vec::new();
for task in tasks {
outputs.push(task.await);
}
(trace.lock().clone(), outputs)
});
assert_eq!(
outcomes, outcomes_replay,
"per-seed outcomes should be reproducible"
);
// Sanity: at least one seed produced a non-monotonic trace,
// demonstrating that dedicated tasks really do interleave under the
// scheduler's randomization.
let any_interleaved = outcomes.iter().any(|(trace, _)| {
trace
.windows(2)
.any(|window| window[0] / 100 != window[1] / 100)
});
assert!(
any_interleaved,
"expected at least one seed to interleave dedicated tasks"
);
}
#[test]
fn test_spawn_dedicated_dropping_task_cancels_future() {
use parking_lot::Mutex;
let counter_after = TestScheduler::once(async |scheduler| {
let counter = Arc::new(Mutex::new(0_u32));
let (resume_tx, resume_rx) = oneshot::channel::<()>();
let task = {
let counter = counter.clone();
scheduler
.background()
.spawn_dedicated(move |_executor| async move {
*counter.lock() = 1;
// Park here until the test resumes us. If the task is
// dropped before this resolves, the second assignment
// below must never happen.
let _ = resume_rx.await;
*counter.lock() = 2;
})
};
// Let the dedicated future make its first observable step.
scheduler.run();
assert_eq!(*counter.lock(), 1);
// Cancel by dropping the root task, then unblock the parked oneshot.
// The future must not advance past the await: counter stays at 1.
drop(task);
let _ = resume_tx.send(());
scheduler.run();
*counter.lock()
});
assert_eq!(
counter_after, 1,
"dropping the dedicated task must cancel the root future before its second write"
);
}
#[test]
fn test_spawn_dedicated_detached_child_runs_after_root_completes() {
use parking_lot::Mutex;
let child_ran = TestScheduler::once(async |scheduler| {
let child_ran = Arc::new(Mutex::new(false));
let task = {
let child_ran = child_ran.clone();
scheduler
.background()
.spawn_dedicated(move |executor| async move {
executor
.spawn(async move {
*child_ran.lock() = true;
})
.detach();
// Root returns immediately, before the child has had a
// chance to run.
})
};
task.await;
// Drain the dedicated session. The detached child must run.
scheduler.run();
*child_ran.lock()
});
assert!(
child_ran,
"detached child must complete after the root, not be cancelled with it"
);
}
// The production smoke test for `spawn_dedicated` lives in the `gpui` crate
// alongside `PlatformScheduler`, which is the real production implementation
// of the `Scheduler` trait. See `crates/gpui/src/platform_scheduler.rs`.

View file

@ -734,11 +734,14 @@ mod tests {
use std::{path::PathBuf, sync::Arc};
use editor::{Editor, SelectionEffects};
use gpui::{TestAppContext, VisualTestContext};
use language::{Language, LanguageConfig, LanguageMatcher, Point};
use gpui::{App, Entity, Task, TestAppContext, VisualTestContext};
use language::{
Buffer, ContextProvider, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher,
LanguageServerName, Point,
};
use project::{ContextProviderWithTasks, FakeFs, Project};
use serde_json::json;
use task::TaskTemplates;
use task::{TaskTemplate, TaskTemplates};
use util::path;
use workspace::{CloseInactiveTabsAndPanes, MultiWorkspace, OpenOptions, OpenVisible};
@ -1033,6 +1036,80 @@ mod tests {
cx.executor().run_until_parked();
}
#[gpui::test]
async fn test_empty_lsp_task_response_keeps_language_tasks_in_modal(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/dir"), json!({ "main.test": "test" }))
.await;
let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(Arc::new(
Language::new(
LanguageConfig {
name: "Test".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["test".to_string()],
..LanguageMatcher::default()
},
..LanguageConfig::default()
},
None,
)
.with_context_provider(Some(Arc::new(
ContextProviderWithLspTaskSource::new(ContextProviderWithTasks::new(
TaskTemplates(vec![TaskTemplate {
label: "Run language task".to_string(),
command: "echo".to_string(),
args: vec!["language task".to_string()],
..TaskTemplate::default()
}]),
)),
))),
));
let mut fake_servers = language_registry.register_fake_lsp(
"Test",
FakeLspAdapter {
name: TEST_LSP_NAME,
..FakeLspAdapter::default()
},
);
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace =
multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
let _item = workspace
.update_in(cx, |workspace, window, cx| {
workspace.open_abs_path(
PathBuf::from(path!("/dir/main.test")),
OpenOptions {
visible: Some(OpenVisible::All),
..Default::default()
},
window,
cx,
)
})
.await
.unwrap();
cx.executor().run_until_parked();
let fake_server = fake_servers
.try_recv()
.expect("fake LSP server should have started");
use project::lsp_store::lsp_ext_command::Runnables;
fake_server
.set_request_handler::<Runnables, _, _>(move |_, _| async move { Ok(Vec::new()) });
let tasks_picker = open_spawn_tasks(&workspace, cx);
assert_eq!(
task_names(&tasks_picker, cx),
vec!["Run language task"],
"An empty LSP task response should not suppress language tasks in the modal"
);
}
#[gpui::test]
async fn test_language_task_filtering(cx: &mut TestAppContext) {
init_test(cx);
@ -1238,6 +1315,32 @@ mod tests {
);
}
const TEST_LSP_NAME: &str = "test-lsp";
struct ContextProviderWithLspTaskSource {
tasks: ContextProviderWithTasks,
}
impl ContextProviderWithLspTaskSource {
fn new(tasks: ContextProviderWithTasks) -> Self {
Self { tasks }
}
}
impl ContextProvider for ContextProviderWithLspTaskSource {
fn associated_tasks(
&self,
buffer: Option<Entity<Buffer>>,
cx: &App,
) -> Task<Option<TaskTemplates>> {
self.tasks.associated_tasks(buffer, cx)
}
fn lsp_task_source(&self) -> Option<LanguageServerName> {
Some(LanguageServerName::new_static(TEST_LSP_NAME))
}
}
fn emulate_task_schedule(
tasks_picker: Entity<Picker<TasksModalDelegate>>,
project: &Entity<Project>,

View file

@ -122,4 +122,11 @@ impl Model {
Self::Custom { .. } => false,
}
}
pub fn supports_reasoning_effort(&self) -> bool {
match self {
Self::Grok43 => true,
Self::Grok420Reasoning | Self::Grok420NonReasoning | Self::Custom { .. } => false,
}
}
}

View file

@ -156,7 +156,6 @@ menu.workspace = true
migrator.workspace = true
miniprofiler_ui.workspace = true
mimalloc = { version = "0.1", optional = true }
nc.workspace = true
node_runtime.workspace = true
notifications.workspace = true
onboarding.workspace = true

View file

@ -244,17 +244,6 @@ fn main() {
return;
}
// `zed --nc` Makes zed operate in nc/netcat mode for use with MCP
if let Some(socket) = &args.nc {
match nc::main(socket) {
Ok(()) => return,
Err(err) => {
eprintln!("Error: {}", err);
process::exit(1);
}
}
}
#[cfg(all(not(debug_assertions), target_os = "windows"))]
unsafe {
use windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole};
@ -1831,11 +1820,6 @@ struct Args {
#[arg(long)]
system_specs: bool,
/// Used for the MCP Server, to remove the need for netcat as a dependency,
/// by having Zed act like netcat communicating over a Unix socket.
#[arg(long, hide = true)]
nc: Option<String>,
/// Used for recording minidumps on crashes by having Zed run a separate
/// process communicating over a socket.
#[arg(long, hide = true)]

View file

@ -34,6 +34,7 @@ use futures::{StreamExt, channel::mpsc, select_biased};
use git_ui::commit_view::CommitViewToolbar;
use git_ui::git_panel::GitPanel;
use git_ui::project_diff::{BranchDiffToolbar, ProjectDiffToolbar};
use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar};
use gpui::{
Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent,
Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement,
@ -1305,6 +1306,8 @@ fn initialize_pane(
pane.toolbar().update(cx, |toolbar, cx| {
let multibuffer_hint = cx.new(|_| MultibufferHint::new());
toolbar.add_item(multibuffer_hint, window, cx);
let solo_diff_style_toolbar = cx.new(SoloDiffStyleToolbar::new);
toolbar.add_item(solo_diff_style_toolbar, window, cx);
let breadcrumbs = cx.new(|_| Breadcrumbs::new());
toolbar.add_item(breadcrumbs, window, cx);
let buffer_search_bar = cx.new(|cx| {
@ -1343,6 +1346,8 @@ fn initialize_pane(
toolbar.add_item(project_diff_toolbar, window, cx);
let branch_diff_toolbar = cx.new(BranchDiffToolbar::new);
toolbar.add_item(branch_diff_toolbar, window, cx);
let solo_diff_git_toolbar = cx.new(SoloDiffGitToolbar::new);
toolbar.add_item(solo_diff_git_toolbar, window, cx);
let commit_view_toolbar = cx.new(|_| CommitViewToolbar::new());
toolbar.add_item(commit_view_toolbar, window, cx);
let agent_diff_toolbar = cx.new(AgentDiffToolbar::new);
@ -5252,7 +5257,6 @@ mod tests {
"recent_projects",
"remote_debug",
"repl",
"rules_library",
"search",
"settings_editor",
"settings_profile_selector",

View file

@ -38,6 +38,7 @@
libxfixes,
libxkbcommon,
libxrandr,
lld,
libx11,
libxcb,
nodejs_22,
@ -137,6 +138,8 @@ let
]
++ lib.optionals stdenv'.hostPlatform.isLinux [ makeWrapper ]
++ lib.optionals stdenv'.hostPlatform.isDarwin [
# Provides `ld64.lld` for clang's `-fuse-ld=lld`.
lld
(cargo-bundle.overrideAttrs (
new: old: {
version = "0.6.1-zed";
@ -246,6 +249,11 @@ let
}";
NIX_OUTPATH_USED_AS_RANDOM_SEED = "norebuilds";
}
// lib.optionalAttrs stdenv'.hostPlatform.isDarwin {
# Link with lld on Darwin. nixpkgs' classic open-source ld64 fails to insert
# ARM64 branch thunks for this binary, producing `b(l) ARM64 branch out of range`.
NIX_CFLAGS_LINK = "-fuse-ld=lld";
};
# prevent nix from removing the "unused" wayland/gpu-lib rpaths

View file

@ -37,30 +37,32 @@
name = "zed-editor-dev";
inputsFrom = [ zed-editor ];
packages = with pkgs; [
wrappedCargo # must be first, to shadow the `cargo` provided by `rustToolchain`
rustToolchain # cargo, rustc, and rust-toolchain.toml components included
cargo-nextest
cargo-hakari
cargo-machete
cargo-zigbuild
# TODO: package protobuf-language-server for editing zed.proto
# TODO: add other tools used in our scripts
packages =
with pkgs;
[
wrappedCargo # must be first, to shadow the `cargo` provided by `rustToolchain`
rustToolchain # cargo, rustc, and rust-toolchain.toml components included
cargo-nextest
cargo-hakari
cargo-machete
cargo-zigbuild
# TODO: package protobuf-language-server for editing zed.proto
# TODO: add other tools used in our scripts
# `build.nix` adds this to the `zed-editor` wrapper (see `postFixup`)
# we'll just put it on `$PATH`:
nodejs_22
zig
# `build.nix` adds this to the `zed-editor` wrapper (see `postFixup`)
# we'll just put it on `$PATH`:
nodejs_22
zig
# A11y testing infra
gobject-introspection
at-spi2-core
(python3.withPackages (ps: [
ps.pyatspi
ps.pygobject3
]))
accerciser
];
# A11y testing infra
gobject-introspection
at-spi2-core
(python3.withPackages (ps: [
ps.pyatspi
ps.pygobject3
]))
]
++ lib.optionals stdenv.hostPlatform.isLinux [ accerciser ];
env =
(removeAttrs baseEnv [

View file

@ -27,7 +27,7 @@
},
{
"name": "Markdown Preview",
"labels": ["area:preview/markdown", "area:preview/mermaid"]
"labels": ["area:preview/markdown", "area:preview/mermaid", "area:preview/csv"]
},
{
"name": "NixOS",
@ -88,9 +88,11 @@
"labels": [
"area:command palette",
"area:file finder",
"area:fs",
"area:navigation",
"area:outline",
"area:project panel",
"area:scanning",
"area:workspace"
]
},
@ -100,6 +102,7 @@
"area:code folding",
"area:editor",
"area:editor/brackets",
"area:editor/bookmarks",
"area:editor/linked edits",
"area:multi-buffer",
"area:multi-cursor",
@ -135,6 +138,7 @@
"area:ai",
"area:ai/acp",
"area:ai/agent thread",
"area:ai/agent thread/skills",
"area:ai/anthropic",
"area:ai/assistant",
"area:ai/bedrock",
@ -154,6 +158,7 @@
"area:ai/opencode",
"area:ai/openrouter",
"area:ai/qwen",
"area:ai/terminal threads",
"area:ai/text thread"
]
},
@ -222,6 +227,7 @@
"name": "Performance & Catch-all",
"labels": [
"area:cli",
"area:crashes",
"area:discoverability",
"area:installer-updater",
"area:internationalization",
@ -236,6 +242,7 @@
"area:performance",
"area:performance/memory leak",
"area:release notes",
"area:scripts",
"area:security & privacy",
"area:security & privacy/workspace trust",
"area:serialization",

View file

@ -146,11 +146,15 @@ No action needed. A maintainer will review this shortly.
]
parts.append("**Possibly related open issues:**\n\n" + "\n".join(lines))
if related_closed_issues:
# state_reason is shown only for "duplicate" (the close type is otherwise
# already visible from GitHub's icon next to the issue number on render).
lines = [
f"- #{m['number']} (closed as {m['state_reason']}) — {m['explanation']}"
f"- #{m['number']}"
f"{' (closed as duplicate)' if m['state_reason'] == 'duplicate' else ''}"
f"{m['explanation']}"
for m in related_closed_issues
]
parts.append("**Recently closed, possibly related:**\n\n" + "\n".join(lines))
parts.append("**Recently closed, possibly the same bug:**\n\n" + "\n".join(lines))
body = "\n\n".join(parts)
sections.append(f"""<details>
<summary>Additional recent context for triagers</summary>
@ -280,6 +284,12 @@ def detect_areas(anthropic_key, issue, area_labels):
system_prompt = """You analyze GitHub issues to identify which area labels apply.
Decide the area from the user's stated symptom and reproduction steps. Issue bodies routinely
contain pasted log output, crash dumps, stack traces, settings files, and template headers like
"Attach Zed log file" or "Relevant Zed settings" these are evidence about the symptom and
should not push you toward labels like "logging" or "settings" unless the bug itself is about
how that subsystem works.
Respond with ONLY a comma-separated list of matching area names. No prose, no explanation,
no markdown, no preamble just the names.
@ -500,8 +510,14 @@ def analyze_duplicates(anthropic_key, issue, magnets, search_results):
return [], [], []
log("Analyzing candidates with Claude")
log(f" Candidate pool: {len(top_magnets)} magnets, {len(open_results)} open search results, "
f"{len(closed_results)} closed search results (will pass {min(len(closed_results), 5)} closed)")
enrich_magnets(top_magnets)
closed_candidates_for_claude = closed_results[:5]
if closed_candidates_for_claude:
log(f" Closed candidates given to proposer: {[r['number'] for r in closed_candidates_for_claude]}")
candidates = [
{"number": m["number"], "title": m["title"], "body_preview": m["body_preview"],
"state": "open", "state_reason": None, "source": "known_duplicate_magnet"}
@ -509,7 +525,7 @@ def analyze_duplicates(anthropic_key, issue, magnets, search_results):
] + [
{"number": r["number"], "title": r["title"], "body_preview": r["body_preview"],
"state": r["state"], "state_reason": r["state_reason"], "source": "search_result"}
for r in open_results[:10] + closed_results[:5]
for r in open_results[:10] + closed_candidates_for_claude
]
system_prompt = """You analyze GitHub issues to (a) identify duplicates among OPEN candidates
@ -548,17 +564,63 @@ Examples of things that are NOT duplicates:
For OPEN duplicates (either bucket), false positives are MUCH worse than false negatives they
waste the time of both the issue author and the maintainers. When in doubt, omit.
# (b) Related closed issues — CLOSED candidates only
# (b) Closed candidates that may be the same bug — CLOSED candidates only
The goal is to give triagers extra context, NOT to claim a duplicate. The bar is lower than for
duplicates: include a closed candidate if a triager would plausibly want to see it when reviewing
the new issue. Examples worth surfacing:
- A recently fixed (state_reason "completed") issue describing the same symptom triager may ask
the reporter to retest on the latest build.
- A cluster of similar issues closed as "not_planned" signals a known limitation or design choice.
- A previously triaged duplicate (state_reason "duplicate") in the same code area.
The goal is NOT a "related reading" list. The goal is to surface closed issues where the
new issue is plausibly the SAME bug a duplicate that just happens to be filed against a
closed predecessor instead of an open one. Empty is preferable to weak filler triagers
lose trust in this section quickly if it's stretched. The same false-positives-are-worse
asymmetry as for duplicates applies here.
Include at most 5 closed candidates, prioritized by relevance.
The bar: a triager reading this should be able to act ask the reporter to retest a fix,
point at a known design decision that already declined this request, or point at the
canonical bug this is a duplicate of. "Useful context" or "shared area" is NOT a reason
to include.
Omit a candidate if ANY of these apply (in observed practice, almost everything does):
1. Self-contradiction. If you find yourself writing "while focused on X rather than Y",
"although this is about A, the new issue is about B", "this issue focuses on... rather
than...", or any acknowledgment that the candidate isn't on the same topic — STOP.
You've already made the case for omitting it.
2. Fabricated specifics. Every concrete claim about the candidate (its trigger, its scope,
its conditions) must be visible in the candidate's title or body preview. Specifics
like "when X happens", "under Y conditions", "specifically affecting Z" that aren't
supported by the candidate's actual text mean you're inventing details to fit the new
issue. Omit.
3. Weasel phrases. Paraphrases of these all indicate you don't have a real claim:
"may indicate similar...", "could provide context for...", "shows / demonstrates recent
attention to...", "indicates the team has considered...", "demonstrates a pattern
of...", "may provide useful context...". STOP and omit.
4. Retest by default. The "reporter may need to retest on the latest build" framing only
applies when the candidate's symptom is literally the same as the new issue's. It is
NOT a default justification for "this was a recent fix in roughly the same area."
5. Same area / feature, different mechanism. Examples to omit:
- "ARM compile failure" alongside "ARM runtime perf" same area, different mechanism.
- "Worktree path bug" alongside "worktree display label confusion" same feature,
unrelated.
6. Vague catch-all candidate. A closed issue like "Zed is slow" / "performance" / "agent
panel UX" that could be cited next to almost any new bug is filler. If you'd reuse the
same closed issue across many unrelated new issues, omit.
7. Label or single-keyword overlap. A closed issue whose only connection is a shared
area:* label or one shared keyword is not relevant.
Worth surfacing strict examples:
- A recently fixed ("completed") issue with the SAME specific trigger as the new issue
triager can ask the reporter to retest on the latest build.
- A cluster of "not_planned" closures about the EXACT same request known design choice
the triager can point to.
- A previously triaged "duplicate" pointing at the same canonical issue, or sharing the
same specific mechanism.
Count: typically 0 or 1. Never more than 2 unless there's an obvious cluster of identical
"not_planned" reports. 0 is a normal outcome.
# Output format
@ -614,10 +676,164 @@ Return empty arrays where nothing relevant is found."""
likely = data.get("likely_duplicates", [])
possible = data.get("possible_duplicates", [])
closed = data.get("related_closed_issues", [])
# Claude occasionally places a closed candidate in the duplicate buckets, or vice
# versa. Enforce that each match lives in the bucket consistent with the canonical
# state of the candidate we passed in.
candidate_states = {c["number"]: c["state"] for c in candidates}
def filter_by_state(items, expected_state, label):
kept, wrong = [], []
for m in items:
(kept if candidate_states.get(m["number"]) == expected_state else wrong).append(m)
if wrong:
log(f" Dropped {len(wrong)} from {label} with wrong/unknown state: {[m['number'] for m in wrong]}")
return kept
likely = filter_by_state(likely, "open", "likely_duplicates")
possible = filter_by_state(possible, "open", "possible_duplicates")
closed = filter_by_state(closed, "closed", "related_closed_issues")
# Avoid showing the same issue in both the user-facing alert and the triage section.
likely_numbers = {m["number"] for m in likely}
overlap = [m["number"] for m in possible if m["number"] in likely_numbers]
if overlap:
log(f" Dropped {len(overlap)} from possible_duplicates already in likely_duplicates: {overlap}")
possible = [m for m in possible if m["number"] not in likely_numbers]
log(f" Found {len(likely) + len(possible) + len(closed)} potential matches")
return likely, possible, closed
CRITIQUE_SYSTEM_PROMPT = """You are evaluating ONE recently closed GitHub issue to decide whether a triager looking
at a brand-new bug report would find it useful to be told about that closed issue.
There is no slate to fill. There is no quota. You will be shown exactly one candidate.
The default verdict is OMIT. Zero is the expected outcome for most candidates.
A candidate is worth surfacing ONLY if the new issue is plausibly the SAME BUG as the
closed one a duplicate that happens to be filed against a closed predecessor. Concretely,
the legitimate cases are exactly three:
- The candidate was closed as "completed" (a fix shipped) AND the new issue has the same
specific trigger / symptom. The triager will ask the reporter to retest.
- The candidate was closed as "not_planned" AND the new issue is the EXACT same request
(a feature decision the team already declined). The triager will point at it.
- The candidate was closed as "duplicate" AND it pointed at the same canonical bug the new
issue describes, or it shares the same specific mechanism.
"Same broad area", "similar-sounding symptom", or "recent attention to this subsystem" are
NOT reasons to include. Omit them.
Return "omit" if ANY of the following apply (in observed practice, almost everything does):
1. Self-contradiction. If your reasoning includes "while focused on X rather than Y",
"although this is about A, the new issue is about B", "this issue focuses on... rather
than...", or any acknowledgment the candidate is on a different topic — you've already
decided to omit.
2. Fabricated specifics. Every concrete claim about the candidate (its trigger, scope,
conditions) must be visible in the candidate's title or body preview. If you find
yourself describing the candidate using details that aren't in its text, you're
inventing details to fit the new issue. Omit.
3. Weasel phrases. Paraphrases of "may indicate similar...", "could provide context
for...", "shows / demonstrates recent attention to...", "indicates the team has
considered...", "demonstrates a pattern of...", "may provide useful context..."
these mean you don't have a real claim. Omit.
4. Retest by default. The "reporter may need to retest on the latest build" framing only
applies when the closed issue's symptom is LITERALLY the same as the new issue's. "This
was a recent fix in roughly the same area" is not enough.
5. Same area / feature, different mechanism. Same area label but different bug, different
code path, different trigger. Omit.
6. Vague catch-all candidate. A closed issue like "Zed is slow" / "performance" / "agent
panel UX" that you could cite next to many unrelated new bugs. Omit.
7. Label or single-keyword overlap. Only connection is a shared area:* label or one shared
keyword. Omit.
Output only valid JSON (no markdown code blocks):
{
"verdict": "include" | "omit",
"rule_violated": null | 1 | 2 | 3 | 4 | 5 | 6 | 7,
"rationale": "one concise sentence explaining the verdict"
}
When "verdict" is "include", "rule_violated" must be null.
When "verdict" is "omit", "rule_violated" should be the most relevant rule number, or null
if the candidate is simply too unrelated for any rule to specifically apply."""
def critique_closed_candidates(anthropic_key, issue, proposed, search_results):
"""Run a strict per-candidate critique pass over the proposer's closed candidates.
For each proposed match, call Claude with only the new issue and that single candidate
(blind to the proposer's rationale) and ask for a yes/no verdict. Default is omit.
Returns the subset of `proposed` that passes critique.
"""
if not proposed:
log(" Critique: proposer surfaced 0 closed candidates; skipping")
return []
log(f" Critique: proposer surfaced {len(proposed)} closed candidate(s): "
f"{[m['number'] for m in proposed]}")
results_by_number = {r["number"]: r for r in search_results}
kept = []
for match in proposed:
number = match["number"]
candidate = results_by_number.get(number)
if candidate is None:
# Should not happen — analyze_duplicates only emits numbers from candidates it
# was given — but be defensive rather than crash the bot.
log(f" Critique: dropping #{number} — candidate context not found")
continue
state_reason = candidate.get("state_reason") or "unknown"
user_content = f"""## New Issue #{issue['number']}
**Title:** {issue['title']}
**Body:**
{issue['body'][:3000]}
## Closed Candidate #{number}
**Title:** {candidate.get('title', '')}
**State reason:** {state_reason}
**Body preview:**
{candidate.get('body_preview', '')}"""
log(f" Critique: evaluating #{number}")
try:
response = call_claude(anthropic_key, CRITIQUE_SYSTEM_PROMPT, user_content, max_tokens=300)
except requests.RequestException as e:
# If the critique call fails, prefer omitting the candidate over posting noise.
log(f" Critique: API call failed for #{number} ({e}); omitting candidate")
continue
fence = re.match(r"^\s*```(?:json)?\s*\n?(.*?)\n?```\s*$", response, re.DOTALL)
if fence:
response = fence.group(1)
try:
verdict_data = json.loads(response)
except json.JSONDecodeError as e:
log(f" Critique: failed to parse verdict for #{number} ({e}); omitting candidate")
log(f" Raw response: {response}")
continue
verdict = verdict_data.get("verdict")
rule = verdict_data.get("rule_violated")
rationale = verdict_data.get("rationale", "")
if verdict == "include":
log(f" Critique: keeping #{number}{rationale}")
kept.append(match)
else:
rule_str = f"rule {rule}" if rule else "no specific rule"
log(f" Critique: omitting #{number} ({rule_str}) — {rationale}")
log(f" Critique: kept {len(kept)} of {len(proposed)} closed candidates")
return kept
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Identify potential duplicate issues")
parser.add_argument("issue_number", type=int, help="Issue number to analyze")
@ -658,6 +874,13 @@ if __name__ == "__main__":
anthropic_key, issue, relevant_magnets, search_results
)
# second-pass critique: prompt iteration on the proposer hit a ceiling around 30% noise.
# Re-evaluate each proposed closed candidate in isolation with a stricter prompt that
# has no slate to fill and is blind to the proposer's rationale.
related_closed_issues = critique_closed_candidates(
anthropic_key, issue, related_closed_issues, search_results
)
# resolve close reason from our search results (the source of truth) so we don't depend
# on Claude to faithfully echo it back
results_by_number = {r["number"]: r for r in search_results}

View file

@ -24,6 +24,7 @@ import functools
import os
import re
import sys
import time
from datetime import datetime, timezone
import requests
@ -47,6 +48,8 @@ BOT_START_DATE = "2026-02-18"
NEEDS_TRIAGE_LABEL = "state:needs triage"
DEFAULT_PROJECT_NUMBER = 76
VALID_CLOSED_AS_VALUES = {"duplicate", "not_planned", "completed"}
# HTTP statuses we'll retry on for GET requests
TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504}
# Add a new tuple when you deploy a new version of the bot that you want to
# keep track of (e.g. the prompt gets a rewrite or the model gets swapped).
# Newest first, please. The datetime is for the deployment time (merge to main).
@ -67,10 +70,22 @@ def bot_version_for_time(date_string):
def github_api_get(path, params=None):
"""Fetch JSON from the GitHub REST API, retrying transient failures. Raises on non-2xx status."""
url = f"{GITHUB_API}/{path.lstrip('/')}"
response = requests.get(url, headers=GITHUB_HEADERS, params=params)
response.raise_for_status()
return response.json()
for attempt in range(3):
try:
response = requests.get(url, headers=GITHUB_HEADERS, params=params)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or (
isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES
)
if not transient or attempt == 2:
raise
wait = 2 ** attempt
print(f" Transient GitHub API error ({e}); retrying in {wait}s")
time.sleep(wait)
def github_search_issues(query):
@ -161,9 +176,11 @@ def find_canonical_among(duplicate_number, candidates):
if not candidates:
return None
# candidate issue numbers are baked into the query body via field aliases
# (GraphQL doesn't let you parametrize alias names), so $numbers isn't needed.
data = github_api_graphql(
"""
query($owner: String!, $repo: String!, $numbers: [Int!]!) {
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
PLACEHOLDER
}
@ -174,7 +191,7 @@ def find_canonical_among(duplicate_number, candidates):
f' nodes {{ ... on MarkedAsDuplicateEvent {{ duplicate {{ ... on Issue {{ number }} }} }} }} }} }}'
for number in candidates
)),
{"owner": REPO_OWNER, "repo": REPO_NAME, "numbers": list(candidates)},
{"owner": REPO_OWNER, "repo": REPO_NAME},
partial_errors_ok=True,
)
@ -409,11 +426,10 @@ def classify_as_assist(issue, bot_comment):
bot_comment_time=bot_comment["created_at"])
return
original = None
try:
original = find_canonical_among(issue["number"], suggested)
except (requests.RequestException, RuntimeError) as error:
print(f" Warning: failed to query candidate timelines: {error}")
# Let exceptions from find_canonical_among propagate — a query failure here is
# not the same as "no canonical match" and shouldn't be silently downgraded to
# a Needs review entry. Failing the workflow surfaces the problem immediately.
original = find_canonical_among(issue["number"], suggested)
if original:
status = "Auto-classified"
@ -483,6 +499,8 @@ def classify_open():
errors += 1
print(f" Done: added {added}, skipped {skipped}, errors {errors}")
if errors > 0:
sys.exit(1)
if __name__ == "__main__":

View file

@ -11,4 +11,5 @@ dependencies = [
"typer>=0.15.1",
"types-pytz>=2025.1.0.20250204",
"types-requests>=2.32.0",
"urllib3>=2.7.0",
]

View file

@ -134,7 +134,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.3"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@ -142,9 +142,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
@ -252,6 +252,7 @@ dependencies = [
{ name = "typer" },
{ name = "types-pytz" },
{ name = "types-requests" },
{ name = "urllib3" },
]
[package.metadata]
@ -263,13 +264,14 @@ requires-dist = [
{ name = "typer", specifier = ">=0.15.1" },
{ name = "types-pytz", specifier = ">=2025.1.0.20250204" },
{ name = "types-requests", specifier = ">=2.32.0" },
{ name = "urllib3", specifier = ">=2.7.0" },
]
[[package]]
name = "urllib3"
version = "2.2.3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" },
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]