Compare commits

...

57 commits

Author SHA1 Message Date
Matt Van Horn
5ce2b267fc
Merge 5eaab414fc into 09165c15dc 2026-05-31 11:45:42 +02: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
Seth Wood
46845bf2f5
edit_prediction: Skip cloud Zeta requests when not signed in (#57615)
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 #57962

## What

When a user is not signed in to their Zed account, the edit prediction
system was still attempting a cloud API request on every keystroke. The
request would fail deep in the credential check
(`CloudApiClient::build_request`) with a `ClientApiError::NotSignedIn`
error, which propagated back up and was logged at `ERROR` level via
`.log_err()` at line 2389 of `edit_prediction.rs`.

## Why

The sign-in check was happening too late — only discovered after async
tasks were already spawned and the full request pipeline entered. This
fix gates the request at the top of `request_prediction_internal`,
returning `Task::ready(Ok(None))` immediately before any inputs are
built or tasks spawned.

The guard mirrors the existing `is_cloud` provider check already used
elsewhere in the same file, and only applies to the `Zeta` model on the
cloud provider path. Local providers (Ollama, `OpenAiCompatibleApi`) and
other models (Mercury, Fim) are unaffected.

Note: I haven't added a test for this — testing the early-return would
require mocking auth state, which I wasn't sure was worth the complexity
for a one-liner guard. Happy to add one if preferred.

Release Notes:

- Fixed noisy `not signed in` error log on every keystroke when not
signed in to Zed

---------

Co-authored-by: Oleksiy Syvokon <oleksiy@zed.dev>
Co-authored-by: MrSubidubi <finn@zed.dev>
Co-authored-by: David3u <3udavid@gmail.com>
2026-05-29 07:21:30 +00:00
Finn Evers
e4b81180c1
component_preview: Clean up Component trait (#57731)
This primarily
- requires components to have a description as well as a preview
(especially having no preview makes no sense)
- implements some basic previews where missing
- adds a scrollbar to the preview navigation 

with a sadly large diff due to reformatting (less indentation 🎉 ), but
very little changes at its core.

Release Notes:

- N/A
2026-05-29 07:06:57 +00:00
Jakub Konka
aeb5d6d7ff
ci: Reinstate run-nix label in addition to run-bundling (#58034)
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
deploy_nightly_docs / deploy_docs (push) Has been skipped
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
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 06:01:22 +00:00
Jakub Konka
486e5f7cdd
ci: Remove garnix substitutor (#58033)
Release Notes:

- N/A
2026-05-29 05:56:42 +00:00
Yauhen
d3070bbc0d
dev_container: Respect runServices for Docker Compose (#56293)
This PR fixes Docker Compose dev containers starting every service in
the compose project, even when `devcontainer.json` specifies
`runServices`.

Previously, Zed deserialized `runServices` but did not use it when
invoking Docker Compose. The startup command was:

```sh
docker compose ... up -d
```

With no service operands, Compose starts every enabled service in the
project. This means unrelated services are started even when the
devcontainer config asks to run only the primary service and its
dependencies.

The fix propagates `runServices` into the Docker Compose build/start
path so Zed invokes Compose with the requested services:

```sh
docker compose ... up -d devcontainer
```

Compose will still start services required by `depends_on`, but
unrelated services are left untouched.

**Reproduction**

`.devcontainer/devcontainer.json`:

```json
{
  "name": "Run Services",
  "dockerComposeFile": "../compose.yml",
  "service": "devcontainer",
  "runServices": ["devcontainer"],
  "workspaceFolder": "/workspace"
}
```

`compose.yml`:

```yaml
services:
  devcontainer:
    image: ubuntu:24.04
    command: sleep infinity
    volumes:
      - .:/workspace
    depends_on:
      - database

  database:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: postgres

  unrelated:
    image: nginx:alpine
```

**Expected**: Zed starts `devcontainer` and `database`.

**Before this fix**: Zed also starts `unrelated`.

**After this fix**: `unrelated` remains stopped.

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

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:

- Fixed Docker Compose dev containers starting services not listed in
`runServices`.
2026-05-29 05:12:28 +00:00
Richard Feldman
e5f5767d2c
Preserve terminal spinners in thread titles (#57983)
Summary:

- Preserve spinner/logo prefixes from live terminal titles when terminal
threads have custom titles.
- Store raw terminal titles and custom user titles separately,
recomposing display titles on demand.
- Keep spinner prefixes out of the title editor while preserving
sidebar/search display behavior.

Tests:

- cargo test -p agent_ui
test_terminal_custom_title_recomposes_with_live_spinner -- --nocapture
- cargo test -p agent_ui
test_terminal_title_editor_excludes_spinner_prefix -- --nocapture
- cargo test -p sidebar
test_agent_panel_terminals_appear_in_sidebar_and_search -- --nocapture

Closes AI-304

Release Notes:

- Fixed terminal thread titles to preserve animated spinner and logo
prefixes after renaming.
2026-05-29 03:47:05 +00:00
Mikayla Maki
12aacf3cea
Add skill share linking (#58009)
Added because I'd like to get this skill Danilo made without having to
upload a gist

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-28 23:04:44 +00:00
Anthony Eid
b32570d931
terminal: Move PTY spawning to the background thread and retain exit status (#58004)
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
This PR does two things 

1. Move PTY child spawning to the background executor. Forking to spawn
the terminal child was taking between 10-70ms on profiles I was looking
at, which caused frames to be dropped because it was on the foreground
thread. This should fix issues such as #57574 where the miniprof from
this comment showed terminal creation blocking the foreground thread as
well.
https://github.com/zed-industries/zed/issues/57574#issuecomment-4546858817

2. Stopped overwriting alacrities exit status with `9` on
`AlacTermEvent::Exit` event handling. Before we get the exit event,
alacritty emits `ChildExit(status)` with the real status, which we used
to overwrite with exit status 9 when handling the AlacTermEvent::Exit.
This was an easy fix so I added it in this PR when I noticed the bug.


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 dropped frames caused by agents or users creating new terminals
2026-05-28 22:24:22 +00:00
MartinYe1234
59d8766f35
Show progress when deleting worktrees (#57751)
Adds a progress indicator to the worktree picker so users get visual
feedback while a worktree deletion is in progress, which can take
several seconds.

Closes AI-239

Release Notes:

- Added progress feedback in the worktree picker while deleting a
worktree
2026-05-28 21:52:53 +00:00
morgankrey
27c566c212
Relicense Zed source code under GPL (#57948)
## Summary

This moves the remaining first-party AGPL surface to GPL, a less
restrictive license for these components. Apache-2.0 components are
unchanged.

Changes:
- Updates the `collab` crate from `AGPL-3.0-or-later` to
`GPL-3.0-or-later`
- Removes the root AGPL license file and first-party crate AGPL symlinks
- Updates web, documentation, Flatpak, README, and terms references to
reflect the GPL/Apache licensing split
- Updates the open-source component example list in the terms and
regenerates the RTF copy; no other terms changes are intended
- Adds guardrails so first-party crates cannot declare AGPL licensing or
carry `LICENSE-AGPL` files

Release timing: preview during the week of June 1, 2026; stable during
the week of June 8, 2026.

## Residual AGPL/Affero references

- `LICENSE-GPL`: GPLv3's own compatibility clause; unchanged official
license text.
- `crates/json_schema_store/src/schemas/package.json`: generic npm
package-license schema value, not Zed licensing.
- `script/check-licenses`, `script/new-crate`,
`script/licenses/zed-licenses.toml`: guardrails that reject or warn
against reintroducing AGPL.

## Verification

- `script/check-licenses`
- `script/generate-licenses`
- `script/generate-terms-rtf`
- `script/new-crate license_probe_for_gpl`, then discarded generated
crate
- `script/new-crate license_probe_for_agpl agpl` fails as expected
- `mdbook build docs`
- `./script/clippy`
- `git grep -n -I -E "AGPL|Affero"`
- `git diff --check`

Release Notes:

- The `collab` crate, used to implement Zed's collaboration backend, is
now licensed under the GPL instead of the AGPL. The AGPL license is no
longer used in the zed repository.
2026-05-28 20:19:17 +00:00
JannikRosendahl
2bba4e2220
Make notebook cells follow global font and markdown styling (#57567)
Notebook cells are currently not responding to changes in font-family
(`zed://settings/buffer_font_family`) and font-size
(`zed://settings/buffer_font_size`).

Currently, `MarkdownCell` and `CodeCell` create and set a
`TextStyleRefinement` on their `Editor`, creating copies of font-family
and font-size in the process. As a result, these do not get updated when
the global font-family or font-size change.
By not setting the refinement manually and letting the editor handle
these value instead, these values get updated when the global settings
change.

This behaviour is consistent with how the inline repl already behaves
and in my opinion is according to the users expectations.


After Review: this PR changes the rendered preview of MarkdownCells to
use the themed MarkdownStyle instead of an empty Markdown Style



Before:


https://github.com/user-attachments/assets/e70b9346-8fa1-4d66-aa85-07e987c56ff2

After:


https://github.com/user-attachments/assets/4957e20e-9b5b-4cb9-a9df-3b33538bc686



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
  - not sure if this needs test or how they should look like...
- [x] Performance impact has been considered and is acceptable

~~Closes #ISSUE~~

Release Notes:

- Fixed notebook cells not responding to appearance settings changes
2026-05-28 20:14:34 +00:00
Lee ByeongJun
ec64ba3e69
time_format: Show compound "N years, M months ago" for blame entries (#57973)
The relative date format introduced in #47687 floors to whole years, so
a commit from 22 months ago is shown as "1 year ago". It would be better
to align with `git blame`'s own behavior so it can display timestamps
like "1 year, 10 months ago".

reference:
c69baaf57b/date.c (L189-L205)

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)
- [X] Tests cover the new/changed behavior
- [ ] Performance impact has been considered and is acceptable

Closes #57907

Release Notes:

- Fixed inaccurate humanized date in git blame, e.g. a commit from 22
months ago no longer shows as "1 year ago"
2026-05-28 19:43:30 +00:00
Marshall Bowers
b3d93d4474
anthropic: Fix serialized representation of Effort::XHigh (#57985)
This PR fixes the serialized representation of the `Effort::XHigh`
variant, as it should be `xhigh`, not `x_high`.


https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking#adaptive-thinking-with-the-effort-parameter

Release Notes:

- Fixed using `xhigh` thinking effort with Anthropic models.
2026-05-28 18:20:11 +00:00
Richard Feldman
a6b0ee9f36
Add Claude Opus 4.8 support (#57984)
<img width="627" height="752" alt="Screenshot 2026-05-28 at 1 20 22 PM"
src="https://github.com/user-attachments/assets/0a7825f0-73c5-49e9-b59a-83924a45de98"
/>

Adds Claude Opus 4.8 for BYOK providers, including Anthropic fast-mode
handling and Bedrock/OpenCode model definitions.

Closes AI-336

Release Notes:

- Added Claude Opus 4.8 BYOK support
2026-05-28 17:45:54 +00:00
nullstalgia
3bb2f2a61d
diagnostics: Prefer activating diagnostic under cursor (#52957)
Update the behavior of both `editor: go to diagnostic` and `editor: go
to previous diagnostic` in order to ensure that, if there's a diagnostic
under the user's cursor that isn't active, it is first activated, with a
subsequent call jumping to the next or previous diagnostic,
respectively.

These changes also update how diagnostic activation handles the
situation when the global diagnostic renderer is not registered, as we
used to not update the active diagnostic group in that situation.
However, we now rely on it to determine whether the user's cursor is
already in the active diagnostic, with some tests now failing, so we now
default to an empty set of blocks for the active diagnostic group when
no global renderer is registered.

Release Notes:

- Update both `editor: go to diagnostic` and `editor: go to previous
diagnostic` to prefer activating the diagnostic under the cursor before
jumping to the next or previous diagnostic, respectively

---------

Co-authored-by: dino <dinojoaocosta@gmail.com>
2026-05-28 17:30:36 +00:00
Joseph T. Lyons
24fd1015f0
Sort guild members case insensitively (#57977)
Aligns list sorting with the community champions list.

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-28 16:55:35 +00:00
morgankrey
ef5606bb61
Improve ChatGPT subscription response resilience (#57891)
## Summary

This started from #57636, after we saw ChatGPT subscription/Codex
requests stall over the past week. OpenCode v1.15.11 shipped related
resilience fixes for the same class of Codex subscription endpoint
issues, so this ports the relevant pieces into Zed's native ChatGPT
subscription provider.

When Zed asks ChatGPT/Codex for a response, sometimes the server
connection can get stuck before it even sends the first response
headers. Before this PR, Zed could wait indefinitely, which looks like
OpenCode/Zed “stalling.”

This PR makes Zed:

- Wait up to 10 seconds for the server to start responding.
- If nothing comes back in that window, treat it as a temporary
network/API failure.
- Let the existing retry logic try again instead of leaving the user
stuck.
- Send a stable session-id header so OpenAI’s Codex backend can
associate requests with the same Zed agent thread.
- Add tests to make sure:
  - stuck-before-response requests time out,
  - normal slow streaming responses are not cut off,
  - ChatGPT subscription requests send the right session header,
  - the agent retries this kind of failure.

intended user-facing result is: fewer “the assistant is just sitting
there forever” failures when using ChatGPT subscription models.

## Verification

- cargo test -p open_ai responses
- cargo test -p language_models openai_subscribed
- cargo test -p agent test_send_retry_on_http_send_error
- cargo check -p open_ai
- cargo check -p language_models
- cargo check -p agent

Release Notes:

- Fixed ChatGPT subscription requests stalling indefinitely before
response headers arrive.
2026-05-28 16:41:26 +00:00
Joseph T. Lyons
5abe4bcbc6
Merge community_champion_auto_labeler into pr_labeler (#57898)
Self-Review Checklist:

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

Release Notes:

- N/A
2026-05-28 15:56:02 +00:00
Bennet Bo Fenner
d139a871db
copilot_chat: Fix error when using GPT models (#57958)
We were including reasoning blocks twice. This matches what the Copilot
API does (that is only including the reasoning items in
`output_item.done`).

Before:

```
    {
      "type": "message",
      "role": "user",
      "content": [{ "type": "input_text", "text": "Run ls" }]
    },
    {
      "type": "reasoning",
      "id": "khgBW2vnLWOFgjxAybXdEtL47dY9XaqKW6yvy2JHSaWVeONUkhuQXcDTWwmnbpKKzlzYuaZsO2YUyvieidwC+RbAfD0e2ZjipDSLTdF7DZM6yLUpAwSgV+09d234G93pdg2VRW7dlgcllPCbVeor9V7bZQUOifDp/vU0VIrKVNFjDgigPYojwSZ476o/rTLvTBSlKt1IbkqyLLiix2gokE1lR+DpmG54UiynMFX2WtOVtZP+BriKGfbw3QQF+gz5HQDZuU/Pc7L2CzHTwQVsmTXljeJI9BTZjuiDBVaDZf4ILQiuQ33wVvh3jC5la47lC8jbqlYpmRirsxgBb/vcAFWnUKxcJh/Bgs3tnNk4TKXTeoYhZ+71YGSv2jFnK0JzJ9iZB8w4bkZvHE6136HnSg==",
      "summary": [],
      "encrypted_content": "NgKURnC7g/R/NdnmWu2i70W/hCirYyUWxv7e6Q8CsrTJ1KkUL+e17Frav+KvL1LBzvd0ZklRlmGvLgSUNIW2X+4RG9Ff4m+z660xT00wUqsD5GcAJCTs11k9RPHE0uUJ5gs6EMDNgbgBhj9CcArEbiQEJuzwuRMTINv4lM63aVUcvYfY3R5qQnYm+ixEJr1gQyNmK/WUMvQgZRThjYeMt2/bYtRCPDvKQSXBIrh+tFUMPj9p/ohHSOVRnuwJc7X4JvP0flR0rrqIdZoNXYJ0Jyw2Bm1yz0iy15ckxczFmagqI6r81herd6ZIWA+CrosvLbZujIVUQMYAe0rdESN/kqzgUhqJl6XLShevrWJ+RGxUM5HuICQAuFhMRF6PRBwO+BN4tYi4VEXSBwrR0Z1uhdkPzc1UXPOJFIky25nrhPu0T+k7b3eYWSEAVHvzfsKBC8fKUIFDO8hhSo7LcKrMQx0ADHT4zbbLehF7dP4cR2LgQV2WtPW/AFV160EvR3EySpBSkTgrZ7W3bySYq3PYTbUk7eOYWyauNPD1RBqFIAU51WLlhyagFcfskRjDk7TIEtJnODz4EZvlbwXM1cBt6CwnO9ehy2GWSTSkQbizvNZBr1L8aYI1b3u/nB9ezoM4lOQwPcd1f8g858WlTA1GuIMjDEBoBGNnMolHzn7f64VJZMjinAoyKWFBOLh3U+tQUvPGFpyq9mnDraWwMnkQtsi9NZTbf78kLg5+QRvAepbmCmcdvHuPKZYzFk8BebgQyhiob4ONa2n5q6EmOGKGqkqbAB0dUfjE1+tMsR+rYeF7VLYBjGapYGqJZeYW4wsg6rYrQv1VdleFejmgM7toWDlsd/COEDPAMEZ0mM2doPxfB6JEuxld4T5wJO1uzYA5dVI5ImgnHIXkljjH88/TVSZ0VgOlogsf7qeh8reGSJ4btIReVFZxuHQX7c13Isn+65F0RSx+NCJHh87vuJBE7nGLP0CLNzc7B9pqAQA3G9yVlrBrv3eT1NR0Ha4vah7/1/DNvqzDvBdIzUQB67IpOhQo4gBMUVisXIZHT2U38eusaGry4j7L3SSspBF0cnOsVzdZL34LYgqsl8LxMCgDGOyTyMqKS/PgKofTNtDrwMWKnNApTRdMdrjbDmqY1hNKM6CmQ033XWztToUu5tDW/Mgl5pfUmgdFjVCSHfUpFsNLEdx32LlL1oO/hPofLA8k1SNVBJx55wez73gcWYugkVIV9wPyTEu0tbcOEya1A4CrSDqLf8kbwZ/fhotKjqdKulkGnIhfs0lVathO8ENylCgH0in+jBUZ+b14MMLnBjIK5xV9IXo+rZ4fBsXzwLxYAeUa+Z0VWCZJnjzFhlMUpQZHQxWG83nHTpWcRxE1/JEVBaTi/gJA6VAMaetpEfwoxjdB8Ydh/E2WTlQzqFn1tNdibp46Km1PfwIyeVwwQX2DEkvlN7qDBAQEPdR10/qBEjKXTj1hHZaS+yJt4w5RuCr2/YdUzKz6rDf7oc885NUgTFZtYh+VDVrLl3qYIQyQuAp0h5Hv35cYOnNG2NnxOC+Z79vCK6xoSE5fIYUArzuSyqIrzXR7F4IDgmikH7KplkcLYBP6s9Coc+c2s+WMSUPq1hzZDweWfqnsR22BYzsTTwGAMul3AUurqevcljUmpKsZTOSmuyKt/of2hTzHOrFRiZy5ttFe8T+0CycaediLmx5tdJsPWvJEnIHICaawUxC8O0Mk0shHfKKnR9EYifkBn4ygrm6jPKEE1b7egZ668oPtWIDKxD/HHDCbh1qED7u4PD0i4abZTNwhSpzrB0SOWCNoSYDzEqp37LSlgYubAZJztk8sxmKYWeXwdeZBiuJmNqkclHWQxbPJLXymFlcz1C0LCitwKLLMyUWHWFy47kRXDkH1eYsXgxYANRbKfGHLLxPO+N5cJszqv1DFwOje5PkBxgOAfuYVjO3DtIDqIfdJluNeUPBdphGqm/X3FvUBn3pm6tOQgteaMGwJblN8ewuPY6of+AsubruQ0io0kpDLKCje++9negmo9qMxgVRugEduEs/RlZOJ8E3bAz1DT+nlrCytWXWr9UM8xB8igPlL9cHzeKBv1QFmW/CpHjfjzCqU5fldeDGJ0t2Bu5reLMoNC35hwcD+8K63ePjwb1clMMFFj9fdmGnXUOF+c+twj5xesSLxMB6O4abd+soVb8bHdn49z9w626Br5epVs3Qwf/HNiWDU8CVKgyb9ccj6s8/ETP24zDHUu5EQ517yqm18GPvy/nsyasiH5w0xoZxc1Ba8+KsSYvs+DkPujidl2Wgl3MUJh96R7c8BVTEchPFJoWe1YDDbaEp4aySDApVyTm/YVgG/nACmvPtfUlUgVN7BqR2PxMwZ1FmEYOxxFPEoKDtO9pNx76wsT8H+yKe8VCoguqe9HaXcHm6/pVKfM2Wgkmg5eRtNeFVMjE0Ejys/a4htzma5gPO4xqGc2bH9JxeP94qYkjIe4xp9I55+5vbztXAwsYF00aTQ4K9c6gLugHMbJLLQhoC1AK7ZHPh4XhXIdUBgc00oodMcKR5PaUzFqTZF0XzLCWjhpYiWE4VzmHKjatoPfwQHNsH0mwNWdtFS6UWthUEMvY6azjfZpfyJrry9Pj4RnHvJIaBchGciTo4QSWSh6GmTYspX7QCveF0sVzkC0o0jRtosQ/b6osH1PqOmsDqEw9IWFZCOET0CSyys0Vry2HGa+U1vvQAs8p2ek0va3BKFITeBPkACRhLaZlWHpWjZ5F/AqwUR+Eguo330j3Wk9lQ7F2VV+GAS+ZgDh8Az56ed4kTlA8rHs7NliqKr8nqFUBEWiWDWUlhjEgXtrt9G2/jpFIU/3UF0M1NFQ94F28mTmA=="
    },
    {
      "type": "reasoning",
      "id": "Vj0QJZMbDF6p5SHwQfzk3FCBzFmXvd6ERARDR68BTo93WJpORDWhnaR2CPiPRIMhbvt9fI5VjqR5O5mUXtciw+V74B9petxJMcM1OtsoQqeQr0Go2QjQAYquGV/Q+1n/+x1HjWXRGTzHkwplhZ1KYL9juwFF8PZke8wnl6wQRyUUTjLFxdxNMfl6kTBYAU4RBjnJiz16dSrvzaRLbHc0ZgezT7IqJOIA8paZvwncwY6ftXEcdYTKYUDGChK7aUcVt+RyCGtSl81ayzmoCdv0SGCJBsnX5BuG1GmINkDHEpwTNHteYXmSDxLKgzNIwIBJxR1Z5WMBEZDI4OQWNzLTuKppCOYv0E7UxCclSwkGKEof8yDBVvgdFfwfoFIvFJKJiy/niUDuzKbqW14CtodaLg==",
      "summary": [],
      "encrypted_content": "X9oCaUyulbIo0tcHBAzE0sZ5BIO4OcIAgr/YrjmI24ANFOTxxb16GEwwXGjDQFi402ii2nCbSh0vcaZPmZjbwydelCo0rXC0rkWlKgxPJnIioeVZwKN4GBEW+bgbKkGu1TspCCoJE/Eppkk1qOSkZ1PcuLynj06aDUkTyPMK2ZGQQps3H/TvJ5LHES5bX3Op5pejamPKa4QB7wXIn710j1CIjUyaXgz2aal6OwYHLoZJJyv4TFvmgzP9Wt5eggzpBOFQnJ1tzw+a1/3D41GBogS1UdW0J77bdolkFgoARWAib4uyW9hAiL6+H0qrXH9/FHa9d5WU+XmWG9celRK1zSpV0IXqC1DUOoVeHm4RNRVD/Pwnt2NkFPwpdGOUG4kaDyIxf7rN4Lw571tW8x19DP/lLaqKzWY4BQpzUC3OgL9EX0ky2PhWmYP5b9Zu3U5fhbemvjf7jQ20gzSnWUlyGe5y7ckzBIzhYVxkLGLX1j1CkMnK7Po329Gt1GfPlymOODUOxkxd8e+ZwH66/S19fbgNFdfDFMCGsTtsN1A7YuHYC1KsKnaitpe07t3kWsKkSsaUUyPX1HDW1613QEe56bU45uLQ294p46iFg6xuzPQSfP/ynqExh7xf+dOU+9Iq5kEXh4R3UbTUaVqPlAHdzEYhJflkezVG4cDN3qkUJa1Sh2YtV1Cc2zjEqmfSkhEiOHpthrNHFTC9nHmaV9BR7jMxll7McuoRqjBm/aungGsheXUaTq0+xJUqm/FG5tuQyAga+M+g5/PC9JPcXOXeRpF+43PNnzqNUByA0evd/6qSM2Z8twL7wl5zsGzJuPS9mVVU8zUFUXxH7qJ3aSIu9azwRtNODPn25oIr6yuJlh4yKSYXz48r5/Ak+xYEJTXucm5KJZqffXl72YwjG0M4yk2A0m/mRvVY9G1GpBLWcNw17GBh/JsXv1NLz7FZ3SQLhRGE4yE53/780oMimLWBLcVSG7gyCCnz4+geFOf4mtO1hkQ4bP0n1w7qBZUM9KJUTMHvnXIlL9lR4m6Mgs9hMM/Bx24if7qdHVN/o0bD0cSUiiZsa9jiiibqsumnLte1d3rSjavpc6zkVy/K1H6uXV/YoNS1mn2xdxnLW6W8HD9XU6Luzbg0QjYoIb6BXtb1DTHRq3Op64xIBNmxYMX3OzS5ev+GRFrOBMqj0IJYmnM82gozAMhoHXapW8GNSccxx9/Br6NigwjUV5u/vlTBVqsEB3A7ondmw4S1ZqXuvkBJSg7CoYEKbUK8gf4kNeMD7YpXyFXh0SXVZV+tznKMnjw8aIdXGM6hwQXjym6F9WM4vPah5RrGR7XKyfJQGl4Ouj4PERZyIJcNWHPwBp0rpv+/Vu2el/arBIv5hoal15W2c9rhCg+vA5lrWfPlIRUi4dT/k8RytFSlRlcm0hnwDADaEr4wqwahwwtcSHOPP0eOZYjLEr6PGYdgSxaDnKgtPZHvoTI6zMGIlVMMCVS+46datGm15/3RbjxLXHGCkc0VQGkGfSDjJkgLGHk9LQ/ydzJELZuT392JmDMr1eKKCzVyg4EH4gAFkBKq/B4x7dK7hRJtrbMitdaa0+Llp/McrQADNuG1UKjmz5StXi6ocwDdOr37sQpq1JAYy5ahae85EuZ1WM41n6p5IRQKUG9r786boBYy0zFTIcSjbpNqTteXRWTJOdGQqiW+vxUhmfOTRre3tsszbPNnxhaX2J06EIPSdOXx6T/bBrRkG5b2dwtud/QPbomMXqD17xAsgBQO5+8bKL3Mcks76Y/6yVScSpFVi3tgWsB2+tvY8onE7xsrrhkSSZZhQYJ8wI0D5HJVegs/M7bYTPPMT0r+wAhnC3AMdlbtwrCrDKBwwSUNjc2IoAy7kRfdQWr2+E0Do9xcFKy4cHUXPi/wegiOHCGUimWc9MQNWNrGN5+EpmDyc0kgq4iVvyce/MG61nYXuOxuU4Vr2Q4zRp2xi3ZZAQf6zEMZ5kWGRUJxUO5pZkoQex4JmKdMkyF6xgG25aeqw4ppsiXB0O1puAHhlehCjJcyIDYDoKAToM3u6bfjGj1tWH+t32WTMKZ97JOV8+zi2xHB/qDccxDietNpObIfnEEXn/ZVy6KJ/M9IRLHBy98Ma1LgydXBK68YM/BLUx7NH10JjQStrVSpE4JITADZ7Bw/wi2QSFju7oC6+qtF2pz0FThWXBWHErfYEciAcu2FjNTLH44mjFGF7NC4ETZfo7kBeMq46q4WMzAY6YbkFNvudsYnHO360n/IxTl596944LuVTO2RdqS7DwE5RLVj8hWmlaVTuiDoBhWchHTtcFrk0DCpxRl76Tx8b3LLkotMq3GZfgC0HO1Gz9pJiwseBqg8xS5Kzc6qxKWJbVbqccVz5gGVGSXLiyqI40NO/gQEGNYpTC+0pydbOOgQ1mQEJtpE/6nzBkw7GjDmfmy/1uuU9wQiVt5NLor3dlNH2ClqLnoV5rdZoisDY5Z9vXhp+5qe19S4Qm2WY+h324UzrtGzQgaYdGFRAxS+iki5PSGJEpJnelDCYSzkFoIUGprOzzai0j0jPLG9ueAlKEiMmgz1NV7s5Dx49u2bNGEgfGaKP3o6ey05DrYM19iwfrcGHlzgfj4x1qmx3WzKTwAxL/RFbi788dK3ggn95vWtre1adMpJ8RB9vDnSXmp9gRrosnIgNfMhRhTKZ+3NcvF3sCPJXHe9SLmecUwvXVIE944TihHsLPvpVWRe7RjDFbn3K0TSneJ0Eyf6E5vecRt1S16qHl7zkacxFnl+tL6NOOHC6BvU4anxhX5i0AHOKRxFs4WUf+Nyrl+plURz1lxgoOQBFjrkNHuqSY712AcI8i0KS9SbjlGcRdik6J5gz3fniw=="
    },
    {
      "type": "function_call",
      "call_id": "call_kYosdPp77arTCMT4vxKotORr",
      "name": "update_title",
      "arguments": "{\"title\":\"List project files\"}"
    },
    {
      "type": "function_call_output",
      "call_id": "call_kYosdPp77arTCMT4vxKotORr",
      "output": "Session title updated"
    }
```

After:

```
 {
      "type": "message",
      "role": "user",
      "content": [{ "type": "input_text", "text": "Run ls" }]
    },
    {
      "type": "function_call",
      "call_id": "call_JAUQ6fRiJGBC2WxHjMuG3opI",
      "name": "update_title",
      "arguments": "{\"title\":\"List project files\"}"
    },
    {
      "type": "function_call",
      "call_id": "call_lMZRlqGeJ8sSiITJ4xesgMwt",
      "name": "terminal",
      "arguments": "{\"command\":\"ls\",\"cd\":\"calculator\",\"timeout_ms\":5000,\"allow_network\":false,\"allow_fs_write\":false,\"unsandboxed\":false}"
    },
    {
      "type": "reasoning",
      "summary": [],
      "encrypted_content": "4pRqoArHBOJL2K5USHgwuzGfxOyi4l1sFCcQwTsSxBs4nZJQr/4ELKHuqUK1xCJTO3e9vfPGvZDZtxTiV/ghrF5tNOjl0dxg7G77d2i1DiRm+Wn3dAgr5K1ssOgBMl07252Ukvs7B4zLJ89ovulGe0r/VM6+fg4JeNXYS9lE/whkZnib77lvqLvTsFDHUJkROCMsuUeNEGzavCf5xxG9qhSJQ8ieT2TcNiaTpHAdl4rkCi2JIyOPR6zCQjrptLy3RTJGBzSrChVViHi0v5tnMx0H6vcBR2d3SqYNqvaDfFkCNVOqHeYojSKLNjHoZizx/m9KLM/ZkVdgopsu5DaQpEWXLBzY+JbBkTnNSi+pUVMCCnHaj8UDGqsseCUv3JwsuM0udLFPaWYe+YnjjD340CtmtIG+jjQv80TMSzi9XtTpKS+cuC4OXHSLWMltDJEneoa4JfIKYQObO/xpZ7TwDsxXhR5BVo0KY5UGKcXOAogaLxIS2W5B8mqMfHvjeQtMzfnHjVuLHku/AL6M59SWd4MREFyjXROLLcFyPzwaCR4WrNeMrbIf+8WvzT7HlfD7ImV3McAEwEF/tYrj4pR8zfAMLiUfFCnRQptgoQFwZsm6HxfezPi1Nr4oMjPqlOll6px7lT++b+Tdb1qc6696uZ49MIkUuP4XOfpe6U0p0UqSZHQXQ6WFXm7G5DYHW2JW+sEYc62d1isRbdwoYDt7kVMslmKRCaYdFB0+TpuBOUn/Fpg20PtZe1Zsl6m5feafsLPmS+JypWDpbWj1/XAtCGRKQO0O0soW/QxmOZixvAMJjiym5+6TVeKdJCS1Objyk0faSDAFdlNQVP22B97VaW2aHTP2x1vsSI0Sqvd13iBu8v2oAaeLbfvuGeei9g8V/iEG4rLfK6zDvH8W7OJUoJBEveSSaCzMDzMChTkF5B+naP4PpH9b5rFRepxbWY8eU0Ab3N58aR7G53yXgdwJWBPisqcCj9xp+avO7e/WGro0T30htST0KHH++nVVdHLSb0ZxDec/h866EgMEkh2WrztcwRn5ovb4qwvpbJCSr0cKtyJAV6BT8/z+Pm513Lfe+B64OWAtbBmRasByYtjXSt+GmQ//DJed73UL+OUQm2v9vc9InG0AYWjVC7FRWfVMukTQDapEpo6LULbfbSEI7Z7Qa4eBYUSvoD+OiCHuGrrR+PyliiaGOIIa9/K4/hZQbhABnheVXlxN1igGniZoqtd546oghVGz89pEVO8Dv2zjw3U9+RAQwS2x90Sjqkx7F4Nr+heoXjySlghbSSoxB0ckeQUsn0JIElGH9pQxRgqIQ4swjlVaRtyrXrDK3zX3N6TCFBOlMdmlSZFSo/Gj0oaNM0Aot69DN5MnxkburXnaE7gsLH5xdBfaw8FABZUXxV2hJq/cpB/u9STqdLS6yCySFLwEWCmAn9V3mCClq5nxH3DqhTwo/vhQefQaT9n6+y9NfNV2svIOC+YRMKYbGsmcpxlZ/io/Y453C4Uy83Fl0UHbLieI+y5+Rm7Xs3VK3mcqwMSCniMvK7jiHdiizD9ONQ7KmyAhQyXbPjn/tZXgIHfMEf1RkWaHHxWn6QHSFdAmj+9IYY0owDm64WrmyvVZSuzz795Sicsek+VZZ85fGiemxuNRdkInxbQCC6rG8ZGyWIYTXgGXOrR+t0FvHO/04+RQUOvIJgYm/Bcauprw2oa2ipNQMLg/cdP5wi4pqUHlZiSF0HTixDiRaHS8w/XO0DYqDIKnBikZNmpT57DiiQwIPHK9BNpMi5M/2oQpQVdzCT7GrdPGNWJrl6p7oQCPtOFPQpuGyKvv8sVHMKkqj8l3olK09eimPl67SaLNhaY69+O8b9DXp3nJpWO8jgPfY6cKXPrH+8Sk6nwzc0uTlinZDMLsedJAdmoNObBgq+aqzRrUjuokBat3Ob498Dqajt4VWCpJ2+vO6obcTjML3s2jiU7G8yNrY8SGnMTWuR48GhjcCaub+rtk0zS2f2QVGElucJ1Ev6Fg4INqTjd8PdMLn0KcGF15JZSg3z/SZhxuyI+E6NAeAt/Xnribt4o7gTaqoYm6IMvi7zJ66TSWWp2bjEDJCwabifQRNtMw4UMtCiWNAv3pxG2M+jVFTsJrQ5xVEjoQMwYXWMCoTgtR+3/dK7J0xhkxdx5KweGCdb46pOM5tpSPcyxcr7NOgmpr5ovDKr+vbHK75deKGoQDGogOZehBKLW2ZHGmdAV6RJrtjGE4Ag1MjdO1pk3xAXY9w0tFSIy/YdX85l7pBV0xn719xH0RFG/9T95N9gxBtQAhuAfe9OVobctAZLVOMtQkMyaiPvkI32p6ehe/QD8+b29XhB0iO8HB0bvns2/gQwpIUQeZ3HB8Ef1QJxG3TUbKOP4Ud9XyDwYK4G1ExHXYmHB7bQcl0PrDaW+Au1We+3dmq6TUk/6GndJEVhcMS6rXt42T9cA7pE/8kTVmmea05rZwHyVSDoo8LbxMriAKoZfVi28Kg5rHuGdNOHvPxsa6+BJNY7XW41Q9ck2Pr0wt+aJYgMW9M4jRnQfqUY5QHiaxKYl/1/zASEg76QS1M8YDpjFytyb1S8a1bB5rTO+Ljplf/ubXO4i/tWtnJCecD8iBTnWG6dHBe0DFtDnBtRqFZFmz/stxk1Ti8OxR9klpPVUcuUoLYDwxvblDZnn53/knxbp5cbeIWDAVludm+JqwoycxXvJtbYUPzY2zpU7HcFoPFDHqy3LOvP1QgdWGcWCVplNwbSv2FQiWBfzf08e+o6IjOvk9B19hNbkI1lcAhjN16JlO0j/A2zKWsas3ixGAW216Wx0Z+hdQL6ISzCecQqolLhD/Nqb+MB5kigmH1gtmNQFQn2mrAF4TkoAZr3dS3gsHuq7vnOTGf/GN5g+gLGZvcK0IsG4kUpnja9miPif1bmhXkuHYZxANmr5B3O7vDGl9uirPK/vigLojwz8/bC0lhy4kpayLS1PaG3LBYhsDYKrDVot6R/ogrpfcB620pLN16KkutLIzW/ahDWgIVL/kNvFEIvvmSVuo"
    },
    {
      "type": "function_call_output",
      "call_id": "call_JAUQ6fRiJGBC2WxHjMuG3opI",
      "output": "Session title updated"
    },
    {
      "type": "function_call_output",
      "call_id": "call_lMZRlqGeJ8sSiITJ4xesgMwt",
      "output": "```\nCargo.lock\tCargo.toml\tCLAUDE.md\tsrc\t\ttarget\n```"
    }
```

Closes #57776

Release Notes:

- copilot: Fixed an issue where using GPT models would return an error
in `invalid_request_body`

Co-authored-by: cameron <cameron.studdstreet@gmail.com>
2026-05-28 15:28:26 +00:00
Xiaobo Liu
830414004a
settings: Apply VS Code minimap default when importing settings (#56483)
When importing settings from VS Code, the minimap is now enabled by
default (matching VS Code's behavior) even if the user hasn't explicitly
set minimap-related options in their VS Code settings.json.

Previously, if `editor.minimap.enabled` and `editor.minimap.autohide`
were absent from the VS Code config, Zed would leave minimap at its own
default ("never"). Since VS Code defaults to minimap on, this caused a
mismatch for users expecting their VS Code experience to carry over.

Closes #56297

Release Notes:

- Fixed apply VS Code minimap default when importing settings

Signed-off-by: Xiaobo Liu <cppcoffee@gmail.com>
2026-05-28 15:25:54 +00:00
Anthony Eid
bc64e1f955
copilot: Fix auth db fallback (#57764)
This PR uses https://github.com/zed-industries/zed/pull/57758 as a base
and adds tests, cleans up the comments, and checks changes the database
query used in auth.db to include oauth key.

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 GitHub Copilot Chat showing an empty model dropdown for users on
newer Copilot SDK builds

---------

Co-authored-by: Alexander Shlemov <eodus@users.noreply.github.com>
Co-authored-by: cameron <cameron.studdstreet@gmail.com>
2026-05-28 15:00:31 +00:00
Bennet Bo Fenner
d74e47ea51
git_ui: Fix creating worktree not possible if default branch unavailable (#57918)
Follow up to #57704

This makes sure that we offer a worktree creation option in case
resolving the default branch fails

Release Notes:

- git: Fixed an issue where worktree creation would not be possible if
resolving default branch fails
2026-05-28 14:15:40 +00:00
Smit Barmase
f0ed342c19
markdown: Add frontmatter metadata block rendering (#57845)
Adds opt-in rendering for Markdown frontmatter metadata blocks in
Markdown Preview and agent markdown.

- Simple `key: value` metadata blocks now render as a two-column table,
while more complex metadata falls back to a code-style block.
- Metadata block content and key/value rows are parsed in the parser
step, and the request layout simply takes over rendering.

<img width="1288" height="436" alt="image"
src="https://github.com/user-attachments/assets/b35b949a-8bc4-47db-82ef-ed835e9ac06f"
/>

Release Notes:

- Added support for rendering Markdown frontmatter metadata blocks in
Markdown Preview and Agent Panel.
2026-05-28 13:47:58 +00:00
KyleBarton
92b0efeee0
Update dev-containers.md (#57901)
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 N/A

Release Notes:

- Improved docs for dev containers
2026-05-28 13:36:39 +00:00
Cameron Mcloughlin
4d6a3c7e11
gpui: Application::inaccessible() (#57954)
Provide a way to prevent GPUI from creating AccessKit adapters, and
enable this in Zed.

This will allow us to test AccessKit support in Zed without rolling it
out more broadly, while we gain confidence in the implementation in
GPUI.

I've also added a log statement

## Motivation (i.e. a mini post-mortem about the #56065 panics)

Merging #56065 caused some nasty panics in nightly. This was caused by a
bug in the logic for selecting a focus node for a `TreeUpdate`.
AccessKit panics when an invalid `TreeUpdate` is provided.

My assumption was that, since Zed uses no a11y APIs, and also that
essentially 0 zed users would have AT apps running, that merging this PR
would have no effect on the behaviour of Zed itself. However, two issues
combined to cause the panics:
- It seems like many people (everyone?) on mac gets the activation
callback called by accesskit_macos. A quick search suggests this might
be due to password managers searching for password fields, but not sure
how true that is.
- The bug in question related to *forgetting to check* whether a node
used a11y APIs, so we *were* pushing non-empty `TreeUpdate`s

As a (probably temporary) defensive measure, I added a function to try
to detect the bad cases and fix them. But it would be lovely if this
could live in AccessKit itself, since it would mean we wouldn't have to
do the check twice (once in GPUI, once in AccessKit). This would also
help prevent drift when updating accesskit versions if new invariants
are added.

We also cannot protect against this with `catch_unwind`, since we use
`panic=abort`. So our only option unfortunately is to temporarily
disable AccessKit until we know our implementation is stable.

Release Notes:

- N/A or Added/Fixed/Improved ...
2026-05-28 13:36:24 +00:00
Ben Kunkle
b8c853a63d
ep: Fix agent edits triggering edit predictions due to diagnostic refresh (#57832)
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:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
2026-05-28 13:27:55 +00:00
Ben Kunkle
8042408df4
ep: Collect data for staff in Zed Industries repos (#57733)
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:

- N/A or Added/Fixed/Improved ...
2026-05-28 12:59:56 +00:00
KyleBarton
518502e5ef
Respect override command in devcontainer (#57204)
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 #56357

Release Notes:

- Fixed bug where devcontainers were not respecting override_command
2026-05-28 11:22:41 +00:00
Daniel Martín
6049ceaecf
Auto-scroll hover popup while selecting text (#57518)
Selecting text inside the hover documentation or git popups did not
scroll the popup when the drag passed the visible area, so any text
below the area could not be selected with the mouse.

The popup's container already had a `ScrollHandle` wired to its
scrollbar and wheel scrolling, but the inner `MarkdownElement` was
constructed without one. That left it in the default
`AutoscrollBehavior::Propagate` mode, which routes drag-autoscroll
requests to the editor-wide autoscroll listener, which is a listener
that does not exist inside a floating popover, so the requests were
silently dropped.

Passing the popup's existing `ScrollHandle` into the `MarkdownElement`
switches it to `AutoscrollBehavior::Controlled`, which scrolls the
popup's own container directly during a drag. The markdown preview view
already uses this same pattern.

Release Notes:

- Fixed hover documentation and git popups not scrolling while selecting
text with the mouse

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
2026-05-28 09:55:14 +00:00
Smit Barmase
6726b15fce
terminal: Forward Shift navigation keys to alternate-screen programs (#57479)
I ran into this while using lazygit: the Shift navigation keys were
being captured by Zed to scroll the terminal buffer instead of being
passed through to the program.

Capturing them makes sense in the normal terminal case, where the user
wants to scroll and Shift has no other meaning. But in alternate-screen
TUIs like lazygit, less, or neovim, terminal scrollback isn't relevant,
so we can forward these keys to the program while it's open.

Release Notes:
- Fixed Shift+Up, Shift+Down, Shift+Home, and Shift+End in terminal TUIs
like lazygit, less, and neovim.

---------

Co-authored-by: John Tur <john-tur@outlook.com>
2026-05-28 09:34:57 +00:00
Matt Van Horn
5eaab414fc
json_schema_store: match symlinked settings/keymap files for autocomplete
When a config file is opened via its symlink path, schema_file_match returned
only the path-as-given. The schema selector matches that string against
fileMatch globs like "**/*settings.json", but the opened buffer URI is the
canonicalized real path, so the schema never bound and autocomplete was
disabled.

Return both the original path and the canonicalized path (when they differ),
so either form binds to the schema. No-op for non-symlinked paths.

Closes #54888.
2026-04-26 16:58:22 -07:00
219 changed files with 12277 additions and 9996 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

View file

@ -1,113 +0,0 @@
name: Community Champion Auto Labeler
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
jobs:
label_community_champion:
if: github.repository_owner == 'zed-industries'
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- name: Check if author is a community champion and apply label
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
COMMUNITY_CHAMPIONS: |
0x2CA
5brian
5herlocked
abdelq
afgomez
AidanV
akbxr
AlvaroParker
amtoaer
artemevsevev
bajrangCoder
bcomnes
Be-ing
blopker
bnjjj
bobbymannino
CharlesChen0823
chbk
davewa
davidbarsky
ddoemonn
djsauble
errmayank
fantacell
fdncred
findrakecil
FloppyDisco
gko
huacnlee
imumesh18
injust
jacobtread
jansol
jeffreyguenther
jenslys
jongretar
lemorage
lingyaochu
lnay
marcocondrache
marius851000
mikebronner
ognevny
PKief
playdohface
RemcoSmitsDev
rgbkrk
romaninsh
rxptr
Simek
someone13574
sourcefrog
suxiaoshao
Takk8IS
tartarughina
thedadams
tidely
timvermeulen
valentinegb
versecafe
vitallium
WhySoBad
ya7010
Zertsov
with:
script: |
const communityChampions = process.env.COMMUNITY_CHAMPIONS
.split('\n')
.map(handle => handle.trim().toLowerCase())
.filter(handle => handle.length > 0);
let author;
if (context.eventName === 'issues') {
author = context.payload.issue.user.login;
} else if (context.eventName === 'pull_request_target') {
author = context.payload.pull_request.user.login;
}
if (!author || !communityChampions.includes(author.toLowerCase())) {
return;
}
const issueNumber = context.payload.issue?.number || context.payload.pull_request?.number;
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: ['community champion']
});
console.log(`Applied 'community champion' label to #${issueNumber} by ${author}`);
} catch (error) {
console.error(`Failed to apply label: ${error.message}`);
}

97
.github/workflows/nix_build.yml vendored Normal file
View file

@ -0,0 +1,97 @@
# Generated from xtask::workflows::nix_build
# Rebuild with `cargo xtask workflows`.
name: nix_build
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: '1'
on:
pull_request:
types:
- labeled
- synchronize
jobs:
build_nix_linux_x86_64:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && (github.event.label.name == 'run-nix' || github.event.label.name == 'run-bundling')) || (github.event.action == 'synchronize' && (contains(github.event.pull_request.labels.*.name, 'run-nix') || contains(github.event.pull_request.labels.*.name, 'run-bundling'))))
runs-on: namespace-profile-32x64-ubuntu-2004
env:
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }}
ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
GIT_LFS_SKIP_SMUDGE: '1'
steps:
- name: steps::checkout_repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
clean: false
- name: steps::cache_nix_dependencies_namespace
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
with:
cache: nix
- name: nix_build::build_nix::install_nix
uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: nix_build::build_nix::cachix_action
uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad
with:
name: zed
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
cachixArgs: -v
pushFilter: -zed-editor-[0-9.]*
- name: nix_build::build_nix::build
run: nix build .#default -L --accept-flake-config
timeout-minutes: 60
continue-on-error: true
build_nix_mac_aarch64:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && (github.event.label.name == 'run-nix' || github.event.label.name == 'run-bundling')) || (github.event.action == 'synchronize' && (contains(github.event.pull_request.labels.*.name, 'run-nix') || contains(github.event.pull_request.labels.*.name, 'run-bundling'))))
runs-on: namespace-profile-mac-large
env:
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }}
ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
GIT_LFS_SKIP_SMUDGE: '1'
steps:
- name: steps::checkout_repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
clean: false
- name: steps::cache_nix_store_macos
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
with:
path: ~/nix-cache
- name: nix_build::build_nix::install_nix
uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: nix_build::build_nix::configure_local_nix_cache
run: |
mkdir -p ~/nix-cache
echo "extra-substituters = file://$HOME/nix-cache?priority=10" | sudo tee -a /etc/nix/nix.conf
echo "require-sigs = false" | sudo tee -a /etc/nix/nix.conf
sudo launchctl kickstart -k system/org.nixos.nix-daemon
- name: nix_build::build_nix::cachix_action
uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad
with:
name: zed
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
cachixArgs: -v
pushFilter: -zed-editor-[0-9.]*
- name: nix_build::build_nix::build
run: nix build .#default -L --accept-flake-config
- name: nix_build::build_nix::export_to_local_nix_cache
if: always()
run: |
if [ -L result ]; then
echo "Copying build closure to local binary cache..."
nix copy --to "file://$HOME/nix-cache" ./result || echo "Warning: nix copy to local cache failed"
else
echo "No build result found, skipping cache export."
fi
timeout-minutes: 60
continue-on-error: true
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash -euxo pipefail {0}

247
.github/workflows/pr_issue_labeler.yml vendored Normal file
View file

@ -0,0 +1,247 @@
# Labels pull requests by author:
# - 'community champion' for community champions
# - 'bot' for bot accounts
# - 'staff' for staff team members
# - 'guild' for guild members
# - 'first contribution' for first-time external contributors
# Labels issues by author:
# - 'community champion' for community champions
name: PR Issue Labeler
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
permissions:
contents: read
jobs:
check-authorship-and-label:
if: github.repository == 'zed-industries/zed'
runs-on: namespace-profile-2x4-ubuntu-2404
timeout-minutes: 5
steps:
- id: get-app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}
owner: zed-industries
- id: apply-authorship-label
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.get-app-token.outputs.token }}
script: |
const BOT_LABEL = 'bot';
const STAFF_LABEL = 'staff';
const STAFF_TEAM_SLUG = 'staff';
const FIRST_CONTRIBUTION_LABEL = 'first contribution';
const GUILD_LABEL = 'guild';
const GUILD_MEMBERS = [
'11happy',
'AidanV',
'alanpjohn',
'AmaanBilwar',
'arjunkomath',
'austincummings',
'ayushk-1801',
'criticic',
'dongdong867',
'emamulandalib',
'eureka928',
'feitreim',
'iam-liam',
'iksuddle',
'ishaksebsib',
'lingyaochu',
'loadingalias',
'marcocondrache',
'mchisolm0',
'MostlyKIGuess',
'nairadithya',
'nihalxkumar',
'notJoon',
'OmChillure',
'Palanikannan1437',
'polyesterswing',
'prayanshchh',
'razeghi71',
'sarmadgulzar',
'seanstrom',
'Shivansh-25',
'SkandaBhat',
'th0jensen',
'tommyming',
'transitoryangel',
'TwistingTwists',
'virajbhartiya',
'YEDASAVG',
'Ziqi-Yang',
];
const COMMUNITY_CHAMPION_LABEL = 'community champion';
const COMMUNITY_CHAMPIONS = [
'0x2CA',
'5brian',
'5herlocked',
'abdelq',
'afgomez',
'AidanV',
'akbxr',
'AlvaroParker',
'amtoaer',
'artemevsevev',
'bajrangCoder',
'bcomnes',
'Be-ing',
'blopker',
'bnjjj',
'bobbymannino',
'CharlesChen0823',
'chbk',
'davewa',
'davidbarsky',
'ddoemonn',
'djsauble',
'errmayank',
'fantacell',
'fdncred',
'findrakecil',
'FloppyDisco',
'gko',
'huacnlee',
'imumesh18',
'injust',
'jacobtread',
'jansol',
'jeffreyguenther',
'jenslys',
'jongretar',
'lemorage',
'lingyaochu',
'lnay',
'marcocondrache',
'marius851000',
'mikebronner',
'ognevny',
'PKief',
'playdohface',
'RemcoSmitsDev',
'rgbkrk',
'romaninsh',
'rxptr',
'Simek',
'someone13574',
'sourcefrog',
'suxiaoshao',
'Takk8IS',
'tartarughina',
'thedadams',
'tidely',
'timvermeulen',
'valentinegb',
'versecafe',
'vitallium',
'WhySoBad',
'ya7010',
'Zertsov',
];
const pr = context.payload.pull_request;
const issue = context.payload.issue;
const target = pr || issue;
const author = target.user.login;
const listIncludesAuthor = (members, author) => {
const authorLower = author.toLowerCase();
return members.some((member) => member.toLowerCase() === authorLower);
};
const isStaffMember = async (author) => {
try {
const response = await github.rest.teams.getMembershipForUserInOrg({
org: 'zed-industries',
team_slug: STAFF_TEAM_SLUG,
username: author
});
return response.data.state === 'active';
} catch (error) {
if (error.status !== 404) {
throw error;
}
return false;
}
};
const getIssueLabels = () => {
if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) {
return [COMMUNITY_CHAMPION_LABEL];
}
return [];
};
const getPullRequestLabels = async () => {
if (target.user.type === 'Bot') {
return [BOT_LABEL];
}
if (await isStaffMember(author)) {
return [STAFF_LABEL];
}
// External contributors
const labelsToAdd = [];
if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) {
labelsToAdd.push(COMMUNITY_CHAMPION_LABEL);
}
if (listIncludesAuthor(GUILD_MEMBERS, author)) {
labelsToAdd.push(GUILD_LABEL);
}
// We use inverted logic here due to a suspected GitHub bug where first-time contributors
// get 'NONE' instead of 'FIRST_TIME_CONTRIBUTOR' or 'FIRST_TIMER'.
// https://github.com/orgs/community/discussions/78038
// This will break if GitHub ever adds new associations.
const association = pr.author_association;
const knownAssociations = ['CONTRIBUTOR', 'COLLABORATOR', 'MEMBER', 'OWNER', 'MANNEQUIN'];
if (knownAssociations.includes(association)) {
console.log(`PR #${pr.number} by ${author}: not a first-time contributor (association: '${association}')`);
} else {
labelsToAdd.push(FIRST_CONTRIBUTION_LABEL);
}
return labelsToAdd;
};
const labelsToAdd = pr ? await getPullRequestLabels() : getIssueLabels();
if (labelsToAdd.length === 0) {
return;
}
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: target.number,
labels: labelsToAdd
});
const targetType = pr ? 'PR' : 'issue';
const labels = labelsToAdd.map((label) => `'${label}'`).join(', ');
console.log(`${targetType} #${target.number} by ${author}: labeled ${labels}`);
} catch (error) {
if (pr) {
throw error;
}
console.error(`Failed to label issue #${target.number}: ${error.message}`);
}

View file

@ -1,150 +0,0 @@
# Labels pull requests by author: 'bot' for bot accounts, 'staff' for
# staff team members, 'guild' for guild members, 'first contribution' for
# first-time external contributors.
name: PR Labeler
on:
pull_request_target:
types: [opened]
permissions:
contents: read
jobs:
check-authorship-and-label:
if: github.repository == 'zed-industries/zed'
runs-on: namespace-profile-2x4-ubuntu-2404
timeout-minutes: 5
steps:
- id: get-app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}
owner: zed-industries
- id: apply-authorship-label
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.get-app-token.outputs.token }}
script: |
const BOT_LABEL = 'bot';
const STAFF_LABEL = 'staff';
const GUILD_LABEL = 'guild';
const FIRST_CONTRIBUTION_LABEL = 'first contribution';
const STAFF_TEAM_SLUG = 'staff';
const GUILD_MEMBERS = [
'11happy',
'AidanV',
'AmaanBilwar',
'MostlyKIGuess',
'OmChillure',
'Palanikannan1437',
'Shivansh-25',
'SkandaBhat',
'TwistingTwists',
'YEDASAVG',
'Ziqi-Yang',
'alanpjohn',
'arjunkomath',
'austincummings',
'ayushk-1801',
'criticic',
'dongdong867',
'emamulandalib',
'eureka928',
'feitreim',
'iam-liam',
'iksuddle',
'ishaksebsib',
'lingyaochu',
'loadingalias',
'marcocondrache',
'mchisolm0',
'nairadithya',
'nihalxkumar',
'notJoon',
'polyesterswing',
'prayanshchh',
'razeghi71',
'sarmadgulzar',
'seanstrom',
'th0jensen',
'tommyming',
'transitoryangel',
'virajbhartiya',
];
const pr = context.payload.pull_request;
const author = pr.user.login;
if (pr.user.type === 'Bot') {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [BOT_LABEL]
});
console.log(`PR #${pr.number} by ${author}: labeled '${BOT_LABEL}' (user type: '${pr.user.type}')`);
return;
}
let isStaff = false;
try {
const response = await github.rest.teams.getMembershipForUserInOrg({
org: 'zed-industries',
team_slug: STAFF_TEAM_SLUG,
username: author
});
isStaff = response.data.state === 'active';
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
if (isStaff) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [STAFF_LABEL]
});
console.log(`PR #${pr.number} by ${author}: labeled '${STAFF_LABEL}' (staff team member)`);
return;
}
const authorLower = author.toLowerCase();
const isGuildMember = GUILD_MEMBERS.some(
(member) => member.toLowerCase() === authorLower
);
if (isGuildMember) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [GUILD_LABEL]
});
console.log(`PR #${pr.number} by ${author}: labeled '${GUILD_LABEL}' (guild member)`);
// No early return: guild members can also get 'first contribution'
}
// We use inverted logic here due to a suspected GitHub bug where first-time contributors
// get 'NONE' instead of 'FIRST_TIME_CONTRIBUTOR' or 'FIRST_TIMER'.
// https://github.com/orgs/community/discussions/78038
// This will break if GitHub ever adds new associations.
const association = pr.author_association;
const knownAssociations = ['CONTRIBUTOR', 'COLLABORATOR', 'MEMBER', 'OWNER', 'MANNEQUIN'];
if (knownAssociations.includes(association)) {
console.log(`PR #${pr.number} by ${author}: not a first-time contributor (association: '${association}')`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [FIRST_CONTRIBUTION_LABEL]
});
console.log(`PR #${pr.number} by ${author}: labeled '${FIRST_CONTRIBUTION_LABEL}' (association: '${association}')`);

View file

@ -264,85 +264,6 @@ jobs:
path: target/zed-remote-server-windows-x86_64.zip
if-no-files-found: error
timeout-minutes: 60
build_nix_linux_x86_64:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')))
runs-on: namespace-profile-32x64-ubuntu-2004
env:
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }}
ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
GIT_LFS_SKIP_SMUDGE: '1'
steps:
- name: steps::checkout_repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
clean: false
- name: steps::cache_nix_dependencies_namespace
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
with:
cache: nix
- name: nix_build::build_nix::install_nix
uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: nix_build::build_nix::cachix_action
uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad
with:
name: zed
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
cachixArgs: -v
pushFilter: -zed-editor-[0-9.]*
- name: nix_build::build_nix::build
run: nix build .#default -L --accept-flake-config
timeout-minutes: 60
continue-on-error: true
build_nix_mac_aarch64:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')))
runs-on: namespace-profile-mac-large
env:
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }}
ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
GIT_LFS_SKIP_SMUDGE: '1'
steps:
- name: steps::checkout_repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
clean: false
- name: steps::cache_nix_store_macos
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
with:
path: ~/nix-cache
- name: nix_build::build_nix::install_nix
uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: nix_build::build_nix::configure_local_nix_cache
run: |
mkdir -p ~/nix-cache
echo "extra-substituters = file://$HOME/nix-cache?priority=10" | sudo tee -a /etc/nix/nix.conf
echo "require-sigs = false" | sudo tee -a /etc/nix/nix.conf
sudo launchctl kickstart -k system/org.nixos.nix-daemon
- name: nix_build::build_nix::cachix_action
uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad
with:
name: zed
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
cachixArgs: -v
pushFilter: -zed-editor-[0-9.]*
- name: nix_build::build_nix::build
run: nix build .#default -L --accept-flake-config
- name: nix_build::build_nix::export_to_local_nix_cache
if: always()
run: |
if [ -L result ]; then
echo "Copying build closure to local binary cache..."
nix copy --to "file://$HOME/nix-cache" ./result || echo "Warning: nix copy to local cache failed"
else
echo "No build result found, skipping cache export."
fi
timeout-minutes: 60
continue-on-error: true
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true

