Compare commits

...

24 commits

Author SHA1 Message Date
G36maid
78cffb6103
Merge 487ea9ff0d into 09165c15dc 2026-05-31 11:07:44 +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
G36maid
487ea9ff0d run cargo fmt
Remove unused cfg-if and release_channel deps from crashes crate

Update Cargo.lock
2026-05-14 22:05:50 +08:00
G36maid
cbacd2e2a9 Update libz-sys to 1.1.28 2026-05-14 22:05:50 +08:00
G36maid
e722eaa4e4 remote: Add FreeBSD to RemoteOs enum and platform detection
Add FreeBSD as a recognized remote OS so the Zed client can connect
to FreeBSD hosts without requiring a uname wrapper workaround.

- Add RemoteOs::FreeBSD variant with "freebsd" identifier
- Add "FreeBSD" to parse_platform() uname parsing
- Add "unknown-freebsd" target triple mapping
- FreeBSD uses PathStyle::Posix (already covered by wildcard arms)
2026-05-14 22:05:50 +08:00
G36maid
ac1fe538df Rename crashes_full.rs back to crashes.rs
Per review feedback, the original name is clearer since it's the
default (non-FreeBSD) implementation.
2026-05-14 22:05:50 +08:00
G36maid
7959d600c1 Add FreeBSD support for remote_server
Gate crash-handler and minidumper behind cfg(not(target_os = "freebsd"))
since neither crate supports FreeBSD. Provide no-op stubs in the crashes
crate so remote_server compiles and links without them.

Add target_os = "freebsd" to gpui queue module cfg gates (same POSIX
APIs as Linux).

Fix MaybeUninit usage in fs::current_path() for FreeBSD: use zeroed()
and assume_init_mut() instead of uninit() + as_mut_ptr() which is UB
when accessing fields of uninitialized memory.

Release Notes:

- N/A
2026-05-13 20:38:02 +08:00
54 changed files with 2729 additions and 1024 deletions

69
Cargo.lock generated
View file

@ -109,7 +109,6 @@ dependencies = [
"parking_lot",
"portable-pty",
"project",
"prompt_store",
"rand 0.9.4",
"sandbox",
"serde",
@ -406,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",
@ -2163,7 +2162,7 @@ dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools 0.11.0",
"itertools 0.10.5",
"log",
"prettyplease",
"proc-macro2",
@ -2183,7 +2182,7 @@ dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools 0.11.0",
"itertools 0.10.5",
"proc-macro2",
"quote",
"regex",
@ -4335,10 +4334,12 @@ version = "0.1.0"
dependencies = [
"async-process",
"crash-handler",
"futures 0.3.32",
"log",
"mach2 0.5.0",
"minidumper",
"parking_lot",
"paths",
"serde",
"serde_json",
"system_specs",
@ -5313,7 +5314,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -5783,7 +5784,7 @@ dependencies = [
"client",
"clock",
"collections",
"convert_case 0.8.0",
"convert_case 0.11.0",
"criterion",
"ctor",
"dap",
@ -6148,7 +6149,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]]
@ -7604,7 +7605,7 @@ dependencies = [
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -9065,7 +9066,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@ -9083,7 +9084,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.56.0",
]
[[package]]
@ -9337,7 +9338,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",
]
@ -10147,7 +10148,7 @@ dependencies = [
"cloud_api_types",
"collections",
"component",
"convert_case 0.8.0",
"convert_case 0.11.0",
"copilot",
"copilot_chat",
"copilot_ui",
@ -10480,7 +10481,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",
@ -10503,9 +10504,9 @@ dependencies = [
[[package]]
name = "libz-sys"
version = "1.1.22"
version = "1.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d"
checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22"
dependencies = [
"cc",
"libc",
@ -10590,7 +10591,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",
@ -10616,7 +10617,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",
@ -10643,7 +10644,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",
@ -10659,7 +10660,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",
@ -11365,7 +11366,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"collections",
"convert_case 0.8.0",
"convert_case 0.11.0",
"log",
"pretty_assertions",
"serde_json",
@ -11951,7 +11952,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]]
@ -14483,7 +14484,6 @@ dependencies = [
"db",
"fs",
"futures 0.3.32",
"fuzzy",
"gpui",
"handlebars 4.5.0",
"heed",
@ -14491,7 +14491,6 @@ dependencies = [
"log",
"parking_lot",
"paths",
"rope",
"serde",
"serde_json",
"strum 0.27.2",
@ -14590,7 +14589,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4"
dependencies = [
"bytes 1.11.1",
"heck 0.5.0",
"itertools 0.11.0",
"itertools 0.10.5",
"log",
"multimap",
"once_cell",
@ -14623,7 +14622,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",
@ -14885,7 +14884,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",
@ -14922,9 +14921,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]]
@ -16149,7 +16148,7 @@ dependencies = [
"errno 0.3.14",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -18771,7 +18770,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -19488,7 +19487,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",
@ -19696,7 +19695,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]]
@ -21489,7 +21488,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",
@ -21503,7 +21502,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",
@ -21801,7 +21800,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]]