119
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",
@ -426,6 +427,7 @@ name = "agent_skills"
version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"const_format",
"fs",
"futures 0.3.32",
@ -434,6 +436,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml_ng",
"url",
"util",
]
@ -512,7 +515,6 @@ dependencies = [
"remote_server",
"reqwest_client",
"rope",
"rules_library",
"schemars 1.0.4",
"search",
"semver",
@ -597,15 +599,14 @@ dependencies = [
[[package]]
name = "alacritty_terminal"
version = "0.25.1"
source = "git+https://github.com/zed-industries/alacritty?rev=9d9640d4#9d9640d4e56d67a09d049f9c0a300aae08d4f61e"
version = "0.26.1-dev"
source = "git+https://github.com/zed-industries/alacritty?rev=fcf32feacb367b75ec84dd40f041e4fd411d3cc1#fcf32feacb367b75ec84dd40f041e4fd411d3cc1"
dependencies = [
"base64 0.22.1",
"bitflags 2.10.0",
"home",
"libc",
"log",
"mach2 0.5.0",
"miow",
"parking_lot",
"piper",
@ -2161,7 +2162,7 @@ dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools 0.11.0",
"itertools 0.10.5",
"log",
"prettyplease",
"proc-macro2",
@ -2181,7 +2182,7 @@ dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools 0.11.0",
"itertools 0.10.5",
"proc-macro2",
"quote",
"regex",
@ -3880,6 +3881,8 @@ dependencies = [
"serde",
"serde_json",
"settings",
"sqlez",
"tempfile",
]
[[package]]
@ -5309,7 +5312,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -5779,7 +5782,7 @@ dependencies = [
"client",
"clock",
"collections",
"convert_case 0.8.0",
"convert_case 0.11.0",
"criterion",
"ctor",
"dap",
@ -6144,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]]
@ -7600,7 +7603,7 @@ dependencies = [
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -9061,7 +9064,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@ -9079,7 +9082,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.56.0",
]
[[package]]
@ -9333,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",
]
@ -9746,6 +9749,7 @@ dependencies = [
"settings",
"snippet_provider",
"task",
"tempfile",
"theme",
"util",
]
@ -10143,7 +10147,7 @@ dependencies = [
"cloud_api_types",
"collections",
"component",
"convert_case 0.8.0",
"convert_case 0.11.0",
"copilot",
"copilot_chat",
"copilot_ui",
@ -10476,7 +10480,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",
@ -10586,7 +10590,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",
@ -10612,7 +10616,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",
@ -10639,7 +10643,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",
@ -10655,7 +10659,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",
@ -11361,7 +11365,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"collections",
"convert_case 0.8.0",
"convert_case 0.11.0",
"log",
"pretty_assertions",
"serde_json",
@ -11722,16 +11726,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"
@ -11957,7 +11951,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]]
@ -14489,7 +14483,6 @@ dependencies = [
"db",
"fs",
"futures 0.3.32",
"fuzzy",
"gpui",
"handlebars 4.5.0",
"heed",
@ -14497,7 +14490,6 @@ dependencies = [
"log",
"parking_lot",
"paths",
"rope",
"serde",
"serde_json",
"strum 0.27.2",
@ -14596,7 +14588,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4"
dependencies = [
"bytes 1.11.1",
"heck 0.5.0",
"itertools 0.11.0",
"itertools 0.10.5",
"log",
"multimap",
"once_cell",
@ -14629,7 +14621,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",
@ -14891,7 +14883,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",
@ -14928,9 +14920,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]]
@ -16014,33 +16006,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"
@ -16182,7 +16147,7 @@ dependencies = [
"errno 0.3.14",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -17339,9 +17304,9 @@ dependencies = [
[[package]]
name = "signal-hook"
version = "0.3.18"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
dependencies = [
"libc",
"signal-hook-registry",
@ -18804,7 +18769,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -19521,7 +19486,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",
@ -19729,7 +19694,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]]
@ -21522,7 +21487,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",
@ -21536,7 +21501,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",
@ -21834,7 +21799,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]]
@ -23512,6 +23477,7 @@ dependencies = [
"agent-client-protocol",
"agent_servers",
"agent_settings",
"agent_skills",
"agent_ui",
"anyhow",
"ashpd",
@ -23594,7 +23560,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" }
@ -511,7 +507,7 @@ accesskit_unix = "0.21.0"
accesskit_windows = "0.32.1"
agent-client-protocol = { version = "=0.12.1", features = ["unstable"] }
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" }
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" }
any_vec = "0.14"
anyhow = "1.0.86"
ashpd = { version = "0.13", default-features = false, features = [
@ -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

@ -1,788 +0,0 @@
Copyright 2022 - 2025 Zed Industries, Inc.
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View file

@ -29,6 +29,8 @@ Also... we're hiring! Check out our [jobs](https://zed.dev/jobs) page for open r
### Licensing
Zed source code is licensed primarily under GPL-3.0-or-later, with Apache-2.0 components where marked.
License information for third party dependencies must be correctly provided for CI to pass.
We use [`cargo-about`](https://github.com/EmbarkStudios/cargo-about) to automatically comply with open source licenses. If CI is failing, check the following:

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

@ -16,7 +16,9 @@ use gpui::{
};
use itertools::Itertools;
use language::language_settings::FormatOnSave;
use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff};
use language::{
Anchor, Buffer, BufferEditSource, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff,
};
use markdown::{Markdown, MarkdownOptions};
pub use mention::*;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
@ -773,6 +775,7 @@ impl ContentBlock {
None,
MarkdownOptions {
render_mermaid_diagrams: true,
render_metadata_blocks: true,
..Default::default()
},
cx,
@ -2912,7 +2915,9 @@ impl AcpThread {
});
let format_on_save = buffer.update(cx, |buffer, cx| {
buffer.start_transaction();
buffer.edit(edits, None, cx);
buffer.end_transaction_with_source(BufferEditSource::Agent, cx);
let settings =
language::language_settings::LanguageSettings::for_buffer(buffer, cx);

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

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

@ -12,7 +12,7 @@ use collections::HashSet;
use futures::{FutureExt, channel::oneshot};
use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
use language::language_settings::{self, FormatOnSave};
use language::{Buffer, BufferEvent, LanguageRegistry};
use language::{Buffer, BufferEditSource, BufferEvent, LanguageRegistry};
use language_model::LanguageModelToolResultContent;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
use project::{AgentLocation, Project, ProjectPath};
@ -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(
@ -975,7 +972,9 @@ fn agent_edit_buffer<I, S, T>(
{
cx.update(|cx| {
buffer.update(cx, |buffer, cx| {
buffer.start_transaction();
buffer.edit(edits, None, cx);
buffer.end_transaction_with_source(BufferEditSource::Agent, cx);
});
action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
});

View file

@ -13,6 +13,7 @@ path = "agent_skills.rs"
[dependencies]
anyhow.workspace = true
base64.workspace = true
const_format.workspace = true
fs.workspace = true
futures.workspace = true
@ -20,6 +21,7 @@ gpui.workspace = true
paths.workspace = true
serde.workspace = true
serde_yaml_ng.workspace = true
url.workspace = true
util.workspace = true
[dev-dependencies]

View file

@ -6,6 +6,7 @@ use gpui::{Global, SharedString};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use url::Url;
use util::paths::component_matches_ignore_ascii_case;
/// First segment of the skills directory path: `.agents`.
@ -731,6 +732,58 @@ pub fn is_agents_skills_path(path: &Path) -> bool {
false
}
/// The `zed://` scheme used by share links.
const SKILL_SHARE_LINK_SCHEME: &str = "zed";
/// The host (the part after `zed://`) that identifies a skill share link.
const SKILL_SHARE_LINK_HOST: &str = "skill";
/// The query parameter that carries the embedded `SKILL.md` payload.
const SKILL_SHARE_LINK_DATA_PARAM: &str = "data";
/// The `zed://` deep-link prefix for a shared skill. Opening a link with this
/// prefix prompts the recipient to review and install the embedded skill.
pub const SKILL_SHARE_LINK_PREFIX: &str =
concatcp!(SKILL_SHARE_LINK_SCHEME, "://", SKILL_SHARE_LINK_HOST);
/// Build a shareable `zed://skill?data=…` link that fully embeds the given
/// `SKILL.md` file contents.
///
/// The contents are base64url-encoded (no padding) so the link is
/// self-contained and URL-safe: the recipient doesn't need the skill to be
/// hosted anywhere. Recover the contents with [`decode_skill_share_link`].
pub fn encode_skill_share_link(skill_file_content: &str) -> String {
use base64::Engine as _;
let data =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(skill_file_content.as_bytes());
let mut url = Url::parse(SKILL_SHARE_LINK_PREFIX).expect("skill share link prefix is valid");
url.query_pairs_mut()
.append_pair(SKILL_SHARE_LINK_DATA_PARAM, &data);
url.into()
}
/// Recover the `SKILL.md` contents embedded in a `zed://skill?data=…` link
/// produced by [`encode_skill_share_link`].
pub fn decode_skill_share_link(link: &str) -> Result<String> {
use base64::Engine as _;
let url = Url::parse(link).context("skill share link is not a valid URL")?;
anyhow::ensure!(
url.scheme() == SKILL_SHARE_LINK_SCHEME && url.host_str() == Some(SKILL_SHARE_LINK_HOST),
"not a skill share link"
);
let data = url
.query_pairs()
.find_map(|(key, value)| (key == SKILL_SHARE_LINK_DATA_PARAM).then_some(value))
.context("skill share link is missing the `data` parameter")?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(data.as_bytes())
.context("skill share link `data` is not valid base64")?;
anyhow::ensure!(
bytes.len() <= MAX_SKILL_FILE_SIZE,
"shared skill exceeds the maximum size of {MAX_SKILL_FILE_SIZE} bytes"
);
let content = String::from_utf8(bytes).context("skill share link `data` is not valid UTF-8")?;
Ok(content)
}
#[cfg(test)]
mod tests {
use super::*;
@ -1959,4 +2012,25 @@ description: A skill with no body content
}
}
}
#[test]
fn skill_share_link_round_trips() {
let content =
"---\nname: my-skill\ndescription: Does a thing.\n---\n\n## Steps\n\nDo the thing.\n";
let link = encode_skill_share_link(content);
let data = link
.strip_prefix("zed://skill?data=")
.expect("link should start with the skill share prefix");
// base64url (no-pad) output must not require percent-encoding.
assert!(!data.contains('+') && !data.contains('/') && !data.contains('='));
assert_eq!(decode_skill_share_link(&link).unwrap(), content);
}
#[test]
fn decode_skill_share_link_rejects_non_skill_links() {
assert!(decode_skill_share_link("zed://settings/agent.skills").is_err());
assert!(decode_skill_share_link("zed://skill").is_err());
assert!(decode_skill_share_link("zed://skill?other=1").is_err());
assert!(decode_skill_share_link("zed://skill?data=!!!notbase64").is_err());
}
}

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

@ -38,7 +38,10 @@ use crate::ExpandMessageEditor;
use crate::ManageProfiles;
use crate::agent_connection_store::AgentConnectionStore;
use crate::completion_provider::AgentContextSource;
use crate::terminal_thread_metadata_store::{TerminalThreadMetadata, TerminalThreadMetadataStore};
use crate::terminal_thread_metadata_store::{
TerminalThreadMetadata, TerminalThreadMetadataStore, compose_terminal_thread_title,
terminal_title_without_prefix,
};
use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadataStoreEvent};
use crate::{
AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow,
@ -75,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};
@ -870,6 +872,7 @@ struct AgentTerminal {
title_editor_initial_title: Option<String>,
title_editor_subscription: Option<Subscription>,
last_known_title: String,
last_known_terminal_title: String,
last_observed_program: Option<String>,
working_directory: Option<PathBuf>,
created_at: DateTime<Utc>,
@ -880,32 +883,58 @@ struct AgentTerminal {
}
impl AgentTerminal {
fn title(&self, cx: &App) -> SharedString {
let view = self.view.read(cx);
let title = if let Some(custom_title) = view.custom_title() {
SharedString::from(custom_title)
} else {
let terminal = view.terminal().read(cx);
if terminal.breadcrumb_text.is_empty() {
let title = terminal.title(true);
if title == "Terminal" {
SharedString::from("")
} else {
title.into()
}
fn terminal_title_for_view(view: &TerminalView, cx: &App) -> SharedString {
let terminal = view.terminal().read(cx);
if terminal.breadcrumb_text.is_empty() {
let title = terminal.title(true);
if title == "Terminal" {
SharedString::from("")
} else {
terminal.breadcrumb_text.clone().into()
title.into()
}
};
} else {
terminal.breadcrumb_text.clone().into()
}
}
if title.is_empty() && !self.last_known_title.is_empty() {
SharedString::from(self.last_known_title.clone())
fn current_terminal_title(&self, cx: &App) -> SharedString {
let view = self.view.read(cx);
Self::terminal_title_for_view(view, cx)
}
fn terminal_title(&self, cx: &App) -> SharedString {
let title = self.current_terminal_title(cx);
if title.is_empty() && !self.last_known_terminal_title.is_empty() {
SharedString::from(self.last_known_terminal_title.clone())
} else {
title
}
}
fn title(&self, cx: &App) -> SharedString {
let terminal_title = self.terminal_title(cx);
let custom_title = self.custom_title(cx);
compose_terminal_thread_title(
terminal_title.as_ref(),
custom_title.as_ref().map(|title| title.as_ref()),
)
}
fn editable_title(&self, cx: &App) -> SharedString {
if let Some(custom_title) = self.custom_title(cx) {
custom_title
} else {
let terminal_title = self.terminal_title(cx);
SharedString::from(terminal_title_without_prefix(terminal_title.as_ref()).to_string())
}
}
fn refresh_title(&mut self, cx: &mut App) -> bool {
let terminal_title = self.current_terminal_title(cx);
if !terminal_title.is_empty() {
self.last_known_terminal_title = terminal_title.to_string();
}
let title = self.title(cx);
let changed = self.last_known_title != title.as_ref();
if changed {
@ -1019,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>>,
@ -1140,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()
@ -1271,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();
@ -1351,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();
@ -1438,7 +1456,6 @@ impl AgentPanel {
project: project.clone(),
fs: fs.clone(),
language_registry,
prompt_store,
connection_store,
configuration: None,
configuration_subscription: None,
@ -1517,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
}
@ -1981,14 +1994,16 @@ impl AgentPanel {
},
);
let last_known_terminal_title = initial_title
.map(|title| title.to_string())
.unwrap_or_default();
let mut terminal = AgentTerminal {
view: terminal_view,
title_editor: None,
title_editor_initial_title: None,
title_editor_subscription: None,
last_known_title: initial_title
.map(|title| title.to_string())
.unwrap_or_default(),
last_known_title: last_known_terminal_title.clone(),
last_known_terminal_title,
last_observed_program: None,
working_directory,
created_at: created_at.unwrap_or_else(Utc::now),
@ -2164,7 +2179,7 @@ impl AgentPanel {
let project = self.project.read(cx);
Some(TerminalThreadMetadata {
terminal_id,
title: terminal.title(cx),
title: terminal.terminal_title(cx),
custom_title: terminal.custom_title(cx),
created_at: terminal.created_at,
worktree_paths: project.worktree_paths(cx),
@ -2242,10 +2257,7 @@ impl AgentPanel {
}
fn terminal_restore_initial_title(metadata: &TerminalThreadMetadata) -> Option<SharedString> {
metadata
.custom_title
.clone()
.or_else(|| (!metadata.title.is_empty()).then(|| metadata.title.clone()))
(!metadata.title.is_empty()).then(|| metadata.title.clone())
}
fn edit_terminal_title(
@ -2263,7 +2275,7 @@ impl AgentPanel {
return;
}
let title = terminal.title(cx).to_string();
let title = terminal.editable_title(cx).to_string();
let title_editor_initial_title = title.clone();
let title_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
@ -2331,7 +2343,7 @@ impl AgentPanel {
if !title_editor.read(cx).is_focused(window) {
return;
}
let Some((terminal_view, initial_title)) =
let Some((terminal_view, initial_title, terminal_title)) =
self.terminals.get(&terminal_id).and_then(|terminal| {
terminal
.title_editor
@ -2341,25 +2353,23 @@ impl AgentPanel {
(
terminal.view.clone(),
terminal.title_editor_initial_title.clone(),
terminal.terminal_title(cx),
)
})
})
else {
return;
};
let new_title = title_editor.read(cx).text(cx).trim().to_string();
if initial_title.as_deref().map(str::trim) == Some(new_title.as_str()) {
let new_title = title_editor.read(cx).text(cx);
if initial_title.as_deref() == Some(new_title.as_str()) {
return;
}
let label = if new_title.is_empty() {
let label = if new_title.trim().is_empty()
|| new_title == terminal_title_without_prefix(terminal_title.as_ref())
{
None
} else {
let terminal_title = terminal_view.read(cx).terminal().read(cx).title(true);
if new_title == terminal_title {
None
} else {
Some(new_title)
}
Some(new_title)
};
cx.defer(move |cx| {
@ -3251,6 +3261,13 @@ impl AgentPanel {
self.open_skill_creator(SkillCreatorOpenMode::Url { initial_url }, cx);
}
/// Open the skill creator pre-filled with a skill received from a
/// `zed://skill` share link, so the user can review it and choose a scope
/// before installing.
pub fn install_shared_skill(&mut self, content: String, cx: &mut Context<Self>) {
self.open_skill_creator(SkillCreatorOpenMode::Install { content }, cx);
}
fn open_skill_creator(&mut self, open_mode: SkillCreatorOpenMode, cx: &mut Context<Self>) {
let this = cx.weak_entity();
let on_saved: Rc<dyn Fn(&mut App)> = Rc::new(move |cx: &mut App| {
@ -4361,7 +4378,6 @@ impl AgentPanel {
workspace.clone(),
project,
thread_store,
self.prompt_store.clone(),
source,
window,
cx,
@ -6053,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
@ -6560,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| {
@ -6586,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| {
@ -6689,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| {
@ -6884,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| {
@ -6961,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| {
@ -7053,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
@ -7152,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
});
@ -7252,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
});
@ -7632,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
});
@ -7819,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
});
@ -8048,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
});
@ -8134,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
});
@ -8224,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)
@ -8271,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
@ -8401,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
});
@ -8514,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
});
@ -9001,6 +9017,182 @@ mod tests {
});
}
#[gpui::test]
async fn test_terminal_custom_title_recomposes_with_live_spinner(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
let terminal_id = panel
.update_in(&mut cx, |panel, window, cx| {
panel.insert_test_terminal("Fix bug", true, window, cx)
})
.expect("test terminal should be inserted");
cx.run_until_parked();
let terminal_entity = panel.read_with(&cx, |panel, _cx| {
panel
.terminals
.get(&terminal_id)
.expect("terminal should remain in the panel")
.view
.clone()
});
let terminal_entity =
terminal_entity.read_with(&cx, |terminal_view, _cx| terminal_view.terminal().clone());
terminal_entity.update(&mut cx, |terminal, cx| {
terminal.breadcrumb_text = "⠋ Thinking".to_string();
cx.emit(TerminalEvent::BreadcrumbsChanged);
});
cx.run_until_parked();
panel.read_with(&cx, |panel, cx| {
let terminals = panel.terminals(cx);
assert_eq!(terminals.len(), 1);
assert_eq!(terminals[0].title.as_ref(), "⠋ Fix bug");
let metadata = panel
.terminal_metadata(terminal_id, cx)
.expect("terminal metadata should be available");
assert_eq!(metadata.title.as_ref(), "⠋ Thinking");
assert_eq!(
metadata.custom_title.as_ref().map(|title| title.as_ref()),
Some("Fix bug")
);
assert_eq!(metadata.display_title().as_ref(), "⠋ Fix bug");
});
terminal_entity.update(&mut cx, |terminal, cx| {
terminal.breadcrumb_text = "⠙ Thinking".to_string();
cx.emit(TerminalEvent::BreadcrumbsChanged);
});
cx.run_until_parked();
panel.read_with(&cx, |panel, cx| {
let terminals = panel.terminals(cx);
assert_eq!(terminals.len(), 1);
assert_eq!(terminals[0].title.as_ref(), "⠙ Fix bug");
let metadata = panel
.terminal_metadata(terminal_id, cx)
.expect("terminal metadata should be available");
assert_eq!(metadata.title.as_ref(), "⠙ Thinking");
assert_eq!(metadata.display_title().as_ref(), "⠙ Fix bug");
});
terminal_entity.update(&mut cx, |terminal, cx| {
terminal.breadcrumb_text = "Thinking".to_string();
cx.emit(TerminalEvent::BreadcrumbsChanged);
});
cx.run_until_parked();
panel.read_with(&cx, |panel, cx| {
let terminals = panel.terminals(cx);
assert_eq!(terminals.len(), 1);
assert_eq!(terminals[0].title.as_ref(), "Fix bug");
let metadata = panel
.terminal_metadata(terminal_id, cx)
.expect("terminal metadata should be available");
assert_eq!(metadata.title.as_ref(), "Thinking");
assert_eq!(metadata.display_title().as_ref(), "Fix bug");
});
}
#[gpui::test]
async fn test_terminal_title_editor_excludes_spinner_prefix(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
let terminal_id = panel
.update_in(&mut cx, |panel, window, cx| {
panel.insert_test_terminal("Initial Custom Title", true, window, cx)
})
.expect("test terminal should be inserted");
cx.run_until_parked();
let terminal_view = panel.read_with(&cx, |panel, _cx| {
panel
.terminals
.get(&terminal_id)
.expect("terminal should remain in the panel")
.view
.clone()
});
terminal_view.update(&mut cx, |terminal_view, cx| {
terminal_view.set_custom_title(None, cx);
});
let terminal_entity =
terminal_view.read_with(&cx, |terminal_view, _cx| terminal_view.terminal().clone());
terminal_entity.update(&mut cx, |terminal, cx| {
terminal.breadcrumb_text = "⠋ Thinking".to_string();
cx.emit(TerminalEvent::BreadcrumbsChanged);
});
cx.run_until_parked();
panel.update_in(&mut cx, |panel, window, cx| {
panel.edit_terminal_title(terminal_id, window, cx);
});
cx.run_until_parked();
let title_editor = panel.read_with(&cx, |panel, cx| {
let terminal = panel
.terminals
.get(&terminal_id)
.expect("terminal should remain in the panel");
let title_editor = terminal
.title_editor
.as_ref()
.expect("terminal title editor should be active while editing")
.clone();
assert_eq!(title_editor.read(cx).text(cx), "Thinking");
title_editor
});
title_editor.update_in(&mut cx, |editor, window, cx| {
editor.set_text("Fix bug", window, cx);
editor.focus_handle(cx).focus(window, cx);
});
panel.update_in(&mut cx, |panel, window, cx| {
panel.handle_terminal_title_editor_event(
terminal_id,
&title_editor,
&editor::EditorEvent::BufferEdited,
window,
cx,
);
});
cx.run_until_parked();
terminal_view.read_with(&cx, |terminal_view, _cx| {
assert_eq!(terminal_view.custom_title(), Some("Fix bug"));
});
panel.read_with(&cx, |panel, cx| {
let terminals = panel.terminals(cx);
assert_eq!(terminals.len(), 1);
assert_eq!(terminals[0].title.as_ref(), "⠋ Fix bug");
let metadata = panel
.terminal_metadata(terminal_id, cx)
.expect("terminal metadata should be available");
assert_eq!(metadata.title.as_ref(), "⠋ Thinking");
assert_eq!(
metadata.custom_title.as_ref().map(|title| title.as_ref()),
Some("Fix bug")
);
});
panel.update_in(&mut cx, |panel, window, cx| {
panel.stop_editing_terminal_title(terminal_id, false, window, cx);
panel.edit_terminal_title(terminal_id, window, cx);
});
cx.run_until_parked();
panel.read_with(&cx, |panel, cx| {
let terminal = panel
.terminals
.get(&terminal_id)
.expect("terminal should remain in the panel");
let title_editor = terminal
.title_editor
.as_ref()
.expect("terminal title editor should be active while editing");
assert_eq!(title_editor.read(cx).text(cx), "Fix bug");
});
}
#[gpui::test]
async fn test_terminal_bell_marks_and_activation_clears_notification(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
@ -9581,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
});
@ -10173,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
@ -10442,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();
@ -10450,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();
@ -10521,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
});
@ -10578,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
});
@ -10668,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
});
@ -10756,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
});
@ -10866,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
});
@ -10972,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
});
@ -11471,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();
@ -11572,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
});
@ -11782,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
});
@ -12019,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
});
@ -12043,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
});
@ -12113,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
});
@ -12137,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
});
@ -12181,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