View file

@ -559,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"] }
@ -891,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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,13 +6,17 @@ edition.workspace = true
license = "GPL-3.0-or-later"
[dependencies]
async-process.workspace = true
crash-handler.workspace = true
futures.workspace = true
log.workspace = true
minidumper.workspace = true
parking_lot.workspace = true
serde.workspace = true
serde_json.workspace = true
[target.'cfg(not(target_os = "freebsd"))'.dependencies]
async-process.workspace = true
crash-handler.workspace = true
minidumper.workspace = true
paths.workspace = true
system_specs.workspace = true
zstd.workspace = true
@ -26,4 +30,4 @@ windows.workspace = true
workspace = true
[lib]
path = "src/crashes.rs"
path = "src/lib.rs"

View file

@ -0,0 +1,33 @@
use std::future::Future;
use std::path::Path;
use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
pub static REQUESTED_MINIDUMP: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct InitCrashHandler {
pub session_id: String,
pub zed_version: String,
pub binary: String,
pub release_channel: String,
pub commit_sha: String,
}
pub fn init<F: Future<Output = ()> + Send + Sync + 'static>(
_crash_init: InitCrashHandler,
_spawn: impl FnOnce(BoxFuture<'static, ()>),
_wait_timer: impl (Fn(std::time::Duration) -> F) + Send + Sync + 'static,
) {
log::info!("crash handler disabled on FreeBSD");
}
pub fn crash_server(_socket: &Path) {
log::info!("crash server disabled on FreeBSD");
}
pub fn set_gpu_info(_specs: ()) {}
pub fn set_user_info(_info: ()) {}

11
crates/crashes/src/lib.rs Normal file
View file

@ -0,0 +1,11 @@
#[cfg(not(target_os = "freebsd"))]
mod crashes;
#[cfg(not(target_os = "freebsd"))]
pub use crashes::*;
#[cfg(target_os = "freebsd")]
mod crashes_freebsd;
#[cfg(target_os = "freebsd")]
pub use crashes_freebsd::*;

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