@ -12,7 +12,9 @@ use futures::{
stream::BoxStream,
};
use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task};
use language::{Buffer, IndentKind, LanguageName, Point, TransactionId, line_diff};
use language::{
Buffer, BufferEditSource, IndentKind, LanguageName, Point, TransactionId, line_diff,
};
use language_model::{
CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
@ -978,7 +980,7 @@ impl CodegenAlternative {
buffer.finalize_last_transaction(cx);
buffer.start_transaction(cx);
buffer.edit(edits, None, cx);
buffer.end_transaction(cx)
buffer.end_transaction_with_source(BufferEditSource::Agent, cx)
});
if let Some(transaction) = transaction {

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

@ -63,6 +63,76 @@ impl TerminalThreadMetadata {
pub fn main_worktree_paths(&self) -> &PathList {
self.worktree_paths.main_worktree_path_list()
}
pub fn display_title(&self) -> SharedString {
compose_terminal_thread_title(
self.title.as_ref(),
self.custom_title.as_ref().map(|title| title.as_ref()),
)
}
}
pub(crate) fn compose_terminal_thread_title(
terminal_title: &str,
custom_title: Option<&str>,
) -> SharedString {
let Some(custom_title) = custom_title.filter(|title| !title.trim().is_empty()) else {
return SharedString::from(terminal_title.to_string());
};
if let Some(prefix) = terminal_title_prefix(terminal_title) {
SharedString::from(format!("{prefix}{custom_title}"))
} else {
SharedString::from(custom_title.to_string())
}
}
pub(crate) fn terminal_title_without_prefix(title: &str) -> &str {
terminal_title_prefix(title)
.map(|prefix| &title[prefix.len()..])
.unwrap_or(title)
}
fn terminal_title_prefix(title: &str) -> Option<&str> {
let mut prefix_byte_len = 0;
let mut saw_prefix_character = false;
let mut saw_whitespace_after_prefix = false;
let mut chars = title.chars().peekable();
while let Some(character) = chars.next() {
if character.is_alphanumeric() {
return None;
}
if character.is_whitespace() {
if !saw_prefix_character {
return None;
}
prefix_byte_len += character.len_utf8();
saw_whitespace_after_prefix = true;
while let Some(character) = chars.peek() {
if !character.is_whitespace() {
break;
}
prefix_byte_len += character.len_utf8();
chars.next();
}
break;
}
saw_prefix_character = true;
prefix_byte_len += character.len_utf8();
}
if saw_whitespace_after_prefix {
Some(&title[..prefix_byte_len])
} else {
None
}
}
pub struct TerminalThreadMetadataStore {
@ -563,6 +633,32 @@ mod tests {
}
}
#[test]
fn test_terminal_title_prefix_preserves_non_alphanumeric_prefixes() {
assert_eq!(terminal_title_prefix("✳ Thinking"), Some(""));
assert_eq!(terminal_title_prefix(">>> Thinking"), Some(">>> "));
assert_eq!(terminal_title_prefix("⠋ Running"), Some(""));
assert_eq!(terminal_title_prefix("* Claude"), Some("* "));
assert_eq!(terminal_title_prefix("✳Thinking"), None);
assert_eq!(terminal_title_prefix("Thinking"), None);
assert_eq!(terminal_title_prefix(" Thinking"), None);
assert_eq!(terminal_title_prefix(""), None);
assert_eq!(terminal_title_prefix("v1 Running"), None);
}
#[test]
fn test_terminal_thread_display_title_combines_raw_and_custom_titles() {
let mut metadata = metadata(
"⠋ Thinking",
WorktreePaths::from_folder_paths(&PathList::default()),
);
metadata.custom_title = Some("Fix bug".into());
assert_eq!(metadata.display_title().as_ref(), "⠋ Fix bug");
metadata.title = "Thinking".into();
assert_eq!(metadata.display_title().as_ref(), "Fix bug");
}
#[gpui::test]
async fn test_change_worktree_paths_reindexes_terminal_metadata(cx: &mut TestAppContext) {
init_test(cx);

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

@ -103,13 +103,16 @@ impl Component for EndTrialUpsell {
"End of Trial Upsell Banner"
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.child(EndTrialUpsell {
dismiss_upsell: Arc::new(|_, _| {}),
})
.into_any_element(),
)
fn description() -> &'static str {
"A banner shown in the agent panel when a user's trial has ended, \
inviting them to upgrade to a paid plan to continue using the agent."
}
fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
v_flex()
.child(EndTrialUpsell {
dismiss_upsell: Arc::new(|_, _| {}),
})
.into_any_element()
}
}

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