@ -451,14 +451,15 @@ impl FileHandle for std::fs::File {
};
let fd = self.as_fd();
let mut kif = MaybeUninit::<libc::kinfo_file>::uninit();
let mut kif = MaybeUninit::<libc::kinfo_file>::zeroed();
// SAFETY: zeroed memory is a valid initial state for kinfo_file.
let kif = unsafe { kif.assume_init_mut() };
kif.kf_structsize = libc::KINFO_FILE_SIZE;
let result = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_KINFO, kif.as_mut_ptr()) };
let result = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_KINFO, kif as *mut _) };
anyhow::ensure!(result != -1, "fcntl returned -1");
// SAFETY: `fcntl` will initialize the kif.
let c_str = unsafe { CStr::from_ptr(kif.assume_init().kf_path.as_ptr()) };
let c_str = unsafe { CStr::from_ptr(kif.kf_path.as_ptr()) };
anyhow::ensure!(!c_str.is_empty(), "Could find a path for the file handle");
let path = PathBuf::from(OsStr::from_bytes(c_str.to_bytes()));
Ok(path)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -35,7 +35,12 @@ mod platform;
pub mod prelude;
/// Profiling utilities for task timing and thread performance tracking.
pub mod profiler;
#[cfg(any(target_os = "windows", target_os = "linux", target_family = "wasm"))]
#[cfg(any(
target_os = "windows",
target_os = "linux",
target_os = "freebsd",
target_family = "wasm"
))]
#[expect(missing_docs)]
pub mod queue;
mod scene;
@ -107,7 +112,12 @@ pub use keymap::*;
pub use path_builder::*;
pub use platform::*;
pub use profiler::*;
#[cfg(any(target_os = "windows", target_os = "linux", target_family = "wasm"))]
#[cfg(any(
target_os = "windows",
target_os = "linux",
target_os = "freebsd",
target_family = "wasm"
))]
pub use queue::{PriorityQueueReceiver, PriorityQueueSender};
pub use refineable::*;
pub use scene::*;

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

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

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

View file

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

View file

@ -57,6 +57,7 @@ pub enum RemoteOs {
Linux,
MacOs,
Windows,
FreeBSD,
}
impl RemoteOs {
@ -65,6 +66,7 @@ impl RemoteOs {
RemoteOs::Linux => "linux",
RemoteOs::MacOs => "macos",
RemoteOs::Windows => "windows",
RemoteOs::FreeBSD => "freebsd",
}
}
@ -1555,7 +1557,7 @@ type ResponseChannels = Mutex<HashMap<MessageId, oneshot::Sender<(Envelope, ones
type StreamResponseChannels =
Arc<Mutex<HashMap<MessageId, UnboundedSender<(Result<Envelope>, oneshot::Sender<()>)>>>>;
struct Signal<T> {
struct Signal<T: 'static> {
tx: Mutex<Option<oneshot::Sender<T>>>,
rx: Shared<Task<Option<T>>>,
}

View file

@ -32,6 +32,7 @@ fn parse_platform(output: &str) -> Result<RemotePlatform> {
let os = match os {
"Darwin" => RemoteOs::MacOs,
"Linux" => RemoteOs::Linux,
"FreeBSD" => RemoteOs::FreeBSD,
_ => anyhow::bail!(
"Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
),
@ -244,6 +245,7 @@ async fn build_remote_server_from_source(
"unknown-linux-gnu"
},
RemoteOs::MacOs => "apple-darwin",
RemoteOs::FreeBSD => "unknown-freebsd",
RemoteOs::Windows if cfg!(windows) => "pc-windows-msvc",
RemoteOs::Windows => "pc-windows-gnu",
}
@ -444,6 +446,10 @@ mod tests {
assert!(parse_platform("Windows x86_64\n").is_err());
assert!(parse_platform("Linux armv7l\n").is_err());
let result = parse_platform("FreeBSD x86_64\n").unwrap();
assert_eq!(result.os, RemoteOs::FreeBSD);
assert_eq!(result.arch, RemoteArch::X86_64);
}
#[test]

View file

@ -75,11 +75,13 @@ thiserror.workspace = true
rayon.workspace = true
uuid = { workspace = true, features = ["v4"] }
[target.'cfg(not(windows))'.dependencies]
[target.'cfg(not(any(target_os = "windows", target_os = "freebsd")))'.dependencies]
crash-handler.workspace = true
minidumper.workspace = true
[target.'cfg(not(windows))'.dependencies]
fork.workspace = true
libc.workspace = true
minidumper.workspace = true
[target.'cfg(windows)'.dependencies]
windows.workspace = true

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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