@ -376,7 +376,13 @@ impl Component for ZedAiOnboarding {
"Agent New User Onboarding"
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
fn description() -> &'static str {
"The onboarding surface shown to new agent panel users, \
guiding them through signing in to Zed and selecting a plan \
before they can start using the agent."
}
fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
fn onboarding(
sign_in_status: SignInStatus,
plan: Option<Plan>,
@ -402,41 +408,39 @@ impl Component for ZedAiOnboarding {
.into_any_element()
}
Some(
v_flex()
.min_w_0()
.gap_4()
.children(vec![
single_example(
"Not Signed-in",
onboarding(SignInStatus::SignedOut, None, false),
),
single_example(
"Young Account",
onboarding(SignInStatus::SignedIn, None, true),
),
single_example(
"Free Plan",
onboarding(SignInStatus::SignedIn, Some(Plan::ZedFree), false),
),
single_example(
"Pro Trial",
onboarding(SignInStatus::SignedIn, Some(Plan::ZedProTrial), false),
),
single_example(
"Pro Plan",
onboarding(SignInStatus::SignedIn, Some(Plan::ZedPro), false),
),
single_example(
"Business Plan",
onboarding(SignInStatus::SignedIn, Some(Plan::ZedBusiness), false),
),
single_example(
"Student Plan",
onboarding(SignInStatus::SignedIn, Some(Plan::ZedStudent), false),
),
])
.into_any_element(),
)
v_flex()
.min_w_0()
.gap_4()
.children(vec![
single_example(
"Not Signed-in",
onboarding(SignInStatus::SignedOut, None, false),
),
single_example(
"Young Account",
onboarding(SignInStatus::SignedIn, None, true),
),
single_example(
"Free Plan",
onboarding(SignInStatus::SignedIn, Some(Plan::ZedFree), false),
),
single_example(
"Pro Trial",
onboarding(SignInStatus::SignedIn, Some(Plan::ZedProTrial), false),
),
single_example(
"Pro Plan",
onboarding(SignInStatus::SignedIn, Some(Plan::ZedPro), false),
),
single_example(
"Business Plan",
onboarding(SignInStatus::SignedIn, Some(Plan::ZedBusiness), false),
),
single_example(
"Student Plan",
onboarding(SignInStatus::SignedIn, Some(Plan::ZedStudent), false),
),
])
.into_any_element()
}
}

View file

@ -122,9 +122,6 @@ impl Model {
let mut supported_effort_levels = Vec::new();
if let Some(effort) = entry.capabilities.as_ref().and_then(|e| e.effort.as_ref()) {
// The `xhigh` effort level reported by the API has no
// corresponding `Effort` variant in the request enum, so it is
// intentionally dropped here.
for (level, supported) in [
(Effort::Low, effort.low.as_ref()),
(Effort::Medium, effort.medium.as_ref()),
@ -148,7 +145,10 @@ impl Model {
AnthropicModelMode::Default
};
let supports_speed = matches!(entry.id.as_str(), "claude-opus-4-6" | "claude-opus-4-7");
let supports_speed = matches!(
entry.id.as_str(),
"claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8"
);
let mut extra_beta_headers = Vec::new();
if supports_speed {
@ -676,6 +676,8 @@ pub enum Effort {
Low,
Medium,
High,
#[serde(rename = "xhigh")]
#[strum(serialize = "xhigh")]
XHigh,
Max,
}
@ -1056,6 +1058,17 @@ mod tests {
assert_eq!(model.mode, AnthropicModelMode::Default);
}
#[test]
fn from_listed_enables_fast_mode_for_opus_4_8() {
let model = Model::from_listed(listed_entry(
"claude-opus-4-8",
ModelCapabilities::default(),
));
assert!(model.supports_speed);
assert_eq!(model.beta_headers().as_deref(), Some(FAST_MODE_BETA_HEADER));
}
#[test]
fn from_listed_collects_supported_effort_levels() {
let entry = listed_entry(

View file

@ -300,6 +300,7 @@ pub fn into_anthropic(
"low" => Some(crate::Effort::Low),
"medium" => Some(crate::Effort::Medium),
"high" => Some(crate::Effort::High),
"xhigh" => Some(crate::Effort::XHigh),
"max" => Some(crate::Effort::Max),
_ => None,
};
@ -705,6 +706,44 @@ mod tests {
));
}
#[test]
fn test_xhigh_effort_is_serialized_for_adaptive_thinking() {
let request = LanguageModelRequest {
messages: vec![LanguageModelRequestMessage {
role: Role::User,
content: vec![MessageContent::Text("Hi".to_string())],
cache: false,
reasoning_details: None,
}],
thread_id: None,
prompt_id: None,
intent: None,
stop: vec![],
temperature: None,
tools: vec![],
tool_choice: None,
thinking_allowed: true,
thinking_effort: Some("xhigh".into()),
speed: None,
};
let anthropic_request = into_anthropic(
request,
"claude-opus-4-8".to_string(),
1.0,
128_000,
AnthropicModelMode::AdaptiveThinking,
AnthropicPromptCacheMode::Automatic,
);
assert_eq!(
anthropic_request
.output_config
.and_then(|config| config.effort),
Some(crate::Effort::XHigh)
);
}
#[test]
fn test_no_cache_control_when_caching_disabled() {
let request = LanguageModelRequest {

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

@ -8,6 +8,7 @@ pub enum BedrockAdaptiveThinkingEffort {
Medium,
#[default]
High,
XHigh,
Max,
}
@ -17,6 +18,7 @@ impl BedrockAdaptiveThinkingEffort {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Max => "max",
}
}
@ -91,6 +93,13 @@ pub enum Model {
alias = "claude-opus-4-7-thinking-latest"
)]
ClaudeOpus4_7,
#[serde(
rename = "claude-opus-4-8",
alias = "claude-opus-4-8-latest",
alias = "claude-opus-4-8-thinking",
alias = "claude-opus-4-8-thinking-latest"
)]
ClaudeOpus4_8,
#[serde(
rename = "claude-sonnet-4-6",
alias = "claude-sonnet-4-6-latest",
@ -210,7 +219,9 @@ impl Model {
}
pub fn from_id(id: &str) -> anyhow::Result<Self> {
if id.starts_with("claude-opus-4-7") {
if id.starts_with("claude-opus-4-8") {
Ok(Self::ClaudeOpus4_8)
} else if id.starts_with("claude-opus-4-7") {
Ok(Self::ClaudeOpus4_7)
} else if id.starts_with("claude-opus-4-6") {
Ok(Self::ClaudeOpus4_6)
@ -240,6 +251,7 @@ impl Model {
Self::ClaudeOpus4_5 => "claude-opus-4-5",
Self::ClaudeOpus4_6 => "claude-opus-4-6",
Self::ClaudeOpus4_7 => "claude-opus-4-7",
Self::ClaudeOpus4_8 => "claude-opus-4-8",
Self::ClaudeSonnet4_6 => "claude-sonnet-4-6",
Self::Llama4Scout17B => "llama-4-scout-17b",
Self::Llama4Maverick17B => "llama-4-maverick-17b",
@ -290,6 +302,7 @@ impl Model {
Self::ClaudeOpus4_5 => "anthropic.claude-opus-4-5-20251101-v1:0",
Self::ClaudeOpus4_6 => "anthropic.claude-opus-4-6-v1",
Self::ClaudeOpus4_7 => "anthropic.claude-opus-4-7",
Self::ClaudeOpus4_8 => "anthropic.claude-opus-4-8",
Self::ClaudeSonnet4_6 => "anthropic.claude-sonnet-4-6",
Self::Llama4Scout17B => "meta.llama4-scout-17b-instruct-v1:0",
Self::Llama4Maverick17B => "meta.llama4-maverick-17b-instruct-v1:0",
@ -340,6 +353,7 @@ impl Model {
Self::ClaudeOpus4_5 => "Claude Opus 4.5",
Self::ClaudeOpus4_6 => "Claude Opus 4.6",
Self::ClaudeOpus4_7 => "Claude Opus 4.7",
Self::ClaudeOpus4_8 => "Claude Opus 4.8",
Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6",
Self::Llama4Scout17B => "Llama 4 Scout 17B",
Self::Llama4Maverick17B => "Llama 4 Maverick 17B",
@ -391,6 +405,7 @@ impl Model {
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeOpus4_8
| Self::ClaudeSonnet4_6 => 1_000_000,
Self::ClaudeOpus4_1 => 200_000,
Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000,
@ -425,7 +440,7 @@ impl Model {
| Self::ClaudeOpus4_5
| Self::ClaudeSonnet4_6 => 64_000,
Self::ClaudeOpus4_1 => 32_000,
Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 => 128_000,
Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 => 128_000,
Self::Llama4Scout17B
| Self::Llama4Maverick17B
| Self::Gemma3_4B
@ -464,6 +479,7 @@ impl Model {
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeOpus4_8
| Self::ClaudeSonnet4_6 => 1.0,
Self::Custom {
default_temperature,
@ -482,6 +498,7 @@ impl Model {
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeOpus4_8
| Self::ClaudeSonnet4_6 => true,
Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => true,
Self::MistralLarge3 | Self::PixtralLarge | Self::MagistralSmall => true,
@ -513,6 +530,7 @@ impl Model {
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeOpus4_8
| Self::ClaudeSonnet4_6 => true,
Self::NovaLite | Self::NovaPro => true,
Self::PixtralLarge => true,
@ -531,6 +549,7 @@ impl Model {
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeOpus4_8
| Self::ClaudeSonnet4_6 => true,
Self::Custom {
cache_configuration,
@ -550,6 +569,7 @@ impl Model {
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeOpus4_8
| Self::ClaudeSonnet4_6
)
}
@ -557,10 +577,14 @@ impl Model {
pub fn supports_adaptive_thinking(&self) -> bool {
matches!(
self,
Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeSonnet4_6
Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6
)
}
pub fn supports_xhigh_adaptive_thinking(&self) -> bool {
matches!(self, Self::ClaudeOpus4_8)
}
pub fn thinking_mode(&self) -> BedrockModelMode {
if self.supports_adaptive_thinking() {
BedrockModelMode::AdaptiveThinking {
@ -590,6 +614,7 @@ impl Model {
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeOpus4_8
| Self::ClaudeSonnet4_6
| Self::Nova2Lite
);
@ -650,6 +675,7 @@ impl Model {
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeOpus4_8
| Self::ClaudeSonnet4_6
| Self::Nova2Lite,
"global",
@ -667,6 +693,7 @@ impl Model {
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeOpus4_8
| Self::ClaudeSonnet4_6
| Self::Llama4Scout17B
| Self::Llama4Maverick17B
@ -689,6 +716,7 @@ impl Model {
| Self::ClaudeSonnet4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeOpus4_8
| Self::ClaudeSonnet4_6
| Self::NovaLite
| Self::NovaPro
@ -702,6 +730,7 @@ impl Model {
| Self::ClaudeSonnet4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeOpus4_8
| Self::ClaudeSonnet4_6,
"au",
) => Ok(format!("{}.{}", region_group, model_id)),
@ -779,6 +808,10 @@ mod tests {
Model::ClaudeOpus4_7.cross_region_inference_id("eu-west-1", false)?,
"eu.anthropic.claude-opus-4-7"
);
assert_eq!(
Model::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?,
"eu.anthropic.claude-opus-4-8"
);
Ok(())
}
@ -813,6 +846,10 @@ mod tests {
Model::ClaudeOpus4_7.cross_region_inference_id("ap-southeast-2", false)?,
"au.anthropic.claude-opus-4-7"
);
assert_eq!(
Model::ClaudeOpus4_8.cross_region_inference_id("ap-southeast-2", false)?,
"au.anthropic.claude-opus-4-8"
);
Ok(())
}
@ -877,6 +914,10 @@ mod tests {
Model::ClaudeOpus4_7.cross_region_inference_id("us-east-1", true)?,
"global.anthropic.claude-opus-4-7"
);
assert_eq!(
Model::ClaudeOpus4_8.cross_region_inference_id("us-east-1", true)?,
"global.anthropic.claude-opus-4-8"
);
assert_eq!(
Model::Nova2Lite.cross_region_inference_id("us-east-1", true)?,
"global.amazon.nova-2-lite-v1:0"
@ -978,6 +1019,9 @@ mod tests {
assert!(!Model::ClaudeSonnet4.supports_adaptive_thinking());
assert!(Model::ClaudeOpus4_6.supports_adaptive_thinking());
assert!(Model::ClaudeSonnet4_6.supports_adaptive_thinking());
assert!(!Model::ClaudeOpus4_7.supports_xhigh_adaptive_thinking());
assert!(Model::ClaudeOpus4_8.supports_xhigh_adaptive_thinking());
assert_eq!(BedrockAdaptiveThinkingEffort::XHigh.as_str(), "xhigh");
assert_eq!(
Model::ClaudeSonnet4.thinking_mode(),

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>>>>),
}

View file

@ -5,7 +5,7 @@ edition.workspace = true
name = "collab"
version = "0.44.0"
publish.workspace = true
license = "AGPL-3.0-or-later"
license = "GPL-3.0-or-later"
[lints]
workspace = true

View file

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

View file

@ -48,9 +48,9 @@ pub fn register_component<T: Component>() {
let id = T::id();
let metadata = ComponentMetadata {
id: id.clone(),
description: T::description().map(Into::into),
description: SharedString::new_static(T::description()),
name: SharedString::new_static(T::name()),
preview: Some(T::preview),
preview: T::preview,
scope: T::scope(),
sort_name: SharedString::new_static(T::sort_name()),
status: T::status(),
@ -69,15 +69,12 @@ pub struct ComponentRegistry {
}
impl ComponentRegistry {
pub fn previews(&self) -> Vec<&ComponentMetadata> {
self.components
.values()
.filter(|c| c.preview.is_some())
.collect()
pub fn previews(&self) -> impl Iterator<Item = &ComponentMetadata> {
self.components.values()
}
pub fn sorted_previews(&self) -> Vec<ComponentMetadata> {
let mut previews: Vec<ComponentMetadata> = self.previews().into_iter().cloned().collect();
let mut previews: Vec<_> = self.previews().cloned().collect();
previews.sort_by_key(|a| a.name());
previews
}
@ -112,9 +109,9 @@ pub struct ComponentId(pub &'static str);
#[derive(Clone)]
pub struct ComponentMetadata {
id: ComponentId,
description: Option<SharedString>,
description: SharedString,
name: SharedString,
preview: Option<fn(&mut Window, &mut App) -> Option<AnyElement>>,
preview: fn(&mut Window, &mut App) -> AnyElement,
scope: ComponentScope,
sort_name: SharedString,
status: ComponentStatus,
@ -125,7 +122,7 @@ impl ComponentMetadata {
self.id.clone()
}
pub fn description(&self) -> Option<SharedString> {
pub fn description(&self) -> SharedString {
self.description.clone()
}
@ -133,7 +130,7 @@ impl ComponentMetadata {
self.name.clone()
}
pub fn preview(&self) -> Option<fn(&mut Window, &mut App) -> Option<AnyElement>> {
pub fn preview(&self) -> fn(&mut Window, &mut App) -> AnyElement {
self.preview
}
@ -234,17 +231,15 @@ pub trait Component {
/// struct MyComponent;
///
/// impl MyComponent {
/// fn description() -> Option<&'static str> {
/// Some(Self::DOCS)
/// fn description() -> &'static str {
/// Self::DOCS
/// }
/// }
/// ```
///
/// This will result in "This is a doc comment." being passed
/// to the component's description.
fn description() -> Option<&'static str> {
None
}
fn description() -> &'static str;
/// The component's preview.
///
/// An element returned here will be shown in the component's preview.
@ -259,9 +254,7 @@ pub trait Component {
/// This is useful for displaying related UI to the component you are
/// trying to preview, such as a button that opens a modal or shows a
/// tooltip on hover, or a grid of icons showcasing all the icons available.
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
None
}
fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement;
}
/// The ready status of this component.
@ -286,14 +279,17 @@ impl ComponentStatus {
pub fn description(&self) -> &str {
match self {
ComponentStatus::WorkInProgress => {
"These components are still being designed or refined. They shouldn't be used in the app yet."
"These components are still being designed or refined. \
They shouldn't be used in the app yet."
}
ComponentStatus::EngineeringReady => {
"These components are design complete or partially implemented, and are ready for an engineer to complete their implementation."
"These components are design complete or partially implemented, \
and are ready for an engineer to complete their implementation."
}
ComponentStatus::Live => "These components are ready for use in the app.",
ComponentStatus::Deprecated => {
"These components are no longer recommended for use in the app, and may be removed in a future release."
"These components are no longer recommended for use in the app, \
and may be removed in a future release."
}
}
}

View file

@ -12,7 +12,10 @@ use notifications::status_toast::StatusToast;
use persistence::ComponentPreviewDb;
use project::Project;
use std::{iter::Iterator, ops::Range, sync::Arc};
use ui::{ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Tooltip, prelude::*};
use ui::{
ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Scrollbars, Tooltip,
WithScrollbar, prelude::*,
};
use ui_input::InputField;
use workspace::AppState;
use workspace::{
@ -197,10 +200,7 @@ impl ComponentPreview {
.filter(|component| {
let component_name = component.name().to_lowercase();
let scope_name = component.scope().to_string().to_lowercase();
let description = component
.description()
.map(|d| d.to_lowercase())
.unwrap_or_default();
let description = component.description().to_lowercase();
component_name.contains(&filter)
|| scope_name.contains(&filter)
@ -231,7 +231,7 @@ impl ComponentPreview {
// let full_component_name = component.name();
let scopeless_name = component.scopeless_name();
let scope_name = component.scope().to_string();
let description = component.description().unwrap_or_default();
let description = component.description();
let lowercase_scopeless = scopeless_name.to_lowercase();
let lowercase_scope = scope_name.to_lowercase();
@ -445,45 +445,40 @@ impl ComponentPreview {
let description = component.description();
// Build the content container
let mut preview_container = v_flex().py_2().child(
v_flex()
.border_1()
.border_color(cx.theme().colors().border)
.rounded_sm()
.w_full()
.gap_4()
.py_4()
.px_6()
.flex_none()
.child(
v_flex()
.gap_1()
.child(
h_flex()
.gap_1()
.text_xl()
.child(div().child(name))
.when(!matches!(scope, ComponentScope::None), |this| {
this.child(div().opacity(0.5).child(format!("({})", scope)))
}),
)
.when_some(description, |this, description| {
this.child(
v_flex()
.py_2()
.child(
v_flex()
.border_1()
.border_color(cx.theme().colors().border)
.rounded_sm()
.w_full()
.gap_4()
.py_4()
.px_6()
.flex_none()
.child(
v_flex()
.gap_1()
.child(
h_flex().gap_1().text_xl().child(div().child(name)).when(
scope != ComponentScope::None,
|this| {
this.child(div().opacity(0.5).child(format!("({})", scope)))
},
),
)
.child(
div()
.text_ui_sm(cx)
.text_color(cx.theme().colors().text_muted)
.max_w(px(600.0))
.child(description),
)
}),
),
);
if let Some(preview) = component.preview() {
preview_container = preview_container.children(preview(window, cx));
}
preview_container.into_any_element()
),
),
)
.child((component.preview())(window, cx))
.into_any_element()
}
fn render_all_components(&self, cx: &Context<Self>) -> impl IntoElement {
@ -593,6 +588,7 @@ impl Render for ComponentPreview {
}
let sidebar_entries = self.scope_ordered_entries();
let active_page = self.active_page.clone();
let background_color = cx.theme().colors().editor_background;
h_flex()
.id("component-preview")
@ -601,37 +597,45 @@ impl Render for ComponentPreview {
.overflow_hidden()
.size_full()
.track_focus(&self.focus_handle)
.bg(cx.theme().colors().editor_background)
.bg(background_color)
.child(
v_flex()
.h_full()
.border_r_1()
.border_color(cx.theme().colors().border)
.child(
gpui::uniform_list(
"component-nav",
sidebar_entries.len(),
cx.processor(move |this, range: Range<usize>, _window, cx| {
range
.filter_map(|ix| {
if ix < sidebar_entries.len() {
Some(this.render_sidebar_entry(
ix,
&sidebar_entries[ix],
cx,
))
} else {
None
}
})
.collect()
}),
)
.track_scroll(&self.nav_scroll_handle)
.p_2p5()
.w(px(231.)) // Matches perfectly with the size of the "Component Preview" tab, if that's the first one in the pane
.h_full()
.flex_1(),
div()
.size_full()
.child(
gpui::uniform_list(
"component-nav",
sidebar_entries.len(),
cx.processor(move |this, range: Range<usize>, _window, cx| {
range
.filter(|ix| ix < &sidebar_entries.len())
.map(|ix| {
this.render_sidebar_entry(
ix,
&sidebar_entries[ix],
cx,
)
})
.collect()
}),
)
.track_scroll(&self.nav_scroll_handle)
.p_2p5()
.w(px(231.)) // Matches perfectly with the size of the "Component Preview" tab, if that's the first one in the pane
.h_full()
.flex_1(),
)
.custom_scrollbars(
Scrollbars::new(ui::ScrollAxes::Vertical)
.with_track_along(ui::ScrollAxes::Vertical, background_color)
.tracked_scroll_handle(&self.nav_scroll_handle),
window,
cx,
),
)
.child(
div()
@ -961,23 +965,10 @@ impl ComponentPreviewPage {
.children(self.render_component_status(cx)),
),
)
.when_some(self.component.description(), |this, description| {
this.child(Label::new(description).size(LabelSize::Small))
})
.child(Label::new(self.component.description()).size(LabelSize::Small))
}
fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let content = if let Some(preview) = self.component.preview() {
// Fall back to component preview
preview(window, cx).unwrap_or_else(|| {
div()
.child("Failed to load preview. This path should be unreachable")
.into_any_element()
})
} else {
div().child("No preview available").into_any_element()
};
v_flex()
.id(("component-preview", self.reset_key))
.size_full()
@ -985,7 +976,7 @@ impl ComponentPreviewPage {
.px_12()
.py_6()
.bg(cx.theme().colors().editor_background)
.child(content)
.child((self.component.preview())(window, cx))
}
}

View file

@ -511,8 +511,14 @@ impl Copilot {
};
}
if let Ok(oauth_token) = env::var(copilot_chat::COPILOT_OAUTH_ENV_VAR) {
env.insert(copilot_chat::COPILOT_OAUTH_ENV_VAR.to_string(), oauth_token);
for env_var in [
copilot_chat::COPILOT_OAUTH_ENV_VAR,
copilot_chat::GITHUB_COPILOT_OAUTH_ENV_VAR,
] {
if let Ok(oauth_token) = env::var(env_var) {
env.insert(env_var.to_string(), oauth_token);
break;
}
}
if env.is_empty() { None } else { Some(env) }
@ -1259,6 +1265,7 @@ impl Copilot {
| request::SignInStatus::AlreadySignedIn { .. } => {
server.sign_in_status = SignInStatus::Authorized;
cx.emit(Event::CopilotAuthSignedIn);
notify_copilot_chat_auth_changed(cx);
for buffer in self.buffers.iter().cloned().collect::<Vec<_>>() {
if let Some(buffer) = buffer.upgrade() {
self.register_buffer(&buffer, cx);
@ -1278,6 +1285,7 @@ impl Copilot {
};
}
cx.emit(Event::CopilotAuthSignedOut);
notify_copilot_chat_auth_changed(cx);
for buffer in self.buffers.iter().cloned().collect::<Vec<_>>() {
self.unregister_buffer(&buffer);
}
@ -1381,6 +1389,15 @@ fn notify_did_change_config_to_server(
Ok(())
}
/// Notify Copilot Chat after the Copilot LSP reports an auth state change.
/// This replaces watching the SDK's token files, which is unreliable for
/// SQLite backed auth because writes may go through WAL files.
fn notify_copilot_chat_auth_changed(cx: &mut Context<Copilot>) {
if let Some(copilot_chat) = copilot_chat::CopilotChat::global(cx) {
copilot_chat.update(cx, |chat, cx| chat.reload_auth(cx));
}
}
async fn clear_copilot_dir() {
remove_matching(paths::copilot_dir(), |_| true).await
}

View file

@ -34,7 +34,9 @@ paths.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
sqlez.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
serde_json.workspace = true
tempfile.workspace = true

View file

@ -1,6 +1,6 @@
pub mod responses;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::OnceLock;
@ -17,9 +17,10 @@ use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
use paths::home_dir;
use serde::{Deserialize, Serialize};
use settings::watch_config_dir;
// The Copilot language server unofficially supports both token env vars:
// https://github.com/github/copilot-language-server-release/issues/3#issuecomment-2699433055
pub const COPILOT_OAUTH_ENV_VAR: &str = "GH_COPILOT_TOKEN";
pub const GITHUB_COPILOT_OAUTH_ENV_VAR: &str = "GITHUB_COPILOT_TOKEN";
const DEFAULT_COPILOT_API_ENDPOINT: &str = "https://api.githubcopilot.com";
#[derive(Default, Clone, Debug, PartialEq)]
@ -501,6 +502,7 @@ pub struct CopilotChat {
configuration: CopilotChatConfiguration,
models: Option<Vec<Model>>,
client: Arc<dyn HttpClient>,
fs: Arc<dyn Fs>,
}
pub fn init(
@ -529,11 +531,19 @@ pub fn copilot_chat_config_dir() -> &'static PathBuf {
})
}
/// Legacy JSON token-storage paths used by older Copilot SDK builds.
/// TODO(copilot): once Copilot SDK supports `auth.db`, remove these paths.
fn copilot_chat_config_paths() -> [PathBuf; 2] {
let base_dir = copilot_chat_config_dir();
[base_dir.join("hosts.json"), base_dir.join("apps.json")]
}
fn oauth_token_from_env() -> Option<String> {
std::env::var(COPILOT_OAUTH_ENV_VAR)
.ok()
.or_else(|| std::env::var(GITHUB_COPILOT_OAUTH_ENV_VAR).ok())
}
impl CopilotChat {
pub fn global(cx: &App) -> Option<gpui::Entity<Self>> {
cx.try_global::<GlobalCopilotChat>()
@ -546,40 +556,42 @@ impl CopilotChat {
configuration: CopilotChatConfiguration,
cx: &mut Context<Self>,
) -> Self {
let config_paths: HashSet<PathBuf> = copilot_chat_config_paths().into_iter().collect();
let dir_path = copilot_chat_config_dir();
cx.spawn(async move |this, cx| {
let mut parent_watch_rx = watch_config_dir(
cx.background_executor(),
fs.clone(),
dir_path.clone(),
config_paths,
);
while let Some(contents) = parent_watch_rx.next().await {
// Initial async scan of token sources. Live reload is driven by the
// Copilot LSP's auth status notifications instead of watching files,
// because SQLite WAL writes can make directory watchers racy.
cx.spawn({
let fs = fs.clone();
async move |this, cx| {
let oauth_domain =
this.read_with(cx, |this, _| this.configuration.oauth_domain())?;
let oauth_token = extract_oauth_token(contents, &oauth_domain);
let config_paths: HashSet<PathBuf> =
copilot_chat_config_paths().into_iter().collect();
let auth_db_path = copilot_chat_config_dir().join("auth.db");
this.update(cx, |this, cx| {
this.oauth_token = oauth_token.clone();
cx.notify();
})?;
let oauth_token =
read_oauth_token(&fs, &config_paths, &oauth_domain, &auth_db_path, cx).await;
if oauth_token.is_some() {
this.update(cx, |this, cx| {
this.oauth_token = oauth_token;
cx.notify();
})?;
Self::update_models(&this, cx).await?;
}
anyhow::Ok(())
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
// Initial state uses env var because it's cheap. The others do IO, so
// are on the background.
let this = Self {
oauth_token: std::env::var(COPILOT_OAUTH_ENV_VAR).ok(),
oauth_token: oauth_token_from_env(),
api_endpoint: None,
models: None,
configuration,
client,
fs,
};
if this.oauth_token.is_some() {
@ -764,6 +776,39 @@ impl CopilotChat {
.detach();
}
}
pub fn reload_auth(&mut self, cx: &mut Context<Self>) {
let fs = self.fs.clone();
let oauth_domain = self.configuration.oauth_domain();
cx.spawn(async move |this, cx| {
let config_paths: HashSet<PathBuf> = copilot_chat_config_paths().into_iter().collect();
let auth_db_path = copilot_chat_config_dir().join("auth.db");
let new_token =
read_oauth_token(&fs, &config_paths, &oauth_domain, &auth_db_path, cx).await;
let token_present = this.update(cx, |this, cx| {
let changed = this.oauth_token != new_token;
if changed {
this.oauth_token = new_token.clone();
if new_token.is_none() {
// Sign-out: drop derived state so a future sign-in
// re-discovers the endpoint and re-fetches models.
this.api_endpoint = None;
this.models = None;
}
cx.notify();
}
new_token.is_some()
})?;
if token_present {
Self::update_models(&this, cx).await?;
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
}
async fn get_models(
@ -917,6 +962,40 @@ async fn request_models(
Ok(models)
}
async fn read_oauth_token(
fs: &Arc<dyn Fs>,
config_paths: &HashSet<PathBuf>,
oauth_domain: &str,
auth_db_path: &std::path::Path,
cx: &AsyncApp,
) -> Option<String> {
if let Some(token) = oauth_token_from_env() {
return Some(token);
}
let token_from_db = cx
.background_spawn({
let auth_db_path = auth_db_path.to_path_buf();
let oauth_domain = oauth_domain.to_string();
async move { extract_oauth_token_from_db(&auth_db_path, &oauth_domain) }
})
.await;
if let Some(token) = token_from_db {
return Some(token);
}
for file_path in config_paths {
if let Ok(contents) = fs.load(file_path).await {
if let Some(token) = extract_oauth_token(contents, oauth_domain) {
return Some(token);
}
}
}
None
}
fn extract_oauth_token(contents: String, domain: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(&contents)
.map(|v| {
@ -934,6 +1013,36 @@ fn extract_oauth_token(contents: String, domain: &str) -> Option<String> {
.flatten()
}
fn extract_oauth_token_from_db(db_path: &Path, auth_authority: &str) -> Option<String> {
if !db_path.exists() {
return None;
}
let db = sqlez::connection::Connection::open_file(db_path.to_str()?);
let token_bytes: Option<Vec<u8>> = db
.select_row_bound::<&str, Vec<u8>>(
"SELECT token_ciphertext FROM oauth_tokens WHERE auth_authority = ? ORDER BY last_used_at DESC, token_id DESC LIMIT 1",
)
.ok()
.and_then(|mut select| select(auth_authority).ok().flatten());
let token = token_bytes.and_then(|bytes| String::from_utf8(bytes).ok())?;
if token.starts_with("ghu_")
&& token.len() >= 36
&& token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
{
log::debug!("Copilot OAuth token loaded from auth.db");
Some(token)
} else {
log::warn!(
"Copilot auth.db: token does not match expected GitHub OAuth format (ghu_<alphanumeric>)"
);
None
}
}
async fn stream_completion(
client: Arc<dyn HttpClient>,
oauth_token: String,
@ -1751,4 +1860,61 @@ mod tests {
"\"none\""
);
}
#[test]
fn test_extract_oauth_token_from_db_matches_auth_authority_and_recency() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("auth.db");
let older_github_token = "ghu_oldergithubtokenvalue000000000000";
let newer_github_token = "ghu_newergithubtokenvalue000000000000";
let enterprise_token = "ghu_enterprisetokenvalue0000000000000";
let connection = sqlez::connection::Connection::open_file(db_path.to_str().unwrap());
connection
.exec(
"CREATE TABLE oauth_tokens (
token_id INTEGER PRIMARY KEY AUTOINCREMENT,
auth_authority TEXT NOT NULL,
token_ciphertext BLOB NOT NULL,
last_used_at INTEGER NOT NULL
);",
)
.unwrap()()
.unwrap();
{
let mut insert_token = connection
.exec_bound::<(&str, Vec<u8>, i64)>(
"INSERT INTO oauth_tokens (auth_authority, token_ciphertext, last_used_at) VALUES (?, ?, ?);",
)
.unwrap();
insert_token(("github.com", older_github_token.as_bytes().to_vec(), 10)).unwrap();
insert_token((
"github.enterprise.test",
enterprise_token.as_bytes().to_vec(),
30,
))
.unwrap();
insert_token(("github.com", newer_github_token.as_bytes().to_vec(), 20)).unwrap();
}
drop(connection);
assert_eq!(
extract_oauth_token_from_db(&db_path, "github.com").as_deref(),
Some(newer_github_token)
);
assert_eq!(
extract_oauth_token_from_db(&db_path, "github.enterprise.test").as_deref(),
Some(enterprise_token)
);
}
#[test]
fn test_extract_oauth_token_from_db_missing_db_does_not_create_file() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("auth.db");
assert_eq!(extract_oauth_token_from_db(&db_path, "github.com"), None);
assert!(!db_path.exists());
}
}

View file

@ -211,7 +211,7 @@ pub(crate) struct DevContainer {
#[serde(rename = "updateRemoteUserUID")]
pub(crate) update_remote_user_uid: Option<bool>,
user_env_probe: Option<UserEnvProbe>,
override_command: Option<bool>,
pub(crate) override_command: Option<bool>,
shutdown_action: Option<ShutdownAction>,
init: Option<bool>,
pub(crate) privileged: Option<bool>,
@ -232,7 +232,7 @@ pub(crate) struct DevContainer {
#[serde(default, deserialize_with = "deserialize_string_or_array")]
pub(crate) docker_compose_file: Option<Vec<String>>,
pub(crate) service: Option<String>,
run_services: Option<Vec<String>>,
pub(crate) run_services: Option<Vec<String>>,
pub(crate) initialize_command: Option<LifecycleScript>,
pub(crate) on_create_command: Option<LifecycleScript>,
pub(crate) update_content_command: Option<LifecycleScript>,

View file

@ -794,24 +794,30 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
let privileged = dev_container.privileged.unwrap_or(false)
|| self.features.iter().any(|f| f.privileged());
let mut entrypoint_script_lines = vec![
"echo Container started".to_string(),
"trap \"exit 0\" 15".to_string(),
];
let entrypoint_script = if dev_container.override_command == Some(false) {
None
} else {
let mut entrypoint_script_lines = vec![
"echo Container started".to_string(),
"trap \"exit 0\" 15".to_string(),
];
for entrypoint in self.features.iter().filter_map(|f| f.entrypoint()) {
entrypoint_script_lines.push(entrypoint.clone());
}
entrypoint_script_lines.append(&mut vec![
"exec \"$@\"".to_string(),
"while sleep 1 & wait $!; do :; done".to_string(),
]);
for entrypoint in self.features.iter().filter_map(|f| f.entrypoint()) {
entrypoint_script_lines.push(entrypoint.clone());
}
entrypoint_script_lines.append(&mut vec![
"exec \"$@\"".to_string(),
"while sleep 1 & wait $!; do :; done".to_string(),
]);
Some(entrypoint_script_lines.join("\n").trim().to_string())
};
Ok(DockerBuildResources {
image: base_image,
additional_mounts: mounts,
privileged,
entrypoint_script: entrypoint_script_lines.join("\n").trim().to_string(),
entrypoint_script,
})
}
@ -1052,7 +1058,11 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
let project_name = self.project_name().await?;
self.docker_client
.docker_compose_build(&docker_compose_resources.files, &project_name)
.docker_compose_build(
&docker_compose_resources.files,
&project_name,
dev_container.run_services.as_ref(),
)
.await?;
(
self.docker_client
@ -1145,7 +1155,11 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
let project_name = self.project_name().await?;
self.docker_client
.docker_compose_build(&docker_compose_resources.files, &project_name)
.docker_compose_build(
&docker_compose_resources.files,
&project_name,
dev_container.run_services.as_ref(),
)
.await?;
(
@ -1255,13 +1269,17 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
})
.collect();
let mut main_service = DockerComposeService {
entrypoint: Some(vec![
let entrypoint = resources.entrypoint_script.map(|script| {
vec![
"/bin/sh".to_string(),
"-c".to_string(),
resources.entrypoint_script,
script,
"-".to_string(),
]),
]
});
let mut main_service = DockerComposeService {
entrypoint,
cap_add: Some(vec!["SYS_PTRACE".to_string()]),
security_opt: Some(vec!["seccomp=unconfined".to_string()]),
labels: Some(runtime_labels),
@ -1775,6 +1793,9 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
command.args(&["-f", &docker_compose_file.display().to_string()]);
}
command.args(&["up", "-d"]);
if let Some(run_services) = self.dev_container().run_services.as_ref() {
command.args(run_services);
}
let output = self
.command_runner
@ -1977,13 +1998,16 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
command.arg(app_port);
}
command.arg("--entrypoint");
command.arg("/bin/sh");
command.arg(&build_resources.image.id);
command.arg("-c");
command.arg(build_resources.entrypoint_script);
command.arg("-");
if let Some(entrypoint_script) = build_resources.entrypoint_script {
command.arg("--entrypoint");
command.arg("/bin/sh");
command.arg(&build_resources.image.id);
command.arg("-c");
command.arg(entrypoint_script);
command.arg("-");
} else {
command.arg(&build_resources.image.id);
}
Ok(command)
}
@ -2409,7 +2433,7 @@ struct DockerBuildResources {
image: DockerInspect,
additional_mounts: Vec<MountDefinition>,
privileged: bool,
entrypoint_script: String,
entrypoint_script: Option<String>,
}
#[derive(Debug)]
@ -3166,7 +3190,7 @@ mod test {
},
additional_mounts: vec![],
privileged: false,
entrypoint_script: "echo Container started\n trap \"exit 0\" 15\n exec \"$@\"\n while sleep 1 & wait $!; do :; done".to_string(),
entrypoint_script: Some("echo Container started\n trap \"exit 0\" 15\n exec \"$@\"\n while sleep 1 & wait $!; do :; done".to_string()),
};
let docker_run_command = devcontainer_manifest.create_docker_run_command(build_resources);
@ -3212,6 +3236,56 @@ mod test {
)
}
#[gpui::test]
async fn should_not_override_entrypoint_when_override_command_is_false(
cx: &mut TestAppContext,
) {
let (_, mut devcontainer_manifest) = init_default_devcontainer_manifest(
cx,
r#"{
"name": "test",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"overrideCommand": false
}"#,
)
.await
.unwrap();
devcontainer_manifest.parse_nonremote_vars().unwrap();
let base_image = DockerInspect {
id: "mcr.microsoft.com/devcontainers/base:ubuntu".to_string(),
config: DockerInspectConfig {
labels: DockerConfigLabels { metadata: None },
image_user: None,
env: Vec::new(),
},
mounts: None,
state: None,
};
let resources = devcontainer_manifest
.build_merged_resources(base_image)
.unwrap();
assert!(
resources.entrypoint_script.is_none(),
"overrideCommand: false must not produce an entrypoint script"
);
let docker_run_command = devcontainer_manifest
.create_docker_run_command(resources)
.unwrap();
let args: Vec<&OsStr> = docker_run_command.get_args().collect();
assert!(
!args.contains(&OsStr::new("--entrypoint")),
"overrideCommand: false must not pass --entrypoint to docker run"
);
assert!(
args.contains(&OsStr::new("mcr.microsoft.com/devcontainers/base:ubuntu")),
"image id must still be present in docker run command"
);
}
#[gpui::test]
async fn should_find_primary_service_in_docker_compose(cx: &mut TestAppContext) {
// State where service not defined in dev container
@ -4720,6 +4794,111 @@ ENV DOCKER_BUILDKIT=1
);
}
#[gpui::test]
async fn test_spawns_only_requested_compose_services(cx: &mut TestAppContext) {
cx.executor().allow_parking();
env_logger::try_init().ok();
let given_devcontainer_contents = r#"
{
"name": "Devcontainer and PostgreSQL",
"dockerComposeFile": "docker-compose.yml",
"service": "devcontainer",
"runServices": ["devcontainer", "db"],
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"updateRemoteUserUID": false
}
"#;
let (test_dependencies, mut devcontainer_manifest) =
init_default_devcontainer_manifest(cx, given_devcontainer_contents)
.await
.unwrap();
test_dependencies
.fs
.atomic_write(
PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"),
r#"
version: '3.8'
x-base: &base
build:
context: .
dockerfile: Dockerfile
env_file:
- .env
volumes:
postgres-data:
services:
app:
<<: *base
ports:
- "3000:3000"
devcontainer:
<<: *base
ports:
- "3000:3000"
volumes:
- ../..:/workspaces:cached
db:
image: postgres:14.1
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
env_file:
- .env
"#
.trim()
.to_string(),
)
.await
.unwrap();
test_dependencies
.fs
.atomic_write(
PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
r#"
FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install clang lld \
&& apt-get autoremove -y && apt-get clean -y
"#
.trim()
.to_string(),
)
.await
.unwrap();
devcontainer_manifest.parse_nonremote_vars().unwrap();
let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
let docker_commands = test_dependencies
.command_runner
.commands_by_program("docker");
let compose_up = docker_commands
.iter()
.find(|c| {
c.args.first().map(String::as_str) == Some("compose")
&& c.args.iter().any(|a| a == "up")
})
.expect("docker compose up command recorded");
assert!(
compose_up.args.ends_with(&[
"up".to_string(),
"-d".to_string(),
"devcontainer".to_string(),
"db".to_string(),
]),
"compose up should target only the requested service, got: {:?}",
compose_up.args
);
}
#[cfg(not(target_os = "windows"))]
#[gpui::test]
async fn test_spawns_devcontainer_with_docker_compose_and_podman(cx: &mut TestAppContext) {
@ -6004,6 +6183,19 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de
return Ok(Some(DockerComposeConfig {
name: None,
services: HashMap::from([
(
"devcontainer".to_string(),
DockerComposeService {
image: Some("test_image:latest".to_string()),
volumes: vec![MountDefinition {
source: Some("../..".to_string()),
target: "/workspaces".to_string(),
mount_type: Some("bind".to_string()),
}],
command: vec!["sleep".to_string(), "infinity".to_string()],
..Default::default()
},
),
(
"app".to_string(),
DockerComposeService {
@ -6130,6 +6322,7 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de
&self,
_config_files: &Vec<PathBuf>,
_project_name: &str,
_services: Option<&Vec<String>>,
) -> Result<(), DevContainerError> {
Ok(())
}

View file

@ -291,6 +291,7 @@ impl DockerClient for Docker {
&self,
config_files: &Vec<PathBuf>,
project_name: &str,
services: Option<&Vec<String>>,
) -> Result<(), DevContainerError> {
let mut command = Command::new(&self.docker_cli);
if !self.is_podman() {
@ -301,6 +302,9 @@ impl DockerClient for Docker {
command.args(&["-f", &docker_compose_file.display().to_string()]);
}
command.arg("build");
if let Some(services) = services {
command.args(services);
}
let output = command.output().await.map_err(|e| {
log::error!("Error running docker compose up: {e}");
@ -457,6 +461,7 @@ pub(crate) trait DockerClient {
&self,
config_files: &Vec<PathBuf>,
project_name: &str,
services: Option<&Vec<String>>,
) -> Result<(), DevContainerError>;
async fn run_docker_exec(
&self,

View file

@ -1550,6 +1550,8 @@ async fn go_to_diagnostic_with_severity(cx: &mut TestAppContext) {
// Default, should cycle through all diagnostics
go!(GoToDiagnosticSeverityFilter::default());
cx.assert_editor_state(indoc! {"error warning info ˇhint"});
go!(GoToDiagnosticSeverityFilter::default());
cx.assert_editor_state(indoc! {"ˇerror warning info hint"});
go!(GoToDiagnosticSeverityFilter::default());
cx.assert_editor_state(indoc! {"error ˇwarning info hint"});

View file

@ -64,13 +64,19 @@ impl Render for DiagnosticIndicator {
.message
.split_once('\n')
.map_or(&*diagnostic.message, |(first, _)| first);
let diagnostics_already_active = self.any_active_diagnostics(cx);
let tooltip = if !diagnostics_already_active {
"Expand Diagnostics"
} else {
"Next Diagnostic"
};
Some(
Button::new("diagnostic_message", SharedString::new(message))
.label_size(LabelSize::Small)
.truncate(true)
.tooltip(|_window, cx| {
.tooltip(move |_window, cx| {
Tooltip::for_action(
"Next Diagnostic",
tooltip,
&editor::actions::GoToDiagnostic::default(),
cx,
)
@ -154,10 +160,18 @@ impl DiagnosticIndicator {
}
}
fn any_active_diagnostics(&self, cx: &mut Context<Self>) -> bool {
if let Some(editor) = self.active_editor.as_ref().and_then(|e| e.upgrade()) {
editor.read(cx).any_active_diagnostics()
} else {
false
}
}
fn go_to_next_diagnostic(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(editor) = self.active_editor.as_ref().and_then(|e| e.upgrade()) {
editor.update(cx, |editor, cx| {
editor.go_to_diagnostic_impl(
editor.go_to_diagnostic_at_cursor(
editor::Direction::Next,
GoToDiagnosticSeverityFilter::default(),
window,

View file

@ -38,9 +38,9 @@ use gpui::{
};
use heapless::Vec as ArrayVec;
use language::{
Anchor, Buffer, BufferSnapshot, EditPredictionPromptFormat, EditPredictionsMode, EditPreview,
File, OffsetRangeExt, Point, TextBufferSnapshot, ToOffset, ToPoint,
language_settings::all_language_settings,
Anchor, Buffer, BufferEditSource, BufferSnapshot, EditPredictionPromptFormat,
EditPredictionsMode, EditPreview, File, OffsetRangeExt, Point, TextBufferSnapshot, ToOffset,
ToPoint, language_settings::all_language_settings,
};
use project::{DisableAiSettings, Project, ProjectPath, WorktreeId};
use release_channel::AppVersion;
@ -324,6 +324,7 @@ struct ProjectState {
recent_paths: VecDeque<ProjectPath>,
registered_buffers: HashMap<gpui::EntityId, RegisteredBuffer>,
current_prediction: Option<CurrentEditPrediction>,
last_edit_source: Option<BufferEditSource>,
next_pending_prediction_id: usize,
pending_predictions: ArrayVec<PendingPrediction, 2, u8>,
debug_tx: Option<mpsc::UnboundedSender<DebugEvent>>,
@ -1212,6 +1213,7 @@ impl EditPredictionStore {
debug_tx: None,
registered_buffers: HashMap::default(),
current_prediction: None,
last_edit_source: None,
cancelled_predictions: HashSet::default(),
pending_predictions: ArrayVec::new(),
next_pending_prediction_id: 0,
@ -1315,6 +1317,9 @@ impl EditPredictionStore {
}
// TODO [zeta2] init with recent paths
match event {
project::Event::BufferEdited { source } => {
self.get_or_init_project(&project, cx).last_edit_source = Some(*source);
}
project::Event::ActiveEntryChanged(Some(active_entry_id)) => {
let Some(project_state) = self.projects.get_mut(&project.entity_id()) else {
return;
@ -1332,6 +1337,15 @@ impl EditPredictionStore {
}
}
project::Event::DiagnosticsUpdated { .. } => {
if self
.projects
.get(&project.entity_id())
.and_then(|project_state| project_state.last_edit_source)
== Some(BufferEditSource::Agent)
{
return;
}
if cx.has_flag::<EditPredictionJumpsFeatureFlag>() {
self.refresh_prediction_from_diagnostics(
project,
@ -1391,11 +1405,17 @@ impl EditPredictionStore {
cx.subscribe(buffer, {
let project = project.downgrade();
move |this, buffer, event, cx| {
if let language::BufferEvent::Edited { is_local } = event
if let language::BufferEvent::Edited { source } = event
&& let Some(project) = project.upgrade()
{
let project_state = this.get_or_init_project(&project, cx);
project_state.last_edit_source = Some(*source);
this.report_changes_for_buffer(
&buffer, &project, false, *is_local, cx,
&buffer,
&project,
false,
source.is_local(),
cx,
);
}
}
@ -2530,6 +2550,15 @@ impl EditPredictionStore {
allow_jump: bool,
cx: &mut Context<Self>,
) -> Task<Result<Option<EditPredictionResult>>> {
let is_cloud_zeta = matches!(self.edit_prediction_model, EditPredictionModel::Zeta)
&& !matches!(
all_language_settings(None, cx).edit_predictions.provider,
EditPredictionProvider::Ollama | EditPredictionProvider::OpenAiCompatibleApi
);
if is_cloud_zeta && !self.client.cloud_client().has_credentials() {
return Task::ready(Ok(None));
}
self.get_or_init_project(&project, cx);
let project_state = self.projects.get(&project.entity_id()).unwrap();
let stored_events = project_state.events(cx);
@ -2551,11 +2580,24 @@ impl EditPredictionStore {
EditPredictionsMode::Subtle => PredictEditsMode::Subtle,
};
let is_open_source = snapshot
.file()
.map_or(false, |file| self.is_file_open_source(&project, file, cx))
&& events.iter().all(|event| event.in_open_source_repo())
&& related_files.iter().all(|file| file.in_open_source_repo);
let buffer_id = active_buffer.read(cx).remote_id();
let repo_url = project
.read(cx)
.git_store()
.read(cx)
.repository_and_path_for_buffer_id(buffer_id, cx)
.and_then(|(repo, _)| repo.read(cx).default_remote_url());
let is_staff_zed_repo = cx.is_staff()
&& repo_url
.as_ref()
.is_some_and(|url| is_zed_industries_repo(url));
let is_open_source = is_staff_zed_repo
|| (snapshot
.file()
.map_or(false, |file| self.is_file_open_source(&project, file, cx))
&& events.iter().all(|event| event.in_open_source_repo())
&& related_files.iter().all(|file| file.in_open_source_repo));
let can_collect_data = !cfg!(test)
&& is_open_source
@ -2594,7 +2636,7 @@ impl EditPredictionStore {
)
});
zeta::request_prediction_with_zeta(self, inputs, capture_events, cx)
zeta::request_prediction_with_zeta(self, inputs, capture_events, repo_url, cx)
}
EditPredictionModel::Fim { format } => fim::request_prediction(inputs, format, cx),
EditPredictionModel::Mercury => {
@ -3286,3 +3328,11 @@ pub fn init(cx: &mut App) {
})
.detach();
}
fn is_zed_industries_repo(url: &str) -> bool {
url.strip_prefix("https://github.com/zed-industries/")
.or_else(|| url.strip_prefix("http://github.com/zed-industries/"))
.or_else(|| url.strip_prefix("git@github.com:zed-industries/"))
.or_else(|| url.strip_prefix("ssh://git@github.com/zed-industries/"))
.is_some_and(|repo| !repo.is_empty())
}

View file

@ -26,8 +26,8 @@ use gpui::{
};
use indoc::indoc;
use language::{
Anchor, Buffer, Capability, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet,
DiagnosticSeverity, Operation, Point, Selection, SelectionGoal,
Anchor, Buffer, BufferEditSource, Capability, CursorShape, Diagnostic, DiagnosticEntry,
DiagnosticSet, DiagnosticSeverity, Operation, Point, Selection, SelectionGoal,
};
use lsp::LanguageServerId;
@ -352,6 +352,70 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon
});
}
#[gpui::test]
async fn test_diagnostics_refresh_suppressed_after_agent_edit(cx: &mut TestAppContext) {
let (ep_store, mut requests) = init_test_with_fake_client(cx);
cx.update(|cx| {
cx.update_flags(
false,
vec![EditPredictionJumpsFeatureFlag::NAME.to_string()],
);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"1.txt": "Hello!\nHow\nBye\n",
"2.txt": "Hola!\nComo\nAdios\n"
}),
)
.await;
let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await;
let buffer = project
.update(cx, |project, cx| {
let path = project.find_project_path(path!("root/1.txt"), cx).unwrap();
project.set_active_path(Some(path.clone()), cx);
project.open_buffer(path, cx)
})
.await
.unwrap();
ep_store.update(cx, |ep_store, cx| {
ep_store.register_project(&project, cx);
ep_store.register_buffer(&buffer, &project, cx);
});
buffer.update(cx, |buffer, cx| {
buffer.start_transaction();
buffer.edit([(Point::new(1, 3)..Point::new(1, 3), "!")], None, cx);
buffer.end_transaction_with_source(BufferEditSource::Agent, cx);
});
cx.run_until_parked();
update_test_diagnostics(&project, path!("/root/2.txt"), "Sentence is incomplete", cx);
cx.run_until_parked();
assert_no_predict_request_ready(&mut requests.predict);
buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(1, 4)..Point::new(1, 4), "?")], None, cx);
});
cx.run_until_parked();
update_test_diagnostics(
&project,
path!("/root/2.txt"),
"Sentence is still incomplete",
cx,
);
let (_request, respond_tx) = requests.predict.next().await.unwrap();
respond_tx.send(empty_response()).unwrap();
cx.run_until_parked();
}
#[gpui::test]
async fn test_simple_request(cx: &mut TestAppContext) {
let (ep_store, mut requests) = init_test_with_fake_client(cx);
@ -2498,6 +2562,39 @@ fn assert_no_predict_request_ready(
}
}
fn update_test_diagnostics(
project: &Entity<Project>,
path: &str,
message: &str,
cx: &mut TestAppContext,
) {
let diagnostic = lsp::Diagnostic {
range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)),
severity: Some(lsp::DiagnosticSeverity::ERROR),
message: message.to_string(),
..Default::default()
};
project.update(cx, |project, cx| {
project.lsp_store().update(cx, |lsp_store, cx| {
lsp_store
.update_diagnostics(
LanguageServerId(0),
lsp::PublishDiagnosticsParams {
uri: lsp::Uri::from_file_path(path).unwrap(),
diagnostics: vec![diagnostic],
version: None,
},
None,
language::DiagnosticSourceKind::Pushed,
&[],
cx,
)
.unwrap();
});
});
}
struct RequestChannels {
predict: mpsc::UnboundedReceiver<(
PredictEditsV3Request,
@ -3107,11 +3204,18 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let http_client = FakeHttpClient::create(|_req| async move {
Ok(gpui::http_client::Response::builder()
.status(401)
.body("Unauthorized".into())
.unwrap())
let request_count = Arc::new(std::sync::atomic::AtomicUsize::default());
let http_client = FakeHttpClient::create({
let request_count = request_count.clone();
move |_req| {
request_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async move {
Ok(gpui::http_client::Response::builder()
.status(401)
.body("Unauthorized".into())
.unwrap())
}
}
});
let client =
@ -3144,11 +3248,8 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut
ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx)
});
let result = completion_task.await;
assert!(
result.is_err(),
"Without authentication and without custom URL, prediction should fail"
);
assert!(completion_task.await.unwrap().is_none());
assert_eq!(request_count.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[gpui::test]

View file

@ -53,6 +53,7 @@ pub fn request_prediction_with_zeta(
Vec<crate::StoredEvent>,
Task<Result<collections::HashMap<Arc<Path>, Entity<BufferDiff>>>>,
)>,
repo_url: Option<String>,
cx: &mut Context<EditPredictionStore>,
) -> Task<Result<Option<EditPredictionResult>>> {
let settings = &all_language_settings(None, cx).edit_predictions;
@ -73,17 +74,7 @@ pub fn request_prediction_with_zeta(
let excerpt_path = buffer_path_with_id_fallback(snapshot.file(), &snapshot.text, cx);
let repo_url = if can_collect_data {
let buffer_id = buffer.read(cx).remote_id();
project
.read(cx)
.git_store()
.read(cx)
.repository_and_path_for_buffer_id(buffer_id, cx)
.and_then(|(repo, _)| repo.read(cx).default_remote_url())
} else {
None
};
let repo_url = repo_url.filter(|_| can_collect_data);
let client = store.client.clone();
let llm_token = store.llm_token.clone();
let organization_id = store

View file

@ -323,7 +323,8 @@ pub struct SplitSelectionIntoLines {
pub keep_selections: bool,
}
/// Goes to the next diagnostic in the file.
/// Expands the diagnostic under the cursor, if any, in case diagnostics are not
/// yet active. Otherwise, goes to the next diagnostic in the file.
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
#[action(namespace = editor)]
#[serde(deny_unknown_fields)]
@ -332,7 +333,8 @@ pub struct GoToDiagnostic {
pub severity: GoToDiagnosticSeverityFilter,
}
/// Goes to the previous diagnostic in the file.
/// Expands the diagnostic under the cursor, if any, in case diagnostics are not
/// yet active. Otherwise, goes to the previous diagnostic in the file.
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
#[action(namespace = editor)]
#[serde(deny_unknown_fields)]

View file

@ -77,7 +77,8 @@ impl Editor {
if !self.diagnostics_enabled() {
return;
}
self.go_to_diagnostic_impl(Direction::Next, action.severity, window, cx)
self.go_to_diagnostic_at_cursor(Direction::Next, action.severity, window, cx);
}
pub fn go_to_prev_diagnostic(
@ -89,10 +90,43 @@ impl Editor {
if !self.diagnostics_enabled() {
return;
}
self.go_to_diagnostic_impl(Direction::Prev, action.severity, window, cx)
self.go_to_diagnostic_at_cursor(Direction::Prev, action.severity, window, cx);
}
pub fn go_to_diagnostic_impl(
fn diagnostics_before_cursor<'a>(
buffer: &'a MultiBufferSnapshot,
cursor: MultiBufferOffset,
severity: GoToDiagnosticSeverityFilter,
) -> impl Iterator<Item = DiagnosticEntryRef<'a, MultiBufferOffset>> {
buffer
.diagnostics_in_range(MultiBufferOffset(0)..cursor)
.filter(move |entry| entry.range.start <= cursor)
.filter(move |entry| severity.matches(entry.diagnostic.severity))
.filter(|entry| entry.range.start != entry.range.end)
.filter(|entry| !entry.diagnostic.is_unnecessary)
}
fn diagnostics_after_cursor<'a>(
buffer: &'a MultiBufferSnapshot,
cursor: MultiBufferOffset,
severity: GoToDiagnosticSeverityFilter,
) -> impl Iterator<Item = DiagnosticEntryRef<'a, MultiBufferOffset>> {
buffer
.diagnostics_in_range(cursor..buffer.len())
.filter(move |entry| entry.range.start >= cursor)
.filter(move |entry| severity.matches(entry.diagnostic.severity))
.filter(|entry| entry.range.start != entry.range.end)
.filter(|entry| !entry.diagnostic.is_unnecessary)
}
/// Attempts to expand the diagnostic at the current cursor position,
/// updating the cursor position to the diagnostic's start point.
///
/// In case there's no diagnostic at the current cursor position, this will
/// fallback to finding the next or previous diagnostic instead, depending
/// on the provided `direction`.
pub fn go_to_diagnostic_at_cursor(
&mut self,
direction: Direction,
severity: GoToDiagnosticSeverityFilter,
@ -104,6 +138,71 @@ impl Editor {
.selections
.newest::<MultiBufferOffset>(&self.display_snapshot(cx));
let before = Self::diagnostics_before_cursor(&buffer, selection.start, severity);
let after = Self::diagnostics_after_cursor(&buffer, selection.start, severity);
let active_group_id = match &self.active_diagnostics {
ActiveDiagnostic::Group(group) => Some(group.group_id),
_ => None,
};
let mut cursor_on_active = false;
let mut target = None;
for diagnostic in after.chain(before) {
let contains_cursor = diagnostic.range.contains(&selection.start)
|| diagnostic.range.end == selection.head();
if !contains_cursor {
continue;
}
if active_group_id == Some(diagnostic.diagnostic.group_id) {
cursor_on_active = true;
} else if target.is_none() {
target = Some(diagnostic);
}
}
match (target, cursor_on_active) {
(Some(diagnostic), false) => self.activate_diagnostic(&buffer, diagnostic, window, cx),
_ => self.go_to_diagnostic_in_direction(
&buffer, &selection, direction, severity, window, cx,
),
}
}
fn activate_diagnostic(
&mut self,
buffer: &MultiBufferSnapshot,
diagnostic: DiagnosticEntryRef<MultiBufferOffset>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let diagnostic_start = buffer.anchor_after(diagnostic.range.start);
let Some((buffer_anchor, _)) = buffer.anchor_to_buffer_anchor(diagnostic_start) else {
return;
};
let buffer_id = buffer_anchor.buffer_id;
let snapshot = self.snapshot(window, cx);
if snapshot.intersects_fold(diagnostic.range.start) {
self.unfold_ranges(std::slice::from_ref(&diagnostic.range), true, false, cx);
}
self.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(vec![diagnostic.range.start..diagnostic.range.start])
});
self.activate_diagnostics(buffer_id, diagnostic, window, cx);
self.refresh_edit_prediction(false, true, window, cx);
}
pub fn go_to_diagnostic_in_direction(
&mut self,
buffer: &MultiBufferSnapshot,
selection: &Selection<MultiBufferOffset>,
direction: Direction,
severity: GoToDiagnosticSeverityFilter,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut active_group_id = None;
if let ActiveDiagnostic::Group(active_group) = &self.active_diagnostics
&& active_group.active_range.start.to_offset(&buffer) == selection.start
@ -111,28 +210,8 @@ impl Editor {
active_group_id = Some(active_group.group_id);
}
fn filtered<'a>(
severity: GoToDiagnosticSeverityFilter,
diagnostics: impl Iterator<Item = DiagnosticEntryRef<'a, MultiBufferOffset>>,
) -> impl Iterator<Item = DiagnosticEntryRef<'a, MultiBufferOffset>> {
diagnostics
.filter(move |entry| severity.matches(entry.diagnostic.severity))
.filter(|entry| entry.range.start != entry.range.end)
.filter(|entry| !entry.diagnostic.is_unnecessary)
}
let before = filtered(
severity,
buffer
.diagnostics_in_range(MultiBufferOffset(0)..selection.start)
.filter(|entry| entry.range.start <= selection.start),
);
let after = filtered(
severity,
buffer
.diagnostics_in_range(selection.start..buffer.len())
.filter(|entry| entry.range.start >= selection.start),
);
let before = Self::diagnostics_before_cursor(&buffer, selection.start, severity);
let after = Self::diagnostics_after_cursor(&buffer, selection.start, severity);
let mut found: Option<DiagnosticEntryRef<MultiBufferOffset>> = None;
if direction == Direction::Prev {
@ -158,31 +237,12 @@ impl Editor {
}
}
}
let Some(next_diagnostic) = found else {
return;
};
let next_diagnostic_start = buffer.anchor_after(next_diagnostic.range.start);
let Some((buffer_anchor, _)) = buffer.anchor_to_buffer_anchor(next_diagnostic_start) else {
return;
};
let buffer_id = buffer_anchor.buffer_id;
let snapshot = self.snapshot(window, cx);
if snapshot.intersects_fold(next_diagnostic.range.start) {
self.unfold_ranges(
std::slice::from_ref(&next_diagnostic.range),
true,
false,
cx,
);
}
self.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(vec![
next_diagnostic.range.start..next_diagnostic.range.start,
])
});
self.activate_diagnostics(buffer_id, next_diagnostic, window, cx);
self.refresh_edit_prediction(false, true, window, cx);
self.activate_diagnostic(&buffer, next_diagnostic, window, cx);
}
#[cfg(any(test, feature = "test-support"))]
@ -324,32 +384,37 @@ impl Editor {
return;
}
self.dismiss_diagnostics(cx);
let snapshot = self.snapshot(window, cx);
let buffer = self.buffer.read(cx).snapshot(cx);
let Some(renderer) = GlobalDiagnosticRenderer::global(cx) else {
return;
let blocks = if let Some(renderer) = GlobalDiagnosticRenderer::global(cx) {
let snapshot = self.snapshot(window, cx);
let diagnostic_group = buffer
.diagnostic_group(buffer_id, diagnostic.diagnostic.group_id)
.collect::<Vec<_>>();
let language_registry = self
.project()
.map(|project| project.read(cx).languages().clone());
let blocks = renderer.render_group(
diagnostic_group,
buffer_id,
snapshot,
cx.weak_entity(),
language_registry,
cx,
);
self.display_map.update(cx, |display_map, cx| {
display_map.insert_blocks(blocks, cx).into_iter().collect()
})
} else {
// Ensure that, even if there's no global renderer set, we still use
// an empty set of blocks, such that we can record the active group
// below instead of bailing out.
HashSet::default()
};
let diagnostic_group = buffer
.diagnostic_group(buffer_id, diagnostic.diagnostic.group_id)
.collect::<Vec<_>>();
let language_registry = self
.project()
.map(|project| project.read(cx).languages().clone());
let blocks = renderer.render_group(
diagnostic_group,
buffer_id,
snapshot,
cx.weak_entity(),
language_registry,
cx,
);
let blocks = self.display_map.update(cx, |display_map, cx| {
display_map.insert_blocks(blocks, cx).into_iter().collect()
});
self.active_diagnostics = ActiveDiagnostic::Group(ActiveDiagnosticGroup {
active_range: buffer.anchor_before(diagnostic.range.start)
..buffer.anchor_after(diagnostic.range.end),
@ -516,4 +581,12 @@ impl Editor {
self.scrollbar_marker_state.dirty = true;
cx.notify();
}
pub fn any_active_diagnostics(&self) -> bool {
match &self.active_diagnostics {
ActiveDiagnostic::None => false,
ActiveDiagnostic::All => true,
ActiveDiagnostic::Group(_) => true,
}
}
}

View file

@ -9231,7 +9231,7 @@ impl Editor {
match event {
multi_buffer::Event::Edited {
edited_buffer,
is_local,
source,
} => {
self.scrollbar_marker_state.dirty = true;
self.active_indent_guides_state.dirty = true;
@ -9242,7 +9242,7 @@ impl Editor {
self.refresh_matching_bracket_highlights(&snapshot, cx);
self.refresh_outline_symbols_at_cursor(cx);
self.refresh_sticky_headers(&snapshot, cx);
if *is_local && self.has_active_edit_prediction() {
if source.is_local() && self.has_active_edit_prediction() {
self.update_visible_edit_prediction(window, cx);
}

View file

@ -20506,6 +20506,130 @@ async fn go_to_prev_overlapping_diagnostic(executor: BackgroundExecutor, cx: &mu
"});
}
#[gpui::test]
async fn go_to_diagnostic(executor: BackgroundExecutor, cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let lsp_store =
cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store());
// Place the cursor inside the `def` diagnostic (`[12, 15)`) before any
// diagnostic is active so we can later confirm that running `editor: go to
// diagnostic` will activate this diagnostic instead of advancing to the
// next one.
cx.set_state(indoc! {"
fn func(abc dˇef: i32) -> u32 {
}
"});
// Set up the diagnostics:
//
// * `[11, 12)` (the space before `def`),
// * `[12, 15)` (`def`),
// * `[25, 28)` (`u32`).
cx.update(|_, cx| {
lsp_store.update(cx, |lsp_store, cx| {
lsp_store
.update_diagnostics(
LanguageServerId(0),
lsp::PublishDiagnosticsParams {
uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(),
version: None,
diagnostics: vec![
lsp::Diagnostic {
range: lsp::Range::new(
lsp::Position::new(0, 11),
lsp::Position::new(0, 12),
),
severity: Some(lsp::DiagnosticSeverity::ERROR),
..Default::default()
},
lsp::Diagnostic {
range: lsp::Range::new(
lsp::Position::new(0, 12),
lsp::Position::new(0, 15),
),
severity: Some(lsp::DiagnosticSeverity::ERROR),
..Default::default()
},
lsp::Diagnostic {
range: lsp::Range::new(
lsp::Position::new(0, 25),
lsp::Position::new(0, 28),
),
severity: Some(lsp::DiagnosticSeverity::ERROR),
..Default::default()
},
],
},
None,
DiagnosticSourceKind::Pushed,
&[],
cx,
)
.unwrap()
});
});
executor.run_until_parked();
// When the cursor is at an inactive diagnostic, cursor should be moved to
// the start of that same diagnostic and activate it.
cx.update_editor(|editor, window, cx| {
editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx);
});
cx.assert_editor_state(indoc! {"
fn func(abc ˇdef: i32) -> u32 {
}
"});
cx.update_editor(|editor, window, cx| {
editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx);
});
cx.assert_editor_state(indoc! {"
fn func(abc def: i32) -> ˇu32 {
}
"});
cx.update_editor(|editor, window, cx| {
editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx);
});
cx.assert_editor_state(indoc! {"
fn func(abcˇ def: i32) -> u32 {
}
"});
// Manually move the cursor to a different, not yet active diagnostic to
// confirm that using `editor: go to diagnostic` will now activate this one.
cx.update_editor(|editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges([Point::new(0, 26)..Point::new(0, 26)])
});
});
cx.update_editor(|editor, window, cx| {
editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx);
});
cx.assert_editor_state(indoc! {"
fn func(abc def: i32) -> ˇu32 {
}
"});
cx.update_editor(|editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges([Point::new(0, 0)..Point::new(0, 0)])
});
});
cx.update_editor(|editor, window, cx| {
editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx);
});
cx.assert_editor_state(indoc! {"
fn func(abcˇ def: i32) -> u32 {
}
"});
}
#[gpui::test]
async fn test_go_to_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext) {
init_test(cx, |_| {});

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

@ -1088,6 +1088,7 @@ impl InfoPopover {
.track_scroll(&self.scroll_handle)
.child(
MarkdownElement::new(markdown, hover_markdown_style(window, cx))
.scroll_handle(self.scroll_handle.clone())
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,

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

@ -238,7 +238,11 @@ impl BlameRenderer for GitBlameRenderer {
let message = details
.as_ref()
.map(|_| MarkdownElement::new(markdown.clone(), markdown_style).into_any())
.map(|_| {
MarkdownElement::new(markdown.clone(), markdown_style)
.scroll_handle(scroll_handle.clone())
.into_any()
})
.unwrap_or("<no commit message>".into_any());
let pull_request = details

View file

@ -258,7 +258,11 @@ impl Render for CommitTooltip {
.commit
.message
.as_ref()
.map(|_| MarkdownElement::new(self.markdown.clone(), markdown_style).into_any())
.map(|_| {
MarkdownElement::new(self.markdown.clone(), markdown_style)
.scroll_handle(self.scroll_handle.clone())
.into_any()
})
.unwrap_or("<no commit message>".into_any());
let pull_request = self

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))
@ -7147,7 +7090,11 @@ impl Component for PanelRepoFooter {
ComponentScope::VersionControl
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
fn description() -> &'static str {
"The footer shown at the bottom of the git panel."
}
fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
let unknown_upstream = None;
let no_remote_upstream = Some(UpstreamTracking::Gone);
let ahead_of_upstream = Some(
@ -7221,177 +7168,176 @@ impl Component for PanelRepoFooter {
}
let example_width = px(340.);
Some(
v_flex()
.gap_6()
.w_full()
.flex_none()
.children(vec![
example_group_with_title(
"Action Button States",
vec![
single_example(
"No Branch",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(active_repository(1), None))
.into_any_element(),
),
single_example(
"Remote status unknown",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
active_repository(2),
Some(branch(unknown_upstream)),
))
.into_any_element(),
),
single_example(
"No Remote Upstream",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
active_repository(3),
Some(branch(no_remote_upstream)),
))
.into_any_element(),
),
single_example(
"Not Ahead or Behind",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
active_repository(4),
Some(branch(not_ahead_or_behind_upstream)),
))
.into_any_element(),
),
single_example(
"Behind remote",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
active_repository(5),
Some(branch(behind_upstream)),
))
.into_any_element(),
),
single_example(
"Ahead of remote",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
active_repository(6),
Some(branch(ahead_of_upstream)),
))
.into_any_element(),
),
single_example(
"Ahead and behind remote",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
active_repository(7),
Some(branch(ahead_and_behind_upstream)),
))
.into_any_element(),
),
],
)
.grow()
.vertical(),
])
.children(vec![
example_group_with_title(
"Labels",
vec![
single_example(
"Short Branch & Repo",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
SharedString::from("zed"),
Some(custom("main", behind_upstream)),
))
.into_any_element(),
),
single_example(
"Long Branch",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
SharedString::from("zed"),
Some(custom(
"redesign-and-update-git-ui-list-entry-style",
behind_upstream,
)),
))
.into_any_element(),
),
single_example(
"Long Repo",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
SharedString::from("zed-industries-community-examples"),
Some(custom("gpui", ahead_of_upstream)),
))
.into_any_element(),
),
single_example(
"Long Repo & Branch",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
SharedString::from("zed-industries-community-examples"),
Some(custom(
"redesign-and-update-git-ui-list-entry-style",
behind_upstream,
)),
))
.into_any_element(),
),
single_example(
"Uppercase Repo",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
SharedString::from("LICENSES"),
Some(custom("main", ahead_of_upstream)),
))
.into_any_element(),
),
single_example(
"Uppercase Branch",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
SharedString::from("zed"),
Some(custom("update-README", behind_upstream)),
))
.into_any_element(),
),
],
)
.grow()
.vertical(),
])
.into_any_element(),
)
v_flex()
.gap_6()
.w_full()
.flex_none()
.children(vec![
example_group_with_title(
"Action Button States",
vec![
single_example(
"No Branch",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(active_repository(1), None))
.into_any_element(),
),
single_example(
"Remote status unknown",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
active_repository(2),
Some(branch(unknown_upstream)),
))
.into_any_element(),
),
single_example(
"No Remote Upstream",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
active_repository(3),
Some(branch(no_remote_upstream)),
))
.into_any_element(),
),
single_example(
"Not Ahead or Behind",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
active_repository(4),
Some(branch(not_ahead_or_behind_upstream)),
))
.into_any_element(),
),
single_example(
"Behind remote",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
active_repository(5),
Some(branch(behind_upstream)),
))
.into_any_element(),
),
single_example(
"Ahead of remote",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
active_repository(6),
Some(branch(ahead_of_upstream)),
))
.into_any_element(),
),
single_example(
"Ahead and behind remote",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
active_repository(7),
Some(branch(ahead_and_behind_upstream)),
))
.into_any_element(),
),
],
)
.grow()
.vertical(),
])
.children(vec![
example_group_with_title(
"Labels",
vec![
single_example(
"Short Branch & Repo",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
SharedString::from("zed"),
Some(custom("main", behind_upstream)),
))
.into_any_element(),
),
single_example(
"Long Branch",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
SharedString::from("zed"),
Some(custom(
"redesign-and-update-git-ui-list-entry-style",
behind_upstream,
)),
))
.into_any_element(),
),
single_example(
"Long Repo",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
SharedString::from("zed-industries-community-examples"),
Some(custom("gpui", ahead_of_upstream)),
))
.into_any_element(),
),
single_example(
"Long Repo & Branch",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
SharedString::from("zed-industries-community-examples"),
Some(custom(
"redesign-and-update-git-ui-list-entry-style",
behind_upstream,
)),
))
.into_any_element(),
),
single_example(
"Uppercase Repo",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
SharedString::from("LICENSES"),
Some(custom("main", ahead_of_upstream)),
))
.into_any_element(),
),
single_example(
"Uppercase Branch",
div()
.w(example_width)
.overflow_hidden()
.child(PanelRepoFooter::new_preview(
SharedString::from("zed"),
Some(custom("update-README", behind_upstream)),
))
.into_any_element(),
),
],
)
.grow()
.vertical(),
])
.into_any_element()
}
}

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;
@ -1038,7 +1039,12 @@ impl Component for GitStatusIcon {
ComponentScope::VersionControl
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
fn description() -> &'static str {
"An icon that visually represents the git status of a file, \
using a distinct glyph and color for modified, added, deleted, and conflicted states."
}
fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
fn tracked_file_status(code: StatusCode) -> FileStatus {
FileStatus::Tracked(git::status::TrackedStatus {
index_status: code,
@ -1055,20 +1061,18 @@ impl Component for GitStatusIcon {
}
.into();
Some(
v_flex()
.gap_6()
.children(vec![example_group(vec![
single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
single_example("Added", GitStatusIcon::new(added).into_any_element()),
single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
single_example(
"Conflicted",
GitStatusIcon::new(conflict).into_any_element(),
),
])])
.into_any_element(),
)
v_flex()
.gap_6()
.children(vec![example_group(vec![
single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
single_example("Added", GitStatusIcon::new(added).into_any_element()),
single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
single_example(
"Conflicted",
GitStatusIcon::new(conflict).into_any_element(),
),
])])
.into_any_element()
}
}

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

@ -14,8 +14,8 @@ use picker::{Picker, PickerDelegate, PickerEditorPosition};
use project::Project;
use project::git_store::RepositoryEvent;
use ui::{
Button, Divider, HighlightedLabel, IconButton, KeyBinding, ListItem, ListItemSpacing, Tooltip,
prelude::*,
Button, CommonAnimationExt as _, Divider, HighlightedLabel, IconButton, KeyBinding, ListItem,
ListItemSpacing, Tooltip, prelude::*,
};
use util::ResultExt as _;
use util::paths::PathExt;
@ -116,6 +116,7 @@ impl WorktreePicker {
show_footer,
modifiers: Modifiers::default(),
hovered_delete_index: None,
deleting_worktree_paths: HashSet::default(),
};
let picker = cx.new(|cx| {
@ -313,6 +314,7 @@ struct WorktreePickerDelegate {
show_footer: bool,
modifiers: Modifiers,
hovered_delete_index: Option<usize>,
deleting_worktree_paths: HashSet<PathBuf>,
}
fn remove_worktree_command(path: &Path, force: bool) -> String {
@ -420,18 +422,18 @@ impl WorktreePickerDelegate {
fn build_fixed_entries(&self) -> Vec<WorktreeEntry> {
let mut entries = Vec::new();
if !self.has_multiple_repositories {
if let Some(ref default_branch) = self.default_branch {
let is_different = self
.current_branch_name
.as_ref()
.is_none_or(|current| current != &default_branch.branch_name);
entries.push(WorktreeEntry::CreateFromDefaultBranch {
default_branch: default_branch.clone(),
});
if is_different {
entries.push(WorktreeEntry::CreateFromCurrentBranch);
}
if self.has_multiple_repositories {
entries.push(WorktreeEntry::CreateFromCurrentBranch);
} else if let Some(ref default_branch) = self.default_branch {
let is_different = self
.current_branch_name
.as_ref()
.is_none_or(|current| current != &default_branch.branch_name);
entries.push(WorktreeEntry::CreateFromDefaultBranch {
default_branch: default_branch.clone(),
});
if is_different {
entries.push(WorktreeEntry::CreateFromCurrentBranch);
}
} else {
entries.push(WorktreeEntry::CreateFromCurrentBranch);
@ -464,7 +466,7 @@ impl WorktreePickerDelegate {
}
fn delete_worktree(
&self,
&mut self,
ix: usize,
force: bool,
window: &mut Window,
@ -476,7 +478,9 @@ impl WorktreePickerDelegate {
let WorktreeEntry::Worktree { worktree, .. } = entry else {
return;
};
if !self.can_delete_worktree(worktree) {
if !self.can_delete_worktree(worktree)
|| self.deleting_worktree_paths.contains(&worktree.path)
{
return;
}
@ -493,10 +497,27 @@ impl WorktreePickerDelegate {
);
let workspace = self.workspace.clone();
self.deleting_worktree_paths.insert(path.clone());
if self.hovered_delete_index == Some(ix) {
self.hovered_delete_index = None;
}
cx.notify();
cx.spawn_in(window, async move |picker, cx| {
let initial_result = repo
let initial_result = match repo
.update(cx, |repo, _| repo.remove_worktree(path.clone(), force))
.await?;
.await
{
Ok(result) => result,
Err(error) => {
picker.update_in(cx, |picker, _window, cx| {
if picker.delegate.deleting_worktree_paths.remove(&path) {
cx.notify();
}
})?;
return Err(error.into());
}
};
let (result, attempted_force) = match initial_result {
Ok(()) => (Ok(()), force),
@ -510,6 +531,12 @@ impl WorktreePickerDelegate {
.flatten();
if let Some(prompt_message) = force_delete_prompt {
picker.update_in(cx, |picker, _window, cx| {
if picker.delegate.deleting_worktree_paths.remove(&path) {
cx.notify();
}
})?;
let answer = cx.update(|window, cx| {
window.prompt(
PromptLevel::Warning,
@ -524,9 +551,39 @@ impl WorktreePickerDelegate {
return Ok(());
}
let retry = repo
let should_retry = picker.update_in(cx, |picker, _window, cx| {
let worktree_still_exists = picker
.delegate
.all_worktrees
.iter()
.any(|worktree| worktree.path == path);
if !worktree_still_exists
|| !picker.delegate.deleting_worktree_paths.insert(path.clone())
{
return false;
}
cx.notify();
true
})?;
if !should_retry {
return Ok(());
}
let retry = match repo
.update(cx, |repo, _| repo.remove_worktree(path.clone(), true))
.await?;
.await
{
Ok(result) => result,
Err(error) => {
picker.update_in(cx, |picker, _window, cx| {
if picker.delegate.deleting_worktree_paths.remove(&path) {
cx.notify();
}
})?;
return Err(error.into());
}
};
if let Err(error) = &retry {
log::error!("Failed to force remove worktree: {error}");
@ -540,6 +597,12 @@ impl WorktreePickerDelegate {
};
if let Err(error) = result {
picker.update_in(cx, |picker, _window, cx| {
if picker.delegate.deleting_worktree_paths.remove(&path) {
cx.notify();
}
})?;
if let Some(workspace) = workspace.upgrade() {
cx.update(|_window, cx| {
show_error_toast(
@ -555,6 +618,7 @@ impl WorktreePickerDelegate {
}
picker.update_in(cx, |picker, _window, cx| {
picker.delegate.deleting_worktree_paths.remove(&path);
picker.delegate.matches.retain(|e| {
!matches!(e, WorktreeEntry::Worktree { worktree, .. } if worktree.path == path)
});
@ -814,6 +878,10 @@ impl PickerDelegate for WorktreePickerDelegate {
}
}
WorktreeEntry::Worktree { worktree, .. } => {
if self.deleting_worktree_paths.contains(&worktree.path) {
return;
}
let is_current = self.project_worktree_paths.contains(&worktree.path);
if !is_current {
@ -956,6 +1024,7 @@ impl PickerDelegate for WorktreePickerDelegate {
let sha = worktree.sha.chars().take(7).collect::<String>();
let is_current = self.project_worktree_paths.contains(&worktree.path);
let is_deleting = self.deleting_worktree_paths.contains(&worktree.path);
let can_delete = self.can_delete_worktree(worktree);
let entry_icon = if is_current {
@ -1035,7 +1104,24 @@ impl PickerDelegate for WorktreePickerDelegate {
),
),
)
.when(!is_current, |this| {
.when(is_deleting, |this| {
this.end_slot(
h_flex()
.gap_1()
.child(
Icon::new(IconName::LoadCircle)
.size(IconSize::Small)
.color(Color::Muted)
.with_rotate_animation(2),
)
.child(
Label::new("Deleting…")
.size(LabelSize::Small)
.color(Color::Muted),
),
)
})
.when(!is_deleting && !is_current, |this| {
let open_in_new_window_button =
IconButton::new(("open-new-window", ix), IconName::ArrowUpRight)
.icon_size(IconSize::Small)
@ -1045,6 +1131,13 @@ impl PickerDelegate for WorktreePickerDelegate {
return;
};
if let WorktreeEntry::Worktree { worktree, .. } = entry {
if picker
.delegate
.deleting_worktree_paths
.contains(&worktree.path)
{
return;
}
window.dispatch_action(
Box::new(OpenWorktreeInNewWindow {
path: worktree.path.clone(),
@ -1083,12 +1176,8 @@ impl PickerDelegate for WorktreePickerDelegate {
.into()
})
.on_click(cx.listener(move |picker, _, window, cx| {
picker.delegate.delete_worktree(
ix,
picker.delegate.modifiers.alt,
window,
cx,
);
let force = picker.delegate.modifiers.alt;
picker.delegate.delete_worktree(ix, force, window, cx);
})),
);
@ -1162,6 +1251,10 @@ impl PickerDelegate for WorktreePickerDelegate {
matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.project_worktree_paths.contains(&worktree.path))
});
let is_deleting = selected_entry.is_some_and(|e| {
matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.deleting_worktree_paths.contains(&worktree.path))
});
let footer = h_flex()
.w_full()
.p_1p5()
@ -1188,7 +1281,14 @@ impl PickerDelegate for WorktreePickerDelegate {
} else if is_existing_worktree {
Some(
footer
.when(can_delete, |this| {
.when(is_deleting, |this| {
this.child(
Button::new("delete-worktree", "Deleting…")
.loading(true)
.disabled(true),
)
})
.when(!is_deleting && can_delete, |this| {
let focus_handle = focus_handle.clone();
this.child(
Button::new("delete-worktree", "Delete")
@ -1201,7 +1301,7 @@ impl PickerDelegate for WorktreePickerDelegate {
}),
)
})
.when(!is_current, |this| {
.when(!is_deleting && !is_current, |this| {
let focus_handle = focus_handle.clone();
this.child(
Button::new("open-in-new-window", "Open in New Window")
@ -1218,16 +1318,18 @@ impl PickerDelegate for WorktreePickerDelegate {
}),
)
})
.child(
Button::new("open-worktree", "Open")
.key_binding(
KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
window.dispatch_action(menu::Confirm.boxed_clone(), cx)
}),
)
.when(!is_deleting, |this| {
this.child(
Button::new("open-worktree", "Open")
.key_binding(
KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
window.dispatch_action(menu::Confirm.boxed_clone(), cx)
}),
)
})
.into_any(),
)
} else {
@ -1482,6 +1584,33 @@ mod tests {
})
}
fn picker_contains_worktree(
worktree_picker: &Entity<WorktreePicker>,
worktree_path: &Path,
cx: &mut VisualTestContext,
) -> bool {
worktree_picker.update(cx, |worktree_picker, cx| {
worktree_picker.picker.update(cx, |picker, _| {
picker.delegate.all_worktrees.iter().any(|worktree| {
worktree.path == *worktree_path
}) && picker.delegate.matches.iter().any(|entry| {
matches!(entry, WorktreeEntry::Worktree { worktree, .. } if worktree.path == *worktree_path)
})
})
})
}
fn deleting_worktree_paths(
worktree_picker: &Entity<WorktreePicker>,
cx: &mut VisualTestContext,
) -> HashSet<PathBuf> {
worktree_picker.update(cx, |worktree_picker, cx| {
worktree_picker.picker.update(cx, |picker, _| {
picker.delegate.deleting_worktree_paths.clone()
})
})
}
async fn repo_contains_worktree(
repository: &Entity<project::git_store::Repository>,
worktree_path: &Path,
@ -1497,6 +1626,54 @@ mod tests {
.any(|worktree| worktree.path == *worktree_path)
}
#[gpui::test]
async fn test_delete_worktree_marks_row_pending_immediately(cx: &mut TestAppContext) {
let (_, worktree_picker, _repository, worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker.delegate.delete_worktree(index, false, window, cx);
})
});
let pending_paths = deleting_worktree_paths(&worktree_picker, &mut cx);
assert_eq!(pending_paths.len(), 1);
assert!(pending_paths.contains(&worktree_path));
cx.run_until_parked();
}
#[gpui::test]
async fn test_delete_worktree_clears_pending_and_removes_row_on_success(
cx: &mut TestAppContext,
) {
let (_, worktree_picker, repository, worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker.delegate.delete_worktree(index, false, window, cx);
})
});
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path));
cx.run_until_parked();
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty());
assert!(!picker_contains_worktree(
&worktree_picker,
&worktree_path,
&mut cx
));
assert!(
!repo_contains_worktree(&repository, &worktree_path, &mut cx).await,
"worktree should be removed after successful delete"
);
}
#[gpui::test]
async fn test_remote_default_branch_is_preferred_create_target(cx: &mut TestAppContext) {
let (_fs, worktree_picker, _repository, _worktree_path, mut cx) =
@ -1539,6 +1716,37 @@ mod tests {
});
}
#[gpui::test]
async fn test_current_branch_create_target_is_shown_without_default_branch(
cx: &mut TestAppContext,
) {
let (_fs, worktree_picker, _repository, _worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker.delegate.default_branch = None;
picker.refresh(window, cx);
});
});
cx.run_until_parked();
worktree_picker.update(&mut cx, |worktree_picker, cx| {
worktree_picker.picker.update(cx, |picker, _| {
assert!(matches!(
picker.delegate.matches.first(),
Some(WorktreeEntry::CreateFromCurrentBranch)
));
assert!(
!picker.delegate.matches.iter().any(|entry| matches!(
entry,
WorktreeEntry::CreateFromDefaultBranch { .. }
))
);
});
});
}
#[gpui::test]
async fn test_delete_dirty_worktree_prompts_for_force_delete(cx: &mut TestAppContext) {
let (fs, worktree_picker, repository, worktree_path, mut cx) =
@ -1557,19 +1765,96 @@ mod tests {
picker.delegate.delete_worktree(index, false, window, cx);
})
});
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path));
cx.run_until_parked();
assert!(cx.has_pending_prompt());
assert!(
!deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path),
"pending delete state should clear while waiting for force-delete confirmation"
);
cx.simulate_prompt_answer("Force Delete");
cx.run_until_parked();
assert!(!cx.has_pending_prompt());
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty());
assert!(!picker_contains_worktree(
&worktree_picker,
&worktree_path,
&mut cx
));
assert!(
!repo_contains_worktree(&repository, &worktree_path, &mut cx).await,
"worktree should be removed after confirming force delete"
);
}
#[gpui::test]
async fn test_duplicate_delete_worktree_is_ignored_while_pending(cx: &mut TestAppContext) {
let (fs, worktree_picker, _repository, worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
fs.with_git_state(path!("/root/project/.git").as_ref(), true, |state| {
state
.worktrees_requiring_force_delete
.insert(worktree_path.clone());
})
.expect("failed to mark test worktree as requiring force delete");
let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker.delegate.delete_worktree(index, false, window, cx);
picker.delegate.delete_worktree(index, false, window, cx);
})
});
let pending_paths = deleting_worktree_paths(&worktree_picker, &mut cx);
assert_eq!(pending_paths.len(), 1);
assert!(pending_paths.contains(&worktree_path));
cx.run_until_parked();
assert!(cx.has_pending_prompt());
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty());
cx.simulate_prompt_answer("Cancel");
cx.run_until_parked();
assert!(!cx.has_pending_prompt());
assert!(picker_contains_worktree(
&worktree_picker,
&worktree_path,
&mut cx
));
}
#[gpui::test]
async fn test_selected_deleting_worktree_cannot_be_opened(cx: &mut TestAppContext) {
let (_, worktree_picker, _repository, worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
let subscription = cx.update(|_, cx| {
cx.subscribe(&worktree_picker, |_, _: &DismissEvent, _| {
panic!("DismissEvent should not be emitted for a deleting worktree");
})
});
let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker.delegate.selected_index = index;
picker.delegate.delete_worktree(index, false, window, cx);
picker.delegate.confirm(false, window, cx);
})
});
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path));
drop(subscription);
cx.run_until_parked();
}
#[gpui::test]
async fn test_force_delete_worktree_deletes_without_prompt(cx: &mut TestAppContext) {
let (fs, worktree_picker, repository, worktree_path, mut cx) =
@ -1589,9 +1874,17 @@ mod tests {
picker.delegate.delete_worktree(index, true, window, cx);
})
});
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path));
cx.run_until_parked();
assert!(!cx.has_pending_prompt());
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty());
assert!(!picker_contains_worktree(
&worktree_picker,
&worktree_path,
&mut cx
));
assert!(
!repo_contains_worktree(&repository, &worktree_path, &mut cx).await,
"worktree should be removed by explicit force delete"

View file

@ -151,6 +151,21 @@ impl Application {
))
}
/// Builds an app with accessibility (AccessKit) integration forcibly
/// disabled.
///
/// In this mode, accessibility APIs (e.g.
/// [`div().role()`][crate::StatefulInteractiveElement::role]) silently
/// no-op.
///
/// See the [accessibility guide](crate::_accessibility) for an overview of
/// the features this disables.
pub fn new_inaccessible(platform: Rc<dyn Platform>) -> Self {
let this = Self::with_platform(platform);
this.0.borrow_mut().accessibility_force_disabled = true;
this
}
/// Assigns the source of assets for the application.
pub fn with_assets(self, asset_source: impl AssetSource) -> Self {
let mut context_lock = self.0.borrow_mut();
@ -666,6 +681,9 @@ pub struct App {
pub(crate) window_update_stack: Vec<WindowId>,
pub(crate) mode: GpuiMode,
pub(crate) cursor_hide_mode: CursorHideMode,
/// Whether the app was created by [`Application::new_inaccessible`]. No
/// accesskit APIs will be called when this flag is set.
pub(crate) accessibility_force_disabled: bool,
flushing_effects: bool,
pending_updates: usize,
quit_mode: QuitMode,
@ -755,6 +773,7 @@ impl App {
quit_mode: QuitMode::default(),
quitting: false,
cursor_hide_mode: CursorHideMode::default(),
accessibility_force_disabled: false,
#[cfg(any(test, feature = "test-support", debug_assertions))]
name: None,

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

@ -1330,10 +1330,11 @@ impl Window {
WindowBounds::Windowed(_) => {}
}
let accessibility_force_disabled = cx.accessibility_force_disabled;
let a11y_active_flag = Arc::new(AtomicBool::new(false));
#[cfg(not(target_family = "wasm"))]
{
if !accessibility_force_disabled {
let initial_tree = accesskit::TreeUpdate {
nodes: vec![(ROOT_NODE_ID, accesskit::Node::new(accesskit::Role::Window))],
tree: Some(accesskit::Tree::new(ROOT_NODE_ID)),
@ -1717,7 +1718,7 @@ impl Window {
captured_hitbox: None,
#[cfg(any(feature = "inspector", debug_assertions))]
inspector: None,
a11y: A11y::new(a11y_active_flag),
a11y: A11y::new(a11y_active_flag, accessibility_force_disabled),
})
}

View file

@ -107,6 +107,10 @@ pub(crate) type A11yActionListener =
/// Manages the AccessKit tree that is built each frame and the mappings
/// needed to dispatch incoming action requests back to the right elements.
pub(crate) struct A11y {
/// Whether accessibility has been [forcibly disabled] for this window.
///
/// [forcibly disabled]: crate::Application::new_inaccessible
force_disabled: bool,
/// Whether a11y features have been requested by the system.
///
/// Updated by AccessKit using callbacks provided to the adapter. Can change
@ -131,8 +135,9 @@ pub(crate) struct A11y {
}
impl A11y {
pub(crate) fn new(active_flag: Arc<AtomicBool>) -> Self {
pub(crate) fn new(active_flag: Arc<AtomicBool>, force_disabled: bool) -> Self {
Self {
force_disabled,
active_flag,
active_this_frame: false,
nodes: A11yNodeBuilder::new(),
@ -147,7 +152,7 @@ impl A11y {
/// See the docs for [`Self::active_flag`] and [`Self::active_this_frame`]
/// for more commentary.
pub(crate) fn sync_active_flag(&mut self) {
self.active_this_frame = self.active_flag.load(Ordering::SeqCst);
self.active_this_frame = !self.force_disabled && self.active_flag.load(Ordering::SeqCst);
}
pub(crate) fn is_active(&self) -> bool {
@ -164,7 +169,21 @@ impl A11y {
/// Finalize the tree and produce a [`TreeUpdate`] for the platform adapter.
pub(crate) fn end_frame(&mut self) -> TreeUpdate {
self.nodes.finalize()
let tree_update = self.nodes.finalize();
// Zed currently doesn't set any a11y APIs on *any* UI elements, so a
// tree with nodes other than the root indicates a bug in the
// `TreeUpdate`-producing logic.
//
// Remove this when adding aria attributes.
if tree_update.nodes.len() > 1 {
log::warn!(
"expected an empty a11y tree update (only the root node), but got {} nodes; Zed has no accessible UI elements yet",
tree_update.nodes.len()
);
}
tree_update
}
}

View file

@ -17,6 +17,7 @@ default = []
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
tempfile.workspace = true
[dependencies]
anyhow.workspace = true

View file

@ -449,45 +449,48 @@ pub fn all_schema_file_associations(
.flat_map(|(_, glob_strings)| glob_strings)
.cloned();
let jsonc_globs = extension_globs.chain(override_globs).collect::<Vec<_>>();
let settings_file_matches = schema_file_match_entries(paths::settings_file());
let keymap_file_matches = schema_file_match_entries(paths::keymap_file());
let mut tasks_file_matches = schema_file_match_entries(paths::tasks_file());
tasks_file_matches.push(
paths::local_tasks_file_relative_path()
.as_unix_str()
.to_string(),
);
let mut debug_file_matches = schema_file_match_entries(paths::debug_scenarios_file());
debug_file_matches.push(
paths::local_debug_file_relative_path()
.as_unix_str()
.to_string(),
);
let snippet_file_matches =
schema_file_match_entries(paths::snippets_dir().join("*.json").as_path());
let mut file_associations = serde_json::json!([
{
"fileMatch": [
schema_file_match(paths::settings_file()),
],
"fileMatch": settings_file_matches,
"url": format!("{SCHEMA_URI_PREFIX}settings"),
},
{
"fileMatch": [
paths::local_settings_file_relative_path()],
paths::local_settings_file_relative_path()
],
"url": format!("{SCHEMA_URI_PREFIX}project_settings"),
},
{
"fileMatch": [schema_file_match(paths::keymap_file())],
"fileMatch": keymap_file_matches,
"url": format!("{SCHEMA_URI_PREFIX}keymap"),
},
{
"fileMatch": [
schema_file_match(paths::tasks_file()),
paths::local_tasks_file_relative_path()
],
"fileMatch": tasks_file_matches,
"url": format!("{SCHEMA_URI_PREFIX}tasks"),
},
{
"fileMatch": [
schema_file_match(paths::debug_scenarios_file()),
paths::local_debug_file_relative_path()
],
"fileMatch": debug_file_matches,
"url": format!("{SCHEMA_URI_PREFIX}debug_tasks"),
},
{
"fileMatch": [
schema_file_match(
paths::snippets_dir()
.join("*.json")
.as_path()
)
],
"fileMatch": snippet_file_matches,
"url": format!("{SCHEMA_URI_PREFIX}snippets"),
},
{
@ -619,11 +622,80 @@ fn root_schema_from_action_schema(
schema
}
/// Build the LSP fileMatch entries for `path`.
///
/// The JSON LSP matches incoming file URIs against these glob patterns,
/// so we register both the symlinked location (if any) and the resolved
/// canonical path. Without this, opening `~/.config/zed/settings.json`
/// when it is a symlink to a file under a different directory no longer
/// binds the settings schema (zed-industries/zed#54888).
fn schema_file_match_entries(path: &std::path::Path) -> Vec<String> {
let mut out = Vec::with_capacity(2);
out.push(stripped_match(path));
if let Ok(canonical) = path.canonicalize() {
if canonical != path {
out.push(stripped_match(&canonical));
}
}
out
}
#[inline]
fn schema_file_match(path: &std::path::Path) -> String {
path.strip_prefix(path.parent().unwrap().parent().unwrap())
.unwrap()
fn stripped_match(path: &std::path::Path) -> String {
let parent = path.parent().and_then(|p| p.parent()).unwrap_or(path);
path.strip_prefix(parent)
.unwrap_or(path)
.display()
.to_string()
.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
#[test]
fn stripped_match_drops_two_parent_components() {
let path = PathBuf::from("/home/user/.config/zed/settings.json");
assert_eq!(stripped_match(&path), "zed/settings.json");
}
#[test]
fn schema_file_match_entries_returns_single_for_regular_file() {
let tmp = tempfile::TempDir::new().unwrap();
// Some platforms expose the temp directory through a symlinked prefix.
// Canonicalize the root so this test only covers non-symlinked files.
let root = tmp.path().canonicalize().unwrap();
let zed_dir = root.join("zed");
fs::create_dir(&zed_dir).unwrap();
let regular = zed_dir.join("settings.json");
fs::write(&regular, "{}").unwrap();
let entries = schema_file_match_entries(&regular);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0], "zed/settings.json");
}
#[test]
fn schema_file_match_entries_returns_both_for_symlink() {
let tmp = tempfile::TempDir::new().unwrap();
let zed_dir = tmp.path().join("zed");
fs::create_dir(&zed_dir).unwrap();
let target = tmp.path().join("settings_target.json");
fs::write(&target, "{}").unwrap();
let link = zed_dir.join("settings.json");
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&target, &link).unwrap();
let entries = schema_file_match_entries(&link);
assert!(entries.iter().any(|entry| entry == "zed/settings.json"));
assert!(
entries
.iter()
.any(|entry| entry.ends_with("settings_target.json"))
);
assert_eq!(entries.len(), 2);
}
}

View file

@ -297,6 +297,19 @@ pub enum Operation {
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BufferEditSource {
User,
Agent,
Remote,
}
impl BufferEditSource {
pub fn is_local(self) -> bool {
!matches!(self, Self::Remote)
}
}
/// An event that occurs in a buffer.
#[derive(Clone, Debug, PartialEq)]
pub enum BufferEvent {
@ -307,7 +320,7 @@ pub enum BufferEvent {
is_local: bool,
},
/// The buffer was edited.
Edited { is_local: bool },
Edited { source: BufferEditSource },
/// The buffer's `dirty` bit changed.
DirtyChanged,
/// The buffer was saved.
@ -2433,6 +2446,14 @@ impl Buffer {
self.end_transaction_at(Instant::now(), cx)
}
pub fn end_transaction_with_source(
&mut self,
source: BufferEditSource,
cx: &mut Context<Self>,
) -> Option<TransactionId> {
self.end_transaction_at_internal(Instant::now(), source, cx)
}
/// Terminates the current transaction, providing the current time. Subsequent transactions
/// that occur within a short period of time will be grouped together. This
/// is controlled by the buffer's undo grouping duration.
@ -2440,6 +2461,15 @@ impl Buffer {
&mut self,
now: Instant,
cx: &mut Context<Self>,
) -> Option<TransactionId> {
self.end_transaction_at_internal(now, BufferEditSource::User, cx)
}
fn end_transaction_at_internal(
&mut self,
now: Instant,
source: BufferEditSource,
cx: &mut Context<Self>,
) -> Option<TransactionId> {
assert!(self.transaction_depth > 0);
self.transaction_depth -= 1;
@ -2449,7 +2479,7 @@ impl Buffer {
false
};
if let Some((transaction_id, start_version)) = self.text.end_transaction_at(now) {
self.did_edit(&start_version, was_dirty, true, cx);
self.did_edit(&start_version, was_dirty, source, cx);
Some(transaction_id)
} else {
None
@ -2844,7 +2874,7 @@ impl Buffer {
&mut self,
old_version: &clock::Global,
was_dirty: bool,
is_local: bool,
source: BufferEditSource,
cx: &mut Context<Self>,
) {
self.was_changed();
@ -2854,7 +2884,7 @@ impl Buffer {
}
self.reparse(cx, true);
cx.emit(BufferEvent::Edited { is_local });
cx.emit(BufferEvent::Edited { source });
let is_dirty = self.is_dirty();
if was_dirty != is_dirty {
cx.emit(BufferEvent::DirtyChanged);
@ -2976,7 +3006,7 @@ impl Buffer {
self.text.apply_ops(buffer_ops);
self.deferred_ops.insert(deferred_ops);
self.flush_deferred_ops(cx);
self.did_edit(&old_version, was_dirty, false, cx);
self.did_edit(&old_version, was_dirty, BufferEditSource::Remote, cx);
// Notify independently of whether the buffer was edited as the operations could include a
// selection update.
cx.notify();
@ -3131,7 +3161,7 @@ impl Buffer {
if let Some((transaction_id, operation)) = self.text.undo() {
self.send_operation(Operation::Buffer(operation), true, cx);
self.did_edit(&old_version, was_dirty, true, cx);
self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx);
self.restore_encoding_for_transaction(transaction_id, was_dirty);
Some(transaction_id)
} else {
@ -3149,7 +3179,7 @@ impl Buffer {
let old_version = self.version.clone();
if let Some(operation) = self.text.undo_transaction(transaction_id) {
self.send_operation(Operation::Buffer(operation), true, cx);
self.did_edit(&old_version, was_dirty, true, cx);
self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx);
true
} else {
false
@ -3171,7 +3201,7 @@ impl Buffer {
self.send_operation(Operation::Buffer(operation), true, cx);
}
if undone {
self.did_edit(&old_version, was_dirty, true, cx)
self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx)
}
undone
}
@ -3181,7 +3211,7 @@ impl Buffer {
let operation = self.text.undo_operations(counts);
let old_version = self.version.clone();
self.send_operation(Operation::Buffer(operation), true, cx);
self.did_edit(&old_version, was_dirty, true, cx);
self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx);
}
/// Manually redoes a specific transaction in the buffer's redo history.
@ -3191,7 +3221,7 @@ impl Buffer {
if let Some((transaction_id, operation)) = self.text.redo() {
self.send_operation(Operation::Buffer(operation), true, cx);
self.did_edit(&old_version, was_dirty, true, cx);
self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx);
self.restore_encoding_for_transaction(transaction_id, was_dirty);
Some(transaction_id)
} else {
@ -3232,7 +3262,7 @@ impl Buffer {
self.send_operation(Operation::Buffer(operation), true, cx);
}
if redone {
self.did_edit(&old_version, was_dirty, true, cx)
self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx)
}
redone
}
@ -3342,7 +3372,7 @@ impl Buffer {
if !ops.is_empty() {
for op in ops {
self.send_operation(Operation::Buffer(op), true, cx);
self.did_edit(&old_version, was_dirty, true, cx);
self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx);
}
}
}

View file

@ -460,16 +460,24 @@ fn test_edit_events(cx: &mut gpui::App) {
assert_eq!(
mem::take(&mut *buffer_1_events.lock()),
vec![
BufferEvent::Edited { is_local: true },
BufferEvent::Edited {
source: BufferEditSource::User
},
BufferEvent::DirtyChanged,
BufferEvent::Edited { is_local: true },
BufferEvent::Edited { is_local: true },
BufferEvent::Edited {
source: BufferEditSource::User
},
BufferEvent::Edited {
source: BufferEditSource::User
},
]
);
assert_eq!(
mem::take(&mut *buffer_2_events.lock()),
vec![
BufferEvent::Edited { is_local: false },
BufferEvent::Edited {
source: BufferEditSource::Remote
},
BufferEvent::DirtyChanged
]
);
@ -487,14 +495,18 @@ fn test_edit_events(cx: &mut gpui::App) {
assert_eq!(
mem::take(&mut *buffer_1_events.lock()),
vec![
BufferEvent::Edited { is_local: true },
BufferEvent::Edited {
source: BufferEditSource::User
},
BufferEvent::DirtyChanged,
]
);
assert_eq!(
mem::take(&mut *buffer_2_events.lock()),
vec![
BufferEvent::Edited { is_local: false },
BufferEvent::Edited {
source: BufferEditSource::Remote
},
BufferEvent::DirtyChanged
]
);

View file

@ -670,12 +670,22 @@ impl LanguageModel for BedrockModel {
value: "high".into(),
is_default: true,
},
language_model::LanguageModelEffortLevel {
name: "XHigh".into(),
value: "xhigh".into(),
is_default: false,
},
language_model::LanguageModelEffortLevel {
name: "Max".into(),
value: "max".into(),
is_default: false,
},
]
.into_iter()
.filter(|effort_level| {
effort_level.value != "xhigh" || self.model.supports_xhigh_adaptive_thinking()
})
.collect()
} else {
Vec::new()
}
@ -1128,6 +1138,7 @@ pub fn into_bedrock(
"low" => Some(bedrock::BedrockAdaptiveThinkingEffort::Low),
"medium" => Some(bedrock::BedrockAdaptiveThinkingEffort::Medium),
"high" => Some(bedrock::BedrockAdaptiveThinkingEffort::High),
"xhigh" => Some(bedrock::BedrockAdaptiveThinkingEffort::XHigh),
"max" => Some(bedrock::BedrockAdaptiveThinkingEffort::Max),
_ => None,
})

View file

@ -721,7 +721,13 @@ impl Component for ZedAiConfiguration {
ComponentScope::Onboarding
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
fn description() -> &'static str {
"The configuration surface for Zed's hosted AI models, \
showing the user's connection status, current plan, trial eligibility, \
and entry points for enabling the Zed model provider."
}
fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
struct PreviewConfiguration {
plan: Option<Plan>,
is_connected: bool,
@ -741,94 +747,92 @@ impl Component for ZedAiConfiguration {
.into_any_element()
};
Some(
v_flex()
.p_4()
.gap_4()
.children(vec![
single_example(
"Not connected",
configuration(PreviewConfiguration {
plan: None,
is_connected: false,
is_zed_model_provider_enabled: true,
eligible_for_trial: false,
}),
),
single_example(
"Accept Terms of Service",
configuration(PreviewConfiguration {
plan: None,
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"No Plan - Not eligible for trial",
configuration(PreviewConfiguration {
plan: None,
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: false,
}),
),
single_example(
"No Plan - Eligible for trial",
configuration(PreviewConfiguration {
plan: None,
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"Free Plan",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedFree),
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"Zed Pro Trial Plan",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedProTrial),
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"Zed Pro Plan",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedPro),
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"Business Plan - Zed models enabled",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedBusiness),
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: false,
}),
),
single_example(
"Business Plan - Zed models disabled",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedBusiness),
is_connected: true,
is_zed_model_provider_enabled: false,
eligible_for_trial: false,
}),
),
])
.into_any_element(),
)
v_flex()
.p_4()
.gap_4()
.children(vec![
single_example(
"Not connected",
configuration(PreviewConfiguration {
plan: None,
is_connected: false,
is_zed_model_provider_enabled: true,
eligible_for_trial: false,
}),
),
single_example(
"Accept Terms of Service",
configuration(PreviewConfiguration {
plan: None,
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"No Plan - Not eligible for trial",
configuration(PreviewConfiguration {
plan: None,
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: false,
}),
),
single_example(
"No Plan - Eligible for trial",
configuration(PreviewConfiguration {
plan: None,
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"Free Plan",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedFree),
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"Zed Pro Trial Plan",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedProTrial),
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"Zed Pro Plan",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedPro),
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"Business Plan - Zed models enabled",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedBusiness),
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: false,
}),
),
single_example(
"Business Plan - Zed models disabled",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedBusiness),
is_connected: true,
is_zed_model_provider_enabled: false,
eligible_for_trial: false,
}),
),
])
.into_any_element()
}
}

View file

@ -773,7 +773,6 @@ impl CopilotResponsesEventMapper {
copilot_responses::StreamEvent::Completed { response } => {
let mut events = Vec::new();
events.extend(self.capture_reasoning_items_from_output(&response.output));
if let Some(usage) = response.usage {
events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
input_tokens: usage.input_tokens.unwrap_or(0),
@ -805,7 +804,6 @@ impl CopilotResponsesEventMapper {
};
let mut events = Vec::new();
events.extend(self.capture_reasoning_items_from_output(&response.output));
if let Some(usage) = response.usage {
events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
input_tokens: usage.input_tokens.unwrap_or(0),
@ -847,28 +845,6 @@ impl CopilotResponsesEventMapper {
}
}
fn capture_reasoning_items_from_output(
&mut self,
output: &[copilot_responses::ResponseOutputItem],
) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
let mut events = Vec::new();
for item in output {
if let copilot_responses::ResponseOutputItem::Reasoning {
id,
summary: _,
encrypted_content,
} = item
{
if let Some(reasoning_item) =
reasoning_input_item_from_output(&id, encrypted_content.clone())
{
events.extend(self.capture_reasoning_item(reasoning_item));
}
}
}
events
}
fn capture_reasoning_item(
&mut self,
reasoning_item: copilot_responses::ResponseReasoningInputItem,
@ -1597,6 +1573,60 @@ mod tests {
}
}
#[test]
fn responses_stream_ignores_reasoning_items_repeated_in_completed_output() {
let events = vec![
responses::StreamEvent::OutputItemDone {
output_index: 0,
sequence_number: None,
item: responses::ResponseOutputItem::Reasoning {
id: "r1".into(),
summary: Some(Vec::new()),
encrypted_content: Some("ENC1".into()),
},
},
responses::StreamEvent::Completed {
response: responses::Response {
output: vec![
responses::ResponseOutputItem::Reasoning {
id: "r1".into(),
summary: Some(Vec::new()),
encrypted_content: Some("ENC1".into()),
},
responses::ResponseOutputItem::Reasoning {
id: "r2".into(),
summary: Some(Vec::new()),
encrypted_content: Some("ENC2".into()),
},
],
..Default::default()
},
},
];
let mapped = map_events(events);
let reasoning_details = mapped
.iter()
.filter_map(|event| match event {
LanguageModelCompletionEvent::ReasoningDetails(details) => Some(details),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
reasoning_details,
vec![&json!({
"reasoning_items": [
{
"id": "r1",
"summary": [],
"encrypted_content": "ENC1"
}
]
})]
);
}
#[test]
fn into_copilot_responses_replays_reasoning_details() {
let model = test_responses_model();

View file

@ -49,7 +49,7 @@ fn reasoning_effort_display(effort: ReasoningEffort) -> (&'static str, &'static
ReasoningEffort::Low => ("Low", "low"),
ReasoningEffort::Medium => ("Medium", "medium"),
ReasoningEffort::High => ("High", "high"),
ReasoningEffort::XHigh => ("Max", "max"),
ReasoningEffort::XHigh => ("XHigh", "xhigh"),
}
}

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

@ -40,7 +40,8 @@ use gpui::{
use language::{CharClassifier, Language, LanguageRegistry, Rope};
use parser::CodeBlockMetadata;
use parser::{
MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown_with_options,
MarkdownEvent, MarkdownTag, MarkdownTagEnd, ParsedMetadataBlock, parse_links_only,
parse_markdown_with_options,
};
use pulldown_cmark::{Alignment, BlockQuoteKind};
use sum_tree::TreeMap;
@ -350,6 +351,7 @@ pub struct MarkdownOptions {
pub parse_html: bool,
pub render_mermaid_diagrams: bool,
pub parse_heading_slugs: bool,
pub render_metadata_blocks: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
@ -847,6 +849,7 @@ impl Markdown {
let should_parse_html = self.options.parse_html;
let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams;
let should_parse_heading_slugs = self.options.parse_heading_slugs;
let should_parse_metadata_blocks = self.options.render_metadata_blocks;
let language_registry = self.language_registry.clone();
let fallback = self.fallback_code_block_language.clone();
@ -860,6 +863,7 @@ impl Markdown {
languages_by_path: TreeMap::default(),
root_block_starts: Arc::default(),
html_blocks: BTreeMap::default(),
metadata_blocks: BTreeMap::default(),
mermaid_diagrams: BTreeMap::default(),
heading_slugs: HashMap::default(),
footnote_definitions: HashMap::default(),
@ -868,13 +872,18 @@ impl Markdown {
);
}
let parsed =
parse_markdown_with_options(&source, should_parse_html, should_parse_heading_slugs);
let parsed = parse_markdown_with_options(
&source,
should_parse_html,
should_parse_heading_slugs,
should_parse_metadata_blocks,
);
let events = parsed.events;
let language_names = parsed.language_names;
let paths = parsed.language_paths;
let root_block_starts = parsed.root_block_starts;
let html_blocks = parsed.html_blocks;
let metadata_blocks = parsed.metadata_blocks;
let heading_slugs = parsed.heading_slugs;
let footnote_definitions = parsed.footnote_definitions;
let mermaid_diagrams = if should_render_mermaid_diagrams {
@ -942,6 +951,7 @@ impl Markdown {
languages_by_path,
root_block_starts: Arc::from(root_block_starts),
html_blocks,
metadata_blocks,
mermaid_diagrams,
heading_slugs,
footnote_definitions,
@ -1070,6 +1080,7 @@ pub struct ParsedMarkdown {
pub languages_by_path: TreeMap<Arc<str>, Arc<Language>>,
pub root_block_starts: Arc<[usize]>,
pub(crate) html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>,
pub(crate) metadata_blocks: BTreeMap<usize, ParsedMetadataBlock>,
pub(crate) mermaid_diagrams: BTreeMap<usize, ParsedMarkdownMermaidDiagram>,
pub heading_slugs: HashMap<SharedString, usize>,
pub footnote_definitions: HashMap<SharedString, usize>,
@ -1398,6 +1409,114 @@ impl MarkdownElement {
builder.pop_text_style();
}
fn push_metadata_block(
&self,
builder: &mut MarkdownElementBuilder,
source: &str,
metadata_block: &ParsedMetadataBlock,
markdown_end: usize,
cx: &App,
) {
let content_range = &metadata_block.content_range;
if let Some(rows) = metadata_block.rows.as_deref() {
builder.push_div(
div()
.grid()
.grid_cols(2)
.w_full()
.mb_2()
.border_1()
.border_color(cx.theme().colors().border)
.rounded_sm()
.overflow_hidden(),
content_range,
markdown_end,
);
for (row_index, row) in rows.iter().enumerate() {
self.push_metadata_cell(
builder,
source,
row.key.clone(),
content_range,
markdown_end,
MetadataCellStyle {
row_index,
is_key: true,
},
cx,
);
self.push_metadata_cell(
builder,
source,
row.value.clone(),
content_range,
markdown_end,
MetadataCellStyle {
row_index,
is_key: false,
},
cx,
);
}
builder.pop_div();
} else {
let mut metadata_block = div().w_full().rounded_md();
metadata_block.style().refine(&self.style.code_block);
builder.push_text_style(self.style.code_block.text.to_owned());
builder.push_code_block(None);
builder.push_div(metadata_block, content_range, markdown_end);
builder.push_text(&source[content_range.clone()], content_range.clone());
builder.trim_trailing_newline();
builder.pop_div();
builder.pop_code_block();
builder.pop_text_style();
}
}
fn push_metadata_cell(
&self,
builder: &mut MarkdownElementBuilder,
source: &str,
text_range: Range<usize>,
block_range: &Range<usize>,
markdown_end: usize,
cell_style: MetadataCellStyle,
cx: &App,
) {
builder.push_div(
div()
.flex()
.flex_col()
.min_w_0()
.px_2()
.py_1()
.border_color(cx.theme().colors().border)
.when(cell_style.row_index > 0, |this| this.border_t_1())
.when(!cell_style.is_key, |this| this.border_l_1())
.when(cell_style.is_key, |this| {
this.bg(cx.theme().colors().panel_background)
}),
block_range,
markdown_end,
);
let text_style = if cell_style.is_key {
TextStyleRefinement {
color: Some(cx.theme().colors().text_muted),
font_weight: Some(FontWeight::SEMIBOLD),
..Default::default()
}
} else {
TextStyleRefinement::default()
};
builder.push_text_style(text_style);
builder.push_text(&source[text_range.clone()], text_range);
builder.pop_text_style();
builder.pop_div();
}
fn push_markdown_list_item(
&self,
builder: &mut MarkdownElementBuilder,
@ -1809,6 +1928,7 @@ impl Element for MarkdownElement {
let mut current_img_block_range: Option<Range<usize>> = None;
let mut handled_html_block = false;
let mut rendered_mermaid_block = false;
let mut rendered_metadata_block = false;
for (index, (range, event)) in parsed_markdown.events.iter().enumerate() {
// Skip alt text for images that rendered
if let Some(current_img_block_range) = &current_img_block_range
@ -1832,6 +1952,13 @@ impl Element for MarkdownElement {
continue;
}
if rendered_metadata_block {
if matches!(event, MarkdownEvent::End(MarkdownTagEnd::MetadataBlock(_))) {
rendered_metadata_block = false;
}
continue;
}
match event {
MarkdownEvent::RootStart => {
if self.show_root_block_markers {
@ -2147,7 +2274,20 @@ impl Element for MarkdownElement {
);
builder.push_div(div().flex_1().w_0(), range, markdown_end);
}
MarkdownTag::MetadataBlock(_) => {}
MarkdownTag::MetadataBlock(_) => {
if let Some(metadata_block) =
parsed_markdown.metadata_blocks.get(&range.start)
{
self.push_metadata_block(
&mut builder,
&parsed_markdown.source,
metadata_block,
markdown_end,
cx,
);
rendered_metadata_block = true;
}
}
MarkdownTag::Table(alignments) => {
builder.table.start(alignments.clone());
@ -2359,6 +2499,7 @@ impl Element for MarkdownElement {
builder.pop_div();
builder.pop_div();
}
MarkdownTagEnd::MetadataBlock(_) => {}
_ => log::debug!("unsupported markdown tag end: {:?}", tag),
},
MarkdownEvent::Text => {
@ -2752,6 +2893,11 @@ fn alignment_to_text_align(alignment: Alignment) -> Option<TextAlign> {
}
}
struct MetadataCellStyle {
row_index: usize,
is_key: bool,
}
struct MarkdownElementBuilder {
div_stack: Vec<AnyDiv>,
rendered_lines: Vec<RenderedLine>,
@ -3586,6 +3732,34 @@ mod tests {
render_markdown_with_language_registry(markdown, None, cx)
}
#[gpui::test]
fn test_frontmatter_renders_without_delimiters(cx: &mut TestAppContext) {
let rendered = render_markdown_with_options(
"---\ntitle: Post\n---\nBody",
None,
MarkdownOptions {
render_metadata_blocks: true,
..Default::default()
},
cx,
);
assert_eq!(rendered.text_for_range(0..24), "title\nPost\nBody");
}
#[gpui::test]
fn test_frontmatter_falls_back_to_code_block_for_nested_yaml(cx: &mut TestAppContext) {
let rendered = render_markdown_with_options(
"---\ntags:\n - zed\n---\nBody",
None,
MarkdownOptions {
render_metadata_blocks: true,
..Default::default()
},
cx,
);
assert_eq!(rendered.text_for_range(0..26), "tags:\n - zed\nBody");
}
fn render_markdown_with_code_span_link(
markdown: &str,
callback: impl Fn(&str, &App) -> Option<SharedString> + 'static,
@ -3873,7 +4047,7 @@ mod tests {
#[test]
fn test_table_checkbox_detection() {
let md = "| Done |\n|------|\n| [x] |\n| [ ] |";
let events = crate::parser::parse_markdown_with_options(md, false, false).events;
let events = crate::parser::parse_markdown_with_options(md, false, false, false).events;
let mut in_table = false;
let mut cell_texts: Vec<String> = Vec::new();
@ -3915,7 +4089,7 @@ mod tests {
#[test]
fn test_table_checkbox_marker_source_range() {
let md = "| Done |\n|------|\n| [x] |\n| [ ] |";
let events = crate::parser::parse_markdown_with_options(md, false, false).events;
let events = crate::parser::parse_markdown_with_options(md, false, false, false).events;
let mut in_cell = false;
let mut pending_text = String::new();
@ -4192,7 +4366,7 @@ mod tests {
}
fn has_code_block(markdown: &str) -> bool {
let parsed_data = parse_markdown_with_options(markdown, false, false);
let parsed_data = parse_markdown_with_options(markdown, false, false, false);
parsed_data
.events
.iter()

View file

@ -686,7 +686,8 @@ mod tests {
#[test]
fn test_extract_mermaid_diagrams_parses_scale() {
let markdown = "```mermaid 150\ngraph TD;\n```\n\n```rust\nfn main() {}\n```";
let events = crate::parser::parse_markdown_with_options(markdown, false, false).events;
let events =
crate::parser::parse_markdown_with_options(markdown, false, false, false).events;
let diagrams = extract_mermaid_diagrams(markdown, &events);
assert_eq!(diagrams.len(), 1);
@ -702,7 +703,8 @@ mod tests {
"```mermaid\nblock-beta\n```\n\n",
"```mermaid\nflowchart TD\n A --> B\n```",
);
let events = crate::parser::parse_markdown_with_options(markdown, false, false).events;
let events =
crate::parser::parse_markdown_with_options(markdown, false, false, false).events;
let diagrams = extract_mermaid_diagrams(markdown, &events);
assert_eq!(
diagrams.len(),

View file

@ -37,10 +37,23 @@ pub(crate) struct ParsedMarkdownData {
pub language_paths: HashSet<Arc<str>>,
pub root_block_starts: Vec<usize>,
pub html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>,
pub metadata_blocks: BTreeMap<usize, ParsedMetadataBlock>,
pub heading_slugs: HashMap<SharedString, usize>,
pub footnote_definitions: HashMap<SharedString, usize>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ParsedMetadataBlock {
pub content_range: Range<usize>,
pub rows: Option<Vec<MetadataRow>>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct MetadataRow {
pub key: Range<usize>,
pub value: Range<usize>,
}
impl ParseState {
fn push_event(&mut self, range: Range<usize>, event: MarkdownEvent) {
match &event {
@ -149,27 +162,83 @@ fn build_heading_slugs(
slugs
}
fn parse_metadata_table_rows(source: &str, source_range: Range<usize>) -> Option<Vec<MetadataRow>> {
let mut rows = Vec::new();
let mut line_start = source_range.start;
for line in source[source_range].split_inclusive('\n') {
let line_end = line_start + line.len();
let content_end = line_start + line.trim_end_matches(['\r', '\n']).len();
let content_range = line_start..content_end;
let line_text = &source[content_range.clone()];
if line_text.is_empty()
|| line_text
.chars()
.next()
.is_some_and(|character| character.is_whitespace())
{
return None;
}
let delimiter = line_text.find(':')?;
let key = trim_metadata_range(source, content_range.start..content_range.start + delimiter);
let value = trim_metadata_range(
source,
content_range.start + delimiter + 1..content_range.end,
);
if key.is_empty() || value.is_empty() {
return None;
}
rows.push(MetadataRow { key, value });
line_start = line_end;
}
if rows.is_empty() { None } else { Some(rows) }
}
fn trim_metadata_range(source: &str, range: Range<usize>) -> Range<usize> {
let text = &source[range.clone()];
let start_offset = text.len() - text.trim_start().len();
let end_offset = text.trim_end().len();
let start = range.start + start_offset;
let end = (range.start + end_offset).max(start);
start..end
}
pub(crate) fn parse_markdown_with_options(
text: &str,
parse_html: bool,
parse_heading_slugs: bool,
parse_metadata_blocks: bool,
) -> ParsedMarkdownData {
let mut state = ParseState::default();
let mut language_names = HashSet::default();
let mut language_paths = HashSet::default();
let mut html_blocks = BTreeMap::default();
let mut metadata_blocks = BTreeMap::default();
let mut within_link = false;
let mut within_code_block = false;
let mut within_metadata = false;
let mut parser = Parser::new_ext(text, PARSE_OPTIONS)
let mut current_metadata_block_start = None;
let mut metadata_block_content_range: Option<Range<usize>> = None;
let parse_options = if parse_metadata_blocks {
PARSE_OPTIONS.union(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS)
} else {
PARSE_OPTIONS
};
let mut parser = Parser::new_ext(text, parse_options)
.into_offset_iter()
.peekable();
while let Some((pulldown_event, range)) = parser.next() {
if within_metadata {
if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock { .. }) =
if within_metadata && !parse_metadata_blocks {
if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock(_)) =
pulldown_event
{
within_metadata = false;
current_metadata_block_start = None;
metadata_block_content_range = None;
}
continue;
}
@ -216,9 +285,14 @@ pub(crate) fn parse_markdown_with_options(
id: SharedString::from(id.into_string()),
}
}
pulldown_cmark::Tag::MetadataBlock(_kind) => {
pulldown_cmark::Tag::MetadataBlock(kind) => {
within_metadata = true;
continue;
current_metadata_block_start = Some(range.start);
metadata_block_content_range = None;
if !parse_metadata_blocks {
continue;
}
MarkdownTag::MetadataBlock(kind)
}
pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Indented) => {
within_code_block = true;
@ -347,6 +421,25 @@ pub(crate) fn parse_markdown_with_options(
within_link = false;
} else if let pulldown_cmark::TagEnd::CodeBlock = tag {
within_code_block = false;
} else if let pulldown_cmark::TagEnd::MetadataBlock(_) = tag {
within_metadata = false;
let block_start = current_metadata_block_start.take();
let content_range = metadata_block_content_range.take();
if parse_metadata_blocks
&& let (Some(block_start), Some(content_range)) =
(block_start, content_range)
{
metadata_blocks.insert(
block_start,
ParsedMetadataBlock {
rows: parse_metadata_table_rows(text, content_range.clone()),
content_range,
},
);
}
if !parse_metadata_blocks {
continue;
}
}
state.push_event(range, MarkdownEvent::End(tag));
}
@ -363,6 +456,18 @@ pub(crate) fn parse_markdown_with_options(
}
}
if within_metadata {
match &mut metadata_block_content_range {
Some(content_range) => {
content_range.start = content_range.start.min(range.start);
content_range.end = content_range.end.max(range.end);
}
None => metadata_block_content_range = Some(range.clone()),
}
state.push_event(range, MarkdownEvent::Text);
continue;
}
if within_code_block {
let (range, event) = event_for(text, range, &parsed);
state.push_event(range, event);
@ -541,6 +646,7 @@ pub(crate) fn parse_markdown_with_options(
language_paths,
root_block_starts: state.root_block_starts,
html_blocks,
metadata_blocks,
heading_slugs,
footnote_definitions,
}
@ -798,8 +904,8 @@ mod tests {
use super::MarkdownTag::*;
use super::*;
const UNWANTED_OPTIONS: Options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS
.union(Options::ENABLE_MATH)
const CONDITIONAL_OPTIONS: Options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS;
const UNWANTED_OPTIONS: Options = Options::ENABLE_MATH
.union(Options::ENABLE_DEFINITION_LIST)
.union(Options::ENABLE_WIKILINKS);
@ -807,21 +913,174 @@ mod tests {
fn all_options_considered() {
// The purpose of this is to fail when new options are added to pulldown_cmark, so that they
// can be evaluated for inclusion.
assert_eq!(PARSE_OPTIONS.union(UNWANTED_OPTIONS), Options::all());
assert_eq!(
PARSE_OPTIONS
.union(CONDITIONAL_OPTIONS)
.union(UNWANTED_OPTIONS),
Options::all()
);
}
#[test]
fn wanted_and_unwanted_options_disjoint() {
assert_eq!(
PARSE_OPTIONS.intersection(UNWANTED_OPTIONS),
PARSE_OPTIONS
.union(CONDITIONAL_OPTIONS)
.intersection(UNWANTED_OPTIONS),
Options::empty()
);
}
#[test]
fn test_yaml_style_metadata_block() {
assert_eq!(
parse_markdown_with_options("---\ntitle: Post\n---\n# Heading", false, false, true),
ParsedMarkdownData {
events: vec![
(0..19, RootStart),
(0..19, Start(MetadataBlock(MetadataBlockKind::YamlStyle))),
(4..16, Text),
(
0..19,
End(MarkdownTagEnd::MetadataBlock(MetadataBlockKind::YamlStyle))
),
(0..19, RootEnd(0)),
(20..29, RootStart),
(
20..29,
Start(Heading {
level: HeadingLevel::H1,
id: None,
classes: Vec::new(),
attrs: Vec::new(),
})
),
(22..29, Text),
(20..29, End(MarkdownTagEnd::Heading(HeadingLevel::H1))),
(20..29, RootEnd(1)),
],
root_block_starts: vec![0, 20],
metadata_blocks: BTreeMap::from_iter([(
0,
ParsedMetadataBlock {
content_range: 4..16,
rows: Some(vec![MetadataRow {
key: 4..9,
value: 11..15,
}]),
},
)]),
..Default::default()
}
)
}
#[test]
fn test_metadata_block_text_is_verbatim() {
let parsed =
parse_markdown_with_options("---\nurl: https://zed.dev\n---\nBody", false, false, true);
assert!(
parsed
.events
.iter()
.all(|(_, event)| !matches!(event, Start(Link { .. })))
);
}
#[test]
fn test_metadata_blocks_store_table_rows() {
let parsed = parse_markdown_with_options(
"---\ntitle: Post\nauthor: Zed\n---\nBody",
false,
false,
true,
);
assert_eq!(
parsed.metadata_blocks,
BTreeMap::from_iter([(
0,
ParsedMetadataBlock {
content_range: 4..28,
rows: Some(vec![
MetadataRow {
key: 4..9,
value: 11..15,
},
MetadataRow {
key: 16..22,
value: 24..27,
},
]),
},
)])
);
}
#[test]
fn test_metadata_blocks_store_fallback_for_nested_yaml() {
let parsed =
parse_markdown_with_options("---\ntags:\n - zed\n---\nBody", false, false, true);
assert_eq!(
parsed.metadata_blocks,
BTreeMap::from_iter([(
0,
ParsedMetadataBlock {
content_range: 4..18,
rows: None,
},
)])
);
}
#[test]
fn test_metadata_table_rows_parse_simple_colon_pairs() {
let source = "title: Post\nauthor: Zed\n";
let Some(rows) = parse_metadata_table_rows(source, 0..source.len()) else {
panic!("expected metadata rows");
};
let pairs = rows
.into_iter()
.map(|row| (&source[row.key], &source[row.value]))
.collect::<Vec<_>>();
assert_eq!(pairs, vec![("title", "Post"), ("author", "Zed")]);
}
#[test]
fn test_metadata_table_rows_reject_non_simple_colon_pairs() {
for source in [
"tags:\n - zed\n",
"title = Post\n",
"title:\n",
"title: \n",
": Post\n",
" title: Post\n",
"\n",
] {
assert!(parse_metadata_table_rows(source, 0..source.len()).is_none());
}
}
#[test]
fn test_trim_metadata_range_returns_valid_empty_range() {
let source = "key: \n";
let trimmed = trim_metadata_range(source, 4..7);
assert_eq!(trimmed, 7..7);
assert!(source[trimmed].is_empty());
}
#[test]
fn test_html_comments() {
assert_eq!(
parse_markdown_with_options(" <!--\nrdoc-file=string.c\n-->\nReturns", false, false),
parse_markdown_with_options(
" <!--\nrdoc-file=string.c\n-->\nReturns",
false,
false,
false
),
ParsedMarkdownData {
events: vec![
(2..30, RootStart),
@ -851,6 +1110,7 @@ mod tests {
"&nbsp;&nbsp; https://some.url some \\`&#9658;\\` text",
false,
false,
false,
),
ParsedMarkdownData {
events: vec![
@ -891,6 +1151,7 @@ mod tests {
"You can use the [GitHub Search API](https://docs.github.com/en",
false,
false,
false,
)
.events,
vec![
@ -925,6 +1186,7 @@ mod tests {
"-- --- ... \"double quoted\" 'single quoted' ----------",
false,
false,
false,
),
ParsedMarkdownData {
events: vec![
@ -957,7 +1219,12 @@ mod tests {
#[test]
fn test_code_block_metadata() {
assert_eq!(
parse_markdown_with_options("```rust\nfn main() {\n let a = 1;\n}\n```", false, false),
parse_markdown_with_options(
"```rust\nfn main() {\n let a = 1;\n}\n```",
false,
false,
false
),
ParsedMarkdownData {
events: vec![
(0..37, RootStart),
@ -986,7 +1253,7 @@ mod tests {
}
);
assert_eq!(
parse_markdown_with_options(" fn main() {}", false, false),
parse_markdown_with_options(" fn main() {}", false, false, false),
ParsedMarkdownData {
events: vec![
(4..16, RootStart),
@ -1012,7 +1279,7 @@ mod tests {
}
fn assert_code_block_does_not_emit_links(markdown: &str) {
let parsed = parse_markdown_with_options(markdown, false, false);
let parsed = parse_markdown_with_options(markdown, false, false, false);
let mut code_block_depth = 0;
let mut code_block_count = 0;
let mut saw_text_inside_code_block = false;
@ -1064,9 +1331,54 @@ mod tests {
}
#[test]
fn test_metadata_blocks_do_not_affect_root_blocks() {
fn test_metadata_blocks_are_root_blocks() {
assert_eq!(
parse_markdown_with_options("+++\ntitle = \"Example\"\n+++\n\nParagraph", false, false),
parse_markdown_with_options(
"+++\ntitle = \"Example\"\n+++\n\nParagraph",
false,
false,
true
),
ParsedMarkdownData {
events: vec![
(0..25, RootStart),
(0..25, Start(MetadataBlock(MetadataBlockKind::PlusesStyle))),
(4..22, Text),
(
0..25,
End(MarkdownTagEnd::MetadataBlock(
MetadataBlockKind::PlusesStyle
))
),
(0..25, RootEnd(0)),
(27..36, RootStart),
(27..36, Start(Paragraph)),
(27..36, Text),
(27..36, End(MarkdownTagEnd::Paragraph)),
(27..36, RootEnd(1)),
],
root_block_starts: vec![0, 27],
metadata_blocks: BTreeMap::from_iter([(
0,
ParsedMetadataBlock {
content_range: 4..22,
rows: None,
},
)]),
..Default::default()
}
);
}
#[test]
fn test_metadata_blocks_are_omitted_by_default() {
assert_eq!(
parse_markdown_with_options(
"+++\ntitle = \"Example\"\n+++\n\nParagraph",
false,
false,
false
),
ParsedMarkdownData {
events: vec![
(27..36, RootStart),
@ -1088,7 +1400,7 @@ mod tests {
|------|---------|
| [x] | Fix bug |
| [ ] | Add feature |";
let parsed = parse_markdown_with_options(markdown, false, false);
let parsed = parse_markdown_with_options(markdown, false, false, false);
let mut in_table = false;
let mut saw_task_list_marker = false;
@ -1164,6 +1476,7 @@ mod tests {
"Text with a footnote[^1] and some more text.\n\n[^1]: This is the footnote content.",
false,
false,
false,
);
assert_eq!(
parsed.events,
@ -1194,6 +1507,7 @@ mod tests {
"Text[^a] and[^b].\n\n[^a]: First.\n\n[^b]: Second.",
false,
false,
false,
);
assert_eq!(parsed.footnote_definitions.len(), 2);
assert!(parsed.footnote_definitions.contains_key("a"));
@ -1211,6 +1525,7 @@ mod tests {
"https:/\\/example.com is equivalent to https://example&#46;com!",
false,
false,
false,
)
.events,
vec![
@ -1253,6 +1568,7 @@ mod tests {
"Visit https://example.com/cat\\/é&#8205;☕ for coffee!",
false,
false,
false,
)
.events,
[
@ -1286,6 +1602,7 @@ mod tests {
"# Hello World\n\n## Code `block`\n\n### Third Level\n\n#### Fourth Level\n\n## Hello World",
false,
true,
false,
);
assert_eq!(parsed.heading_slugs.len(), 5);
assert!(parsed.heading_slugs.contains_key("hello-world"));
@ -1301,6 +1618,7 @@ mod tests {
"# Duplicate\n\nText\n\n## Duplicate\n\nMore text",
false,
true,
false,
);
let first = parsed.heading_slugs.get("duplicate").copied();
let second = parsed.heading_slugs.get("duplicate-1").copied();
@ -1311,7 +1629,7 @@ mod tests {
#[test]
fn test_heading_slug_collision_with_dedup_suffix() {
let parsed = parse_markdown_with_options("# Foo\n\n## Foo\n\n## Foo 1", false, true);
let parsed = parse_markdown_with_options("# Foo\n\n## Foo\n\n## Foo 1", false, true, false);
assert_eq!(parsed.heading_slugs.len(), 3);
assert!(parsed.heading_slugs.contains_key("foo"));
assert!(parsed.heading_slugs.contains_key("foo-1"));
@ -1323,7 +1641,7 @@ mod tests {
use pulldown_cmark::BlockQuoteKind;
let markdown = "\n> [!NOTE]\n> A note.\n\n> [!TIP]\n> A tip.\n\n> [!IMPORTANT]\n> Important.\n\n> [!WARNING]\n> A warning.\n\n> [!CAUTION]\n> A caution.\n\n> Plain quote.\n";
let parsed = parse_markdown_with_options(markdown, false, false);
let parsed = parse_markdown_with_options(markdown, false, false, false);
let block_quote_kinds: Vec<_> = parsed
.events

View file

@ -223,6 +223,7 @@ impl MarkdownPreviewView {
parse_html: true,
render_mermaid_diagrams: true,
parse_heading_slugs: true,
render_metadata_blocks: true,
..Default::default()
},
cx,

View file

@ -20,11 +20,11 @@ use futures_lite::future::yield_now;
use gpui::{App, Context, Entity, EventEmitter};
use itertools::Itertools;
use language::{
AutoindentMode, Buffer, BufferChunks, BufferRow, BufferSnapshot, Capability, CharClassifier,
CharKind, CharScopeContext, Chunk, CursorShape, DiagnosticEntryRef, File, IndentGuideSettings,
IndentSize, Language, LanguageAwareStyling, LanguageScope, OffsetRangeExt, OffsetUtf16,
Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject, ToOffset as _,
ToPoint as _, TransactionId, TreeSitterOptions, Unclipped,
AutoindentMode, Buffer, BufferChunks, BufferEditSource, BufferRow, BufferSnapshot, Capability,
CharClassifier, CharKind, CharScopeContext, Chunk, CursorShape, DiagnosticEntryRef, File,
IndentGuideSettings, IndentSize, Language, LanguageAwareStyling, LanguageScope, OffsetRangeExt,
OffsetUtf16, Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject,
ToOffset as _, ToPoint as _, TransactionId, TreeSitterOptions, Unclipped,
language_settings::{AllLanguageSettings, LanguageSettings},
};
@ -110,7 +110,7 @@ pub enum Event {
DiffHunksToggled,
Edited {
edited_buffer: Option<Entity<Buffer>>,
is_local: bool,
source: BufferEditSource,
},
TransactionUndone {
transaction_id: TransactionId,
@ -1828,7 +1828,7 @@ impl MultiBuffer {
}
cx.emit(Event::Edited {
edited_buffer: None,
is_local: true,
source: BufferEditSource::User,
});
cx.emit(Event::BuffersRemoved { removed_buffer_ids });
cx.notify();
@ -1952,9 +1952,9 @@ impl MultiBuffer {
use language::BufferEvent;
let buffer_id = buffer.read(cx).remote_id();
cx.emit(match event {
&BufferEvent::Edited { is_local } => Event::Edited {
&BufferEvent::Edited { source } => Event::Edited {
edited_buffer: Some(buffer),
is_local,
source,
},
BufferEvent::DirtyChanged => Event::DirtyChanged,
BufferEvent::Saved => Event::Saved,
@ -2044,7 +2044,7 @@ impl MultiBuffer {
}
cx.emit(Event::Edited {
edited_buffer: None,
is_local: true,
source: BufferEditSource::User,
});
}
@ -2090,7 +2090,7 @@ impl MultiBuffer {
}
cx.emit(Event::Edited {
edited_buffer: None,
is_local: true,
source: BufferEditSource::User,
});
}
@ -2313,7 +2313,7 @@ impl MultiBuffer {
cx.emit(Event::DiffHunksToggled);
cx.emit(Event::Edited {
edited_buffer: None,
is_local: true,
source: BufferEditSource::User,
});
}
@ -2449,7 +2449,7 @@ impl MultiBuffer {
cx.emit(Event::DiffHunksToggled);
cx.emit(Event::Edited {
edited_buffer: None,
is_local: true,
source: BufferEditSource::User,
});
}
@ -3102,7 +3102,7 @@ impl MultiBuffer {
cx.emit(Event::DiffHunksToggled);
cx.emit(Event::Edited {
edited_buffer: None,
is_local: true,
source: BufferEditSource::User,
});
}
}

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