From 97b1e64a177a2fe3c2803e323087b5c2fa6fff1e Mon Sep 17 00:00:00 2001
From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com>
Date: Wed, 2 Sep 2026 05:03:27 +0000
Subject: [PATCH 001/283] gpui: Record hang trigger class and set the release
frame budget to 24ms (#63586)
Hang incidents come in two classes that consumers could only tell apart
by comparing `stall_ms` against the hang threshold, which is not
recorded in the event: a single event over the threshold, or an interval
whose cumulative foreground spend reached the frame budget. This PR
makes the class explicit and re-tunes the budget.
- `HangIncident` and `SerializedHangIncident` gain `trigger:
HangTrigger` (`threshold` | `budget`).
- The `Hang Incidents` telemetry event reports `threshold_incidents` and
`budget_incidents` alongside the existing `total_incidents` (kept for
existing queries; it is now the sum of the two).
- The release frame budget moves from 8 ms to 24 ms. At 8 ms almost
every busy 60 Hz frame qualified, so the budget class dominated incident
counts without saying much. 24 ms is above every refresh period, so a
budget incident now means the interval dropped at least one frame on any
display. Frame smoothness below that is measured per-frame by `Frame
Duration Report`. `budget_incidents` is the signal for lowering it
further as hangs get fixed. Debug builds already used 100 ms and are
unchanged.
Part of a series on hang-telemetry data quality; #63588 handles
withheld-frame `dirty_at`, and marking OS prompt waits so they stop
registering as task-poll hangs is next.
Release Notes:
- N/A
---
crates/gpui/src/profiler/hang.rs | 41 ++++++++++++++++---
crates/zed/src/reliability/hang_detection.rs | 6 +--
.../reliability/hang_detection/telemetry.rs | 28 +++++++++----
3 files changed, 59 insertions(+), 16 deletions(-)
diff --git a/crates/gpui/src/profiler/hang.rs b/crates/gpui/src/profiler/hang.rs
index 9514931225..7aea645b81 100644
--- a/crates/gpui/src/profiler/hang.rs
+++ b/crates/gpui/src/profiler/hang.rs
@@ -40,13 +40,30 @@ pub struct HangIncident {
/// The interval the hangs occurred in, including all non-hang foreground
/// work recorded alongside them.
pub snapshot: FrameSnapshot,
- /// The events that blocked the foreground for at least the detector's
- /// threshold, longest first. When the incident was triggered by the
- /// frame budget alone, no event crossed the threshold and this instead
+ /// Which detection rule qualified the interval.
+ pub trigger: HangTrigger,
+ /// For [`HangTrigger::Threshold`], the events that blocked the foreground
+ /// for at least the detector's threshold, longest first. For
+ /// [`HangTrigger::Budget`], no event crossed the threshold and this instead
/// holds every event in the interval, longest first.
pub contributors: Vec,
}
+/// The detection rule that qualified an interval as a [`HangIncident`].
+///
+/// Recorded explicitly so consumers can separate the two classes without
+/// re-deriving them from `stall_ms`, which stops working whenever the
+/// detector's thresholds change.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum HangTrigger {
+ /// A single event blocked the foreground for at least the hang threshold.
+ Threshold,
+ /// No single event crossed the threshold, but the interval's total
+ /// foreground spend reached the frame budget.
+ Budget,
+}
+
impl HangDetector {
/// Creates a detector reporting single events at or above `threshold`
/// and intervals whose total foreground spend reached `frame_budget`.
@@ -98,6 +115,8 @@ pub struct SerializedHangIncident {
/// newly drawn frame finished platform submission (see
/// [`HangDetector::first_present_at`]), otherwise `"steady"`.
pub phase: &'static str,
+ /// `"threshold"` or `"budget"` (see [`HangTrigger`]).
+ pub trigger: HangTrigger,
/// When the incident's active window started, in milliseconds since app
/// startup: the sealing frame's first invalidation, or the earliest
/// contributor's start when nothing was pending a repaint. Foreground
@@ -250,6 +269,7 @@ impl SerializedHangIncident {
Some(first_present_at) if active_start >= first_present_at => "steady",
_ => "startup",
},
+ trigger: incident.trigger,
start_ms: since_startup(active_start),
active_ms: as_millis(active),
stall_ms: incident
@@ -407,7 +427,7 @@ impl HangIncident {
.filter(|event| event.duration() >= threshold)
.copied()
.collect();
- if contributors.is_empty() {
+ let trigger = if contributors.is_empty() {
if snapshot.journal_discontinuous {
return None;
}
@@ -416,10 +436,14 @@ impl HangIncident {
return None;
}
contributors = snapshot.events.clone();
- }
+ HangTrigger::Budget
+ } else {
+ HangTrigger::Threshold
+ };
contributors.sort_by_key(|event| std::cmp::Reverse(event.duration()));
Some(Self {
snapshot,
+ trigger,
contributors,
})
}
@@ -449,7 +473,9 @@ mod tests {
InputTiming, IntervalBoundary, PollSummary, PresentedFrame, SmallPollFlush,
install_test_foreground_journal, record_present,
};
- use super::{HangDetector, HangIncident, SerializedHangContributor, SerializedHangIncident};
+ use super::{
+ HangDetector, HangIncident, HangTrigger, SerializedHangContributor, SerializedHangIncident,
+ };
actions!(hang_test, [HangyAction]);
@@ -567,6 +593,7 @@ mod tests {
let serialized = SerializedHangIncident::convert(startup, &incident, 1, Some(at(50)));
assert_eq!(serialized.phase, "steady");
+ assert_eq!(serialized.trigger, HangTrigger::Threshold);
// The frame's first invalidation anchors the active window, not the
// interval start or the first contributor.
assert_eq!(serialized.start_ms, 100.0);
@@ -755,12 +782,14 @@ mod tests {
let incident = HangIncident::detect(snapshot, HANG_THRESHOLD, FRAME_BUDGET)
.expect("foreground spend exceeded the frame budget");
+ assert_eq!(incident.trigger, HangTrigger::Budget);
assert_eq!(incident.contributors.len(), 3);
assert_eq!(
incident.contributors[0].duration(),
Duration::from_millis(8)
);
let serialized = SerializedHangIncident::convert(startup, &incident, 8, Some(startup));
+ assert_eq!(serialized.trigger, HangTrigger::Budget);
assert_eq!(serialized.stall_ms, 8.0);
assert_eq!(serialized.dirty_to_present_ms, Some(150.0));
assert_eq!(serialized.sealed_by, "present");
diff --git a/crates/zed/src/reliability/hang_detection.rs b/crates/zed/src/reliability/hang_detection.rs
index bb4db32198..55423269ae 100644
--- a/crates/zed/src/reliability/hang_detection.rs
+++ b/crates/zed/src/reliability/hang_detection.rs
@@ -47,9 +47,9 @@ pub(crate) fn start(client: Arc, cx: &mut App) {
// constantly.
Duration::from_millis(100)
} else {
- // One 120Hz refresh: an interval that spends this much on the
- // foreground has plausibly dropped a frame.
- Duration::from_millis(8)
+ // At least one dropped frame on any display. Generous while budget
+ // incidents are plentiful; lower it as they get fixed.
+ Duration::from_millis(24)
};
if cfg!(debug_assertions) {
diff --git a/crates/zed/src/reliability/hang_detection/telemetry.rs b/crates/zed/src/reliability/hang_detection/telemetry.rs
index 97eea191c0..c1bf9e4d5e 100644
--- a/crates/zed/src/reliability/hang_detection/telemetry.rs
+++ b/crates/zed/src/reliability/hang_detection/telemetry.rs
@@ -1,9 +1,9 @@
use std::time::{Duration, Instant};
-use gpui::profiler::hang::SerializedHangIncident;
+use gpui::profiler::hang::{HangTrigger, SerializedHangIncident};
/// Cap on incidents per telemetry event. When more accrue between sends, the
-/// ones with the largest stalls are kept and `total_incidents` still counts
+/// ones with the largest stalls are kept and the incident counts still cover
/// them all.
const MAX_REPORTED_INCIDENTS: usize = 10;
@@ -14,7 +14,8 @@ const SEND_INTERVAL: Duration = Duration::from_mins(30);
pub struct Reporter {
last_send: Instant,
pending: Vec,
- total_incidents: u64,
+ threshold_incidents: u64,
+ budget_incidents: u64,
}
impl Reporter {
@@ -22,12 +23,16 @@ impl Reporter {
Self {
last_send: Instant::now(),
pending: Vec::new(),
- total_incidents: 0,
+ threshold_incidents: 0,
+ budget_incidents: 0,
}
}
pub fn add(&mut self, incident: SerializedHangIncident) {
- self.total_incidents += 1;
+ match incident.trigger {
+ HangTrigger::Threshold => self.threshold_incidents += 1,
+ HangTrigger::Budget => self.budget_incidents += 1,
+ }
self.pending.push(incident);
if self.pending.len() > MAX_REPORTED_INCIDENTS {
self.pending
@@ -49,8 +54,17 @@ impl Reporter {
}
let mut incidents = std::mem::take(&mut self.pending);
incidents.sort_by(|a, b| b.stall_ms.total_cmp(&a.stall_ms));
- let total_incidents = std::mem::take(&mut self.total_incidents);
+ let threshold_incidents = std::mem::take(&mut self.threshold_incidents);
+ let budget_incidents = std::mem::take(&mut self.budget_incidents);
+ // `total_incidents` predates the split; existing queries key on it.
+ let total_incidents = threshold_incidents + budget_incidents;
- telemetry::event!("Hang Incidents", incidents, total_incidents);
+ telemetry::event!(
+ "Hang Incidents",
+ incidents,
+ total_incidents,
+ threshold_incidents,
+ budget_incidents
+ );
}
}
From d56dca8084e88c06df35a44ee103f41d21d06e10 Mon Sep 17 00:00:00 2001
From: Lena <241371603+zelenenka@users.noreply.github.com>
Date: Wed, 2 Sep 2026 11:30:46 +0000
Subject: [PATCH 002/283] Stop labeling reopened triaged issues as 'needs
triage' (#63615)
Release Notes:
- N/A
---
.github/workflows/catch_blank_issues.yml | 20 ++++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/catch_blank_issues.yml b/.github/workflows/catch_blank_issues.yml
index 15a61a50cf..c37ea06d86 100644
--- a/.github/workflows/catch_blank_issues.yml
+++ b/.github/workflows/catch_blank_issues.yml
@@ -57,15 +57,27 @@ jobs:
github-token: ${{ steps.get-app-token.outputs.token }}
script: |
const issue = context.payload.issue;
- const hasTriageLabel = issue.labels.some(
- label => label.name === 'state:needs triage'
- );
+ const labelNames = issue.labels.map(label => label.name);
- if (hasTriageLabel) {
+ if (labelNames.includes('state:needs triage')) {
console.log('Issue already has state:needs triage, skipping');
return;
}
+ if (context.payload.action === 'reopened') {
+ const requiredPrefixes = ['area:', 'reach:', 'severity:'];
+ const alreadyTriaged = requiredPrefixes.every(
+ prefix => labelNames.some(name => name.startsWith(prefix))
+ );
+
+ if (alreadyTriaged) {
+ console.log(
+ `Reopened issue #${issue.number} already has area:, reach: and severity: labels, skipping`
+ );
+ return;
+ }
+ }
+
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
From 837264129f6feff948d93dcd62a6cc1b303b2cc4 Mon Sep 17 00:00:00 2001
From: Matei Oprea
Date: Wed, 2 Sep 2026 11:59:16 +0000
Subject: [PATCH 003/283] workspace: Fix captured focus when opening modals
(#63512)
# Objective
- This PR fixes the captured focus in modals.
- Fixes #63480 (and actually this was not specific to the task modal. it
was generic and applied to all modals)
## Solution
- Previously when a modal was opened the `previous_focus_handle` was
stealing the focus because it was captured in `show_modal`. The fix was
to capture it before calling `show_modal` or `reveal_stashed_modal` and
pass it to `show_modal`. That fixes the bug :) (yuhuu).
## Testing
- Tested by running the build and trying to reproduce the bug. I
couldn't reproduce it anymore.
- For testing, a reviewer also needs to test the revelal stashed modal
functionality. I did it with the file finder.
## 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 adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
---
Release Notes:
- Fixed previous window focus handle not being captured correctly when
opening modals
---
crates/workspace/src/modal_layer.rs | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/crates/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs
index ec0254868b..884db6567a 100644
--- a/crates/workspace/src/modal_layer.rs
+++ b/crates/workspace/src/modal_layer.rs
@@ -157,15 +157,17 @@ impl ModalLayer {
// Opening a modal explicitly supersedes any reveal that was waiting for the
// layer to become free.
self.reveal_stash_when_free = false;
+ let mut previous_focus_handle = window.focused(cx);
if let Some(active_modal) = &self.active_modal {
let should_close = active_modal.modal.view().downcast::().is_ok();
+ previous_focus_handle = active_modal.previous_focus_handle.clone();
let did_close = self.hide_modal(window, cx);
if should_close || !did_close {
return;
}
}
let new_modal = cx.new(|cx| build_view(window, cx));
- self.show_modal(Box::new(new_modal), window, cx);
+ self.show_modal(Box::new(new_modal), previous_focus_handle, window, cx);
cx.emit(ModalOpenedEvent);
}
@@ -174,6 +176,7 @@ impl ModalLayer {
fn show_modal(
&mut self,
modal: Box,
+ previous_focus_handle: Option,
window: &mut Window,
cx: &mut Context,
) {
@@ -190,7 +193,7 @@ impl ModalLayer {
}
}),
],
- previous_focus_handle: window.focused(cx),
+ previous_focus_handle,
focus_handle,
});
cx.defer_in(window, move |_, window, cx| {
@@ -214,7 +217,8 @@ impl ModalLayer {
let Some(modal) = self.stashed_modal.take() else {
return false;
};
- self.show_modal(modal, window, cx);
+ let previous_focus_handle = window.focused(cx);
+ self.show_modal(modal, previous_focus_handle, window, cx);
cx.emit(ModalOpenedEvent);
true
}
From a24cafa9425f9113fdc1e38ea509f651fe71ebe5 Mon Sep 17 00:00:00 2001
From: itsfuad
Date: Wed, 2 Sep 2026 13:11:30 +0000
Subject: [PATCH 004/283] editor: Fix completion menu label overlapping
documentation text (#63581)
Closes #63301
The completion label row set `min_w_0()`, which removes a flex item's
automatic minimum size and lets it shrink below its own content width.
The label inside is `flex_none` and never shrinks, so for long
completion labels the text overflowed the row and, with GPUI's default
`Overflow::Visible`, painted directly on top of the right-aligned
documentation text.
Removing `min_w_0()` restores the row's automatic minimum so it no
longer shrinks past the label. The documentation label already has
`flex_shrink(1.0)`, `min_w_0()` and `truncate()`, so it is now the only
element that gives way, shrinking and ellipsizing as intended.
The resulting layout is `[full label] [documentation, ellipsized when it
overflows]`.
### Before
### After
Release Notes:
- Fixed completion menu labels overlapping the documentation text for
long completion items
---
crates/editor/src/code_context_menus.rs | 1 -
1 file changed, 1 deletion(-)
diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs
index 209fdf13bd..d635a02ff3 100644
--- a/crates/editor/src/code_context_menus.rs
+++ b/crates/editor/src/code_context_menus.rs
@@ -1172,7 +1172,6 @@ impl CompletionsMenu {
.start_slot::(start_slot)
.child(
h_flex()
- .min_w_0()
.flex_grow_1()
.when(left_aligned_suffix, |this| this.justify_start())
.when(right_aligned_suffix, |this| {
From 480d81bf6ae9d344b81b7cb82a535a197a9b4832 Mon Sep 17 00:00:00 2001
From: Neel
Date: Wed, 2 Sep 2026 13:50:06 +0000
Subject: [PATCH 005/283] language_models: Allow cancelling ChatGPT
Subscription setup (#63627)
On `main`, the only way to interrupt ChatGPT Subscription setup is to
restart Zed.
---
Release Notes:
- Add a button to cancel ChatGPT Subscription setup
---
.../src/provider/openai_subscribed.rs | 33 +++--
.../src/openai_subscribed.rs | 127 ++++++++++++++++--
2 files changed, 143 insertions(+), 17 deletions(-)
diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs
index 024489457f..bac7831a6d 100644
--- a/crates/language_models/src/provider/openai_subscribed.rs
+++ b/crates/language_models/src/provider/openai_subscribed.rs
@@ -211,8 +211,10 @@ impl Render for ConfigurationView {
let last_auth_error = state.last_auth_error();
let provider_state = self.state.clone();
+ let cancel_provider_state = self.state.clone();
let is_signing_in = state.is_signing_in();
+ let is_sign_in_cancellable = state.is_sign_in_cancellable();
let button_label = if is_signing_in {
"Signing in…"
} else {
@@ -225,14 +227,29 @@ impl Render for ConfigurationView {
this.child(Label::new(SUBSCRIPTION_DESCRIPTION))
})
.child(
- Button::new("sign-in", button_label)
- .when(!self.compact, |this| this.full_width())
- .style(ButtonStyle::Outlined)
- .size(ButtonSize::Medium)
- .loading(is_signing_in)
- .disabled(is_signing_in)
- .on_click(move |_, _window, cx| {
- provider_state.update(cx, |state, cx| state.sign_in(cx));
+ h_flex()
+ .gap_2()
+ .child(
+ Button::new("sign-in", button_label)
+ .when(!self.compact, |this| this.full_width())
+ .style(ButtonStyle::Outlined)
+ .size(ButtonSize::Medium)
+ .loading(is_signing_in)
+ .disabled(is_signing_in)
+ .on_click(move |_, _window, cx| {
+ provider_state.update(cx, |state, cx| state.sign_in(cx));
+ }),
+ )
+ .when(is_sign_in_cancellable, |this| {
+ this.child(
+ Button::new("cancel-sign-in", "Cancel")
+ .style(ButtonStyle::Subtle)
+ .size(ButtonSize::Medium)
+ .on_click(move |_, _window, cx| {
+ cancel_provider_state
+ .update(cx, |state, cx| state.cancel_sign_in(cx));
+ }),
+ )
}),
)
.when_some(last_auth_error, |this, error| {
diff --git a/crates/openai_subscribed/src/openai_subscribed.rs b/crates/openai_subscribed/src/openai_subscribed.rs
index 431bab98bd..9d8de4e093 100644
--- a/crates/openai_subscribed/src/openai_subscribed.rs
+++ b/crates/openai_subscribed/src/openai_subscribed.rs
@@ -62,9 +62,15 @@ impl CodexCredentials {
}
}
+enum SignInState {
+ Idle,
+ Authorizing(Task>),
+ PersistingCredentials { _task: Task> },
+}
+
pub struct State {
credentials: Option,
- sign_in_task: Option>>,
+ sign_in_state: SignInState,
refresh_task: Option>>>>,
load_task: Option>>>>,
credentials_provider: Arc,
@@ -154,7 +160,7 @@ impl State {
Self {
credentials: None,
- sign_in_task: None,
+ sign_in_state: SignInState::Idle,
refresh_task: None,
load_task: Some(load_task),
credentials_provider,
@@ -177,7 +183,22 @@ impl State {
}
pub fn is_signing_in(&self) -> bool {
- self.sign_in_task.is_some()
+ !matches!(self.sign_in_state, SignInState::Idle)
+ }
+
+ pub fn is_sign_in_cancellable(&self) -> bool {
+ matches!(self.sign_in_state, SignInState::Authorizing(_))
+ }
+
+ fn begin_persisting_credentials(&mut self, cx: &mut Context) {
+ let sign_in_state = std::mem::replace(&mut self.sign_in_state, SignInState::Idle);
+ self.sign_in_state = match sign_in_state {
+ SignInState::Authorizing(task) => {
+ cx.notify();
+ SignInState::PersistingCredentials { _task: task }
+ }
+ sign_in_state => sign_in_state,
+ };
}
pub fn last_auth_error(&self) -> Option {
@@ -280,6 +301,10 @@ impl State {
let task = cx.spawn(async move |this, cx| {
match do_oauth_flow(http_client, cx).await {
Ok(creds) => {
+ this.update(cx, |state, cx| {
+ state.begin_persisting_credentials(cx);
+ })?;
+
let persist_result = async {
let credentials_provider =
this.read_with(cx, |state, _| state.credentials_provider.clone())?;
@@ -303,7 +328,7 @@ impl State {
log::warn!("Failed to refresh ChatGPT models: {error:#}");
}
this.update(cx, |state, cx| {
- state.sign_in_task = None;
+ state.sign_in_state = SignInState::Idle;
cx.notify();
})?;
}
@@ -312,7 +337,7 @@ impl State {
"ChatGPT subscription sign-in failed to persist credentials: {err:?}"
);
this.update(cx, |state, cx| {
- state.sign_in_task = None;
+ state.sign_in_state = SignInState::Idle;
state.last_auth_error =
Some("Failed to save credentials. Please try again.".into());
cx.notify();
@@ -324,7 +349,7 @@ impl State {
Err(err) => {
log::error!("ChatGPT subscription sign-in failed: {err:?}");
this.update(cx, |state, cx| {
- state.sign_in_task = None;
+ state.sign_in_state = SignInState::Idle;
state.last_auth_error = Some("Sign-in failed. Please try again.".into());
cx.notify();
})
@@ -335,17 +360,24 @@ impl State {
});
self.last_auth_error = None;
- self.sign_in_task = Some(task);
+ self.sign_in_state = SignInState::Authorizing(task);
cx.notify();
}
+ pub fn cancel_sign_in(&mut self, cx: &mut Context) {
+ if matches!(self.sign_in_state, SignInState::Authorizing(_)) {
+ self.sign_in_state = SignInState::Idle;
+ cx.notify();
+ }
+ }
+
/// Clears credentials and in-flight work immediately (so observers see the
/// sign-out right away); the returned task deletes the persisted
/// credentials.
pub fn sign_out(&mut self, cx: &mut Context) -> Task> {
self.auth_generation += 1;
self.credentials = None;
- self.sign_in_task = None;
+ self.sign_in_state = SignInState::Idle;
self.refresh_task = None;
self.last_auth_error = None;
self.reset_model_catalog();
@@ -1510,6 +1542,83 @@ mod tests {
});
}
+ #[gpui::test]
+ async fn test_cancel_sign_in_drops_pending_task(cx: &mut TestAppContext) {
+ let http: Arc = FakeHttpClient::create(|_| async {
+ Ok(http_client::Response::builder()
+ .status(200)
+ .body(http_client::AsyncBody::default())?)
+ });
+ let state = make_state(http, None, cx);
+ let (continue_sign_in_tx, continue_sign_in_rx) = futures::channel::oneshot::channel::<()>();
+
+ state.update(cx, |state, cx| {
+ let task = cx.spawn(async move |_this, _cx| {
+ continue_sign_in_rx.await?;
+ anyhow::Ok(())
+ });
+ state.sign_in_state = SignInState::Authorizing(task);
+ });
+
+ cx.read(|cx| assert!(state.read(cx).is_signing_in()));
+ state.update(cx, |state, cx| state.cancel_sign_in(cx));
+ cx.run_until_parked();
+ cx.read(|cx| assert!(!state.read(cx).is_signing_in()));
+ assert!(
+ continue_sign_in_tx.send(()).is_err(),
+ "canceling sign-in should drop the task"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_sign_in_task_remains_alive_while_persisting_credentials(cx: &mut TestAppContext) {
+ let http: Arc = FakeHttpClient::create(|_| async {
+ Ok(http_client::Response::builder()
+ .status(200)
+ .body(http_client::AsyncBody::default())?)
+ });
+ let state = make_state(http, None, cx);
+ let (begin_persisting_tx, begin_persisting_rx) = futures::channel::oneshot::channel::<()>();
+ let (finish_persisting_tx, finish_persisting_rx) =
+ futures::channel::oneshot::channel::<()>();
+
+ state.update(cx, |state, cx| {
+ let task = cx.spawn(async move |this, cx| {
+ begin_persisting_rx.await?;
+ this.update(cx, |state, cx| {
+ state.begin_persisting_credentials(cx);
+ })?;
+ finish_persisting_rx.await?;
+ this.update(cx, |state, cx| {
+ state.sign_in_state = SignInState::Idle;
+ cx.notify();
+ })?;
+ anyhow::Ok(())
+ });
+ state.sign_in_state = SignInState::Authorizing(task);
+ });
+
+ begin_persisting_tx
+ .send(())
+ .expect("sign-in task should be waiting to persist credentials");
+ cx.run_until_parked();
+ cx.read(|cx| {
+ let state = state.read(cx);
+ assert!(state.is_signing_in());
+ assert!(!state.is_sign_in_cancellable());
+ });
+
+ state.update(cx, |state, cx| state.cancel_sign_in(cx));
+ cx.run_until_parked();
+ cx.read(|cx| assert!(state.read(cx).is_signing_in()));
+
+ finish_persisting_tx
+ .send(())
+ .expect("sign-in task should still be persisting credentials");
+ cx.run_until_parked();
+ cx.read(|cx| assert!(!state.read(cx).is_signing_in()));
+ }
+
#[gpui::test]
async fn test_sign_out_during_refresh_discards_result(cx: &mut TestAppContext) {
let (gate_tx, gate_rx) = futures::channel::oneshot::channel::<()>();
@@ -2219,7 +2328,7 @@ mod tests {
) -> Entity {
cx.new(|_cx| State {
credentials,
- sign_in_task: None,
+ sign_in_state: SignInState::Idle,
refresh_task: None,
load_task: None,
credentials_provider,
From dbfeae7735ece8f929244744c219ace6c02f0dc5 Mon Sep 17 00:00:00 2001
From: Sk Akram
Date: Wed, 2 Sep 2026 14:15:00 +0000
Subject: [PATCH 006/283] Ensure stale git commit template are not persisted
(#63604)
## Objective
Fixes #63478
Git commit templates were being persisted as pending commit message
drafts. As a result, if the commit template changed, the previously
applied template could be restored after restarting Zed instead of
loading the updated template.
## Solution
Do not persist the active commit message buffer as a draft when its
contents exactly match the currently applied commit template.
This preserves the existing behavior for genuine user drafts while
allowing the current commit template to be loaded when the panel is
restored.
## Testing
Added tests covering:
* Applying a new commit template after the template changes and the
panel is restored.
* Preserving a user-edited commit message when the commit template
changes.
Validated with:
* `cargo test -p git_ui --
test_commit_template_applied_fresh_after_template_file_change
test_user_draft_preserved_when_template_changes --nocapture`
* `cargo fmt -p git_ui`
* `cargo check -p git_ui`
* `cargo test -p git_ui --lib`
* `cargo clippy -p git_ui --tests -- -D warnings`
## 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 adheres to Zed's UI standards
* [x] Tests cover the new/changed behavior
* [x] Performance impact has been considered and is acceptable
---
Release Notes:
- Fixed stale git commit templates being restored as pending commit
message drafts.
---------
Co-authored-by: MrSubidubi
---
crates/git_ui/src/git_panel.rs | 200 ++++++++++++++++++++++++++++++++-
1 file changed, 199 insertions(+), 1 deletion(-)
diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs
index f4a15545a6..4dd2416159 100644
--- a/crates/git_ui/src/git_panel.rs
+++ b/crates/git_ui/src/git_panel.rs
@@ -1821,7 +1821,13 @@ impl GitPanel {
}
if let Some(work_directory_abs_path) = active_work_directory_abs_path {
let text = self.commit_message_buffer(cx).read(cx).text();
- let message = (!text.trim().is_empty()).then_some(text);
+ let trimmed_text = text.trim();
+ let use_buffer_text = !trimmed_text.is_empty()
+ && self
+ .commit_template
+ .as_ref()
+ .is_none_or(|commit_template| commit_template.template.trim() != trimmed_text);
+ let message = use_buffer_text.then_some(text);
let original_message = self.original_commit_message.clone();
let amend_pending = self.amend_pending;
if message.is_some() || original_message.is_some() || amend_pending {
@@ -12145,6 +12151,198 @@ mod tests {
});
}
+ #[gpui::test]
+ async fn test_commit_template_applied_fresh_after_template_file_change(
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "project": {
+ ".git": {},
+ "src": { "main.rs": "fn main() {}" }
+ }
+ }),
+ )
+ .await;
+ fs.set_status_for_repo(
+ Path::new(path!("/root/project/.git")),
+ &[("src/main.rs", StatusCode::Modified.worktree())],
+ );
+ fs.with_git_state(Path::new(path!("/root/project/.git")), false, |state| {
+ state.commit_template = Some(GitCommitTemplate {
+ template: "Template1\n".to_string(),
+ })
+ })
+ .unwrap();
+
+ let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
+ let repository = project.read_with(cx, |project, cx| {
+ project
+ .git_store()
+ .read(cx)
+ .repositories()
+ .values()
+ .next()
+ .unwrap()
+ .clone()
+ });
+ repository.update(cx, |repository, cx| repository.set_as_active_repository(cx));
+
+ let window_handle =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = window_handle
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
+ let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
+ register_git_commit_language(&project, cx);
+
+ let panel = workspace.update_in(cx, GitPanel::new);
+ cx.run_until_parked();
+
+ panel.read_with(cx, |panel, cx| {
+ assert_eq!(
+ panel.commit_message_buffer(cx).read(cx).text(),
+ "Template1\n",
+ "template should be applied initially"
+ );
+ });
+
+ // Update the commit template returned by the fake git repository and
+ // simulate restart: empty the active commit buffer and reload the panel
+ // from the same serialized state the previous session would have written.
+ fs.with_git_state(Path::new(path!("/root/project/.git")), false, |state| {
+ state.commit_template = Some(GitCommitTemplate {
+ template: "Template2\n".to_string(),
+ })
+ })
+ .unwrap();
+ let serialized_panel = panel.update(cx, |panel, cx| SerializedGitPanel {
+ signoff_enabled: false,
+ commit_messages: panel.serialized_commit_messages(cx),
+ });
+ let buffer = repository.read_with(cx, |repository, _| {
+ repository.commit_message_buffer().unwrap().clone()
+ });
+ buffer.update(cx, |buffer, cx| {
+ let start = buffer.anchor_before(0);
+ let end = buffer.anchor_after(buffer.len());
+ buffer.edit([(start..end, "")], None, cx);
+ });
+
+ let restored_panel = workspace.update_in(cx, |workspace, window, cx| {
+ GitPanel::new_with_serialized_panel(workspace, Some(serialized_panel), window, cx)
+ });
+ cx.run_until_parked();
+
+ restored_panel.read_with(cx, |panel, cx| {
+ assert_eq!(
+ panel.commit_message_buffer(cx).read(cx).text(),
+ "Template2\n",
+ "updated commit template should be re-applied across restarts"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_user_draft_preserved_when_template_changes(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "project": {
+ ".git": {},
+ "src": { "main.rs": "fn main() {}" }
+ }
+ }),
+ )
+ .await;
+ fs.set_status_for_repo(
+ Path::new(path!("/root/project/.git")),
+ &[("src/main.rs", StatusCode::Modified.worktree())],
+ );
+ fs.with_git_state(Path::new(path!("/root/project/.git")), false, |state| {
+ state.commit_template = Some(GitCommitTemplate {
+ template: "Template1\n".to_string(),
+ })
+ })
+ .unwrap();
+
+ let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
+ let repository = project.read_with(cx, |project, cx| {
+ project
+ .git_store()
+ .read(cx)
+ .repositories()
+ .values()
+ .next()
+ .unwrap()
+ .clone()
+ });
+ repository.update(cx, |repository, cx| repository.set_as_active_repository(cx));
+
+ let window_handle =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = window_handle
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
+ let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
+ register_git_commit_language(&project, cx);
+
+ let panel = workspace.update_in(cx, GitPanel::new);
+ cx.run_until_parked();
+
+ // User edits the buffer after the template was applied.
+ let user_message = "fix: my actual commit\n";
+ panel.update(cx, |panel, cx| {
+ panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
+ let start = buffer.anchor_before(0);
+ let end = buffer.anchor_after(buffer.len());
+ buffer.edit([(start..end, user_message)], None, cx);
+ });
+ });
+
+ // The commit template returned by the fake git repository changes
+ // after the user has already started typing. We must not clobber the
+ // user's draft.
+ fs.with_git_state(Path::new(path!("/root/project/.git")), false, |state| {
+ state.commit_template = Some(GitCommitTemplate {
+ template: "Template2\n".to_string(),
+ })
+ })
+ .unwrap();
+ let serialized_panel = panel.update(cx, |panel, cx| SerializedGitPanel {
+ signoff_enabled: false,
+ commit_messages: panel.serialized_commit_messages(cx),
+ });
+
+ // Simulate a restart and restore from the serialized state.
+ let buffer = repository.read_with(cx, |repository, _| {
+ repository.commit_message_buffer().unwrap().clone()
+ });
+ buffer.update(cx, |buffer, cx| {
+ let start = buffer.anchor_before(0);
+ let end = buffer.anchor_after(buffer.len());
+ buffer.edit([(start..end, "")], None, cx);
+ });
+
+ let restored_panel = workspace.update_in(cx, |workspace, window, cx| {
+ GitPanel::new_with_serialized_panel(workspace, Some(serialized_panel), window, cx)
+ });
+ cx.run_until_parked();
+
+ restored_panel.read_with(cx, |panel, cx| {
+ assert_eq!(
+ panel.commit_message_buffer(cx).read(cx).text(),
+ user_message,
+ "user-typed draft must be restored verbatim, never overwritten by a later template"
+ );
+ });
+ }
+
#[gpui::test]
async fn test_pending_commit_state_is_per_repository(cx: &mut TestAppContext) {
init_test(cx);
From 6309c722133cb05146e22151567fe21a2bbc8cde Mon Sep 17 00:00:00 2001
From: Richard Feldman
Date: Wed, 2 Sep 2026 14:47:22 +0000
Subject: [PATCH 007/283] language_model_core: Classify Anthropic
prompt-too-long 400s as context overflow (#63596)
Anthropic reports a context-window overflow with HTTP 400 and a message
of the form `prompt is too long: N tokens > M maximum`. The
`BAD_REQUEST` arm of `ProviderErrorCategory::from_http_status` only
recognized OpenAI's phrasings (`context_length_exceeded`, "exceeds the
context window"), and `parse_prompt_too_long` only ran for HTTP 413, so
these rejections were classified as `InvalidRequest` instead of
`PromptTooLarge`. This affects every wire path that classifies by
upstream HTTP status, including Zed cloud's `upstream_http_400` code,
and it silently disables any downstream handling keyed on the
`PromptTooLarge` category. This change sniffs the Anthropic phrasing on
400 responses too, carrying the provider-reported token count.
Closes AI-462
Release Notes:
- Fixed Anthropic "prompt is too long" errors reported with HTTP 400 not
being recognized as context-window overflows.
---
.../src/language_model_core.rs | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs
index 470844f8f0..adf14a90b3 100644
--- a/crates/language_model_core/src/language_model_core.rs
+++ b/crates/language_model_core/src/language_model_core.rs
@@ -148,9 +148,18 @@ impl ProviderErrorCategory {
StatusCode::BAD_REQUEST if is_invalid_encrypted_content_message(message) => {
Self::InvalidEncryptedContent
}
- StatusCode::BAD_REQUEST if is_context_window_exceeded_message(message) => {
- Self::PromptTooLarge { tokens: None }
- }
+ // Anthropic reports a context-window overflow as HTTP 400 with a
+ // "prompt is too long" message rather than HTTP 413, so a
+ // bad request must be sniffed for both providers' phrasings.
+ StatusCode::BAD_REQUEST => match parse_prompt_too_long(message) {
+ Some(tokens) => Self::PromptTooLarge {
+ tokens: Some(tokens),
+ },
+ None if is_context_window_exceeded_message(message) => {
+ Self::PromptTooLarge { tokens: None }
+ }
+ None => Self::InvalidRequest,
+ },
StatusCode::UNAUTHORIZED => Self::Authentication,
StatusCode::FORBIDDEN => Self::Permission,
StatusCode::NOT_FOUND => Self::EndpointNotFound,
@@ -158,7 +167,6 @@ impl ProviderErrorCategory {
StatusCode::PAYLOAD_TOO_LARGE => Self::PromptTooLarge {
tokens: parse_prompt_too_long(message),
},
- StatusCode::BAD_REQUEST => Self::InvalidRequest,
StatusCode::CONFLICT => Self::Conflict,
StatusCode::REQUEST_TIMEOUT | StatusCode::GATEWAY_TIMEOUT => Self::Timeout,
StatusCode::TOO_MANY_REQUESTS => Self::RateLimit,
From c3cf80c0d1be43f3b84e21ef2c82e91b0d78e788 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Reinier=20Millo=20S=C3=A1nchez?=
<18516500+reinier-millo@users.noreply.github.com>
Date: Wed, 2 Sep 2026 14:48:12 +0000
Subject: [PATCH 008/283] git_ui: Fix 'Go Back' skipping the Git Graph view
(#63597)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Objective
- "Go back" behaved inconsistently when navigating from the Git Graph
view: opening a commit diff (double-click on a commit) and then using
"Go Back" would not return to the Git Graph — instead it jumped to
whatever file was open before the Git Graph was opened.
- Fixes #63293
## Solution
- `GitGraph`'s `Item` implementation never hooked into the pane's
navigation history (missing `deactivated` / `set_nav_history`), unlike
every sibling diff view (`CommitView`, `ProjectDiffEditor`,
`MultiDiffView`, `BranchDiff`). Because of this, when a commit diff was
opened on top of it, deactivating Git Graph never pushed an entry onto
the pane's back-stack, so "Go Back" skipped straight past it to the
previously active file.
- Added a `nav_history: Option` field to `GitGraph` and
implemented `set_nav_history`/`deactivated` to push a nav-history entry
when the Git Graph tab is deactivated, matching the pattern used by
other `git_ui` diff views.
## Testing
- Added `test_go_back_from_commit_view_returns_to_git_graph` in
`crates/git_ui/src/git_graph.rs`, which opens a file, then Git Graph,
then a commit diff, and asserts that `Pane::navigate_backward` returns
to the Git Graph view rather than the file.
- Verified the test actually catches the regression by temporarily
reverting the fix and confirming the test fails, then restoring the fix
and confirming it passes.
- `cargo test -p git_ui --lib git_graph::` — all 28 tests pass.
- `./script/clippy -p git_ui` — clean, no warnings.
## Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
## Showcase
Click to view showcase
Open the Git Graph and double click on any commit to see the details,
after that press go back button, it should return to the Git Graph
instead of previos opened file.
https://github.com/user-attachments/assets/15f659a7-a60b-4d43-ad59-8be69a94cb31
---
Release Notes:
- Fixed "Go Back" not returning to the Git Graph view after opening a
commit diff
---
crates/git_ui/src/git_graph.rs | 143 ++++++++++++++++++++++++++++++++-
1 file changed, 142 insertions(+), 1 deletion(-)
diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs
index d304a9db4d..68984da791 100644
--- a/crates/git_ui/src/git_graph.rs
+++ b/crates/git_ui/src/git_graph.rs
@@ -58,7 +58,7 @@ use ui::{
};
use util::{ResultExt, debug_panic};
use workspace::{
- ModalView, Workspace,
+ ItemNavHistory, ModalView, Workspace,
item::{Item, ItemEvent, TabTooltipContent},
};
@@ -1329,6 +1329,7 @@ pub struct GitGraph {
changed_files_view_mode: ChangedFilesViewMode,
changed_files_expanded_dirs: HashMap,
pending_select_sha: Option,
+ nav_history: Option,
}
impl GitGraph {
@@ -1575,6 +1576,7 @@ impl GitGraph {
changed_files_view_mode: ChangedFilesViewMode::default(),
changed_files_expanded_dirs: HashMap::default(),
pending_select_sha: None,
+ nav_history: None,
};
this.fetch_initial_graph_data(cx);
@@ -4178,6 +4180,21 @@ impl Item for GitGraph {
fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
f(*event)
}
+
+ fn deactivated(&mut self, _window: &mut Window, cx: &mut Context) {
+ if let Some(nav_history) = self.nav_history.as_mut() {
+ nav_history.push::<()>(None, None, cx);
+ }
+ }
+
+ fn set_nav_history(
+ &mut self,
+ nav_history: ItemNavHistory,
+ _window: &mut Window,
+ _cx: &mut Context,
+ ) {
+ self.nav_history = Some(nav_history);
+ }
}
impl workspace::SerializableItem for GitGraph {
@@ -6852,6 +6869,130 @@ mod tests {
});
}
+ #[gpui::test]
+ async fn test_go_back_from_commit_view_returns_to_git_graph(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ Path::new("/project"),
+ json!({ ".git": {}, "file.txt": "content" }),
+ )
+ .await;
+
+ let first_sha = Oid::from_bytes(&[1; 20]).expect("valid commit SHA");
+ fs.set_graph_commits(
+ Path::new("/project/.git"),
+ vec![Arc::new(InitialGraphCommitData {
+ sha: first_sha,
+ parents: smallvec![],
+ ref_names: vec!["HEAD -> main".into()],
+ })],
+ );
+ fs.set_commit_data(
+ Path::new("/project/.git"),
+ [(
+ CommitData {
+ sha: first_sha,
+ parents: smallvec![],
+ author_name: "Author".into(),
+ author_email: "author@example.com".into(),
+ commit_timestamp: 1_700_000_000,
+ subject: "Commit subject".into(),
+ message: "Commit message".into(),
+ },
+ false,
+ )],
+ );
+
+ let project = Project::test(fs, [Path::new("/project")], cx).await;
+ cx.run_until_parked();
+
+ let repository = project.read_with(cx, |project, cx| {
+ project
+ .active_repository(cx)
+ .expect("should have a repository")
+ });
+
+ let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
+ workspace::MultiWorkspace::test_new(project.clone(), window, cx)
+ });
+ let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
+
+ // Open a file first, so there's something in nav history before the Git Graph tab.
+ let buffer = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(Path::new("/project/file.txt"), cx)
+ })
+ .await
+ .expect("file should open");
+ let buffer_editor = cx.new_window_entity(|window, cx| {
+ Editor::for_buffer(buffer, Some(project.clone()), window, cx)
+ });
+ workspace.update_in(cx, |workspace, window, cx| {
+ workspace.add_item_to_active_pane(
+ Box::new(buffer_editor.clone()),
+ None,
+ true,
+ window,
+ cx,
+ );
+ });
+ cx.run_until_parked();
+
+ let git_graph = cx.new_window_entity(|window, cx| {
+ GitGraph::new(
+ repository.read(cx).id,
+ project.read(cx).git_store().clone(),
+ workspace.downgrade(),
+ None,
+ window,
+ cx,
+ )
+ });
+ workspace.update_in(cx, |workspace, window, cx| {
+ workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
+ });
+ cx.run_until_parked();
+
+ git_graph.update(cx, |graph, cx| {
+ graph.select_commit_by_sha(first_sha, cx);
+ });
+ cx.run_until_parked();
+
+ git_graph.update_in(cx, |graph, window, cx| {
+ graph.open_selected_commit_view(window, cx);
+ });
+ cx.run_until_parked();
+
+ let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
+
+ pane.read_with(cx, |pane, _cx| {
+ assert!(
+ pane.active_item()
+ .and_then(|item| item.downcast::())
+ .is_some(),
+ "expected the commit diff view to be active after opening a commit"
+ );
+ });
+
+ pane.update_in(cx, |pane, window, cx| {
+ pane.navigate_backward(&Default::default(), window, cx);
+ });
+ cx.run_until_parked();
+
+ pane.read_with(cx, |pane, _cx| {
+ let active_git_graph = pane
+ .active_item()
+ .and_then(|item| item.downcast::());
+ assert_eq!(
+ active_git_graph,
+ Some(git_graph.clone()),
+ "Go Back from the commit diff view should return to the Git Graph view"
+ );
+ });
+ }
+
#[gpui::test]
async fn test_git_graph_navigation(cx: &mut TestAppContext) {
init_test(cx);
From 8514ce3ba1ee7a6786e35f6566c7cfeb3c42d3b7 Mon Sep 17 00:00:00 2001
From: Finn Evers
Date: Wed, 2 Sep 2026 15:41:05 +0000
Subject: [PATCH 009/283] Unify language model event stream handling (#63520)
Closes https://github.com/zed-industries/zed/issues/63314
Closes https://github.com/zed-industries/zed/issues/42584
Follow-up to https://github.com/zed-industries/zed/pull/62652
Removes a lot of duplicated code in favor of throwing it
into`language_core` as well as removing duplicate struct definitions
that were only different because of entirely unused fields.
Furthermore, improves error-messages so that these untagged enum errors
are no longer a thing in an effort to make this more maintainable in the
future.
Lastly, includes a small but nice improvement changing a `Value` to a
`RawValue` so that we don't have to clone for deserialization in that
specific case.
Release Notes:
- Improved streaming for OpenAI-compatible providers.
---------
Co-authored-by: Anant Goel
---
Cargo.lock | 3 +
crates/language_model_core/Cargo.toml | 2 +-
.../src/chat_completion.rs | 1632 +++++++++++++++++
.../src/language_model_core.rs | 1 +
.../language_models/src/provider/bedrock.rs | 27 +-
.../language_models/src/provider/llama_cpp.rs | 229 +--
.../language_models/src/provider/lmstudio.rs | 278 +--
.../language_models/src/provider/open_ai.rs | 5 +-
.../src/provider/open_ai_compatible.rs | 7 +-
.../src/provider/open_router.rs | 378 +---
.../language_models/src/provider/opencode.rs | 11 +-
.../src/provider/vercel_ai_gateway.rs | 4 +-
crates/language_models/src/provider/x_ai.rs | 4 +-
.../src/language_models_cloud.rs | 5 +-
crates/llama_cpp/Cargo.toml | 1 +
crates/llama_cpp/src/llama_cpp.rs | 66 +-
crates/lmstudio/Cargo.toml | 1 +
crates/lmstudio/src/lmstudio.rs | 155 +-
.../src/chat_completion_transport_tests.rs | 20 +-
crates/open_ai/src/completion.rs | 561 +-----
crates/open_ai/src/open_ai.rs | 96 +-
crates/open_router/src/open_router.rs | 84 +-
22 files changed, 1725 insertions(+), 1845 deletions(-)
create mode 100644 crates/language_model_core/src/chat_completion.rs
diff --git a/Cargo.lock b/Cargo.lock
index 7db15b1c29..3016639e76 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -9630,6 +9630,7 @@ dependencies = [
"anyhow",
"async-lock",
"cloud_llm_client",
+ "collections",
"futures 0.3.32",
"gpui_shared_string",
"http_client",
@@ -10213,6 +10214,7 @@ dependencies = [
"anyhow",
"futures 0.3.32",
"http_client",
+ "language_model_core",
"schemars 1.0.4",
"serde",
"serde_json",
@@ -10237,6 +10239,7 @@ dependencies = [
"anyhow",
"futures 0.3.32",
"http_client",
+ "language_model_core",
"schemars 1.0.4",
"serde",
"serde_json",
diff --git a/crates/language_model_core/Cargo.toml b/crates/language_model_core/Cargo.toml
index f4a59a3e08..825818f432 100644
--- a/crates/language_model_core/Cargo.toml
+++ b/crates/language_model_core/Cargo.toml
@@ -10,12 +10,12 @@ workspace = true
[lib]
path = "src/language_model_core.rs"
-doctest = false
[dependencies]
anyhow.workspace = true
async-lock.workspace = true
cloud_llm_client.workspace = true
+collections.workspace = true
futures.workspace = true
gpui_shared_string.workspace = true
http_client.workspace = true
diff --git a/crates/language_model_core/src/chat_completion.rs b/crates/language_model_core/src/chat_completion.rs
new file mode 100644
index 0000000000..b5c0219647
--- /dev/null
+++ b/crates/language_model_core/src/chat_completion.rs
@@ -0,0 +1,1632 @@
+//! Wire types and event mapping for streaming responses of OpenAI-compatible
+//! Chat Completions APIs.
+//!
+//! Multiple providers (OpenAI, OpenRouter, LM Studio, llama.cpp, and various
+//! OpenAI-compatible proxies) share this format, so the types are deliberately
+//! lenient: every field a consumer does not strictly require is optional or
+//! defaulted, because real-world providers routinely omit fields or send
+//! explicit `null`s where the OpenAI reference implementation would not.
+
+use crate::util::{fix_streamed_json, parse_tool_arguments};
+use crate::{
+ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelToolUse,
+ LanguageModelToolUseInput, StopReason, TokenUsage,
+};
+use collections::HashMap;
+use futures::{Stream, StreamExt};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::pin::Pin;
+
+/// A single decoded Chat Completions stream chunk: either an event or a
+/// provider error envelope (`{"error": {...}}`).
+///
+/// The error payload is generic so providers with richer error envelopes
+/// (e.g. OpenRouter) can preserve their extra fields.
+#[derive(Serialize, Debug)]
+#[serde(untagged)]
+pub enum ResponseStreamResult {
+ Ok(ResponseStreamEvent),
+ Err { error: E },
+}
+
+/// `#[derive(Deserialize)]` with `#[serde(untagged)]` is avoided here because
+/// untagged enums report unhelpful errors ("data did not match any variant")
+/// and, with the lenient event type below, would silently swallow error
+/// envelopes as empty events. Instead:
+///
+/// * A non-null top-level `error` field decodes as an error, taking
+/// precedence over any event payload in the same chunk.
+/// * A chunk with a `choices` or `usage` field decodes as an event,
+/// preserving the underlying deserialization error message on failure.
+/// * Anything else (e.g. non-standard error envelopes like `{"detail": ...}`
+/// or `{"object": "error", ...}`) is rejected so it surfaces as a stream
+/// error instead of being silently dropped as an empty event.
+impl<'de, E> Deserialize<'de> for ResponseStreamResult
+where
+ E: serde::de::DeserializeOwned,
+{
+ fn deserialize(deserializer: D) -> Result
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = Value::deserialize(deserializer)?;
+ match value.get("error") {
+ Some(error) if !error.is_null() => {
+ let error = E::deserialize(error).map_err(serde::de::Error::custom)?;
+ Ok(ResponseStreamResult::Err { error })
+ }
+ _ => {
+ if value.get("choices").is_none() && value.get("usage").is_none() {
+ return Err(serde::de::Error::custom(format!(
+ "unrecognized chat completion stream chunk: {value}"
+ )));
+ }
+ let event =
+ ResponseStreamEvent::deserialize(&value).map_err(serde::de::Error::custom)?;
+ Ok(ResponseStreamResult::Ok(event))
+ }
+ }
+ }
+}
+
+/// The error payload most OpenAI-compatible providers send inside an
+/// `{"error": {...}}` envelope.
+#[derive(Serialize, Deserialize, Debug)]
+pub struct ResponseStreamError {
+ pub message: String,
+}
+
+#[derive(Serialize, Deserialize, Debug, Default)]
+pub struct ResponseStreamEvent {
+ /// Usage-only chunks from some providers omit `choices` entirely or send
+ /// an explicit `null` instead of an empty array.
+ #[serde(default, deserialize_with = "null_as_default")]
+ pub choices: Vec,
+ pub usage: Option,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct ChoiceDelta {
+ #[serde(default, deserialize_with = "null_as_default")]
+ pub index: u32,
+ pub delta: Option,
+ pub finish_reason: Option,
+}
+
+/// Deserializes a missing field or an explicit `null` as the type's default,
+/// honoring this module's leniency guarantee for fields that are not
+/// themselves optional.
+fn null_as_default<'de, D, T>(deserializer: D) -> Result
+where
+ D: serde::Deserializer<'de>,
+ T: Default + Deserialize<'de>,
+{
+ Ok(Option::::deserialize(deserializer)?.unwrap_or_default())
+}
+
+#[derive(Serialize, Deserialize, Debug, Default, Eq, PartialEq)]
+pub struct ResponseMessageDelta {
+ pub content: Option,
+ /// Reasoning text as sent by OpenRouter and compatible providers.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reasoning: Option,
+ /// Reasoning text as sent by DeepSeek-style providers and `llama-server`
+ /// (when started with a reasoning format, e.g. `--reasoning-format deepseek`).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reasoning_content: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tool_calls: Option>,
+ /// Provider-defined structured reasoning metadata.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reasoning_details: Option,
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub struct ToolCallChunk {
+ /// `None` when the provider omits the index, sends `null`, or sends a
+ /// negative sentinel such as `-1` (#42584).
+ #[serde(
+ default,
+ deserialize_with = "lenient_index",
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub index: Option,
+ pub id: Option,
+
+ // There is also an optional `type` field that would determine if a
+ // function is there. Sometimes this streams in with the `function` before
+ // it streams in the `type`
+ pub function: Option,
+}
+
+/// Treats an explicit `null` and negative sentinels like `-1` (#42584) as an
+/// absent index rather than a malformed chunk.
+fn lenient_index<'de, D>(deserializer: D) -> Result, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ Ok(Option::::deserialize(deserializer)?.and_then(|index| usize::try_from(index).ok()))
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub struct FunctionChunk {
+ pub name: Option,
+ pub arguments: Option,
+ /// Provider-defined metadata required to replay a reasoning tool call.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub thought_signature: Option,
+}
+
+#[derive(Clone, Serialize, Deserialize, Debug, Default)]
+pub struct Usage {
+ pub prompt_tokens: Option,
+ pub completion_tokens: Option,
+ pub total_tokens: Option,
+ /// Prompt-cache usage when reported by the provider.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub prompt_tokens_details: Option,
+}
+
+/// Reports prompt-cache token usage from compatible providers.
+#[derive(Clone, Serialize, Deserialize, Debug, Default)]
+pub struct PromptTokensDetails {
+ /// Tokens read from a prompt cache.
+ pub cached_tokens: Option,
+ /// Tokens written to a prompt cache.
+ pub cache_write_tokens: Option,
+}
+
+impl Usage {
+ /// Converts to a [`TokenUsage`] update, splitting cache reads and writes
+ /// out of `prompt_tokens` when the provider reports them.
+ ///
+ /// Returns `None` unless both `prompt_tokens` and `completion_tokens` are
+ /// present, because a partial usage object carries no usable totals.
+ pub fn token_usage(&self) -> Option {
+ let prompt_tokens = self.prompt_tokens?;
+ let completion_tokens = self.completion_tokens?;
+ let details = self.prompt_tokens_details.as_ref();
+ let cache_creation_input_tokens = details
+ .and_then(|details| details.cache_write_tokens)
+ .unwrap_or(0);
+ let cache_read_input_tokens = details
+ .and_then(|details| details.cached_tokens)
+ .unwrap_or(0);
+ Some(TokenUsage {
+ input_tokens: prompt_tokens
+ .saturating_sub(cache_creation_input_tokens)
+ .saturating_sub(cache_read_input_tokens),
+ output_tokens: completion_tokens,
+ cache_creation_input_tokens,
+ cache_read_input_tokens,
+ })
+ }
+}
+
+/// Accumulates structured reasoning metadata from compatible providers.
+///
+/// Array entries are matched by `index` and then `id`. Fragmented `text`,
+/// `summary`, and `data` fields are concatenated while other non-null fields
+/// replace their previous values.
+///
+/// # Examples
+///
+/// ```
+/// use language_model_core::chat_completion::ReasoningDetailsAccumulator;
+/// use serde_json::json;
+///
+/// let mut accumulator = ReasoningDetailsAccumulator::default();
+/// accumulator.push(json!([{"index": 0, "text": "first "}]));
+/// let details = accumulator
+/// .push(json!([{"index": 0, "text": "second"}]))
+/// .expect("non-empty reasoning details");
+///
+/// assert_eq!(details[0]["text"], "first second");
+/// ```
+#[derive(Debug, Default)]
+pub struct ReasoningDetailsAccumulator {
+ accumulated: Option,
+}
+
+impl ReasoningDetailsAccumulator {
+ /// Merges `chunk` and returns the updated metadata snapshot.
+ ///
+ /// `null` and empty arrays do not replace previously accumulated metadata
+ /// and return `None`.
+ pub fn push(&mut self, chunk: Value) -> Option {
+ match chunk {
+ Value::Null => None,
+ Value::Array(chunks) if chunks.is_empty() => None,
+ Value::Array(chunks) => {
+ let mut details = match self.accumulated.take() {
+ Some(Value::Array(details)) => details,
+ _ => Vec::new(),
+ };
+ for chunk in chunks {
+ merge_reasoning_detail(&mut details, chunk);
+ }
+ let accumulated = Value::Array(details);
+ self.accumulated = Some(accumulated.clone());
+ Some(accumulated)
+ }
+ chunk => {
+ self.accumulated = Some(chunk.clone());
+ Some(chunk)
+ }
+ }
+ }
+}
+
+fn merge_reasoning_detail(details: &mut Vec, chunk: Value) {
+ let index = chunk.get("index").and_then(Value::as_u64);
+ let target_index = index
+ .and_then(|index| {
+ details
+ .iter()
+ .position(|detail| detail.get("index").and_then(Value::as_u64) == Some(index))
+ })
+ .or_else(|| {
+ let id = chunk.get("id").and_then(Value::as_str)?;
+ details
+ .iter()
+ .position(|detail| detail.get("id").and_then(Value::as_str) == Some(id))
+ });
+ let Some(target_index) = target_index else {
+ details.push(chunk);
+ return;
+ };
+ let (Some(target), Some(chunk)) = (details[target_index].as_object_mut(), chunk.as_object())
+ else {
+ return;
+ };
+ for (key, value) in chunk {
+ if matches!(key.as_str(), "text" | "summary" | "data")
+ && let Some(fragment) = value.as_str()
+ && let Some(existing) = target.get(key).and_then(Value::as_str)
+ {
+ target.insert(key.clone(), Value::String(format!("{existing}{fragment}")));
+ } else if !value.is_null() {
+ target.insert(key.clone(), value.clone());
+ }
+ }
+}
+
+#[derive(Default)]
+struct RawToolCall {
+ index: Option,
+ id: String,
+ name: String,
+ arguments: String,
+ thought_signature: Option,
+}
+
+/// Accumulates streamed tool calls, matching chunks by their ID and index when
+/// both are present. Either value alone is unreliable because some providers
+/// omit indices (#42584), reuse indices, or repeat IDs across parallel calls.
+#[derive(Default)]
+struct ToolCallAccumulator {
+ calls: Vec,
+ calls_by_index: HashMap,
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error(
+ "cannot unambiguously attribute a tool call chunk to one of the \
+ {calls_in_flight} tool calls in progress"
+)]
+struct AmbiguousToolCallChunk {
+ calls_in_flight: usize,
+}
+
+impl ToolCallAccumulator {
+ /// Returns the call `chunk` belongs to, creating it if the chunk starts
+ /// a new call. Fails rather than guessing (and corrupting arguments) when
+ /// the available identity matches multiple calls in flight.
+ fn entry(&mut self, chunk: &ToolCallChunk) -> Result<&mut RawToolCall, AmbiguousToolCallChunk> {
+ let id = chunk.id.as_deref().filter(|id| !id.is_empty());
+ let call = match (id, chunk.index) {
+ (Some(id), index) => self.call_for_id(id, index)?,
+ (None, Some(index)) => self.call_for_index(index),
+ (None, None) => match self.calls.len() {
+ 0 => self.new_call(),
+ 1 => 0,
+ calls_in_flight => return Err(AmbiguousToolCallChunk { calls_in_flight }),
+ },
+ };
+ Ok(&mut self.calls[call])
+ }
+
+ fn call_for_id(
+ &mut self,
+ id: &str,
+ index: Option,
+ ) -> Result {
+ let known_call = match index {
+ Some(index) => self
+ .calls
+ .iter()
+ .rposition(|call| call.id == id && call.index == Some(index))
+ .or_else(|| {
+ // A new ID continues the call at its index only while that
+ // call is anonymous; a different ID means the index was
+ // reused for another parallel call.
+ let call = self.calls_by_index.get(&index).copied()?;
+ self.calls[call].id.is_empty().then_some(call)
+ })
+ .or_else(|| {
+ self.calls
+ .iter()
+ .rposition(|call| call.id == id && call.index.is_none())
+ }),
+ None => {
+ let matching_calls = self.calls.iter().filter(|call| call.id == id).count();
+ if matching_calls > 1 {
+ return Err(AmbiguousToolCallChunk {
+ calls_in_flight: matching_calls,
+ });
+ }
+ self.calls
+ .iter()
+ .position(|call| call.id == id)
+ .or_else(|| {
+ // Some providers send the ID only partway through a call.
+ (self.calls.len() == 1 && self.calls[0].id.is_empty()).then_some(0)
+ })
+ }
+ };
+ let call = known_call.unwrap_or_else(|| self.new_call());
+ if self.calls[call].id.is_empty() {
+ self.calls[call].id = id.to_string();
+ }
+ if let Some(index) = index {
+ self.calls[call].index = Some(index);
+ self.calls_by_index.insert(index, call);
+ }
+ Ok(call)
+ }
+
+ fn call_for_index(&mut self, index: usize) -> usize {
+ match self.calls_by_index.get(&index) {
+ Some(&call) => call,
+ None => {
+ let call = self.new_call();
+ self.calls[call].index = Some(index);
+ self.calls_by_index.insert(index, call);
+ call
+ }
+ }
+ }
+
+ fn new_call(&mut self) -> usize {
+ self.calls.push(RawToolCall::default());
+ self.calls.len() - 1
+ }
+
+ /// Removes and returns every call, in the order they appeared in the stream.
+ fn drain(&mut self) -> impl Iterator- + '_ {
+ self.calls_by_index.clear();
+ self.calls.drain(..)
+ }
+}
+
+/// Maps a stream of Chat Completions chunks to [`LanguageModelCompletionEvent`]s.
+///
+/// This is shared by every provider that speaks an OpenAI-compatible Chat
+/// Completions dialect (OpenAI, OpenAI-compatible endpoints, OpenRouter,
+/// LM Studio, llama.cpp, Bedrock/Mantle, and others), so its behavior must
+/// stay provider-neutral: any provider-specific interpretation belongs in the
+/// provider's own adapter before or after this mapping.
+pub struct ChatCompletionEventMapper {
+ tool_calls: ToolCallAccumulator,
+ tool_call_accumulation_failed: bool,
+ reasoning_details: ReasoningDetailsAccumulator,
+}
+
+impl ChatCompletionEventMapper {
+ pub fn new() -> Self {
+ Self {
+ tool_calls: ToolCallAccumulator::default(),
+ tool_call_accumulation_failed: false,
+ reasoning_details: ReasoningDetailsAccumulator::default(),
+ }
+ }
+
+ pub fn map_stream
(
+ mut self,
+ events: Pin>>>,
+ ) -> impl Stream- >
+ where
+ E: Into
,
+ {
+ events.flat_map(move |event| {
+ futures::stream::iter(match event {
+ Ok(event) => self.map_event(event),
+ Err(error) => vec![Err(error.into())],
+ })
+ })
+ }
+
+ pub fn map_event(
+ &mut self,
+ event: ResponseStreamEvent,
+ ) -> Vec> {
+ let mut events = Vec::new();
+ if let Some(token_usage) = event.usage.as_ref().and_then(|usage| usage.token_usage()) {
+ events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)));
+ }
+
+ let Some(choice) = event.choices.first() else {
+ return events;
+ };
+
+ if let Some(delta) = choice.delta.as_ref() {
+ if let Some(reasoning_details) = delta.reasoning_details.clone()
+ && let Some(reasoning_details) = self.reasoning_details.push(reasoning_details)
+ {
+ events.push(Ok(LanguageModelCompletionEvent::ReasoningDetails(
+ reasoning_details,
+ )));
+ }
+ if let Some(reasoning) = delta.reasoning.clone() {
+ push_thinking_event(reasoning, &mut events);
+ }
+ if let Some(reasoning_content) = delta.reasoning_content.clone() {
+ push_thinking_event(reasoning_content, &mut events);
+ }
+ if let Some(content) = delta.content.clone() {
+ if !content.is_empty() {
+ events.push(Ok(LanguageModelCompletionEvent::Text(content)));
+ }
+ }
+
+ if !self.tool_call_accumulation_failed
+ && let Some(tool_calls) = delta.tool_calls.as_ref()
+ {
+ for tool_call in tool_calls {
+ let entry = match self.tool_calls.entry(tool_call) {
+ Ok(entry) => entry,
+ Err(error) => {
+ self.tool_call_accumulation_failed = true;
+ self.tool_calls = ToolCallAccumulator::default();
+ events.push(Err(anyhow::Error::new(error).into()));
+ break;
+ }
+ };
+
+ if let Some(function) = tool_call.function.as_ref() {
+ if let Some(name) = function.name.clone()
+ && !name.is_empty()
+ {
+ entry.name = name;
+ }
+
+ if let Some(arguments) = function.arguments.clone() {
+ entry.arguments.push_str(&arguments);
+ }
+
+ if let Some(thought_signature) = function.thought_signature.clone() {
+ entry.thought_signature = Some(thought_signature);
+ }
+ }
+
+ if !entry.id.is_empty() && !entry.name.is_empty() {
+ if let Ok(input) =
+ serde_json::from_str::(&fix_streamed_json(&entry.arguments))
+ {
+ events.push(Ok(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: entry.id.clone().into(),
+ name: entry.name.as_str().into(),
+ is_input_complete: false,
+ input: LanguageModelToolUseInput::Json(input),
+ raw_input: entry.arguments.clone(),
+ thought_signature: entry.thought_signature.clone(),
+ },
+ )));
+ }
+ }
+ }
+ }
+ }
+
+ match choice.finish_reason.as_deref() {
+ Some("stop") => {
+ events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
+ }
+ Some("tool_calls") => {
+ if !self.tool_call_accumulation_failed {
+ events.extend(self.tool_calls.drain().map(
+ |tool_call| match parse_tool_arguments(&tool_call.arguments) {
+ Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: tool_call.id.clone().into(),
+ name: tool_call.name.as_str().into(),
+ is_input_complete: true,
+ input: LanguageModelToolUseInput::Json(input),
+ raw_input: tool_call.arguments.clone(),
+ thought_signature: tool_call.thought_signature.clone(),
+ },
+ )),
+ Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
+ id: tool_call.id.into(),
+ tool_name: tool_call.name.into(),
+ raw_input: tool_call.arguments.clone().into(),
+ json_parse_error: error.to_string(),
+ }),
+ },
+ ));
+ }
+
+ events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
+ }
+ Some("length") => {
+ events.push(Ok(LanguageModelCompletionEvent::Stop(
+ StopReason::MaxTokens,
+ )));
+ }
+ Some(stop_reason) => {
+ log::error!("Unexpected chat completion stop_reason: {stop_reason:?}",);
+ events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
+ }
+ None => {}
+ }
+
+ events
+ }
+}
+
+fn push_thinking_event(
+ text: String,
+ events: &mut Vec>,
+) {
+ if !text.is_empty() {
+ events.push(Ok(LanguageModelCompletionEvent::Thinking {
+ text,
+ signature: None,
+ }));
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::LanguageModelToolUse;
+ use pretty_assertions::assert_eq;
+ use serde_json::json;
+
+ fn parse(chunk: &str) -> ResponseStreamResult {
+ serde_json::from_str::(chunk).unwrap()
+ }
+
+ fn expect_event(chunk: &str) -> ResponseStreamEvent {
+ match parse(chunk) {
+ ResponseStreamResult::Ok(event) => event,
+ ResponseStreamResult::Err { .. } => panic!("expected an event, got an error: {chunk}"),
+ }
+ }
+
+ #[test]
+ fn parses_usage_only_chunk_with_null_prompt_cache_tokens() {
+ let event = expect_event(
+ r#"{"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":null}}}"#,
+ );
+ let details = event.usage.unwrap().prompt_tokens_details.unwrap();
+ assert_eq!(details.cached_tokens, Some(0));
+ assert_eq!(details.cache_write_tokens, None);
+ }
+
+ #[test]
+ fn parses_chunk_without_choices() {
+ let event = expect_event(r#"{"usage":{"total_tokens":8}}"#);
+ assert!(event.choices.is_empty());
+ assert_eq!(event.usage.unwrap().total_tokens, Some(8));
+ }
+
+ #[test]
+ fn parses_explicit_nulls_for_non_optional_fields() {
+ let event = expect_event(
+ r#"{"choices":[{"index":null,"delta":{"tool_calls":[{"index":null,"id":"call_1","function":null}]},"finish_reason":null}],"usage":null}"#,
+ );
+ let choice = &event.choices[0];
+ assert_eq!(choice.index, 0);
+ let tool_call = &choice.delta.as_ref().unwrap().tool_calls.as_ref().unwrap()[0];
+ assert_eq!(tool_call.index, None);
+ assert_eq!(tool_call.id.as_deref(), Some("call_1"));
+
+ let event = expect_event(r#"{"choices":null,"usage":{"total_tokens":8}}"#);
+ assert!(event.choices.is_empty());
+ assert_eq!(event.usage.unwrap().total_tokens, Some(8));
+ }
+
+ #[test]
+ fn parses_empty_usage_object() {
+ let event = expect_event(r#"{"choices":[],"usage":{}}"#);
+ let usage = event.usage.unwrap();
+ assert_eq!(usage.prompt_tokens, None);
+ assert_eq!(usage.total_tokens, None);
+ }
+
+ #[test]
+ fn parses_tool_call_chunk_without_index() {
+ let event = expect_event(
+ r#"{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"edit_file","arguments":""}}]}}]}"#,
+ );
+ let mut choices = event.choices;
+ let delta = choices.remove(0).delta.unwrap();
+ let tool_call = delta.tool_calls.unwrap().remove(0);
+ assert_eq!(tool_call.index, None);
+ assert_eq!(tool_call.id.as_deref(), Some("call_1"));
+ assert_eq!(
+ tool_call.function.unwrap().name.as_deref(),
+ Some("edit_file")
+ );
+ }
+
+ // MiniMax has sent `index: -1` on every tool call chunk (#42584).
+ #[test]
+ fn parses_negative_tool_call_index_as_absent() {
+ let event = expect_event(
+ r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":-1,"id":"call_1","function":{"name":"edit_file","arguments":"{}"}}]}}]}"#,
+ );
+ let tool_call = &event.choices[0]
+ .delta
+ .as_ref()
+ .unwrap()
+ .tool_calls
+ .as_ref()
+ .unwrap()[0];
+ assert_eq!(tool_call.index, None);
+ assert_eq!(tool_call.id.as_deref(), Some("call_1"));
+ }
+
+ #[test]
+ fn parses_error_envelope() {
+ match parse(r#"{"error":{"message":"quota exceeded"}}"#) {
+ ResponseStreamResult::Err { error } => assert_eq!(error.message, "quota exceeded"),
+ ResponseStreamResult::Ok(_) => panic!("expected an error"),
+ }
+ }
+
+ #[test]
+ fn parses_custom_error_payload() {
+ #[derive(Deserialize)]
+ struct CustomError {
+ code: u16,
+ message: String,
+ }
+
+ let chunk = r#"{"error":{"code":429,"message":"slow down"}}"#;
+ match serde_json::from_str::>(chunk).unwrap() {
+ ResponseStreamResult::Err { error } => {
+ assert_eq!(error.code, 429);
+ assert_eq!(error.message, "slow down");
+ }
+ ResponseStreamResult::Ok(_) => panic!("expected an error"),
+ }
+ }
+
+ #[test]
+ fn error_takes_precedence_over_event_payload() {
+ match parse(r#"{"error":{"message":"boom"},"choices":[{"delta":{"content":"hi"}}]}"#) {
+ ResponseStreamResult::Err { error } => assert_eq!(error.message, "boom"),
+ ResponseStreamResult::Ok(_) => panic!("expected an error"),
+ }
+ }
+
+ #[test]
+ fn rejects_unrecognized_chunks() {
+ for chunk in [
+ r#"{"detail":"Internal Server Error"}"#,
+ r#"{"object":"error","message":"engine overloaded","code":50302}"#,
+ r#""catastrophe""#,
+ ] {
+ let error = serde_json::from_str::(chunk).unwrap_err();
+ assert!(
+ error.to_string().contains("unrecognized"),
+ "expected {chunk} to be rejected, got: {error}"
+ );
+ }
+ }
+
+ #[test]
+ fn null_error_field_is_not_an_error() {
+ let event = expect_event(r#"{"error":null,"choices":[{"delta":{"content":"hi"}}]}"#);
+ assert_eq!(
+ event.choices[0].delta.as_ref().unwrap().content.as_deref(),
+ Some("hi")
+ );
+ }
+
+ #[test]
+ fn token_usage_requires_both_totals() {
+ let usage = Usage {
+ prompt_tokens: Some(10),
+ completion_tokens: None,
+ ..Default::default()
+ };
+ assert!(usage.token_usage().is_none());
+ }
+
+ #[test]
+ fn token_usage_splits_cache_tokens_out_of_prompt_tokens() {
+ let usage = Usage {
+ prompt_tokens: Some(12),
+ completion_tokens: Some(7),
+ total_tokens: Some(19),
+ prompt_tokens_details: Some(PromptTokensDetails {
+ cached_tokens: Some(5),
+ cache_write_tokens: Some(3),
+ }),
+ };
+ let token_usage = usage.token_usage().unwrap();
+ assert_eq!(token_usage.input_tokens, 4);
+ assert_eq!(token_usage.output_tokens, 7);
+ assert_eq!(token_usage.cache_creation_input_tokens, 3);
+ assert_eq!(token_usage.cache_read_input_tokens, 5);
+ }
+
+ #[test]
+ fn reports_the_underlying_field_error() {
+ let error = serde_json::from_str::(
+ r#"{"choices":[{"index":0,"delta":{"content":42}}]}"#,
+ )
+ .unwrap_err();
+ let message = error.to_string();
+ assert!(
+ !message.contains("did not match any variant"),
+ "expected a specific error, got: {message}"
+ );
+ assert!(
+ message.contains("invalid type"),
+ "expected the underlying field error, got: {message}"
+ );
+ }
+
+ fn map_completion_events(
+ events: Vec,
+ ) -> Vec {
+ let mut mapper = ChatCompletionEventMapper::new();
+ let mut all_events = Vec::new();
+ for event in events {
+ all_events.extend(mapper.map_event(event));
+ }
+ all_events.into_iter().filter_map(|e| e.ok()).collect()
+ }
+
+ #[test]
+ fn stream_maps_reasoning() {
+ let events = map_completion_events(vec![ResponseStreamEvent {
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: Some(ResponseMessageDelta {
+ content: None,
+ reasoning: Some("thinking".into()),
+ tool_calls: None,
+ reasoning_content: None,
+ reasoning_details: None,
+ }),
+ finish_reason: None,
+ }],
+ usage: None,
+ }]);
+
+ assert_eq!(
+ events,
+ vec![LanguageModelCompletionEvent::Thinking {
+ text: "thinking".into(),
+ signature: None,
+ }]
+ );
+ }
+
+ #[test]
+ fn stream_maps_length_finish_reason_to_max_tokens_stop() {
+ let events = map_completion_events(vec![ResponseStreamEvent {
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: None,
+ finish_reason: Some("length".into()),
+ }],
+ usage: None,
+ }]);
+
+ assert_eq!(
+ events,
+ vec![LanguageModelCompletionEvent::Stop(StopReason::MaxTokens)]
+ );
+ }
+
+ #[test]
+ fn chunk_without_choices_or_usage_maps_to_no_events() {
+ let mut mapper = ChatCompletionEventMapper::new();
+ assert!(mapper.map_event(ResponseStreamEvent::default()).is_empty());
+ }
+
+ #[test]
+ fn usage_update_precedes_text_and_stop_events_from_the_same_chunk() {
+ let mut mapper = ChatCompletionEventMapper::new();
+ let events = mapper.map_event(ResponseStreamEvent {
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: Some(ResponseMessageDelta {
+ content: Some("Hello!".to_string()),
+ ..Default::default()
+ }),
+ finish_reason: Some("stop".to_string()),
+ }],
+ usage: Some(Usage {
+ prompt_tokens: Some(11),
+ completion_tokens: Some(7),
+ total_tokens: Some(18),
+ prompt_tokens_details: None,
+ }),
+ });
+
+ assert!(matches!(
+ events.as_slice(),
+ [
+ Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+ input_tokens: 11,
+ output_tokens: 7,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ })),
+ Ok(LanguageModelCompletionEvent::Text(text)),
+ Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)),
+ ] if text == "Hello!"
+ ));
+ }
+
+ #[test]
+ fn usage_update_precedes_tool_use_and_stop_events_from_the_same_chunk() {
+ let mut mapper = ChatCompletionEventMapper::new();
+ let events = mapper.map_event(ResponseStreamEvent {
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: Some(ResponseMessageDelta {
+ tool_calls: Some(vec![ToolCallChunk {
+ index: Some(0),
+ id: Some("tool-call-id".to_string()),
+ function: Some(FunctionChunk {
+ name: Some("test_tool".to_string()),
+ arguments: Some(r#"{"value":1}"#.to_string()),
+ thought_signature: None,
+ }),
+ }]),
+ ..Default::default()
+ }),
+ finish_reason: Some("tool_calls".to_string()),
+ }],
+ usage: Some(Usage {
+ prompt_tokens: Some(13),
+ completion_tokens: Some(5),
+ total_tokens: Some(18),
+ prompt_tokens_details: None,
+ }),
+ });
+
+ assert!(matches!(
+ events.as_slice(),
+ [
+ Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+ input_tokens: 13,
+ output_tokens: 5,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ })),
+ Ok(LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
+ is_input_complete: false,
+ ..
+ })),
+ Ok(LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
+ id,
+ name,
+ is_input_complete: true,
+ ..
+ })),
+ Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)),
+ ] if id.to_string() == "tool-call-id" && name.as_ref() == "test_tool"
+ ));
+ }
+
+ #[test]
+ fn stream_merges_reasoning_details_and_maps_compatible_usage_and_signatures() {
+ let response_events = serde_json::from_value(json!([
+ {
+ "choices": [{
+ "index": 0,
+ "delta": {
+ "reasoning_details": [{
+ "id": "reasoning-1",
+ "index": 0,
+ "type": "reasoning.text",
+ "text": "first "
+ }],
+ "tool_calls": [{
+ "index": 0,
+ "id": "call-1",
+ "function": {
+ "name": "search",
+ "arguments": "{",
+ "thought_signature": "signature"
+ }
+ }]
+ },
+ "finish_reason": null
+ }],
+ "usage": null
+ },
+ {
+ "choices": [{
+ "index": 0,
+ "delta": {
+ "reasoning_details": [{
+ "id": "reasoning-1",
+ "index": 0,
+ "text": "second"
+ }],
+ "tool_calls": [{
+ "index": 0,
+ "function": {
+ "arguments": "}"
+ }
+ }]
+ },
+ "finish_reason": "tool_calls"
+ }],
+ "usage": null
+ },
+ {
+ "choices": [],
+ "usage": {
+ "prompt_tokens": 10000,
+ "completion_tokens": 500,
+ "total_tokens": 10500,
+ "prompt_tokens_details": {
+ "cached_tokens": 6000,
+ "cache_write_tokens": 1000
+ }
+ }
+ }
+ ]))
+ .expect("valid compatible Chat Completions events");
+ let events = map_completion_events(response_events);
+
+ assert!(events.iter().any(|event| {
+ matches!(
+ event,
+ LanguageModelCompletionEvent::ReasoningDetails(details)
+ if details[0]["text"] == "first second"
+ )
+ }));
+ assert!(events.iter().any(|event| {
+ matches!(
+ event,
+ LanguageModelCompletionEvent::ToolUse(tool_use)
+ if tool_use.is_input_complete
+ && tool_use.thought_signature.as_deref() == Some("signature")
+ )
+ }));
+ assert!(events.iter().any(|event| {
+ matches!(
+ event,
+ LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+ input_tokens: 3_000,
+ output_tokens: 500,
+ cache_creation_input_tokens: 1_000,
+ cache_read_input_tokens: 6_000,
+ })
+ )
+ }));
+ }
+
+ #[test]
+ fn reasoning_details_accumulator_replaces_an_incompatible_previous_shape() {
+ let mut accumulator = ReasoningDetailsAccumulator::default();
+ assert_eq!(
+ accumulator.push(json!({"summary": "provider-defined"})),
+ Some(json!({"summary": "provider-defined"}))
+ );
+ assert_eq!(
+ accumulator.push(json!([{"index": 0, "text": "reasoning"}])),
+ Some(json!([{"index": 0, "text": "reasoning"}]))
+ );
+ }
+
+ // OpenRouter sends an empty `reasoning_details` array in the finish chunk;
+ // it must not wipe out details accumulated from earlier chunks.
+ #[test]
+ fn reasoning_details_accumulator_ignores_null_and_empty_array_chunks() {
+ let mut accumulator = ReasoningDetailsAccumulator::default();
+ assert!(
+ accumulator
+ .push(json!([{"index": 0, "type": "reasoning.text", "text": "thinking"}]))
+ .is_some()
+ );
+ assert!(
+ accumulator
+ .push(
+ json!([{"index": 0, "type": "reasoning.encrypted", "data": "encrypted-blob"}])
+ )
+ .is_some()
+ );
+
+ assert_eq!(accumulator.push(json!([])), None);
+ assert_eq!(accumulator.push(serde_json::Value::Null), None);
+
+ let details = accumulator
+ .push(json!([{"index": 0, "text": " more"}]))
+ .expect("accumulated reasoning details");
+ assert_eq!(details[0]["text"], "thinking more");
+ assert_eq!(details[0]["data"], "encrypted-blob");
+ }
+
+ #[test]
+ fn stream_maps_preserves_tool_id_and_name_across_empty_deltas() {
+ // DashScope sends id="" and name="" in subsequent tool_calls delta
+ // chunks after the first chunk. ChatCompletionEventMapper must not overwrite
+ // the accumulated id and name with these empty strings.
+
+ let events = vec![
+ // First chunk: id and name are present
+ ResponseStreamEvent {
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: Some(ResponseMessageDelta {
+ content: None,
+ reasoning: None,
+ tool_calls: Some(vec![ToolCallChunk {
+ index: Some(0),
+ id: Some("call_dashscope_test".into()),
+ function: Some(FunctionChunk {
+ name: Some("list_directory".into()),
+ arguments: Some("".into()),
+ thought_signature: None,
+ }),
+ }]),
+ reasoning_content: None,
+ reasoning_details: None,
+ }),
+ finish_reason: None,
+ }],
+ usage: None,
+ },
+ // Subsequent chunks: DashScope sends id="" and name=""
+ ResponseStreamEvent {
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: Some(ResponseMessageDelta {
+ content: None,
+ reasoning: None,
+ tool_calls: Some(vec![ToolCallChunk {
+ index: Some(0),
+ id: Some("".into()),
+ function: Some(FunctionChunk {
+ name: Some("".into()),
+ arguments: Some("{\"path\": \"".into()),
+ thought_signature: None,
+ }),
+ }]),
+ reasoning_content: None,
+ reasoning_details: None,
+ }),
+ finish_reason: None,
+ }],
+ usage: None,
+ },
+ ResponseStreamEvent {
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: Some(ResponseMessageDelta {
+ content: None,
+ reasoning: None,
+ tool_calls: Some(vec![ToolCallChunk {
+ index: Some(0),
+ id: Some("".into()),
+ function: Some(FunctionChunk {
+ name: Some("".into()),
+ arguments: Some("blog-scraper\"}".into()),
+ thought_signature: None,
+ }),
+ }]),
+ reasoning_content: None,
+ reasoning_details: None,
+ }),
+ finish_reason: None,
+ }],
+ usage: None,
+ },
+ // Final chunk: finish_reason = "tool_calls"
+ ResponseStreamEvent {
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: None,
+ finish_reason: Some("tool_calls".into()),
+ }],
+ usage: None,
+ },
+ ];
+
+ let mapped = map_completion_events(events);
+
+ // Events emitted:
+ // 1. Partial ToolUse from chunk 1 (fix_json("") → "{}", parseable)
+ // 2. Partial ToolUse from chunk 3 (arguments fully assembled)
+ // 3. Complete ToolUse from finish_reason="tool_calls" drain
+ // 4. Stop(ToolUse)
+ assert_eq!(mapped.len(), 4);
+
+ // Verify the complete ToolUse event (from finish_reason drain)
+ // has the correct id, name, and accumulated arguments.
+ let complete_tool_use = mapped.iter().find_map(|event| {
+ if let LanguageModelCompletionEvent::ToolUse(tool_use) = event {
+ if tool_use.is_input_complete {
+ return Some(tool_use);
+ }
+ }
+ None
+ });
+ assert!(
+ complete_tool_use.is_some(),
+ "expected a completed ToolUse event"
+ );
+ let tool_use = complete_tool_use.unwrap();
+ assert_eq!(
+ tool_use.id.to_string(),
+ "call_dashscope_test",
+ "id must survive empty-string overwrites"
+ );
+ assert_eq!(
+ tool_use.name.as_ref(),
+ "list_directory",
+ "name must survive empty-string overwrites"
+ );
+ assert_eq!(
+ tool_use.raw_input, "{\"path\": \"blog-scraper\"}",
+ "arguments should accumulate across chunks"
+ );
+
+ // Verify the Stop event
+ assert!(mapped.iter().any(|event| {
+ matches!(
+ event,
+ LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
+ )
+ }));
+ }
+
+ fn tool_call_chunk_event(tool_calls: Vec) -> ResponseStreamEvent {
+ ResponseStreamEvent {
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: Some(ResponseMessageDelta {
+ tool_calls: Some(tool_calls),
+ ..Default::default()
+ }),
+ finish_reason: None,
+ }],
+ usage: None,
+ }
+ }
+
+ fn finish_tool_calls_event() -> ResponseStreamEvent {
+ ResponseStreamEvent {
+ choices: vec![ChoiceDelta {
+ index: 0,
+ delta: None,
+ finish_reason: Some("tool_calls".into()),
+ }],
+ usage: None,
+ }
+ }
+
+ fn completed_tool_calls(events: &[LanguageModelCompletionEvent]) -> Vec<&LanguageModelToolUse> {
+ events
+ .iter()
+ .filter_map(|event| match event {
+ LanguageModelCompletionEvent::ToolUse(tool_use) if tool_use.is_input_complete => {
+ Some(tool_use)
+ }
+ _ => None,
+ })
+ .collect()
+ }
+
+ // Ollama has emitted parallel calls with distinct IDs that both use `index: 0`.
+ #[test]
+ fn separates_parallel_tool_calls_sharing_an_index() {
+ let mapped = map_completion_events(vec![
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: Some(0),
+ id: Some("call_a".into()),
+ function: Some(FunctionChunk {
+ name: Some("get_weather".into()),
+ arguments: Some(r#"{"city":"Berlin"}"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: Some(0),
+ id: Some("call_b".into()),
+ function: Some(FunctionChunk {
+ name: Some("get_weather".into()),
+ arguments: Some(r#"{"city":"Tokyo"}"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ finish_tool_calls_event(),
+ ]);
+
+ let tool_uses = completed_tool_calls(&mapped);
+ assert_eq!(tool_uses.len(), 2);
+ assert_eq!(tool_uses[0].id.to_string(), "call_a");
+ assert_eq!(tool_uses[0].raw_input, r#"{"city":"Berlin"}"#);
+ assert_eq!(tool_uses[1].id.to_string(), "call_b");
+ assert_eq!(tool_uses[1].raw_input, r#"{"city":"Tokyo"}"#);
+ }
+
+ #[test]
+ fn separates_parallel_tool_calls_sharing_an_id() {
+ let mapped = map_completion_events(vec![
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: Some(0),
+ id: Some("call_a".into()),
+ function: Some(FunctionChunk {
+ name: Some("get_weather".into()),
+ arguments: Some(r#"{"city":"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: Some(1),
+ id: Some("call_a".into()),
+ function: Some(FunctionChunk {
+ name: Some("get_weather".into()),
+ arguments: Some(r#"{"city":"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: Some(0),
+ id: None,
+ function: Some(FunctionChunk {
+ name: None,
+ arguments: Some(r#""Berlin"}"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: Some(1),
+ id: None,
+ function: Some(FunctionChunk {
+ name: None,
+ arguments: Some(r#""Tokyo"}"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ finish_tool_calls_event(),
+ ]);
+
+ let tool_uses = completed_tool_calls(&mapped);
+ assert_eq!(tool_uses.len(), 2);
+ assert_eq!(tool_uses[0].raw_input, r#"{"city":"Berlin"}"#);
+ assert_eq!(tool_uses[1].raw_input, r#"{"city":"Tokyo"}"#);
+ }
+
+ #[test]
+ fn rejects_id_only_chunks_for_calls_sharing_an_id() {
+ let mut mapper = ChatCompletionEventMapper::new();
+ for index in [0, 1] {
+ mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk {
+ index: Some(index),
+ id: Some("call_a".into()),
+ function: Some(FunctionChunk {
+ name: Some("get_weather".into()),
+ arguments: Some("{".into()),
+ thought_signature: None,
+ }),
+ }]));
+ }
+
+ let events = mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk {
+ index: None,
+ id: Some("call_a".into()),
+ function: Some(FunctionChunk {
+ name: None,
+ arguments: Some("}".into()),
+ thought_signature: None,
+ }),
+ }]));
+
+ match events.as_slice() {
+ [Err(error)] => {
+ let message = error.to_string();
+ assert!(
+ message.contains("2 tool calls in progress"),
+ "expected an attribution error, got: {message}"
+ );
+ }
+ events => panic!("expected a single error event, got: {events:?}"),
+ }
+ }
+
+ #[test]
+ fn continues_the_latest_call_at_a_reused_index() {
+ let mapped = map_completion_events(vec![
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: Some(0),
+ id: Some("call_a".into()),
+ function: Some(FunctionChunk {
+ name: Some("get_weather".into()),
+ arguments: Some(r#"{"city":"Berlin"}"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: Some(0),
+ id: Some("call_b".into()),
+ function: Some(FunctionChunk {
+ name: Some("get_weather".into()),
+ arguments: Some(r#"{"city":"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: Some(0),
+ id: None,
+ function: Some(FunctionChunk {
+ name: None,
+ arguments: Some(r#""Tokyo"}"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ finish_tool_calls_event(),
+ ]);
+
+ let tool_uses = completed_tool_calls(&mapped);
+ assert_eq!(tool_uses.len(), 2);
+ assert_eq!(tool_uses[0].raw_input, r#"{"city":"Berlin"}"#);
+ assert_eq!(tool_uses[1].raw_input, r#"{"city":"Tokyo"}"#);
+ }
+
+ // MiniMax identifies calls by ID alone, without usable indices (#42584).
+ #[test]
+ fn accumulates_tool_calls_without_indices_by_id() {
+ let mapped = map_completion_events(vec![
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: None,
+ id: Some("call_a".into()),
+ function: Some(FunctionChunk {
+ name: Some("list_directory".into()),
+ arguments: Some(r#"{"path":"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: None,
+ id: Some("call_a".into()),
+ function: Some(FunctionChunk {
+ name: None,
+ arguments: Some(r#""src"}"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ finish_tool_calls_event(),
+ ]);
+
+ let tool_uses = completed_tool_calls(&mapped);
+ assert_eq!(tool_uses.len(), 1);
+ assert_eq!(tool_uses[0].id.to_string(), "call_a");
+ assert_eq!(tool_uses[0].raw_input, r#"{"path":"src"}"#);
+ }
+
+ #[test]
+ fn continues_the_only_call_for_chunks_without_id_or_index() {
+ let mapped = map_completion_events(vec![
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: None,
+ id: Some("call_a".into()),
+ function: Some(FunctionChunk {
+ name: Some("list_directory".into()),
+ arguments: Some(r#"{"path":"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: None,
+ id: None,
+ function: Some(FunctionChunk {
+ name: None,
+ arguments: Some(r#""src"}"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ finish_tool_calls_event(),
+ ]);
+
+ let tool_uses = completed_tool_calls(&mapped);
+ assert_eq!(tool_uses.len(), 1);
+ assert_eq!(tool_uses[0].raw_input, r#"{"path":"src"}"#);
+ }
+
+ #[test]
+ fn adopts_a_late_id_for_the_call_at_the_same_index() {
+ let mapped = map_completion_events(vec![
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: Some(0),
+ id: None,
+ function: Some(FunctionChunk {
+ name: Some("list_directory".into()),
+ arguments: Some(r#"{"path":"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: Some(0),
+ id: Some("call_a".into()),
+ function: Some(FunctionChunk {
+ name: None,
+ arguments: Some(r#""src"}"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ finish_tool_calls_event(),
+ ]);
+
+ let tool_uses = completed_tool_calls(&mapped);
+ assert_eq!(tool_uses.len(), 1);
+ assert_eq!(tool_uses[0].id.to_string(), "call_a");
+ assert_eq!(tool_uses[0].raw_input, r#"{"path":"src"}"#);
+ }
+
+ #[test]
+ fn adopts_a_late_id_for_the_only_anonymous_call_without_an_index() {
+ let mapped = map_completion_events(vec![
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: None,
+ id: None,
+ function: Some(FunctionChunk {
+ name: Some("list_directory".into()),
+ arguments: Some(r#"{"path":"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ tool_call_chunk_event(vec![ToolCallChunk {
+ index: None,
+ id: Some("call_a".into()),
+ function: Some(FunctionChunk {
+ name: None,
+ arguments: Some(r#""src"}"#.into()),
+ thought_signature: None,
+ }),
+ }]),
+ finish_tool_calls_event(),
+ ]);
+
+ let tool_uses = completed_tool_calls(&mapped);
+ assert_eq!(tool_uses.len(), 1);
+ assert_eq!(tool_uses[0].id.to_string(), "call_a");
+ assert_eq!(tool_uses[0].raw_input, r#"{"path":"src"}"#);
+ }
+
+ #[test]
+ fn rejects_unattributable_tool_call_chunks() {
+ let mut mapper = ChatCompletionEventMapper::new();
+ for id in ["call_a", "call_b"] {
+ mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk {
+ index: None,
+ id: Some(id.into()),
+ function: Some(FunctionChunk {
+ name: Some("list_directory".into()),
+ arguments: Some("{".into()),
+ thought_signature: None,
+ }),
+ }]));
+ }
+
+ let events = mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk {
+ index: None,
+ id: None,
+ function: Some(FunctionChunk {
+ name: None,
+ arguments: Some(r#""path":"src"}"#.into()),
+ thought_signature: None,
+ }),
+ }]));
+
+ match events.as_slice() {
+ [Err(error)] => {
+ let message = error.to_string();
+ assert!(
+ message.contains("2 tool calls in progress"),
+ "expected an attribution error, got: {message}"
+ );
+ }
+ events => panic!("expected a single error event, got: {events:?}"),
+ }
+ }
+
+ #[test]
+ fn does_not_emit_tool_calls_after_an_attribution_error() {
+ let mut mapper = ChatCompletionEventMapper::new();
+ for id in ["call_a", "call_b"] {
+ mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk {
+ index: None,
+ id: Some(id.into()),
+ function: Some(FunctionChunk {
+ name: Some("list_directory".into()),
+ arguments: Some("{".into()),
+ thought_signature: None,
+ }),
+ }]));
+ }
+
+ let error_events = mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk {
+ index: None,
+ id: None,
+ function: Some(FunctionChunk {
+ name: None,
+ arguments: Some(r#""path":"src"}"#.into()),
+ thought_signature: None,
+ }),
+ }]));
+ assert!(matches!(error_events.as_slice(), [Err(_)]));
+
+ let continuation_events = mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk {
+ index: None,
+ id: Some("call_a".into()),
+ function: Some(FunctionChunk {
+ name: None,
+ arguments: Some("}".into()),
+ thought_signature: None,
+ }),
+ }]));
+ assert!(continuation_events.is_empty());
+
+ let finish_events = mapper.map_event(finish_tool_calls_event());
+
+ assert!(matches!(
+ finish_events.as_slice(),
+ [Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse))]
+ ));
+ }
+
+ // The `index: -1` stream shape from #42584, end to end.
+ #[test]
+ fn maps_parallel_tool_calls_with_negative_indices() {
+ let response_events = serde_json::from_value(json!([
+ {
+ "choices": [{
+ "index": 0,
+ "delta": {
+ "tool_calls": [
+ {
+ "index": -1,
+ "id": "call_a",
+ "type": "function",
+ "function": {"name": "get_weather", "arguments": "{\"city\":\"Berlin\"}"}
+ },
+ {
+ "index": -1,
+ "id": "call_b",
+ "type": "function",
+ "function": {"name": "get_weather", "arguments": "{\"city\":\"Tokyo\"}"}
+ }
+ ]
+ },
+ "finish_reason": "tool_calls"
+ }]
+ }
+ ]))
+ .expect("valid compatible Chat Completions events");
+
+ let mapped = map_completion_events(response_events);
+ let tool_uses = completed_tool_calls(&mapped);
+ assert_eq!(tool_uses.len(), 2);
+ assert_eq!(tool_uses[0].id.to_string(), "call_a");
+ assert_eq!(tool_uses[0].raw_input, r#"{"city":"Berlin"}"#);
+ assert_eq!(tool_uses[1].id.to_string(), "call_b");
+ assert_eq!(tool_uses[1].raw_input, r#"{"city":"Tokyo"}"#);
+ }
+}
diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs
index adf14a90b3..5bfc4edbbf 100644
--- a/crates/language_model_core/src/language_model_core.rs
+++ b/crates/language_model_core/src/language_model_core.rs
@@ -1,3 +1,4 @@
+pub mod chat_completion;
mod provider;
mod rate_limiter;
mod request;
diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs
index 5710b7ef92..d3b5c41efb 100644
--- a/crates/language_models/src/provider/bedrock.rs
+++ b/crates/language_models/src/provider/bedrock.rs
@@ -66,11 +66,14 @@ use util::ResultExt;
use crate::AllLanguageModelSettings;
use crate::provider::open_ai::{
- ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai,
+ ChatCompletionMaxTokensParameter, OpenAiResponseEventMapper, into_open_ai,
into_open_ai_response,
};
+use language_model::chat_completion::{
+ ChatCompletionEventMapper, ResponseStreamEvent, ResponseStreamResult,
+};
use language_model::util::{fix_streamed_json, parse_tool_arguments};
-use open_ai::{ReasoningEffort, RequestError, ResponseStreamEvent};
+use open_ai::{ReasoningEffort, RequestError};
actions!(bedrock, [Tab, TabPrev]);
@@ -1210,22 +1213,10 @@ async fn resolve_mantle_auth(
}
}
-#[derive(Deserialize)]
-#[serde(untagged)]
-enum MantleChatStreamResult {
- Ok(ResponseStreamEvent),
- Err { error: MantleChatStreamError },
-}
-
-#[derive(Deserialize)]
-struct MantleChatStreamError {
- message: String,
-}
-
fn parse_mantle_chat_stream_line(line: &str) -> Result {
- match serde_json::from_str(line) {
- Ok(MantleChatStreamResult::Ok(response)) => Ok(response),
- Ok(MantleChatStreamResult::Err { error }) => Err(anyhow!(error.message)),
+ match serde_json::from_str::(line) {
+ Ok(ResponseStreamResult::Ok(response)) => Ok(response),
+ Ok(ResponseStreamResult::Err { error }) => Err(anyhow!(error.message)),
Err(error) => {
log::error!(
"Failed to parse Mantle chat completion stream event: `{}`\nResponse: `{}`",
@@ -1973,7 +1964,7 @@ impl LanguageModel for BedrockMantleModel {
let completions = self.stream_completion(request, cx);
let executor = cx.background_executor().clone();
async move {
- let mapper = OpenAiEventMapper::new();
+ let mapper = ChatCompletionEventMapper::new();
Ok(language_model::stream_in_background(
mapper.map_stream(completions.await?).boxed(),
executor,
diff --git a/crates/language_models/src/provider/llama_cpp.rs b/crates/language_models/src/provider/llama_cpp.rs
index 297c1e4284..3b18b73225 100644
--- a/crates/language_models/src/provider/llama_cpp.rs
+++ b/crates/language_models/src/provider/llama_cpp.rs
@@ -2,18 +2,16 @@ use anyhow::Result;
use collections::{HashMap, HashSet};
use credentials_provider::CredentialsProvider;
use fs::Fs;
-use futures::Stream;
use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
use gpui::{App, AsyncApp, Context, Entity, Task, TaskExt};
use http_client::{CustomHeaders, HttpClient};
-use language_model::util::parse_tool_arguments;
use language_model::{
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel,
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
- LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, ProviderSettingsView,
- RateLimiter, Role, StopReason, SubPageProviderSettings, TokenUsage, env_var,
+ LanguageModelToolResultContent, MessageContent, ProviderSettingsView, RateLimiter, Role,
+ SubPageProviderSettings, env_var,
};
use llama_cpp::{
LLAMA_CPP_API_URL, ModelEntry, Props, get_models, get_props, stream_chat_completion,
@@ -21,7 +19,6 @@ use llama_cpp::{
};
pub use settings::LlamaCppAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore, update_settings_file};
-use std::pin::Pin;
use std::sync::LazyLock;
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
use std::time::Duration;
@@ -32,6 +29,7 @@ use ui_input::InputField;
use util::ResultExt;
use crate::AllLanguageModelSettings;
+use language_model::chat_completion::ChatCompletionEventMapper;
const LLAMA_CPP_DOWNLOAD_URL: &str = "https://llama.app";
const LLAMA_CPP_MODELS_URL: &str = "https://huggingface.co/models?library=gguf&sort=trending";
@@ -942,147 +940,13 @@ impl LanguageModel for LlamaCppLanguageModel {
};
let completions = self.stream_completion(request, cx);
async move {
- let mapper = LlamaCppEventMapper::new();
+ let mapper = ChatCompletionEventMapper::new();
Ok(mapper.map_stream(completions.await?).boxed())
}
.boxed()
}
}
-struct LlamaCppEventMapper {
- tool_calls_by_index: HashMap,
-}
-
-impl LlamaCppEventMapper {
- fn new() -> Self {
- Self {
- tool_calls_by_index: HashMap::default(),
- }
- }
-
- pub fn map_stream(
- mut self,
- events: Pin>>>,
- ) -> impl Stream- >
- {
- events.flat_map(move |event| {
- futures::stream::iter(match event {
- Ok(event) => self.map_event(event),
- Err(error) => vec![Err(LanguageModelCompletionError::from(error))],
- })
- })
- }
-
- pub fn map_event(
- &mut self,
- event: llama_cpp::ResponseStreamEvent,
- ) -> Vec
> {
- let mut events = Vec::new();
-
- if let Some(usage) = event.usage {
- events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
- input_tokens: usage.prompt_tokens,
- output_tokens: usage.completion_tokens,
- cache_creation_input_tokens: 0,
- cache_read_input_tokens: 0,
- })));
- }
-
- if let Some(choice) = event.choices.into_iter().next() {
- if let Some(reasoning_content) = choice.delta.reasoning_content {
- events.push(Ok(LanguageModelCompletionEvent::Thinking {
- text: reasoning_content,
- signature: None,
- }));
- }
-
- if let Some(content) = choice.delta.content {
- if !content.is_empty() {
- events.push(Ok(LanguageModelCompletionEvent::Text(content)));
- }
- }
-
- if let Some(tool_calls) = choice.delta.tool_calls {
- for tool_call in tool_calls {
- let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
-
- if let Some(tool_id) = tool_call.id {
- entry.id = tool_id;
- }
-
- if let Some(function) = tool_call.function {
- if let Some(name) = function.name {
- // Only the first chunk carries the function name;
- // later chunks send an empty name with arguments.
- if !name.is_empty() {
- entry.name = name;
- }
- }
-
- if let Some(arguments) = function.arguments {
- entry.arguments.push_str(&arguments);
- }
- }
- }
- }
-
- if let Some(finish_reason) = choice.finish_reason.as_deref() {
- match finish_reason {
- "stop" => {
- events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
- }
- "tool_calls" => {
- events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
- match parse_tool_arguments(&tool_call.arguments) {
- Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
- LanguageModelToolUse {
- id: tool_call.id.into(),
- name: tool_call.name.into(),
- is_input_complete: true,
- input: language_model::LanguageModelToolUseInput::Json(
- input,
- ),
- raw_input: tool_call.arguments,
- thought_signature: None,
- },
- )),
- Err(error) => {
- Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
- id: tool_call.id.into(),
- tool_name: tool_call.name.into(),
- raw_input: tool_call.arguments.into(),
- json_parse_error: error.to_string(),
- })
- }
- }
- }));
-
- events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
- }
- "length" => {
- events.push(Ok(LanguageModelCompletionEvent::Stop(
- StopReason::MaxTokens,
- )));
- }
- unexpected => {
- log::warn!("Unexpected llama.cpp finish_reason: {unexpected:?}");
- events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
- }
- }
- }
- }
-
- events
- }
-}
-
-#[derive(Default)]
-struct RawToolCall {
- id: String,
- name: String,
- arguments: String,
-}
-
fn add_message_content_part(
new_part: llama_cpp::MessagePart,
role: Role,
@@ -1638,6 +1502,7 @@ mod tests {
use super::*;
use gpui::TestAppContext;
use http_client::FakeHttpClient;
+ use language_model::LanguageModelToolUse;
use parking_lot::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -1964,90 +1829,6 @@ mod tests {
}
}
- #[test]
- fn usage_event_precedes_stop_event() {
- let mut mapper = LlamaCppEventMapper::new();
- let events = mapper.map_event(llama_cpp::ResponseStreamEvent {
- model: "test-model".to_string(),
- object: "chat.completion.chunk".to_string(),
- choices: vec![llama_cpp::ChoiceDelta {
- index: 0,
- delta: llama_cpp::ResponseMessageDelta {
- content: None,
- reasoning_content: None,
- tool_calls: None,
- },
- finish_reason: Some("stop".to_string()),
- }],
- usage: Some(llama_cpp::Usage {
- prompt_tokens: 11,
- completion_tokens: 7,
- total_tokens: 18,
- }),
- });
-
- assert!(matches!(
- events.as_slice(),
- [
- Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
- input_tokens: 11,
- output_tokens: 7,
- cache_creation_input_tokens: 0,
- cache_read_input_tokens: 0,
- })),
- Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)),
- ]
- ));
- }
-
- #[test]
- fn usage_event_precedes_tool_use_stop_event() {
- let mut mapper = LlamaCppEventMapper::new();
- let events = mapper.map_event(llama_cpp::ResponseStreamEvent {
- model: "test-model".to_string(),
- object: "chat.completion.chunk".to_string(),
- choices: vec![llama_cpp::ChoiceDelta {
- index: 0,
- delta: llama_cpp::ResponseMessageDelta {
- content: None,
- reasoning_content: None,
- tool_calls: Some(vec![llama_cpp::ToolCallChunk {
- index: 0,
- id: Some("tool-call-id".to_string()),
- function: Some(llama_cpp::FunctionChunk {
- name: Some("test_tool".to_string()),
- arguments: Some(r#"{"value":1}"#.to_string()),
- }),
- }]),
- },
- finish_reason: Some("tool_calls".to_string()),
- }],
- usage: Some(llama_cpp::Usage {
- prompt_tokens: 13,
- completion_tokens: 5,
- total_tokens: 18,
- }),
- });
-
- assert!(matches!(
- events.as_slice(),
- [
- Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
- input_tokens: 13,
- output_tokens: 5,
- cache_creation_input_tokens: 0,
- cache_read_input_tokens: 0,
- })),
- Ok(LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
- id,
- name,
- ..
- })),
- Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)),
- ] if id.to_string() == "tool-call-id" && name.as_ref() == "test_tool"
- ));
- }
-
#[gpui::test]
async fn authenticate_fetches_models_after_loading_api_key(cx: &mut TestAppContext) {
cx.update(|cx| {
diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs
index 518b4a2615..a2de082c1b 100644
--- a/crates/language_models/src/provider/lmstudio.rs
+++ b/crates/language_models/src/provider/lmstudio.rs
@@ -1,14 +1,13 @@
use anyhow::{Result, anyhow};
use credentials_provider::CredentialsProvider;
use fs::Fs;
-use futures::Stream;
use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
use gpui::{App, AsyncApp, Context, Entity, Subscription, Task, TaskExt};
use http_client::{CustomHeaders, HttpClient};
use language_model::{
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolResultContent,
- LanguageModelToolUse, MessageContent, StopReason, TokenUsage, env_var,
+ MessageContent, env_var,
};
use language_model::{
InlineDescription, LanguageModelId, LanguageModelName, LanguageModelProvider,
@@ -19,17 +18,13 @@ use lmstudio::{LMSTUDIO_API_URL, ModelType, get_models};
pub use settings::LmStudioAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore, update_settings_file};
-use std::pin::Pin;
use std::sync::LazyLock;
-use std::{
- collections::{BTreeMap, HashMap},
- sync::Arc,
-};
+use std::{collections::BTreeMap, sync::Arc};
use ui::{ButtonLike, ConfiguredApiCard, Divider, List, ListBulletItem, Tooltip, prelude::*};
use ui_input::InputField;
use crate::AllLanguageModelSettings;
-use language_model::util::parse_tool_arguments;
+use language_model::chat_completion::ChatCompletionEventMapper;
const LMSTUDIO_DOWNLOAD_URL: &str = "https://lmstudio.ai/download";
const LMSTUDIO_CATALOG_URL: &str = "https://lmstudio.ai/models";
@@ -559,278 +554,13 @@ impl LanguageModel for LmStudioLanguageModel {
};
let completions = self.stream_completion(request, cx);
async move {
- let mapper = LmStudioEventMapper::new();
+ let mapper = ChatCompletionEventMapper::new();
Ok(mapper.map_stream(completions.await?).boxed())
}
.boxed()
}
}
-struct LmStudioEventMapper {
- tool_calls_by_index: HashMap,
-}
-
-impl LmStudioEventMapper {
- fn new() -> Self {
- Self {
- tool_calls_by_index: HashMap::default(),
- }
- }
-
- pub fn map_stream(
- mut self,
- events: Pin>>>,
- ) -> impl Stream- >
- {
- events.flat_map(move |event| {
- futures::stream::iter(match event {
- Ok(event) => self.map_event(event),
- Err(error) => vec![Err(LanguageModelCompletionError::from(error))],
- })
- })
- }
-
- pub fn map_event(
- &mut self,
- event: lmstudio::ResponseStreamEvent,
- ) -> Vec
> {
- let mut events = Vec::new();
-
- if let Some(usage) = event.usage {
- events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
- input_tokens: usage.prompt_tokens,
- output_tokens: usage.completion_tokens,
- cache_creation_input_tokens: 0,
- cache_read_input_tokens: 0,
- })));
- }
-
- // The final usage summary chunk from OpenAI-compatible servers has an empty choices array.
- // Return accumulated events instead of treating it as an error.
- let Some(choice) = event.choices.into_iter().next() else {
- return events;
- };
-
- if let Some(content) = choice.delta.content {
- events.push(Ok(LanguageModelCompletionEvent::Text(content)));
- }
-
- if let Some(reasoning_content) = choice.delta.reasoning_content {
- events.push(Ok(LanguageModelCompletionEvent::Thinking {
- text: reasoning_content,
- signature: None,
- }));
- }
-
- if let Some(tool_calls) = choice.delta.tool_calls {
- for tool_call in tool_calls {
- let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
-
- if let Some(tool_id) = tool_call.id {
- entry.id = tool_id;
- }
-
- if let Some(function) = tool_call.function {
- if let Some(name) = function.name {
- // At the time of writing this code LM Studio (0.3.15) is incompatible with the OpenAI API:
- // 1. It sends function name in the first chunk
- // 2. It sends empty string in the function name field in all subsequent chunks for arguments
- // According to https://platform.openai.com/docs/guides/function-calling?api-mode=responses#streaming
- // function name field should be sent only inside the first chunk.
- if !name.is_empty() {
- entry.name = name;
- }
- }
-
- if let Some(arguments) = function.arguments {
- entry.arguments.push_str(&arguments);
- }
- }
- }
- }
-
- match choice.finish_reason.as_deref() {
- Some("stop") => {
- events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
- }
- Some("tool_calls") => {
- events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
- match parse_tool_arguments(&tool_call.arguments) {
- Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
- LanguageModelToolUse {
- id: tool_call.id.into(),
- name: tool_call.name.into(),
- is_input_complete: true,
- input: language_model::LanguageModelToolUseInput::Json(input),
- raw_input: tool_call.arguments,
- thought_signature: None,
- },
- )),
- Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
- id: tool_call.id.into(),
- tool_name: tool_call.name.into(),
- raw_input: tool_call.arguments.into(),
- json_parse_error: error.to_string(),
- }),
- }
- }));
-
- events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
- }
- Some(stop_reason) => {
- log::error!("Unexpected LMStudio stop_reason: {stop_reason:?}",);
- events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
- }
- None => {}
- }
-
- events
- }
-}
-
-#[derive(Default)]
-struct RawToolCall {
- id: String,
- name: String,
- arguments: String,
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use lmstudio::{ChoiceDelta, ResponseMessageDelta, ResponseStreamEvent, Usage};
-
- fn make_event(choices: Vec, usage: Option) -> ResponseStreamEvent {
- ResponseStreamEvent {
- created: 0,
- model: "test-model".to_string(),
- object: "chat.completion.chunk".to_string(),
- choices,
- usage,
- }
- }
-
- fn make_content_choice(content: &str) -> ChoiceDelta {
- ChoiceDelta {
- index: 0,
- delta: ResponseMessageDelta {
- role: None,
- content: Some(content.to_string()),
- reasoning_content: None,
- tool_calls: None,
- },
- finish_reason: None,
- }
- }
-
- fn make_stop_choice() -> ChoiceDelta {
- ChoiceDelta {
- index: 0,
- delta: ResponseMessageDelta {
- role: None,
- content: None,
- reasoning_content: None,
- tool_calls: None,
- },
- finish_reason: Some("stop".to_string()),
- }
- }
-
- // OpenAI-compatible servers send a final chunk with usage data and an empty
- // choices array. Before this fix, the mapper returned an error for empty
- // choices, discarding usage entirely.
- #[test]
- fn test_usage_in_final_empty_choices_chunk() {
- let mut mapper = LmStudioEventMapper::new();
- let event = make_event(
- vec![],
- Some(Usage {
- prompt_tokens: 10,
- completion_tokens: 20,
- total_tokens: 30,
- }),
- );
-
- let results: Vec<_> = mapper
- .map_event(event)
- .into_iter()
- .map(|r| r.unwrap())
- .collect();
-
- assert_eq!(
- results,
- vec![LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
- input_tokens: 10,
- output_tokens: 20,
- cache_creation_input_tokens: 0,
- cache_read_input_tokens: 0,
- })]
- );
- }
-
- #[test]
- fn test_empty_choices_without_usage_returns_empty() {
- let mut mapper = LmStudioEventMapper::new();
- let event = make_event(vec![], None);
-
- let results = mapper.map_event(event);
-
- assert!(results.is_empty());
- }
-
- // Usage data can also arrive in a regular chunk that also contains content.
- // Both events must be emitted, with UsageUpdate first.
- #[test]
- fn test_usage_emitted_alongside_content() {
- let mut mapper = LmStudioEventMapper::new();
- let event = make_event(
- vec![make_content_choice("Hello!")],
- Some(Usage {
- prompt_tokens: 5,
- completion_tokens: 3,
- total_tokens: 8,
- }),
- );
-
- let results: Vec<_> = mapper
- .map_event(event)
- .into_iter()
- .map(|r| r.unwrap())
- .collect();
-
- assert_eq!(
- results[0],
- LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
- input_tokens: 5,
- output_tokens: 3,
- cache_creation_input_tokens: 0,
- cache_read_input_tokens: 0,
- })
- );
- assert_eq!(
- results[1],
- LanguageModelCompletionEvent::Text("Hello!".to_string())
- );
- }
-
- #[test]
- fn test_stop_event_emitted_on_finish_reason() {
- let mut mapper = LmStudioEventMapper::new();
- let event = make_event(vec![make_stop_choice()], None);
-
- let results: Vec<_> = mapper
- .map_event(event)
- .into_iter()
- .map(|r| r.unwrap())
- .collect();
-
- assert_eq!(
- results,
- vec![LanguageModelCompletionEvent::Stop(StopReason::EndTurn)]
- );
- }
-}
-
fn add_message_content_part(
new_part: lmstudio::MessagePart,
role: Role,
diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs
index 8c123999af..328f8005c7 100644
--- a/crates/language_models/src/provider/open_ai.rs
+++ b/crates/language_models/src/provider/open_ai.rs
@@ -25,9 +25,10 @@ use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
use ui::IconName;
+use language_model::chat_completion::ChatCompletionEventMapper;
use open_ai::completion::token_usage_from_response_usage;
pub use open_ai::completion::{
- ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai,
+ ChatCompletionMaxTokensParameter, OpenAiResponseEventMapper, into_open_ai,
into_open_ai_response,
};
@@ -669,7 +670,7 @@ impl LanguageModel for OpenAiLanguageModel {
let completions = self.stream_completion(request, cx);
let executor = cx.background_executor().clone();
async move {
- let mapper = OpenAiEventMapper::new();
+ let mapper = ChatCompletionEventMapper::new();
Ok(stream_in_background(
mapper.map_stream(completions.await?).boxed(),
executor,
diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs
index 47a109b04d..090c348f16 100644
--- a/crates/language_models/src/provider/open_ai_compatible.rs
+++ b/crates/language_models/src/provider/open_ai_compatible.rs
@@ -3,6 +3,7 @@ use credentials_provider::CredentialsProvider;
use futures::{FutureExt, StreamExt, future::BoxFuture};
use gpui::{App, AppContext, AsyncApp, Entity, Task};
use http_client::{CustomHeaders, HttpClient};
+use language_model::chat_completion::ChatCompletionEventMapper;
use language_model::{
AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
@@ -23,9 +24,7 @@ use crate::provider::api_compatible::{
ApiCompatibleProviderConfigurationView, ApiCompatibleProviderSettings,
ApiCompatibleProviderState,
};
-use crate::provider::open_ai::{
- OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response,
-};
+use crate::provider::open_ai::{OpenAiResponseEventMapper, into_open_ai, into_open_ai_response};
pub use settings::OpenAiCompatibleAvailableModel as AvailableModel;
pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities;
@@ -436,7 +435,7 @@ impl LanguageModel for OpenAiCompatibleLanguageModel {
let completions = self.stream_completion(request, cx);
let executor = cx.background_executor().clone();
async move {
- let mapper = OpenAiEventMapper::new();
+ let mapper = ChatCompletionEventMapper::new();
Ok(language_model::stream_in_background(
mapper.map_stream(completions.await?).boxed(),
executor,
diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs
index 381ff36bd1..84b4f7b898 100644
--- a/crates/language_models/src/provider/open_router.rs
+++ b/crates/language_models/src/provider/open_router.rs
@@ -1,30 +1,27 @@
use anyhow::Result;
-use collections::HashMap;
+
use credentials_provider::CredentialsProvider;
-use futures::{FutureExt, Stream, StreamExt, future::BoxFuture};
+use futures::{FutureExt, StreamExt, future::BoxFuture};
use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task};
use http_client::{CustomHeaders, HttpClient};
+use language_model::chat_completion::ChatCompletionEventMapper;
use language_model::{
ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel,
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
- LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
- ProviderSettingsView, RateLimiter, Role, StopReason, TokenUsage, env_var,
+ LanguageModelToolChoice, LanguageModelToolResultContent, MessageContent, ProviderSettingsView,
+ RateLimiter, Role, env_var,
};
-use open_ai::completion::ReasoningDetailsAccumulator;
use open_router::{
Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ReasoningEffort,
ResponseStreamEvent, list_models,
};
use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsStore};
use sha2::{Digest as _, Sha256};
-use std::pin::Pin;
use std::sync::{Arc, LazyLock};
use ui::IconName;
-use language_model::util::{fix_streamed_json, parse_tool_arguments};
-
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openrouter");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenRouter");
@@ -446,7 +443,7 @@ impl LanguageModel for OpenRouterLanguageModel {
let executor = cx.background_executor().clone();
let future = self.request_limiter.stream(async move {
let response = request.await?;
- let events = OpenRouterEventMapper::new().map_stream(response);
+ let events = ChatCompletionEventMapper::new().map_stream(response);
Ok(language_model::stream_in_background(
events.boxed(),
executor,
@@ -774,373 +771,10 @@ fn add_message_content_part(
}
}
-pub struct OpenRouterEventMapper {
- tool_calls_by_index: HashMap,
- reasoning_details: ReasoningDetailsAccumulator,
-}
-
-impl OpenRouterEventMapper {
- pub fn new() -> Self {
- Self {
- tool_calls_by_index: HashMap::default(),
- reasoning_details: ReasoningDetailsAccumulator::default(),
- }
- }
-
- pub fn map_stream(
- mut self,
- events: Pin<
- Box<
- dyn Send + Stream- >,
- >,
- >,
- ) -> impl Stream
- >
- {
- events.flat_map(move |event| {
- futures::stream::iter(match event {
- Ok(event) => self.map_event(event),
- Err(error) => vec![Err(error.into())],
- })
- })
- }
-
- pub fn map_event(
- &mut self,
- event: ResponseStreamEvent,
- ) -> Vec
> {
- let mut events = Vec::new();
-
- if let Some(usage) = event.usage {
- let cache_creation_input_tokens = usage
- .prompt_tokens_details
- .as_ref()
- .map_or(0, |details| details.cache_write_tokens);
- let cache_read_input_tokens = usage
- .prompt_tokens_details
- .as_ref()
- .map_or(0, |details| details.cached_tokens);
- let input_tokens = usage.prompt_tokens.saturating_sub(
- cache_creation_input_tokens.saturating_add(cache_read_input_tokens),
- );
-
- events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
- input_tokens,
- output_tokens: usage.completion_tokens,
- cache_creation_input_tokens,
- cache_read_input_tokens,
- })));
- }
-
- let Some(choice) = event.choices.first() else {
- return events;
- };
-
- if let Some(details) = choice.delta.reasoning_details.clone()
- && let Some(details) = self.reasoning_details.push(details)
- {
- events.push(Ok(LanguageModelCompletionEvent::ReasoningDetails(details)));
- }
-
- if let Some(reasoning) = choice.delta.reasoning.clone() {
- events.push(Ok(LanguageModelCompletionEvent::Thinking {
- text: reasoning,
- signature: None,
- }));
- }
-
- if let Some(content) = choice.delta.content.clone() {
- // OpenRouter send empty content string with the reasoning content
- // This is a workaround for the OpenRouter API bug
- if !content.is_empty() {
- events.push(Ok(LanguageModelCompletionEvent::Text(content)));
- }
- }
-
- if let Some(tool_calls) = choice.delta.tool_calls.as_ref() {
- for tool_call in tool_calls {
- let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
-
- if let Some(tool_id) = tool_call.id.clone() {
- entry.id = tool_id;
- }
-
- if let Some(function) = tool_call.function.as_ref() {
- if let Some(name) = function.name.clone() {
- entry.name = name;
- }
-
- if let Some(arguments) = function.arguments.clone() {
- entry.arguments.push_str(&arguments);
- }
-
- if let Some(signature) = function.thought_signature.clone() {
- entry.thought_signature = Some(signature);
- }
- }
-
- if !entry.id.is_empty() && !entry.name.is_empty() {
- if let Ok(input) = serde_json::from_str::(
- &fix_streamed_json(&entry.arguments),
- ) {
- events.push(Ok(LanguageModelCompletionEvent::ToolUse(
- LanguageModelToolUse {
- id: entry.id.clone().into(),
- name: entry.name.as_str().into(),
- is_input_complete: false,
- input: language_model::LanguageModelToolUseInput::Json(input),
- raw_input: entry.arguments.clone(),
- thought_signature: entry.thought_signature.clone(),
- },
- )));
- }
- }
- }
- }
-
- match choice.finish_reason.as_deref() {
- Some("stop") => {
- events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
- }
- Some("tool_calls") => {
- events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
- match parse_tool_arguments(&tool_call.arguments) {
- Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
- LanguageModelToolUse {
- id: tool_call.id.clone().into(),
- name: tool_call.name.as_str().into(),
- is_input_complete: true,
- input: language_model::LanguageModelToolUseInput::Json(input),
- raw_input: tool_call.arguments.clone(),
- thought_signature: tool_call.thought_signature.clone(),
- },
- )),
- Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
- id: tool_call.id.clone().into(),
- tool_name: tool_call.name.as_str().into(),
- raw_input: tool_call.arguments.clone().into(),
- json_parse_error: error.to_string(),
- }),
- }
- }));
-
- events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
- }
- Some(stop_reason) => {
- log::error!("Unexpected OpenRouter stop_reason: {stop_reason:?}",);
- events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
- }
- None => {}
- }
-
- events
- }
-}
-
-#[derive(Default)]
-struct RawToolCall {
- id: String,
- name: String,
- arguments: String,
- thought_signature: Option,
-}
-
#[cfg(test)]
mod tests {
use super::*;
- use open_router::{ChoiceDelta, FunctionChunk, ResponseMessageDelta, ToolCallChunk};
-
- #[gpui::test]
- async fn test_reasoning_details_preservation_with_tool_calls() {
- // This test verifies that reasoning_details are properly captured and preserved
- // when a model uses tool calling with reasoning/thinking tokens.
- //
- // The key regression this prevents:
- // - OpenRouter sends multiple reasoning_details updates during streaming
- // - First with actual content (encrypted reasoning data)
- // - Then with empty array on completion
- // - We must NOT overwrite the real data with the empty array
-
- let mut mapper = OpenRouterEventMapper::new();
-
- // Simulate the streaming events as they come from OpenRouter/Gemini
- let events = vec![
- // Event 1: Initial reasoning details with text
- ResponseStreamEvent {
- id: Some("response_123".into()),
- created: 1234567890,
- model: "google/gemini-3.1-pro-preview".into(),
- choices: vec![ChoiceDelta {
- index: 0,
- delta: ResponseMessageDelta {
- role: None,
- content: None,
- reasoning: None,
- tool_calls: None,
- reasoning_details: Some(serde_json::json!([
- {
- "type": "reasoning.text",
- "text": "Let me analyze this request...",
- "format": "google-gemini-v1",
- "index": 0
- }
- ])),
- },
- finish_reason: None,
- }],
- usage: None,
- },
- // Event 2: More reasoning details
- ResponseStreamEvent {
- id: Some("response_123".into()),
- created: 1234567890,
- model: "google/gemini-3.1-pro-preview".into(),
- choices: vec![ChoiceDelta {
- index: 0,
- delta: ResponseMessageDelta {
- role: None,
- content: None,
- reasoning: None,
- tool_calls: None,
- reasoning_details: Some(serde_json::json!([
- {
- "type": "reasoning.encrypted",
- "data": "EtgDCtUDAdHtim9OF5jm4aeZSBAtl/randomized123",
- "format": "google-gemini-v1",
- "index": 0,
- "id": "tool_call_abc123"
- }
- ])),
- },
- finish_reason: None,
- }],
- usage: None,
- },
- // Event 3: Tool call starts
- ResponseStreamEvent {
- id: Some("response_123".into()),
- created: 1234567890,
- model: "google/gemini-3.1-pro-preview".into(),
- choices: vec![ChoiceDelta {
- index: 0,
- delta: ResponseMessageDelta {
- role: None,
- content: None,
- reasoning: None,
- tool_calls: Some(vec![ToolCallChunk {
- index: 0,
- id: Some("tool_call_abc123".into()),
- function: Some(FunctionChunk {
- name: Some("list_directory".into()),
- arguments: Some("{\"path\":\"test\"}".into()),
- thought_signature: Some("sha256:test_signature_xyz789".into()),
- }),
- }]),
- reasoning_details: None,
- },
- finish_reason: None,
- }],
- usage: None,
- },
- // Event 4: Empty reasoning_details on tool_calls finish
- // This is the critical event - we must not overwrite with this empty array!
- ResponseStreamEvent {
- id: Some("response_123".into()),
- created: 1234567890,
- model: "google/gemini-3.1-pro-preview".into(),
- choices: vec![ChoiceDelta {
- index: 0,
- delta: ResponseMessageDelta {
- role: None,
- content: None,
- reasoning: None,
- tool_calls: None,
- reasoning_details: Some(serde_json::json!([])),
- },
- finish_reason: Some("tool_calls".into()),
- }],
- usage: None,
- },
- ];
-
- // Process all events
- let mut collected_events = Vec::new();
- for event in events {
- let mapped = mapper.map_event(event);
- collected_events.extend(mapped);
- }
-
- // Verify we got the expected events
- let mut has_tool_use = false;
- let mut reasoning_details_events = Vec::new();
- let mut thought_signature_value = None;
-
- for event_result in collected_events {
- match event_result {
- Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => {
- has_tool_use = true;
- assert_eq!(tool_use.id.to_string(), "tool_call_abc123");
- assert_eq!(tool_use.name.as_ref(), "list_directory");
- thought_signature_value = tool_use.thought_signature.clone();
- }
- Ok(LanguageModelCompletionEvent::ReasoningDetails(details)) => {
- reasoning_details_events.push(details);
- }
- _ => {}
- }
- }
-
- assert!(has_tool_use, "Should have emitted ToolUse event");
- assert_eq!(reasoning_details_events.len(), 2);
- let final_details = reasoning_details_events
- .last()
- .and_then(serde_json::Value::as_array)
- .and_then(|details| details.first())
- .expect("accumulated reasoning details");
- assert_eq!(final_details["text"], "Let me analyze this request...");
- assert_eq!(
- final_details["data"],
- "EtgDCtUDAdHtim9OF5jm4aeZSBAtl/randomized123"
- );
- assert_eq!(
- thought_signature_value.as_deref(),
- Some("sha256:test_signature_xyz789")
- );
- }
-
- #[gpui::test]
- async fn test_usage_only_chunk_with_empty_choices_does_not_error() {
- let mut mapper = OpenRouterEventMapper::new();
-
- let events = mapper.map_event(ResponseStreamEvent {
- id: Some("response_123".into()),
- created: 1234567890,
- model: "google/gemini-3-flash-preview".into(),
- choices: Vec::new(),
- usage: Some(open_router::Usage {
- prompt_tokens: 12,
- completion_tokens: 7,
- total_tokens: 19,
- prompt_tokens_details: Some(open_router::PromptTokensDetails {
- cached_tokens: 5,
- cache_write_tokens: 3,
- }),
- }),
- });
-
- assert_eq!(events.len(), 1);
- match events.into_iter().next() {
- Some(Ok(LanguageModelCompletionEvent::UsageUpdate(usage))) => {
- assert_eq!(usage.input_tokens, 4);
- assert_eq!(usage.output_tokens, 7);
- assert_eq!(usage.cache_creation_input_tokens, 3);
- assert_eq!(usage.cache_read_input_tokens, 5);
- assert_eq!(usage.total_tokens(), 19);
- }
- other => panic!("Expected usage update event, got: {other:?}"),
- }
- }
-
#[gpui::test]
async fn test_session_id_is_stable_without_exposing_thread_id() {
let model = open_router::Model::new(
diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs
index ec0c5a0a90..79e5d7fdbd 100644
--- a/crates/language_models/src/provider/opencode.rs
+++ b/crates/language_models/src/provider/opencode.rs
@@ -29,9 +29,10 @@ use util::ResultExt;
use crate::provider::anthropic::{AnthropicEventMapper, into_anthropic};
use crate::provider::google::{GoogleEventMapper, into_google};
use crate::provider::open_ai::{
- ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai,
+ ChatCompletionMaxTokensParameter, OpenAiResponseEventMapper, into_open_ai,
into_open_ai_response,
};
+use language_model::chat_completion::{ChatCompletionEventMapper, ResponseStreamEvent};
fn normalize_reasoning_effort(effort: &str) -> Option {
match effort.trim().to_ascii_lowercase().as_str() {
@@ -423,10 +424,8 @@ impl OpenCodeLanguageModel {
http_client: Arc,
extra_headers: CustomHeaders,
cx: &AsyncApp,
- ) -> BoxFuture<
- 'static,
- Result>>,
- > {
+ ) -> BoxFuture<'static, Result>>>
+ {
// OpenAI crate appends /chat/completions to api_url, so we pass base + "/v1"
let base_url = self.base_api_url(cx);
let api_url: SharedString = format!("{base_url}/v1").into();
@@ -706,7 +705,7 @@ impl LanguageModel for OpenCodeLanguageModel {
self.stream_openai_chat(openai_request, http_client, extra_headers, cx);
let executor = cx.background_executor().clone();
async move {
- let mapper = OpenAiEventMapper::new();
+ let mapper = ChatCompletionEventMapper::new();
Ok(language_model::stream_in_background(
mapper.map_stream(stream.await?).boxed(),
executor,
diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs
index ede1e45a34..b22c7d3b4a 100644
--- a/crates/language_models/src/provider/vercel_ai_gateway.rs
+++ b/crates/language_models/src/provider/vercel_ai_gateway.rs
@@ -6,6 +6,7 @@ use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task};
use http_client::{
AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http,
};
+use language_model::chat_completion::{ChatCompletionEventMapper, ResponseStreamEvent};
use language_model::{
ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel,
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
@@ -13,7 +14,6 @@ use language_model::{
LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
ProviderSettingsView, RateLimiter, env_var,
};
-use open_ai::ResponseStreamEvent;
use serde::Deserialize;
pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities;
pub use settings::VercelAiGatewayAvailableModel as AvailableModel;
@@ -463,7 +463,7 @@ impl LanguageModel for VercelAiGatewayLanguageModel {
let completions = self.stream_open_ai(request, cx);
let executor = cx.background_executor().clone();
async move {
- let mapper = crate::provider::open_ai::OpenAiEventMapper::new();
+ let mapper = ChatCompletionEventMapper::new();
Ok(language_model::stream_in_background(
mapper.map_stream(completions.await?).boxed(),
executor,
diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs
index 711f71afa0..8bc9e8562d 100644
--- a/crates/language_models/src/provider/x_ai.rs
+++ b/crates/language_models/src/provider/x_ai.rs
@@ -4,6 +4,7 @@ use credentials_provider::CredentialsProvider;
use futures::{FutureExt, StreamExt, future::BoxFuture};
use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task};
use http_client::{CustomHeaders, HttpClient};
+use language_model::chat_completion::{ChatCompletionEventMapper, ResponseStreamEvent};
use language_model::{
ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel,
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
@@ -11,7 +12,6 @@ use language_model::{
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelToolChoice, ProviderSettingsView, RateLimiter, env_var,
};
-use open_ai::ResponseStreamEvent;
pub use settings::XaiAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore};
use std::sync::{Arc, LazyLock};
@@ -430,7 +430,7 @@ impl LanguageModel for XAiLanguageModel {
let completions = self.stream_completion(request, cx);
let executor = cx.background_executor().clone();
async move {
- let mapper = crate::provider::open_ai::OpenAiEventMapper::new();
+ let mapper = ChatCompletionEventMapper::new();
Ok(language_model::stream_in_background(
mapper.map_stream(completions.await?).boxed(),
executor,
diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs
index 2923b80ee0..8808d6514f 100644
--- a/crates/language_models_cloud/src/language_models_cloud.rs
+++ b/crates/language_models_cloud/src/language_models_cloud.rs
@@ -43,8 +43,9 @@ use anthropic::completion::{
AnthropicEventMapper, AnthropicPromptCacheMode, collect_compaction_result, into_anthropic,
};
use google_ai::completion::{GoogleEventMapper, into_google};
+use language_model::chat_completion::ChatCompletionEventMapper;
use open_ai::completion::{
- ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai,
+ ChatCompletionMaxTokensParameter, OpenAiResponseEventMapper, into_open_ai,
into_open_ai_response, token_usage_from_response_usage,
};
@@ -918,7 +919,7 @@ impl LanguageModel for CloudLanguageModel,
- pub usage: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct ChoiceDelta {
- pub index: u32,
- pub delta: ResponseMessageDelta,
- pub finish_reason: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct ResponseMessageDelta {
- pub content: Option,
- /// `llama-server` emits reasoning as a dedicated `reasoning_content` field
- /// when started with a reasoning format (e.g. `--reasoning-format deepseek`).
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub reasoning_content: Option,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub tool_calls: Option>,
-}
-
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct ToolCallChunk {
- pub index: usize,
- pub id: Option,
- pub function: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct FunctionChunk {
- pub name: Option,
- pub arguments: Option,
-}
-
/// Response of `GET /v1/models`.
///
/// In single-model mode `data` has exactly one entry describing the loaded
@@ -522,7 +468,7 @@ pub async fn stream_chat_completion(
if line == "[DONE]" {
None
} else {
- match serde_json::from_str(line) {
+ match serde_json::from_str::(line) {
Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)),
Ok(ResponseStreamResult::Err { error }) => {
Some(Err(anyhow!(error.message)))
@@ -889,7 +835,7 @@ mod tests {
]
});
let event: ResponseStreamEvent = serde_json::from_value(event).unwrap();
- let delta = &event.choices[0].delta;
+ let delta = event.choices[0].delta.as_ref().unwrap();
assert_eq!(delta.reasoning_content.as_deref(), Some("thinking..."));
assert_eq!(delta.tool_calls.as_ref().unwrap().len(), 1);
}
diff --git a/crates/lmstudio/Cargo.toml b/crates/lmstudio/Cargo.toml
index 825507b915..2ef8b96d32 100644
--- a/crates/lmstudio/Cargo.toml
+++ b/crates/lmstudio/Cargo.toml
@@ -19,6 +19,7 @@ schemars = ["dep:schemars"]
anyhow.workspace = true
futures.workspace = true
http_client.workspace = true
+language_model_core.workspace = true
schemars = { workspace = true, optional = true }
serde.workspace = true
serde_json.workspace = true
diff --git a/crates/lmstudio/src/lmstudio.rs b/crates/lmstudio/src/lmstudio.rs
index 4f5c977f12..498cbde985 100644
--- a/crates/lmstudio/src/lmstudio.rs
+++ b/crates/lmstudio/src/lmstudio.rs
@@ -3,46 +3,16 @@ use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::B
use http_client::{
AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http,
};
+pub use language_model_core::chat_completion::{
+ ChoiceDelta, FunctionChunk, ResponseMessageDelta, ResponseStreamError, ResponseStreamEvent,
+ ResponseStreamResult, ToolCallChunk, Usage,
+};
use serde::{Deserialize, Serialize};
use serde_json::Value;
-use std::{convert::TryFrom, time::Duration};
+use std::time::Duration;
pub const LMSTUDIO_API_URL: &str = "http://localhost:1234/api/v0";
-#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
-#[serde(rename_all = "lowercase")]
-pub enum Role {
- User,
- Assistant,
- System,
- Tool,
-}
-
-impl TryFrom for Role {
- type Error = anyhow::Error;
-
- fn try_from(value: String) -> Result {
- match value.as_str() {
- "user" => Ok(Self::User),
- "assistant" => Ok(Self::Assistant),
- "system" => Ok(Self::System),
- "tool" => Ok(Self::Tool),
- _ => anyhow::bail!("invalid role '{value}'"),
- }
- }
-}
-
-impl From for String {
- fn from(val: Role) -> Self {
- match val {
- Role::User => "user".to_owned(),
- Role::Assistant => "assistant".to_owned(),
- Role::System => "system".to_owned(),
- Role::Tool => "tool".to_owned(),
- }
- }
-}
-
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Model {
@@ -231,46 +201,6 @@ pub struct ChatCompletionRequest {
pub tool_choice: Option,
}
-#[derive(Serialize, Deserialize, Debug)]
-pub struct ChatResponse {
- pub id: String,
- pub object: String,
- pub created: u64,
- pub model: String,
- pub choices: Vec,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct ChoiceDelta {
- pub index: u32,
- pub delta: ResponseMessageDelta,
- pub finish_reason: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct ToolCallChunk {
- pub index: usize,
- pub id: Option,
-
- // There is also an optional `type` field that would determine if a
- // function is there. Sometimes this streams in with the `function` before
- // it streams in the `type`
- pub function: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct FunctionChunk {
- pub name: Option,
- pub arguments: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct Usage {
- pub prompt_tokens: u64,
- pub completion_tokens: u64,
- pub total_tokens: u64,
-}
-
#[derive(Debug, Default, Clone, Deserialize, PartialEq)]
#[serde(transparent)]
pub struct Capabilities(Vec);
@@ -285,27 +215,6 @@ impl Capabilities {
}
}
-#[derive(Serialize, Deserialize, Debug)]
-pub struct LmStudioError {
- pub message: String,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-#[serde(untagged)]
-pub enum ResponseStreamResult {
- Ok(ResponseStreamEvent),
- Err { error: LmStudioError },
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct ResponseStreamEvent {
- pub created: u32,
- pub model: String,
- pub object: String,
- pub choices: Vec,
- pub usage: Option,
-}
-
#[derive(Deserialize)]
pub struct ListModelsResponse {
pub data: Vec,
@@ -350,56 +259,6 @@ pub enum CompatibilityType {
Mlx,
}
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct ResponseMessageDelta {
- pub role: Option,
- pub content: Option,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub reasoning_content: Option,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub tool_calls: Option>,
-}
-
-pub async fn complete(
- client: &dyn HttpClient,
- api_url: &str,
- api_key: Option<&str>,
- request: ChatCompletionRequest,
- extra_headers: &CustomHeaders,
-) -> Result {
- let uri = format!("{api_url}/chat/completions");
- let mut request_builder = HttpRequest::builder()
- .method(Method::POST)
- .uri(uri)
- .header("Content-Type", "application/json");
-
- if let Some(api_key) = api_key {
- request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key));
- }
-
- let serialized_request = serde_json::to_string(&request)?;
- let request = request_builder
- .extra_headers(extra_headers)
- .body(AsyncBody::from(serialized_request))?;
-
- let mut response = client.send(request).await?;
- if response.status().is_success() {
- let mut body = Vec::new();
- response.body_mut().read_to_end(&mut body).await?;
- let response_message: ChatResponse = serde_json::from_slice(&body)?;
- Ok(response_message)
- } else {
- let mut body = Vec::new();
- response.body_mut().read_to_end(&mut body).await?;
- let body_str = std::str::from_utf8(&body)?;
- anyhow::bail!(
- "Failed to connect to API: {} {}",
- response.status(),
- body_str
- );
- }
-}
-
pub async fn stream_chat_completion(
client: &dyn HttpClient,
api_url: &str,
@@ -432,9 +291,9 @@ pub async fn stream_chat_completion(
if line == "[DONE]" {
None
} else {
- match serde_json::from_str(line) {
+ match serde_json::from_str::(line) {
Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)),
- Ok(ResponseStreamResult::Err { error, .. }) => {
+ Ok(ResponseStreamResult::Err { error }) => {
Some(Err(anyhow!(error.message)))
}
Err(error) => Some(Err(anyhow!(error))),
diff --git a/crates/open_ai/src/chat_completion_transport_tests.rs b/crates/open_ai/src/chat_completion_transport_tests.rs
index d13a621264..8251b3a6f6 100644
--- a/crates/open_ai/src/chat_completion_transport_tests.rs
+++ b/crates/open_ai/src/chat_completion_transport_tests.rs
@@ -62,13 +62,13 @@ fn streaming_transport_serializes_custom_requests_and_reports_done() {
.expect("stream events")
});
- assert_eq!(
- events,
- vec![
- ChatCompletionStreamEvent::Data(json!({"chunk": 1})),
+ match events.as_slice() {
+ [
+ ChatCompletionStreamEvent::Data(data),
ChatCompletionStreamEvent::Done,
- ]
- );
+ ] => assert_eq!(data.get(), r#"{"chunk":1}"#),
+ events => panic!("unexpected events: {events:?}"),
+ }
assert_eq!(
captured_request
.lock()
@@ -109,10 +109,10 @@ fn streaming_transport_does_not_synthesize_done_at_eof() {
.expect("stream events")
});
- assert_eq!(
- events,
- vec![ChatCompletionStreamEvent::Data(json!({"chunk": 1}))]
- );
+ match events.as_slice() {
+ [ChatCompletionStreamEvent::Data(data)] => assert_eq!(data.get(), r#"{"chunk":1}"#),
+ events => panic!("unexpected events: {events:?}"),
+ }
}
#[test]
diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs
index e82d9695ca..6abd9faf07 100644
--- a/crates/open_ai/src/completion.rs
+++ b/crates/open_ai/src/completion.rs
@@ -25,8 +25,8 @@ use crate::responses::{
provider_compaction_items, provider_compaction_state_from_items,
};
use crate::{
- FunctionContent, FunctionDefinition, ImageUrl, MessagePart, ReasoningEffort,
- ResponseStreamEvent, ServiceTier, ToolCall, ToolCallContent,
+ FunctionContent, FunctionDefinition, ImageUrl, MessagePart, ReasoningEffort, ServiceTier,
+ ToolCall, ToolCallContent,
};
const RESPONSE_MESSAGE_PHASE_COMMENTARY: &str = "commentary";
@@ -729,281 +729,6 @@ fn add_message_content_part(
}
}
-/// Accumulates structured reasoning metadata from compatible providers.
-///
-/// Array entries are matched by `index` and then `id`. Fragmented `text`,
-/// `summary`, and `data` fields are concatenated while other non-null fields
-/// replace their previous values.
-///
-/// # Examples
-///
-/// ```
-/// use open_ai::completion::ReasoningDetailsAccumulator;
-/// use serde_json::json;
-///
-/// let mut accumulator = ReasoningDetailsAccumulator::default();
-/// accumulator.push(json!([{"index": 0, "text": "first "}]));
-/// let details = accumulator
-/// .push(json!([{"index": 0, "text": "second"}]))
-/// .expect("non-empty reasoning details");
-///
-/// assert_eq!(details[0]["text"], "first second");
-/// ```
-#[derive(Debug, Default)]
-pub struct ReasoningDetailsAccumulator {
- accumulated: Option,
-}
-
-impl ReasoningDetailsAccumulator {
- /// Merges `chunk` and returns the updated metadata snapshot.
- ///
- /// `null` and empty arrays do not replace previously accumulated metadata
- /// and return `None`.
- pub fn push(&mut self, chunk: serde_json::Value) -> Option {
- match chunk {
- serde_json::Value::Null => None,
- serde_json::Value::Array(chunks) if chunks.is_empty() => None,
- serde_json::Value::Array(chunks) => {
- let mut details = match self.accumulated.take() {
- Some(serde_json::Value::Array(details)) => details,
- _ => Vec::new(),
- };
- for chunk in chunks {
- merge_reasoning_detail(&mut details, chunk);
- }
- let accumulated = serde_json::Value::Array(details);
- self.accumulated = Some(accumulated.clone());
- Some(accumulated)
- }
- chunk => {
- self.accumulated = Some(chunk.clone());
- Some(chunk)
- }
- }
- }
-}
-
-pub struct OpenAiEventMapper {
- tool_calls_by_index: HashMap,
- reasoning_details: ReasoningDetailsAccumulator,
-}
-
-impl OpenAiEventMapper {
- pub fn new() -> Self {
- Self {
- tool_calls_by_index: HashMap::default(),
- reasoning_details: ReasoningDetailsAccumulator::default(),
- }
- }
-
- pub fn map_stream(
- mut self,
- events: Pin>>>,
- ) -> impl Stream- >
- {
- events.flat_map(move |event| {
- futures::stream::iter(match event {
- Ok(event) => self.map_event(event),
- Err(error) => vec![Err(LanguageModelCompletionError::from(anyhow!(error)))],
- })
- })
- }
-
- pub fn map_event(
- &mut self,
- event: ResponseStreamEvent,
- ) -> Vec
> {
- let mut events = Vec::new();
- if let Some(usage) = event.usage
- && let Some(prompt_tokens) = usage.prompt_tokens
- && let Some(completion_tokens) = usage.completion_tokens
- {
- let cache_creation_input_tokens = usage
- .prompt_tokens_details
- .as_ref()
- .and_then(|details| details.cache_write_tokens)
- .unwrap_or(0);
- let cache_read_input_tokens = usage
- .prompt_tokens_details
- .as_ref()
- .and_then(|details| details.cached_tokens)
- .unwrap_or(0);
- events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
- input_tokens: prompt_tokens
- .saturating_sub(cache_creation_input_tokens)
- .saturating_sub(cache_read_input_tokens),
- output_tokens: completion_tokens,
- cache_creation_input_tokens,
- cache_read_input_tokens,
- })));
- }
-
- let Some(choice) = event.choices.first() else {
- return events;
- };
-
- if let Some(delta) = choice.delta.as_ref() {
- if let Some(reasoning_details) = delta.reasoning_details.clone()
- && let Some(reasoning_details) = self.reasoning_details.push(reasoning_details)
- {
- events.push(Ok(LanguageModelCompletionEvent::ReasoningDetails(
- reasoning_details,
- )));
- }
- if let Some(reasoning) = delta.reasoning.clone() {
- push_thinking_event(reasoning, &mut events);
- }
- if let Some(reasoning_content) = delta.reasoning_content.clone() {
- push_thinking_event(reasoning_content, &mut events);
- }
- if let Some(content) = delta.content.clone() {
- if !content.is_empty() {
- events.push(Ok(LanguageModelCompletionEvent::Text(content)));
- }
- }
-
- if let Some(tool_calls) = delta.tool_calls.as_ref() {
- for tool_call in tool_calls {
- let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
-
- if let Some(tool_id) = tool_call.id.clone()
- && !tool_id.is_empty()
- {
- entry.id = tool_id;
- }
-
- if let Some(function) = tool_call.function.as_ref() {
- if let Some(name) = function.name.clone()
- && !name.is_empty()
- {
- entry.name = name;
- }
-
- if let Some(arguments) = function.arguments.clone() {
- entry.arguments.push_str(&arguments);
- }
-
- if let Some(thought_signature) = function.thought_signature.clone() {
- entry.thought_signature = Some(thought_signature);
- }
- }
-
- if !entry.id.is_empty() && !entry.name.is_empty() {
- if let Ok(input) = serde_json::from_str::(
- &fix_streamed_json(&entry.arguments),
- ) {
- events.push(Ok(LanguageModelCompletionEvent::ToolUse(
- LanguageModelToolUse {
- id: entry.id.clone().into(),
- name: entry.name.as_str().into(),
- is_input_complete: false,
- input: LanguageModelToolUseInput::Json(input),
- raw_input: entry.arguments.clone(),
- thought_signature: entry.thought_signature.clone(),
- },
- )));
- }
- }
- }
- }
- }
-
- match choice.finish_reason.as_deref() {
- Some("stop") => {
- events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
- }
- Some("tool_calls") => {
- events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
- match parse_tool_arguments(&tool_call.arguments) {
- Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
- LanguageModelToolUse {
- id: tool_call.id.clone().into(),
- name: tool_call.name.as_str().into(),
- is_input_complete: true,
- input: LanguageModelToolUseInput::Json(input),
- raw_input: tool_call.arguments.clone(),
- thought_signature: tool_call.thought_signature.clone(),
- },
- )),
- Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
- id: tool_call.id.into(),
- tool_name: tool_call.name.into(),
- raw_input: tool_call.arguments.clone().into(),
- json_parse_error: error.to_string(),
- }),
- }
- }));
-
- events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
- }
- Some(stop_reason) => {
- log::error!("Unexpected OpenAI stop_reason: {stop_reason:?}",);
- events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
- }
- None => {}
- }
-
- events
- }
-}
-
-fn push_thinking_event(
- text: String,
- events: &mut Vec>,
-) {
- if !text.is_empty() {
- events.push(Ok(LanguageModelCompletionEvent::Thinking {
- text,
- signature: None,
- }));
- }
-}
-
-fn merge_reasoning_detail(details: &mut Vec, chunk: serde_json::Value) {
- let index = chunk.get("index").and_then(serde_json::Value::as_u64);
- let target_index = index
- .and_then(|index| {
- details.iter().position(|detail| {
- detail.get("index").and_then(serde_json::Value::as_u64) == Some(index)
- })
- })
- .or_else(|| {
- let id = chunk.get("id").and_then(serde_json::Value::as_str)?;
- details
- .iter()
- .position(|detail| detail.get("id").and_then(serde_json::Value::as_str) == Some(id))
- });
- let Some(target_index) = target_index else {
- details.push(chunk);
- return;
- };
- let (Some(target), Some(chunk)) = (details[target_index].as_object_mut(), chunk.as_object())
- else {
- return;
- };
- for (key, value) in chunk {
- if matches!(key.as_str(), "text" | "summary" | "data")
- && let Some(fragment) = value.as_str()
- && let Some(existing) = target.get(key).and_then(serde_json::Value::as_str)
- {
- target.insert(
- key.clone(),
- serde_json::Value::String(format!("{existing}{fragment}")),
- );
- } else if !value.is_null() {
- target.insert(key.clone(), value.clone());
- }
- }
-}
-
-#[derive(Default)]
-struct RawToolCall {
- id: String,
- name: String,
- arguments: String,
- thought_signature: Option,
-}
-
pub struct OpenAiResponseEventMapper {
/// The backend whose infrastructure produced this stream; stamped on any
/// compaction state it emits so replay is limited to the same backend.
@@ -1774,9 +1499,6 @@ mod tests {
use serde_json::json;
use super::*;
- use crate::{
- ChoiceDelta, FunctionChunk, ResponseMessageDelta, ResponseStreamEvent, ToolCallChunk,
- };
fn map_response_events(events: Vec) -> Vec {
block_on(async {
@@ -1790,17 +1512,6 @@ mod tests {
})
}
- fn map_completion_events(
- events: Vec,
- ) -> Vec {
- let mut mapper = OpenAiEventMapper::new();
- let mut all_events = Vec::new();
- for event in events {
- all_events.extend(mapper.map_event(event));
- }
- all_events.into_iter().filter_map(|e| e.ok()).collect()
- }
-
fn response_item_message(id: &str) -> ResponseOutputItem {
ResponseOutputItem::Message(ResponseOutputMessage {
id: Some(id.to_string()),
@@ -4323,274 +4034,6 @@ mod tests {
);
}
- #[test]
- fn stream_maps_reasoning() {
- let events = map_completion_events(vec![ResponseStreamEvent {
- choices: vec![ChoiceDelta {
- index: 0,
- delta: Some(ResponseMessageDelta {
- role: None,
- content: None,
- reasoning: Some("thinking".into()),
- tool_calls: None,
- reasoning_content: None,
- reasoning_details: None,
- }),
- finish_reason: None,
- }],
- usage: None,
- }]);
-
- assert_eq!(
- events,
- vec![LanguageModelCompletionEvent::Thinking {
- text: "thinking".into(),
- signature: None,
- }]
- );
- }
-
- #[test]
- fn stream_merges_reasoning_details_and_maps_compatible_usage_and_signatures() {
- let response_events = serde_json::from_value(json!([
- {
- "choices": [{
- "index": 0,
- "delta": {
- "reasoning_details": [{
- "id": "reasoning-1",
- "index": 0,
- "type": "reasoning.text",
- "text": "first "
- }],
- "tool_calls": [{
- "index": 0,
- "id": "call-1",
- "function": {
- "name": "search",
- "arguments": "{",
- "thought_signature": "signature"
- }
- }]
- },
- "finish_reason": null
- }],
- "usage": null
- },
- {
- "choices": [{
- "index": 0,
- "delta": {
- "reasoning_details": [{
- "id": "reasoning-1",
- "index": 0,
- "text": "second"
- }],
- "tool_calls": [{
- "index": 0,
- "function": {
- "arguments": "}"
- }
- }]
- },
- "finish_reason": "tool_calls"
- }],
- "usage": null
- },
- {
- "choices": [],
- "usage": {
- "prompt_tokens": 10000,
- "completion_tokens": 500,
- "total_tokens": 10500,
- "prompt_tokens_details": {
- "cached_tokens": 6000,
- "cache_write_tokens": 1000
- }
- }
- }
- ]))
- .expect("valid compatible Chat Completions events");
- let events = map_completion_events(response_events);
-
- assert!(events.iter().any(|event| {
- matches!(
- event,
- LanguageModelCompletionEvent::ReasoningDetails(details)
- if details[0]["text"] == "first second"
- )
- }));
- assert!(events.iter().any(|event| {
- matches!(
- event,
- LanguageModelCompletionEvent::ToolUse(tool_use)
- if tool_use.is_input_complete
- && tool_use.thought_signature.as_deref() == Some("signature")
- )
- }));
- assert!(events.iter().any(|event| {
- matches!(
- event,
- LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
- input_tokens: 3_000,
- output_tokens: 500,
- cache_creation_input_tokens: 1_000,
- cache_read_input_tokens: 6_000,
- })
- )
- }));
- }
-
- #[test]
- fn reasoning_details_accumulator_replaces_an_incompatible_previous_shape() {
- let mut accumulator = ReasoningDetailsAccumulator::default();
- assert_eq!(
- accumulator.push(json!({"summary": "provider-defined"})),
- Some(json!({"summary": "provider-defined"}))
- );
- assert_eq!(
- accumulator.push(json!([{"index": 0, "text": "reasoning"}])),
- Some(json!([{"index": 0, "text": "reasoning"}]))
- );
- }
-
- #[test]
- fn stream_maps_preserves_tool_id_and_name_across_empty_deltas() {
- // DashScope sends id="" and name="" in subsequent tool_calls delta
- // chunks after the first chunk. OpenAiEventMapper must not overwrite
- // the accumulated id and name with these empty strings.
-
- let events = vec![
- // First chunk: id and name are present
- ResponseStreamEvent {
- choices: vec![ChoiceDelta {
- index: 0,
- delta: Some(ResponseMessageDelta {
- role: None,
- content: None,
- reasoning: None,
- tool_calls: Some(vec![ToolCallChunk {
- index: 0,
- id: Some("call_dashscope_test".into()),
- function: Some(FunctionChunk {
- name: Some("list_directory".into()),
- arguments: Some("".into()),
- thought_signature: None,
- }),
- }]),
- reasoning_content: None,
- reasoning_details: None,
- }),
- finish_reason: None,
- }],
- usage: None,
- },
- // Subsequent chunks: DashScope sends id="" and name=""
- ResponseStreamEvent {
- choices: vec![ChoiceDelta {
- index: 0,
- delta: Some(ResponseMessageDelta {
- role: None,
- content: None,
- reasoning: None,
- tool_calls: Some(vec![ToolCallChunk {
- index: 0,
- id: Some("".into()),
- function: Some(FunctionChunk {
- name: Some("".into()),
- arguments: Some("{\"path\": \"".into()),
- thought_signature: None,
- }),
- }]),
- reasoning_content: None,
- reasoning_details: None,
- }),
- finish_reason: None,
- }],
- usage: None,
- },
- ResponseStreamEvent {
- choices: vec![ChoiceDelta {
- index: 0,
- delta: Some(ResponseMessageDelta {
- role: None,
- content: None,
- reasoning: None,
- tool_calls: Some(vec![ToolCallChunk {
- index: 0,
- id: Some("".into()),
- function: Some(FunctionChunk {
- name: Some("".into()),
- arguments: Some("blog-scraper\"}".into()),
- thought_signature: None,
- }),
- }]),
- reasoning_content: None,
- reasoning_details: None,
- }),
- finish_reason: None,
- }],
- usage: None,
- },
- // Final chunk: finish_reason = "tool_calls"
- ResponseStreamEvent {
- choices: vec![ChoiceDelta {
- index: 0,
- delta: None,
- finish_reason: Some("tool_calls".into()),
- }],
- usage: None,
- },
- ];
-
- let mapped = map_completion_events(events);
-
- // Events emitted:
- // 1. Partial ToolUse from chunk 1 (fix_json("") → "{}", parseable)
- // 2. Partial ToolUse from chunk 3 (arguments fully assembled)
- // 3. Complete ToolUse from finish_reason="tool_calls" drain
- // 4. Stop(ToolUse)
- assert_eq!(mapped.len(), 4);
-
- // Verify the complete ToolUse event (from finish_reason drain)
- // has the correct id, name, and accumulated arguments.
- let complete_tool_use = mapped.iter().find_map(|event| {
- if let LanguageModelCompletionEvent::ToolUse(tool_use) = event {
- if tool_use.is_input_complete {
- return Some(tool_use);
- }
- }
- None
- });
- assert!(
- complete_tool_use.is_some(),
- "expected a completed ToolUse event"
- );
- let tool_use = complete_tool_use.unwrap();
- assert_eq!(
- tool_use.id.to_string(),
- "call_dashscope_test",
- "id must survive empty-string overwrites"
- );
- assert_eq!(
- tool_use.name.as_ref(),
- "list_directory",
- "name must survive empty-string overwrites"
- );
- assert_eq!(
- tool_use.raw_input, "{\"path\": \"blog-scraper\"}",
- "arguments should accumulate across chunks"
- );
-
- // Verify the Stop event
- assert!(mapped.iter().any(|event| {
- matches!(
- event,
- LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
- )
- }));
- }
-
#[test]
fn into_open_ai_response_prepends_provider_input_unchanged() {
let provider_input = json!([
diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs
index f25c324966..e2a0cda6a4 100644
--- a/crates/open_ai/src/open_ai.rs
+++ b/crates/open_ai/src/open_ai.rs
@@ -13,18 +13,19 @@ use http_client::{
http::{HeaderMap, HeaderValue},
};
pub use language_model_core::ReasoningEffort;
+pub use language_model_core::chat_completion::{
+ ChoiceDelta, FunctionChunk, PromptTokensDetails, ResponseMessageDelta, ResponseStreamError,
+ ResponseStreamEvent, ResponseStreamResult, ToolCallChunk, Usage,
+};
use serde::{Deserialize, Serialize};
use serde_json::Value;
+use serde_json::value::RawValue;
use std::{convert::TryFrom, future::Future, io};
use strum::EnumIter;
use thiserror::Error;
pub const OPEN_AI_API_URL: &str = "https://api.openai.com/v1";
-fn is_none_or_empty, U>(opt: &Option) -> bool {
- opt.as_ref().is_none_or(|v| v.as_ref().is_empty())
-}
-
#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
@@ -800,66 +801,6 @@ pub struct Choice {
pub finish_reason: Option,
}
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct ResponseMessageDelta {
- pub role: Option,
- pub content: Option,
- pub reasoning: Option,
- #[serde(default, skip_serializing_if = "is_none_or_empty")]
- pub tool_calls: Option>,
- #[serde(default, skip_serializing_if = "is_none_or_empty")]
- pub reasoning_content: Option,
- /// Provider-defined structured reasoning metadata.
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub reasoning_details: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct ToolCallChunk {
- pub index: usize,
- pub id: Option,
-
- // There is also an optional `type` field that would determine if a
- // function is there. Sometimes this streams in with the `function` before
- // it streams in the `type`
- pub function: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct FunctionChunk {
- pub name: Option,
- pub arguments: Option,
- /// Provider-defined metadata required to replay a reasoning tool call.
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub thought_signature: Option,
-}
-
-/// Reports prompt-cache token usage from compatible providers.
-#[derive(Clone, Serialize, Deserialize, Debug, Default)]
-pub struct PromptTokensDetails {
- /// Tokens read from a prompt cache.
- pub cached_tokens: Option,
- /// Tokens written to a prompt cache.
- pub cache_write_tokens: Option,
-}
-
-#[derive(Clone, Serialize, Deserialize, Debug)]
-pub struct Usage {
- pub prompt_tokens: Option,
- pub completion_tokens: Option,
- pub total_tokens: Option,
- /// Prompt-cache usage when reported by the provider.
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub prompt_tokens_details: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct ChoiceDelta {
- pub index: u32,
- pub delta: Option,
- pub finish_reason: Option,
-}
-
/// An error produced while sending an OpenAI-compatible request.
///
/// Transport and wire-format failures retain their category so callers can
@@ -908,37 +849,20 @@ pub enum RequestError {
Other(#[from] anyhow::Error),
}
-#[derive(Serialize, Deserialize, Debug)]
-pub struct ResponseStreamError {
- message: String,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-#[serde(untagged)]
-pub enum ResponseStreamResult {
- Ok(ResponseStreamEvent),
- Err { error: ResponseStreamError },
-}
-
#[derive(Deserialize)]
struct ResponseErrorEnvelope {
error: responses::ResponseError,
}
-#[derive(Serialize, Deserialize, Debug)]
-pub struct ResponseStreamEvent {
- pub choices: Vec,
- pub usage: Option,
-}
-
/// A framed Chat Completions server-sent event.
///
/// `Done` is distinct from the underlying response body ending so callers can
/// tell whether the server completed the stream according to the protocol.
-#[derive(Debug, PartialEq)]
+#[derive(Debug)]
pub enum ChatCompletionStreamEvent {
- /// A JSON payload from a `data` field.
- Data(Value),
+ /// A JSON payload from a `data` field, kept as raw text so consumers can
+ /// deserialize it into their own wire types in a single pass.
+ Data(Box),
/// The protocol terminator `data: [DONE]`.
Done,
}
@@ -1117,7 +1041,7 @@ pub async fn stream_completion(
Ok(ChatCompletionStreamEvent::Done) => return None,
Err(error) => return Some(Err(anyhow!(error))),
};
- match ResponseStreamResult::deserialize(&value) {
+ match serde_json::from_str::(value.get()) {
Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)),
Ok(ResponseStreamResult::Err { error }) => Some(Err(anyhow!(error.message))),
Err(error) => {
diff --git a/crates/open_router/src/open_router.rs b/crates/open_router/src/open_router.rs
index 14cb90079e..6272e063a3 100644
--- a/crates/open_router/src/open_router.rs
+++ b/crates/open_router/src/open_router.rs
@@ -4,6 +4,11 @@ use http_client::{
AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http,
};
pub use language_model_core::ReasoningEffort;
+use language_model_core::chat_completion::ResponseStreamResult;
+pub use language_model_core::chat_completion::{
+ ChoiceDelta, FunctionChunk, PromptTokensDetails, ResponseMessageDelta, ResponseStreamEvent,
+ ToolCallChunk, Usage,
+};
use open_ai::ChatCompletionStreamEvent;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -34,10 +39,6 @@ fn extract_retry_after(headers: &http::HeaderMap) -> Option
None
}
-fn is_none_or_empty, U>(opt: &Option) -> bool {
- opt.as_ref().is_none_or(|v| v.as_ref().is_empty())
-}
-
#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
@@ -397,73 +398,6 @@ pub struct FunctionContent {
pub thought_signature: Option,
}
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct ResponseMessageDelta {
- pub role: Option,
- pub content: Option,
- pub reasoning: Option,
- #[serde(default, skip_serializing_if = "is_none_or_empty")]
- pub tool_calls: Option>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub reasoning_details: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct ToolCallChunk {
- pub index: usize,
- pub id: Option,
- pub function: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct FunctionChunk {
- pub name: Option,
- pub arguments: Option,
- #[serde(default)]
- pub thought_signature: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug, Default)]
-pub struct PromptTokensDetails {
- #[serde(default)]
- pub cached_tokens: u64,
- #[serde(default)]
- pub cache_write_tokens: u64,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct Usage {
- pub prompt_tokens: u64,
- pub completion_tokens: u64,
- pub total_tokens: u64,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub prompt_tokens_details: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct ChoiceDelta {
- pub index: u32,
- pub delta: ResponseMessageDelta,
- pub finish_reason: Option,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct ResponseStreamEvent {
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub id: Option,
- pub created: u32,
- pub model: String,
- pub choices: Vec,
- pub usage: Option,
-}
-
-#[derive(Deserialize)]
-#[serde(untagged)]
-enum ResponseStreamResult {
- Response(ResponseStreamEvent),
- Error(OpenRouterErrorResponse),
-}
-
#[derive(Serialize, Deserialize, Debug)]
pub struct Response {
pub id: String,
@@ -543,9 +477,9 @@ pub async fn stream_completion(
return Some(Err(OpenRouterError::ChatCompletion(error)));
}
};
- match serde_json::from_value(value) {
- Ok(ResponseStreamResult::Response(response)) => Some(Ok(response)),
- Ok(ResponseStreamResult::Error(OpenRouterErrorResponse { error })) => {
+ match serde_json::from_str::>(value.get()) {
+ Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)),
+ Ok(ResponseStreamResult::Err { error }) => {
Some(Err(OpenRouterError::ApiError(ApiError {
status: None,
code: error.code,
@@ -895,7 +829,7 @@ mod tests {
});
assert_eq!(responses.len(), 1);
- assert_eq!(responses[0].model, "vendor/model");
+ assert!(responses[0].choices.is_empty());
let headers = captured_headers.lock().expect("captured headers lock");
let headers = headers.as_ref().expect("captured headers");
assert_eq!(headers["http-referer"], "https://zed.dev");
From 2b0562a825e3c3717ea5c5a457091466cb98083d Mon Sep 17 00:00:00 2001
From: Jakub Konka
Date: Wed, 2 Sep 2026 17:46:50 +0000
Subject: [PATCH 010/283] ui: Allow hiding keybinding labels (#63616)
`KeyBinding` elements currently always render their shortcut labels.
Applications with a touch-oriented interface cannot remove those
keyboard-only visual affordances without changing each consumer or
unregistering the shortcuts themselves.
This adds an application-wide visibility policy to the reusable
`KeyBinding` component, so context menus and other consumers follow the
same setting. Registered keybindings and `aria-keyshortcuts` remain
intact, and labels remain visible by default.
## Testing
- `cargo nextest run -p ui --lib`
- `cargo fmt --check`
- The final unused-override cleanup was not rerun because `nix develop`
currently fails while building the existing `zed-editor-deps`
derivation: `corgi-patches/scratch/Cargo.toml` is absent from its Nix
source.
Release Notes:
- N/A
---
crates/ui/src/components/keybinding.rs | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs
index bc8c68067e..75479df270 100644
--- a/crates/ui/src/components/keybinding.rs
+++ b/crates/ui/src/components/keybinding.rs
@@ -57,6 +57,15 @@ pub struct KeyBinding {
struct VimStyle(bool);
impl Global for VimStyle {}
+struct KeyBindingVisibility(bool);
+impl Global for KeyBindingVisibility {}
+
+impl Default for KeyBindingVisibility {
+ fn default() -> Self {
+ Self(true)
+ }
+}
+
impl KeyBinding {
/// Returns the highest precedence keybinding for an action. This is the last binding added to
/// the keymap. User bindings are added after built-in bindings so that they take precedence.
@@ -89,6 +98,16 @@ impl KeyBinding {
cx.try_global::().is_some_and(|g| g.0)
}
+ /// Sets the application-wide visibility of keybindings.
+ pub fn set_default_visibility(cx: &mut App, visible: bool) {
+ cx.set_global(KeyBindingVisibility(visible));
+ cx.refresh_windows();
+ }
+
+ fn default_visibility(cx: &mut App) -> bool {
+ cx.default_global::().0
+ }
+
pub fn new(action: &dyn Action, focus_handle: Option, cx: &App) -> Self {
Self {
source: Source::Action {
@@ -199,6 +218,10 @@ fn render_key(
impl RenderOnce for KeyBinding {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ if !Self::default_visibility(cx) {
+ return gpui::Empty.into_any_element();
+ }
+
let render_keybinding = |keystrokes: &[KeybindingKeystroke]| {
let color = self.disabled.then_some(Color::Disabled);
From ff68a64caf5c00acb510fb84363d9d1eb9d574ed Mon Sep 17 00:00:00 2001
From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com>
Date: Wed, 2 Sep 2026 17:53:30 +0000
Subject: [PATCH 011/283] Bump Zed to v1.20.0 (#63636)
Release Notes:
- N/A
Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
---
Cargo.lock | 2 +-
crates/zed/Cargo.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 3016639e76..92fda85101 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -22761,7 +22761,7 @@ dependencies = [
[[package]]
name = "zed"
-version = "1.19.0"
+version = "1.20.0"
dependencies = [
"acp_thread",
"acp_tools",
diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml
index 3ea181ca06..bcde4d5e3d 100644
--- a/crates/zed/Cargo.toml
+++ b/crates/zed/Cargo.toml
@@ -2,7 +2,7 @@
description = "The fast, collaborative code editor."
edition.workspace = true
name = "zed"
-version = "1.19.0"
+version = "1.20.0"
publish.workspace = true
license = "GPL-3.0-or-later"
authors = ["Zed Team "]
From cff4edce88d8be881b2ff62ca6d9bf7a6a78ba41 Mon Sep 17 00:00:00 2001
From: Jakub Konka
Date: Wed, 2 Sep 2026 18:13:59 +0000
Subject: [PATCH 012/283] nix: Include corgi patches in build sources (#63644)
The local `scratch` override lives under `corgi-patches`, but the Nix
build source filter omitted that directory. As a result, `nix develop`
failed while loading the patched dependency.
Include `corgi-patches` in the filtered source and preserve the patch
implementation in Crane's dummy dependency source. The latter is
required because `cxx-build` calls `scratch::path` while dependency
artifacts are built.
## Testing
- `nix fmt -- --ci nix/build.nix`
- `nix develop --command true`
- `nix develop --command cargo fmt --check`
Release Notes:
- N/A
---
nix/build.nix | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/nix/build.nix b/nix/build.nix
index c01d869fdd..8abca4066a 100644
--- a/nix/build.nix
+++ b/nix/build.nix
@@ -67,6 +67,7 @@ let
relPath = lib.removePrefix root path;
topLevelIncludes = [
"crates"
+ "corgi-patches"
"assets"
"extensions"
"script"
@@ -79,6 +80,10 @@ let
in
builtins.elem firstComp topLevelIncludes;
+ corgiPatches = builtins.path {
+ path = ../corgi-patches;
+ name = "corgi-patches";
+ };
craneLib = crane.overrideToolchain rustToolchain;
gpu-lib = if withGLES then libglvnd else vulkan-loader;
commonArgs =
@@ -305,7 +310,20 @@ let
drv;
};
};
- cargoArtifacts = craneLib.buildDepsOnly commonArgs;
+ cargoArtifacts = craneLib.buildDepsOnly (
+ builtins.removeAttrs commonArgs [ "src" ]
+ // {
+ dummySrc = craneLib.mkDummySrc {
+ inherit (commonArgs) src cargoLock;
+ # `scratch` is a local dependency of `cxx-build`, so its API is needed
+ # while Crane builds third-party dependencies.
+ extraDummyScript = ''
+ rm -rf $out/corgi-patches
+ cp --recursive ${corgiPatches} $out/corgi-patches
+ '';
+ };
+ }
+ );
in
craneLib.buildPackage (
lib.recursiveUpdate commonArgs {
From 810c6a04a689e9979c329b71e1986fb22ce91804 Mon Sep 17 00:00:00 2001
From: Albert Bogusz
Date: Wed, 2 Sep 2026 18:17:30 +0000
Subject: [PATCH 013/283] Add `on_new_window` setting (#63522)
> [!NOTE]
> This is a remake of #63335 as it's unlikely to be reopened just from
my
[mention](https://github.com/zed-industries/zed/pull/63335#issuecomment-5452544138).
Apologies for the noise!
# Objective
Closes #59368, where users (including me) are asking for a way to show
the Launchpad instead of an empty tab when opening a new window. As per
advice from
https://github.com/zed-industries/zed/issues/60720#issuecomment-4936162420,
adding a separate setting from `restore_on_startup` is more ideal.
`on_new_window` allows for two values:
- `empty_tab`
- `launchpad` (default)
## Testing
Added a regression test which opens a new window with `on_new_window`
set to `launchpad` and asserts that no item gets opened in the window's
pane. I've verified it fails without the setting gate, and confirmed
configuring this setting works manually (see Showcase).
## Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
## Showcase
https://github.com/user-attachments/assets/bf339bd1-8c62-4243-ac9d-1045d1a14865
---
Release Notes:
- Added `on_new_window` setting to configure the opening of new windows.
---
assets/settings/default.json | 7 ++
crates/settings/src/vscode_import.rs | 1 +
crates/settings_content/src/workspace.rs | 27 +++++++
crates/settings_ui/src/page_data.rs | 14 ++++
crates/settings_ui/src/settings_ui.rs | 1 +
crates/workspace/src/workspace_settings.rs | 2 +
crates/zed/src/zed.rs | 87 +++++++++++++++++-----
docs/src/reference/all-settings.md | 24 ++++++
8 files changed, 145 insertions(+), 18 deletions(-)
diff --git a/assets/settings/default.json b/assets/settings/default.json
index 671653708d..aba8037190 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -210,6 +210,13 @@
// 3. Never close the window
// "when_closing_with_no_tabs": "keep_window_open",
"when_closing_with_no_tabs": "platform_default",
+ // What to show when opening a new window.
+ // May take 2 values:
+ // 1. Show an empty untitled buffer
+ // "on_new_window": "empty_tab"
+ // 2. Show the launchpad with recent projects
+ // "on_new_window": "launchpad"
+ "on_new_window": "launchpad",
// What to do when the last window is closed.
// May take 2 values:
// 1. Use the current platform's convention
diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs
index 3e25d4ecc6..a776e23df9 100644
--- a/crates/settings/src/vscode_import.rs
+++ b/crates/settings/src/vscode_import.rs
@@ -1062,6 +1062,7 @@ impl VsCodeSettings {
} else {
None
},
+ on_new_window: None,
on_last_window_closed: None,
pane_split_direction_horizontal: None,
pane_split_direction_vertical: None,
diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs
index 7aa8bc42a8..cfe76bb4ca 100644
--- a/crates/settings_content/src/workspace.rs
+++ b/crates/settings_content/src/workspace.rs
@@ -109,6 +109,10 @@ pub struct WorkspaceSettingsContent {
///
/// Default: none
pub max_tabs: Option,
+ /// What to show when opening a new window.
+ /// Values: empty_tab, launchpad
+ /// Default: launchpad
+ pub on_new_window: Option,
/// What to do when the last window is closed
///
/// Default: auto (nothing on macOS, "app quit" otherwise)
@@ -683,6 +687,29 @@ pub struct CenteredLayoutSettings {
pub right_padding: Option,
}
+#[derive(
+ Copy,
+ Clone,
+ Default,
+ Serialize,
+ Deserialize,
+ JsonSchema,
+ MergeFrom,
+ PartialEq,
+ Eq,
+ Debug,
+ strum::VariantArray,
+ strum::VariantNames,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum OnNewWindow {
+ /// Show an empty untitled buffer when opening a new window
+ EmptyTab,
+ /// Show the launchpad with recent projects when opening a new window
+ #[default]
+ Launchpad,
+}
+
#[derive(
Copy,
Clone,
diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs
index 5d3a5f7ce9..6d328ca24c 100644
--- a/crates/settings_ui/src/page_data.rs
+++ b/crates/settings_ui/src/page_data.rs
@@ -172,6 +172,20 @@ fn general_page(cx: &App) -> SettingsPage {
metadata: None,
files: USER,
}),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "On New Window",
+ description: "What to show when opening a new window.",
+ field: Box::new(SettingField {
+ organization_override: None,
+ json_path: Some("on_new_window"),
+ pick: |settings_content| settings_content.workspace.on_new_window.as_ref(),
+ write: |settings_content, value, _| {
+ settings_content.workspace.on_new_window = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
SettingsPageItem::SettingItem(SettingItem {
title: "On Last Window Closed",
description: "What to do when the last window is closed.",
diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs
index eee5c6e3ec..7f4d4c53b5 100644
--- a/crates/settings_ui/src/settings_ui.rs
+++ b/crates/settings_ui/src/settings_ui.rs
@@ -554,6 +554,7 @@ fn init_renderers(cx: &mut App) {
.add_basic_renderer::(render_toggle_button)
.add_basic_renderer::(render_dropdown)
.add_basic_renderer::(render_dropdown)
+ .add_basic_renderer::(render_dropdown)
.add_basic_renderer::(render_dropdown)
.add_basic_renderer::(render_dropdown)
.add_basic_renderer::(render_dropdown)
diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs
index b88658fea4..2a84de6fb2 100644
--- a/crates/workspace/src/workspace_settings.rs
+++ b/crates/workspace/src/workspace_settings.rs
@@ -33,6 +33,7 @@ pub struct WorkspaceSettings {
pub command_aliases: HashMap,
pub max_tabs: Option,
pub when_closing_with_no_tabs: settings::CloseWindowWhenNoItems,
+ pub on_new_window: settings::OnNewWindow,
pub on_last_window_closed: settings::OnLastWindowClosed,
pub text_rendering_mode: settings::TextRenderingMode,
pub resize_all_panels_in_dock: Vec,
@@ -129,6 +130,7 @@ impl Settings for WorkspaceSettings {
command_aliases: workspace.command_aliases.clone(),
max_tabs: workspace.max_tabs,
when_closing_with_no_tabs: workspace.when_closing_with_no_tabs.unwrap(),
+ on_new_window: workspace.on_new_window.unwrap(),
on_last_window_closed: workspace.on_last_window_closed.unwrap(),
text_rendering_mode: workspace.text_rendering_mode.unwrap(),
resize_all_panels_in_dock: workspace
diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs
index 0f7c563b39..ebe3776df3 100644
--- a/crates/zed/src/zed.rs
+++ b/crates/zed/src/zed.rs
@@ -1340,24 +1340,7 @@ fn register_actions(
cx,
|workspace, window, cx| {
cx.activate(true);
- // Create buffer synchronously to avoid flicker
- let project = workspace.project().clone();
- let buffer = project.update(cx, |project, cx| {
- project.create_local_buffer("", None, true, cx)
- });
- buffer.update(cx, |buffer, _| {
- buffer.set_content_language_detection_enabled(true);
- });
- let editor = cx.new(|cx| {
- Editor::for_buffer(buffer, Some(project), window, cx)
- });
- workspace.add_item_to_active_pane(
- Box::new(editor),
- None,
- true,
- window,
- cx,
- );
+ initialize_new_window(workspace, window, cx);
},
)
.detach();
@@ -2418,6 +2401,30 @@ fn filter_disabled_ai_bindings(bindings: Vec, cx: &App) -> Vec,
+) {
+ if WorkspaceSettings::get_global(cx).on_new_window == settings::OnNewWindow::Launchpad {
+ return;
+ }
+ // Create buffer synchronously to avoid flicker
+ let project = workspace.project().clone();
+ let buffer = project.update(cx, |project, cx| {
+ project.create_local_buffer("", None, true, cx)
+ });
+ buffer.update(cx, |buffer, _| {
+ buffer.set_content_language_detection_enabled(true);
+ });
+ let editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx));
+ workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
+}
+
pub fn open_new_ssh_project_from_project(
workspace: &mut Workspace,
paths: Vec,
@@ -3779,6 +3786,50 @@ mod tests {
.unwrap();
}
+ #[gpui::test]
+ async fn test_new_window_launchpad_opens_without_items(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.workspace.on_new_window = Some(settings::OnNewWindow::Launchpad);
+ });
+ });
+ });
+
+ cx.update(|cx| {
+ open_new(
+ Default::default(),
+ app_state.clone(),
+ cx,
+ |workspace, window, cx| initialize_new_window(workspace, window, cx),
+ )
+ })
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ let multi_workspace = cx
+ .update(|cx| cx.windows().first().unwrap().downcast::())
+ .unwrap();
+
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ assert!(
+ workspace.active_item(cx).is_none(),
+ "launchpad window should not open any items"
+ );
+ assert_eq!(
+ workspace.active_pane().read(cx).items_len(),
+ 0,
+ "launchpad window should have an empty pane so the launchpad is shown"
+ );
+ });
+ })
+ .unwrap();
+ }
+
#[gpui::test]
async fn test_open_entry(cx: &mut TestAppContext) {
let app_state = init_test(cx);
diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md
index 35ae9bb8dd..9dee351809 100644
--- a/docs/src/reference/all-settings.md
+++ b/docs/src/reference/all-settings.md
@@ -3438,6 +3438,30 @@ If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` en
}
```
+## On New Window
+
+- Description: What to show when opening a new window.
+- Setting: `on_new_window`
+- Default: `"launchpad"`
+
+**Options**
+
+1. Show an empty untitled buffer:
+
+```json [settings]
+{
+ "on_new_window": "empty_tab"
+}
+```
+
+2. Show the launchpad with recent projects:
+
+```json [settings]
+{
+ "on_new_window": "launchpad"
+}
+```
+
## Instrumentation
- Description: Configuration for developer-oriented instrumentation tools (profilers, tracers, etc.) that can be toggled at runtime.
From 769d0befa7a97d4bd9d8a8d95d92c6d117d605f2 Mon Sep 17 00:00:00 2001
From: Kirill Bulatov
Date: Wed, 2 Sep 2026 18:21:02 +0000
Subject: [PATCH 014/283] Support Emmet's wrap with abbreviation (#63383)
Closes https://github.com/zed-industries/zed/issues/15588
https://github.com/user-attachments/assets/2813d235-6dac-4fe6-9ae0-2783e16270ac
Release Notes:
- Added Emmet's wrap with abbreviation support
---
assets/keymaps/default-linux.json | 6 +
assets/keymaps/default-macos.json | 7 +
assets/keymaps/default-windows.json | 7 +
crates/collab/src/rpc.rs | 3 +
.../collab/tests/integration/editor_tests.rs | 158 +-
crates/editor/src/actions.rs | 2 +
crates/editor/src/clangd_ext.rs | 5 +-
crates/editor/src/editor.rs | 34 +-
crates/editor/src/element.rs | 7 +
crates/editor/src/emmet_ext.rs | 1628 +++++++++++++++++
crates/editor/src/inline_input.rs | 370 ++++
crates/editor/src/lsp_ext.rs | 1 +
crates/editor/src/navigation.rs | 6 +
crates/editor/src/rust_analyzer_ext.rs | 18 +-
crates/editor/src/selection.rs | 1 +
crates/editor/src/test.rs | 1 +
crates/language/src/language_registry.rs | 14 +
crates/project/src/lsp_command.rs | 6 +-
crates/project/src/lsp_store.rs | 38 +-
crates/project/src/lsp_store/emmet_ext.rs | 186 ++
.../project/src/lsp_store/lsp_ext_command.rs | 48 +-
crates/project/src/project.rs | 12 +-
crates/proto/proto/lsp.proto | 22 +
crates/proto/proto/zed.proto | 4 +-
crates/proto/src/proto.rs | 4 +
docs/src/languages/emmet.md | 33 +
26 files changed, 2581 insertions(+), 40 deletions(-)
create mode 100644 crates/editor/src/emmet_ext.rs
create mode 100644 crates/editor/src/inline_input.rs
create mode 100644 crates/project/src/lsp_store/emmet_ext.rs
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index db670d2655..f75b88cd29 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -834,6 +834,12 @@
"enter": "editor::ConfirmRename",
},
},
+ {
+ "context": "Editor && inline_input",
+ "bindings": {
+ "enter": "menu::Confirm",
+ },
+ },
{
"context": "Editor && showing_completions",
"bindings": {
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index 3324bce7da..a74ab74020 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -887,6 +887,13 @@
"enter": "editor::ConfirmRename",
},
},
+ {
+ "context": "Editor && inline_input",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "menu::Confirm",
+ },
+ },
{
"context": "Editor && showing_completions",
"use_key_equivalents": true,
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 1afd97082a..b11fd86d18 100644
--- a/assets/keymaps/default-windows.json
+++ b/assets/keymaps/default-windows.json
@@ -824,6 +824,13 @@
"enter": "editor::ConfirmRename",
},
},
+ {
+ "context": "Editor && inline_input",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "menu::Confirm",
+ },
+ },
{
"context": "Editor && showing_completions",
"use_key_equivalents": true,
diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs
index 8d8f3fd306..c5693803d7 100644
--- a/crates/collab/src/rpc.rs
+++ b/crates/collab/src/rpc.rs
@@ -376,6 +376,9 @@ impl Server {
.add_request_handler(forward_read_only_project_request::)
.add_request_handler(forward_read_only_project_request::)
.add_request_handler(forward_read_only_project_request::)
+ .add_request_handler(
+ forward_read_only_project_request::,
+ )
.add_request_handler(forward_read_only_project_request::)
.add_request_handler(forward_mutating_project_request::)
.add_request_handler(
diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs
index eabfd10373..b1b9d7e0e7 100644
--- a/crates/collab/tests/integration/editor_tests.rs
+++ b/crates/collab/tests/integration/editor_tests.rs
@@ -8,12 +8,12 @@ use editor::{
actions::{
ConfirmCodeAction, ConfirmCompletion, ConfirmRename, ContextMenuFirst, CopyFileLocation,
CopyFileName, CopyFileNameWithoutExtension, ExpandMacroRecursively, MoveToEnd, Redo,
- Rename, SelectAll, ToggleCodeActions, Undo,
+ Rename, SelectAll, ToggleCodeActions, Undo, WrapWithAbbreviation,
},
code_context_menus::CodeContextMenu,
test::{
editor_test_context::{AssertionContextManager, EditorTestContext},
- expand_macro_recursively,
+ expand_macro_recursively, wrap_with_abbreviation,
},
};
use fs::Fs;
@@ -24,12 +24,16 @@ use gpui::{
VisualTestContext,
};
use indoc::indoc;
-use language::{FakeLspAdapter, language_settings::LanguageSettings, rust_lang};
+use language::{
+ FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, language_settings::LanguageSettings,
+ rust_lang,
+};
use lsp::DEFAULT_LSP_REQUEST_TIMEOUT;
use multi_buffer::{AnchorRangeExt as _, MultiBufferRow};
use pretty_assertions::assert_eq;
use project::{
ProgressToken, ProjectPath, SERVER_PROGRESS_THROTTLE_TIMEOUT,
+ lsp_store::emmet_ext::LspExpandAbbreviation,
lsp_store::lsp_ext_command::{ExpandedMacro, LspExtExpandMacro},
trusted_worktrees::{PathTrust, TrustedWorktrees},
};
@@ -5144,6 +5148,154 @@ async fn test_client_can_query_lsp_ext(cx_a: &mut TestAppContext, cx_b: &mut Tes
});
}
+#[gpui::test]
+async fn test_client_can_wrap_with_emmet_abbreviation(
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ let mut server = TestServer::start(cx_a.executor()).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+ server
+ .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+ .await;
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_b = cx_b.read(ActiveCall::global);
+
+ cx_b.update(editor::init);
+
+ let html_lang = || {
+ Arc::new(Language::new(
+ LanguageConfig {
+ name: "HTML".into(),
+ matcher: LanguageMatcher {
+ path_suffixes: vec!["html".into()],
+ ..LanguageMatcher::default()
+ }
+ .into(),
+ ..LanguageConfig::default()
+ },
+ None,
+ ))
+ };
+ client_a.language_registry().add(html_lang());
+ client_b.language_registry().add(html_lang());
+ let mut fake_html_servers = client_a.language_registry().register_fake_lsp(
+ "HTML",
+ FakeLspAdapter {
+ name: "vscode-html-language-server",
+ ..FakeLspAdapter::default()
+ },
+ );
+ let mut fake_emmet_servers = client_a.language_registry().register_fake_lsp(
+ "HTML",
+ FakeLspAdapter {
+ name: "emmet-language-server",
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().register_fake_lsp_adapter(
+ "HTML",
+ FakeLspAdapter {
+ name: "emmet-language-server",
+ ..FakeLspAdapter::default()
+ },
+ );
+
+ client_a
+ .fs()
+ .insert_tree(
+ path!("/a"),
+ json!({
+ "index.html": "hello
",
+ }),
+ )
+ .await;
+ let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
+ active_call_a
+ .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
+ .await
+ .unwrap();
+ let project_id = active_call_a
+ .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+ .await
+ .unwrap();
+
+ let project_b = client_b.join_remote_project(project_id, cx_b).await;
+ active_call_b
+ .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+ .await
+ .unwrap();
+
+ let buffer_a = project_a
+ .update(cx_a, |project, cx| {
+ project.open_buffer((worktree_id, rel_path("index.html")), cx)
+ })
+ .await
+ .unwrap();
+
+ let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
+
+ let editor_b = workspace_b
+ .update_in(cx_b, |workspace, window, cx| {
+ workspace.open_path(
+ (worktree_id, rel_path("index.html")),
+ None,
+ true,
+ window,
+ cx,
+ )
+ })
+ .await
+ .unwrap()
+ .downcast::()
+ .unwrap();
+
+ let _fake_html_server = fake_html_servers.next().await.unwrap();
+ let fake_emmet_server = fake_emmet_servers.next().await.unwrap();
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
+
+ let mut wrap_request = fake_emmet_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div.wrap");
+ assert_eq!(params.language, "html");
+ assert_eq!(params.options.text, Some(vec!["hello".to_string()]));
+ Ok(Some("hello
".to_string()))
+ },
+ );
+
+ editor_b.update_in(cx_b, |editor, window, cx| {
+ editor.change_selections(SelectionEffects::default(), window, cx, |s| {
+ s.select_ranges([Point::new(0, 3)..Point::new(0, 8)])
+ });
+ wrap_with_abbreviation(editor, &WrapWithAbbreviation, window, cx);
+ });
+ editor_b.update_in(cx_b, |editor, window, cx| {
+ let input = editor
+ .pending_inline_input()
+ .expect("emmet wrap input should be pending on the client")
+ .editor
+ .clone();
+ input.update(cx, |input, cx| input.set_text("div.wrap", window, cx));
+ editor.confirm_inline_input(window, cx);
+ });
+ wrap_request.next().await.unwrap();
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
+
+ editor_b.update(cx_b, |editor, cx| {
+ assert_eq!(editor.text(cx), "
hello
");
+ });
+ buffer_a.read_with(cx_a, |buffer, _| {
+ assert_eq!(
+ buffer.text(),
+ "
hello
",
+ "the wrap edit should sync back to the host"
+ );
+ });
+}
+
#[gpui::test]
async fn test_copy_file_name_without_extension(
cx_a: &mut TestAppContext,
diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs
index 95f961be80..b3cb6fdb7e 100644
--- a/crates/editor/src/actions.rs
+++ b/crates/editor/src/actions.rs
@@ -938,6 +938,8 @@ actions!(
UnwrapSyntaxNode,
/// Wraps selections in tag specified by language.
WrapSelectionsInTag,
+ /// Wraps selections in an expanded Emmet abbreviation.
+ WrapWithAbbreviation,
/// Aligns selections from different rows into the same column
AlignSelections,
/// Saves the current location to navigation history.
diff --git a/crates/editor/src/clangd_ext.rs b/crates/editor/src/clangd_ext.rs
index fbe58b06ab..b0919d5c23 100644
--- a/crates/editor/src/clangd_ext.rs
+++ b/crates/editor/src/clangd_ext.rs
@@ -51,6 +51,7 @@ pub fn switch_source_header(
let request = proto::LspExtSwitchSourceHeader {
project_id,
buffer_id: buffer_id.to_proto(),
+ server_id: server_to_query.to_proto(),
};
let response = client
.request(request)
@@ -63,7 +64,9 @@ pub fn switch_source_header(
project.request_lsp(
buffer,
project::LanguageServerToQuery::Other(server_to_query),
- project::lsp_store::lsp_ext_command::SwitchSourceHeader,
+ project::lsp_store::lsp_ext_command::SwitchSourceHeader {
+ server_id: server_to_query,
+ },
cx,
)
})
diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs
index e52c283b60..df71b2e788 100644
--- a/crates/editor/src/editor.rs
+++ b/crates/editor/src/editor.rs
@@ -23,6 +23,7 @@ mod document_links;
mod document_symbols;
mod editor_settings;
mod element;
+mod emmet_ext;
mod fold;
mod folding_ranges;
mod git;
@@ -31,6 +32,7 @@ pub mod hover_links;
pub mod hover_popover;
mod indent_guides;
mod inlays;
+mod inline_input;
pub mod items;
mod jsx_tag_auto_close;
mod linked_editing_ranges;
@@ -116,6 +118,8 @@ use git::{DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover};
pub(crate) use git::{DisplayDiffHunk, PhantomDiffReviewIndicator};
pub use hover_popover::hover_markdown_style;
pub use inlays::Inlay;
+pub use inline_input::InlineInputState;
+pub(crate) use inline_input::{InlineInputHistoryDirection, InlineInputPreview};
pub use items::MAX_TAB_TITLE_LEN;
pub use linked_editing_ranges::LinkedEdits;
pub use lsp::CompletionContext;
@@ -1045,6 +1049,7 @@ pub struct Editor {
linked_editing_range_task: Option>>,
linked_edit_ranges: linked_editing_ranges::LinkedEditingRanges,
pending_rename: Option,
+ pending_inline_input: Option,
searchable: bool,
cursor_shape: CursorShape,
/// Whether the cursor is offset one character to the left when something is
@@ -2385,6 +2390,7 @@ impl Editor {
document_highlights_task: None,
linked_editing_range_task: None,
pending_rename: None,
+ pending_inline_input: None,
searchable: !is_minimap,
cursor_shape: EditorSettings::get_global(cx)
.cursor_shape
@@ -2734,6 +2740,9 @@ impl Editor {
if self.pending_rename.is_some() {
key_context.add("renaming");
}
+ if self.pending_inline_input.is_some() {
+ key_context.add("inline_input");
+ }
if let Some(snippet_stack) = self.snippet_stack.last() {
key_context.add("in_snippet");
@@ -3496,6 +3505,7 @@ impl Editor {
let mut dismissed = false;
dismissed |= self.take_rename(false, window, cx).is_some();
+ dismissed |= self.take_inline_input(window, cx).is_some();
dismissed |= self.hide_blame_popover(true, cx);
dismissed |= hide_hover(self, cx);
dismissed |= self.hide_signature_help(cx, SignatureHelpHiddenBy::Escape);
@@ -4900,6 +4910,25 @@ impl Editor {
snippet: Snippet,
window: &mut Window,
cx: &mut Context,
+ ) -> Result<()> {
+ self.insert_snippet_with_autoindent(
+ insertion_ranges,
+ snippet,
+ Some(AutoindentMode::Block {
+ original_indent_columns: Vec::new(),
+ }),
+ window,
+ cx,
+ )
+ }
+
+ pub(crate) fn insert_snippet_with_autoindent(
+ &mut self,
+ insertion_ranges: &[Range],
+ snippet: Snippet,
+ autoindent_mode: Option,
+ window: &mut Window,
+ cx: &mut Context,
) -> Result<()> {
struct Tabstop {
is_end_tabstop: bool,
@@ -4913,10 +4942,7 @@ impl Editor {
.iter()
.cloned()
.map(|range| (range, snippet_text.clone()));
- let autoindent_mode = AutoindentMode::Block {
- original_indent_columns: Vec::new(),
- };
- buffer.edit(edits, Some(autoindent_mode), cx);
+ buffer.edit(edits, autoindent_mode, cx);
let snapshot = &*buffer.read(cx);
let snippet = &snippet;
diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs
index 1c618ff094..308eead26f 100644
--- a/crates/editor/src/element.rs
+++ b/crates/editor/src/element.rs
@@ -278,6 +278,13 @@ impl EditorElement {
crate::rust_analyzer_ext::apply_related_actions(editor, window, cx);
crate::clangd_ext::apply_related_actions(editor, window, cx);
+ crate::emmet_ext::apply_related_actions(editor, window, cx);
+
+ if editor.read(cx).pending_inline_input().is_some() {
+ register_action(editor, window, |editor, _: &menu::Confirm, window, cx| {
+ editor.confirm_inline_input(window, cx);
+ });
+ }
register_action(editor, window, Editor::open_context_menu);
register_action(editor, window, Editor::move_left);
diff --git a/crates/editor/src/emmet_ext.rs b/crates/editor/src/emmet_ext.rs
new file mode 100644
index 0000000000..bc0d605e0d
--- /dev/null
+++ b/crates/editor/src/emmet_ext.rs
@@ -0,0 +1,1628 @@
+use std::ops::Range;
+use std::time::Duration;
+
+use futures::future::join_all;
+use gpui::{App, AsyncWindowContext, Context, Entity, Global, SharedString, Task, Window};
+use language::{Buffer, Language};
+use lsp::LanguageServerId;
+use multi_buffer::{
+ Anchor, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot, ToOffset, ToPoint,
+};
+use project::Project;
+use project::lsp_store::emmet_ext::{EMMET_SERVER_NAME, ExpandAbbreviation};
+use snippet::Snippet;
+use text::Point;
+use util::ResultExt as _;
+
+use crate::scroll::Autoscroll;
+use crate::{
+ Editor, InlineInputPreview, SelectionEffects, WrapWithAbbreviation, element::register_action,
+};
+
+const PREVIEW_DEBOUNCE: Duration = Duration::from_millis(150);
+const MAX_HISTORY_ENTRIES: usize = 20;
+
+const ELEMENT_NODE_KINDS: [&str; 5] = [
+ "element",
+ "jsx_element",
+ "jsx_self_closing_element",
+ "script_element",
+ "style_element",
+];
+
+const TAG_NODE_KINDS: [&str; 5] = [
+ "start_tag",
+ "end_tag",
+ "self_closing_tag",
+ "jsx_opening_element",
+ "jsx_closing_element",
+];
+
+const STYLESHEET_LANGUAGE_IDS: [&str; 6] = ["css", "sass", "scss", "less", "sss", "stylus"];
+
+#[derive(Clone, Copy, Default)]
+struct WrapFilters {
+ trim: bool,
+ comment: bool,
+ bem: bool,
+}
+
+#[derive(Default)]
+struct WrapAbbreviationHistory(Vec);
+
+impl Global for WrapAbbreviationHistory {}
+
+pub fn apply_related_actions(editor: &Entity, window: &mut Window, cx: &mut App) {
+ let editor_ref = editor.read(cx);
+ let Some(project) = editor_ref.project() else {
+ return;
+ };
+ let project = project.read(cx);
+ if !project
+ .language_server_statuses(cx)
+ .any(|(_, status)| status.name == EMMET_SERVER_NAME)
+ {
+ return;
+ }
+ let supports_wrap = editor_ref
+ .buffer()
+ .read(cx)
+ .all_buffers_iter()
+ .any(|buffer| {
+ buffer
+ .read(cx)
+ .language()
+ .is_some_and(|language| language_supports_wrap(language, project))
+ });
+ if supports_wrap {
+ register_action(editor, window, wrap_with_abbreviation);
+ }
+}
+
+pub fn wrap_with_abbreviation(
+ editor: &mut Editor,
+ _: &WrapWithAbbreviation,
+ window: &mut Window,
+ cx: &mut Context,
+) {
+ if editor.read_only(cx) {
+ return;
+ }
+
+ let selections = editor.selections.disjoint_anchors_arc();
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ let mut offset_ranges = Vec::>::new();
+ for selection in selections.iter() {
+ let mut range = selection.start.to_offset(&snapshot)..selection.end.to_offset(&snapshot);
+ if range.start == range.end {
+ range = enclosing_element_range(range.start, &snapshot).unwrap_or_else(|| {
+ let row = MultiBufferRow(selection.start.to_point(&snapshot).row);
+ Point::new(row.0, 0).to_offset(&snapshot)
+ ..Point::new(row.0, snapshot.line_len(row)).to_offset(&snapshot)
+ });
+ } else {
+ range = expand_over_partial_tags(range, &snapshot);
+ }
+ offset_ranges.push(trim_whitespace(range, &snapshot));
+ }
+ offset_ranges.sort_by_key(|range| (range.start, range.end));
+ let mut merged_ranges = Vec::>::with_capacity(offset_ranges.len());
+ for range in offset_ranges {
+ match merged_ranges.last_mut() {
+ Some(last) if range.start < last.end || range.start == last.start => {
+ last.end = last.end.max(range.end);
+ }
+ _ => merged_ranges.push(range),
+ }
+ }
+ let ranges = merged_ranges
+ .into_iter()
+ .map(|range| snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end))
+ .collect::>();
+ if ranges.is_empty() {
+ return;
+ }
+ if !ranges.iter().any(|range| {
+ build_wrap_target(editor, range, WrapFilters::default(), &snapshot, cx).is_some()
+ }) {
+ log_unavailable();
+ return;
+ }
+
+ editor.change_selections(SelectionEffects::default(), window, cx, |selections| {
+ selections.select_anchor_ranges(ranges.iter().cloned())
+ });
+ let newest = editor.selections.newest_anchor();
+ let start_column = newest.start.to_point(&snapshot).column;
+ let end_row = MultiBufferRow(newest.end.to_point(&snapshot).row);
+ let position = snapshot.anchor_before(Point::new(
+ end_row.0,
+ start_column.min(snapshot.line_len(end_row)),
+ ));
+ let preview_language = snapshot.language_at(position).cloned();
+ let history = cx
+ .try_global::()
+ .map(|history| history.0.clone())
+ .unwrap_or_default();
+ let confirm_ranges = ranges.clone();
+ let preview_ranges = ranges;
+ editor.show_inline_input(
+ "Emmet abbreviation, e.g. ul>li*",
+ position,
+ preview_language,
+ history,
+ move |editor, text, window, cx| {
+ wrap_targets_in_expanded_abbreviation(editor, &confirm_ranges, text, window, cx)
+ },
+ move |editor, text, window, cx| {
+ update_expansion_preview(editor, &preview_ranges, text, window, cx)
+ },
+ window,
+ cx,
+ );
+}
+
+struct WrapTarget {
+ range: Range,
+ text: Option>,
+ buffer: Entity,
+ server_id: LanguageServerId,
+ language: String,
+ indent: String,
+ base_indent: String,
+}
+
+fn language_supports_wrap(language: &Language, project: &Project) -> bool {
+ project
+ .languages()
+ .lsp_adapter(&language.name(), &EMMET_SERVER_NAME)
+ .is_some_and(|adapter| {
+ !STYLESHEET_LANGUAGE_IDS.contains(&adapter.language_id(&language.name()).as_str())
+ })
+}
+
+fn build_wrap_target(
+ editor: &Editor,
+ range: &Range,
+ filters: WrapFilters,
+ snapshot: &MultiBufferSnapshot,
+ cx: &App,
+) -> Option {
+ let project = editor.project.as_ref()?.read(cx);
+ let multi_buffer = editor.buffer().read(cx);
+ let (buffer, start) = multi_buffer.text_anchor_for_position(range.start, cx)?;
+ let (end_buffer, _) = multi_buffer.text_anchor_for_position(range.end, cx)?;
+ if buffer != end_buffer {
+ return None;
+ }
+ let server_id = project.language_server_id_for_name(buffer.read(cx), &EMMET_SERVER_NAME, cx)?;
+ let language = buffer
+ .read(cx)
+ .language_at(start)
+ .or_else(|| buffer.read(cx).language().cloned())?;
+ let adapter = project
+ .lsp_store()
+ .read(cx)
+ .language_server_adapter_for_id(server_id)?;
+ let language = adapter.language_id(&language.name());
+ if STYLESHEET_LANGUAGE_IDS.contains(&language.as_str()) {
+ return None;
+ }
+ let text = snapshot.text_for_range(range.clone()).collect::();
+ let settings = snapshot.language_settings_at(range.start, cx);
+ let indent = if settings.hard_tabs {
+ "\t".to_string()
+ } else {
+ " ".repeat(settings.tab_size.get() as usize)
+ };
+ let base_indent = base_indent_for(range.start, snapshot);
+ Some(WrapTarget {
+ text: selection_lines(&text, &base_indent, filters.trim),
+ range: range.clone(),
+ buffer,
+ server_id,
+ language,
+ indent,
+ base_indent,
+ })
+}
+
+fn selection_lines(text: &str, base_indent: &str, trim_markers: bool) -> Option> {
+ if text.is_empty() {
+ return None;
+ }
+ let lines = text
+ .split('\n')
+ .enumerate()
+ .map(|(ix, line)| {
+ let line = line.trim_end();
+ let line = if ix == 0 {
+ line
+ } else {
+ line.strip_prefix(base_indent).unwrap_or(line)
+ };
+ let line = if trim_markers {
+ trim_list_marker(line)
+ } else {
+ line
+ };
+ line.replace('\\', "\\\\").replace('$', "\\$")
+ })
+ .collect::>();
+ Some(lines)
+}
+
+fn trim_list_marker(line: &str) -> &str {
+ let trimmed = line.trim_start_matches([' ', '\t', '\u{a0}']);
+ let marker_len = trimmed
+ .chars()
+ .take_while(|ch| ch.is_ascii_digit() || matches!(ch, '#' | '-' | '*' | '•'))
+ .map(char::len_utf8)
+ .sum::();
+ if marker_len == 0 {
+ return line.trim();
+ }
+ let rest = &trimmed[marker_len..];
+ let rest = rest.strip_prefix(['.', ')']).unwrap_or(rest);
+ rest.trim()
+}
+
+fn parse_abbreviation(input: &str) -> Option<(String, WrapFilters)> {
+ let mut abbreviation = input.trim();
+ let mut filters = WrapFilters::default();
+ loop {
+ if let Some(stripped) = abbreviation.strip_suffix("|t") {
+ filters.trim = true;
+ abbreviation = stripped.trim_end();
+ } else if let Some(stripped) = abbreviation.strip_suffix("|c") {
+ filters.comment = true;
+ abbreviation = stripped.trim_end();
+ } else if let Some(stripped) = abbreviation.strip_suffix("|bem") {
+ filters.bem = true;
+ abbreviation = stripped.trim_end();
+ } else {
+ break;
+ }
+ }
+ if abbreviation.is_empty() {
+ return None;
+ }
+ Some((abbreviation.to_string(), filters))
+}
+
+fn wrap_targets_in_expanded_abbreviation(
+ editor: &mut Editor,
+ ranges: &[Range],
+ text: String,
+ window: &mut Window,
+ cx: &mut Context,
+) -> Option> {
+ let input = text.trim().to_string();
+ let (abbreviation, filters) = parse_abbreviation(&input)?;
+ let project = editor.project.clone()?;
+
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ let targets = ranges
+ .iter()
+ .filter_map(|range| build_wrap_target(editor, range, filters, &snapshot, cx))
+ .collect::>();
+ if targets.is_empty() {
+ log_unavailable();
+ editor.take_inline_input(window, cx);
+ return None;
+ }
+
+ Some(cx.spawn_in(window, async move |editor, cx| {
+ let requests = targets
+ .into_iter()
+ .map(|target| {
+ let range = target.range.clone();
+ let request =
+ request_expansion(&project, abbreviation.clone(), filters, target, cx);
+ async move { anyhow::Ok((range, request.await?)) }
+ })
+ .collect::>();
+ let mut expansions = Vec::new();
+ let mut errors = Vec::new();
+ for result in join_all(requests).await {
+ match result {
+ Ok((range, Some(expansion))) => expansions.push((range, expansion)),
+ Ok((_, None)) => {}
+ Err(error) => errors.push(error),
+ }
+ }
+
+ editor
+ .update_in(cx, |editor, window, cx| {
+ for error in &errors {
+ log::error!("{error:#}");
+ }
+ if expansions.is_empty() {
+ let message = match errors.first() {
+ Some(error) => error.root_cause().to_string(),
+ None => no_expansion_message(&abbreviation),
+ };
+ editor.set_inline_input_preview(Some(InlineInputPreview::Error(message)), cx);
+ return anyhow::Ok(());
+ }
+ remember_abbreviation(SharedString::from(input), cx);
+ if let Some(mut state) = editor.take_inline_input(window, cx)
+ && let Some(confirm_task) = state.take_confirm_task()
+ {
+ confirm_task.detach();
+ }
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ if let [(range, expansion)] = expansions.as_slice() {
+ if let Ok(snippet) = Snippet::parse(expansion) {
+ let range =
+ range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot);
+ editor.insert_snippet_with_autoindent(
+ &[range],
+ snippet,
+ None,
+ window,
+ cx,
+ )?;
+ return anyhow::Ok(());
+ }
+ }
+ let edits = expansions
+ .into_iter()
+ .map(|(range, expansion)| (range, expansion_text(&expansion)))
+ .collect::>();
+ editor.transact(window, cx, |editor, _, cx| {
+ editor.buffer().update(cx, |buffer, cx| {
+ buffer.edit(edits, None, cx);
+ });
+ });
+ editor.request_autoscroll(Autoscroll::fit(), cx);
+ anyhow::Ok(())
+ })
+ .and_then(|result| result)
+ .log_err();
+ }))
+}
+
+fn update_expansion_preview(
+ editor: &mut Editor,
+ ranges: &[Range],
+ text: String,
+ window: &mut Window,
+ cx: &mut Context,
+) -> Option> {
+ let Some((abbreviation, filters)) = parse_abbreviation(&text) else {
+ editor.set_inline_input_preview(None, cx);
+ return None;
+ };
+ let Some(project) = editor.project.clone() else {
+ editor.set_inline_input_preview(None, cx);
+ return None;
+ };
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ let Some(mut target) = ranges
+ .iter()
+ .find_map(|range| build_wrap_target(editor, range, filters, &snapshot, cx))
+ else {
+ editor.set_inline_input_preview(None, cx);
+ return None;
+ };
+ target.base_indent.clear();
+
+ Some(cx.spawn_in(window, async move |editor, cx| {
+ cx.background_executor().timer(PREVIEW_DEBOUNCE).await;
+ let result = request_expansion(&project, abbreviation.clone(), filters, target, cx).await;
+ editor
+ .update(cx, |editor, cx| {
+ let preview = match result {
+ Ok(Some(expansion)) => InlineInputPreview::Text(expansion_text(&expansion)),
+ Ok(None) => InlineInputPreview::Error(no_expansion_message(&abbreviation)),
+ Err(error) => InlineInputPreview::Error(error.root_cause().to_string()),
+ };
+ editor.set_inline_input_preview(Some(preview), cx);
+ })
+ .ok();
+ }))
+}
+
+fn request_expansion(
+ project: &Entity,
+ abbreviation: String,
+ filters: WrapFilters,
+ target: WrapTarget,
+ cx: &mut AsyncWindowContext,
+) -> Task>> {
+ project.update(cx, |project, cx| {
+ project.request_lsp(
+ target.buffer,
+ project::LanguageServerToQuery::Other(target.server_id),
+ ExpandAbbreviation {
+ abbreviation,
+ text: target.text,
+ language: target.language,
+ server_id: target.server_id,
+ indent: target.indent,
+ base_indent: target.base_indent,
+ comment_filter: filters.comment,
+ bem_filter: filters.bem,
+ },
+ cx,
+ )
+ })
+}
+
+fn expansion_text(expansion: &str) -> String {
+ Snippet::parse(expansion)
+ .map(|snippet| snippet.text)
+ .unwrap_or_else(|_| expansion.to_string())
+}
+
+fn no_expansion_message(abbreviation: &str) -> String {
+ format!("No Emmet expansion for {abbreviation:?}")
+}
+
+fn remember_abbreviation(abbreviation: SharedString, cx: &mut App) {
+ let history = &mut cx.default_global::().0;
+ history.retain(|entry| *entry != abbreviation);
+ history.insert(0, abbreviation);
+ history.truncate(MAX_HISTORY_ENTRIES);
+}
+
+fn log_unavailable() {
+ log::info!("Wrapping with an Emmet abbreviation is not available for the selected text");
+}
+
+fn base_indent_for(range_start: Anchor, snapshot: &MultiBufferSnapshot) -> String {
+ let row = MultiBufferRow(range_start.to_point(snapshot).row);
+ snapshot.indent_size_for_line(row).chars().collect()
+}
+
+fn enclosing_element_range(
+ position: MultiBufferOffset,
+ snapshot: &MultiBufferSnapshot,
+) -> Option> {
+ let mut range = position..position;
+ while let Some((node, node_range)) = snapshot.syntax_ancestor(range.clone()) {
+ if ELEMENT_NODE_KINDS.contains(&node.kind()) {
+ return Some(node_range);
+ }
+ if node_range == range {
+ return None;
+ }
+ range = node_range;
+ }
+ None
+}
+
+fn expand_over_partial_tags(
+ range: Range,
+ snapshot: &MultiBufferSnapshot,
+) -> Range {
+ let mut expanded = range.clone();
+ for position in [range.start, range.end] {
+ if let Some(element_range) = element_range_for_partial_tag(position, snapshot) {
+ expanded.start = expanded.start.min(element_range.start);
+ expanded.end = expanded.end.max(element_range.end);
+ }
+ }
+ expanded
+}
+
+fn element_range_for_partial_tag(
+ position: MultiBufferOffset,
+ snapshot: &MultiBufferSnapshot,
+) -> Option> {
+ let mut range = position..position;
+ let mut in_tag = false;
+ while let Some((node, node_range)) = snapshot.syntax_ancestor(range.clone()) {
+ if TAG_NODE_KINDS.contains(&node.kind()) {
+ if node_range.start < position && position < node_range.end {
+ in_tag = true;
+ }
+ } else if ELEMENT_NODE_KINDS.contains(&node.kind()) {
+ return in_tag.then_some(node_range);
+ }
+ if node_range == range {
+ return None;
+ }
+ range = node_range;
+ }
+ None
+}
+
+fn trim_whitespace(
+ mut range: Range,
+ snapshot: &MultiBufferSnapshot,
+) -> Range {
+ for ch in snapshot.chars_at(range.start) {
+ if range.start == range.end || !ch.is_whitespace() {
+ break;
+ }
+ range.start += ch.len_utf8();
+ }
+ for ch in snapshot.reversed_chars_at(range.end) {
+ if range.end == range.start || !ch.is_whitespace() {
+ break;
+ }
+ range.end -= ch.len_utf8();
+ }
+ range
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Arc;
+
+ use futures::StreamExt as _;
+ use gpui::{AppContext as _, Focusable as _, TestAppContext, VisualTestContext};
+ use language::{Capability, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher};
+ use multi_buffer::{MultiBuffer, PathKey};
+ use project::lsp_store::emmet_ext::{EmmetOutputOptions, LspExpandAbbreviation};
+ use project::{FakeFs, Project};
+ use util::path;
+
+ use crate::editor_tests::init_test;
+ use crate::test::build_editor_with_project;
+
+ use super::*;
+
+ #[gpui::test]
+ async fn test_wrap_selection_in_emmet_abbreviation(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div.wrap");
+ assert_eq!(params.language, "html");
+ assert_eq!(params.options.text, Some(vec!["hello".to_string()]));
+ assert_eq!(
+ params.options.options,
+ EmmetOutputOptions {
+ indent: " ".to_string(),
+ base_indent: String::new(),
+ ..EmmetOutputOptions::default()
+ },
+ "single-line wraps should not force inline breaks"
+ );
+ Ok(Some("${1:hello}
$0".to_string()))
+ },
+ );
+
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ assert!(input.focus_handle(cx).is_focused(window));
+ input.update(cx, |input, cx| input.set_text("div.wrap", window, cx));
+ });
+ cx.update(|_, cx| {
+ cx.bind_keys([gpui::KeyBinding::new(
+ "enter",
+ menu::Confirm,
+ Some("Editor && inline_input"),
+ )]);
+ });
+ cx.simulate_keystrokes("enter");
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "
hello
");
+ assert!(editor.pending_inline_input.is_none());
+ });
+ }
+
+ #[gpui::test]
+ async fn test_input_stays_open_on_empty_expansion(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let mut requests = fake_server
+ .set_request_handler::(|_, _| async move { Ok(None) });
+
+ confirm_abbreviation(&editor, "@invalid@", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "hello
");
+ assert!(
+ editor.pending_inline_input.is_some(),
+ "the input should stay open so the abbreviation can be corrected"
+ );
+ assert_eq!(
+ editor
+ .pending_inline_input
+ .as_ref()
+ .and_then(|state| state.preview.clone()),
+ Some(InlineInputPreview::Error(
+ "No Emmet expansion for \"@invalid@\"".to_string()
+ )),
+ "the failure should show up next to the input"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_wrap_line_on_empty_selection(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup(" hello world\n", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 4)..Point::new(0, 4)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "strong");
+ assert_eq!(params.options.text, Some(vec!["hello world".to_string()]));
+ Ok(Some("hello world ".to_string()))
+ },
+ );
+
+ confirm_abbreviation(&editor, "strong", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), " hello world \n");
+ });
+ }
+
+ #[gpui::test]
+ async fn test_wrap_enclosing_element_on_empty_selection(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(1, 3)..Point::new(1, 3)], cx);
+ editor.update(cx, |editor, cx| {
+ let snapshot = editor.display_snapshot(cx);
+ assert_eq!(
+ editor.selections.newest::(&snapshot).range(),
+ Point::new(1, 2)..Point::new(1, 14),
+ "the element about to be wrapped should be selected so it is visible"
+ );
+ });
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div.x");
+ assert_eq!(
+ params.options.text,
+ Some(vec!["hello
".to_string()]),
+ "the enclosing element should be wrapped, not the line"
+ );
+ Ok(Some("".to_string()))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div.x", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ ""
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_multiline_expansion_preserves_surrounding_indentation(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) =
+ setup("", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(1, 2)..Point::new(2, 10)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div");
+ assert_eq!(
+ params.options.text,
+ Some(vec!["a
".to_string(), "b
".to_string()]),
+ "lines should be dedented by the base indentation of the first line"
+ );
+ assert_eq!(
+ params.options.options.base_indent, " ",
+ "the wrapped line's indentation should be sent as the base indent"
+ );
+ Ok(Some(format_expansion(
+ "",
+ ¶ms.options.options,
+ )))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "",
+ "inserted lines should be indented relative to the wrapped line, \
+ and surrounding indentation should not change"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_escape_dismisses_emmet_wrap_input(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+ editor.update_in(cx, |editor, window, cx| {
+ assert!(editor.pending_inline_input.is_some());
+ editor.cancel(&crate::actions::Cancel, window, cx);
+ assert!(editor.pending_inline_input.is_none());
+ assert_eq!(editor.text(cx), "hello
");
+ });
+ }
+
+ #[gpui::test]
+ async fn test_multiple_cursors_on_blank_line_wrap_once(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup(" \n", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(
+ &editor,
+ [
+ Point::new(0, 0)..Point::new(0, 0),
+ Point::new(0, 2)..Point::new(0, 2),
+ ],
+ cx,
+ );
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div");
+ assert_eq!(params.options.text, None);
+ Ok(Some("
".to_string()))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "
\n");
+ assert!(editor.pending_inline_input.is_none());
+ });
+ }
+
+ #[gpui::test]
+ async fn test_stale_confirmation_is_ignored(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let (release_fresh, fresh_gate) = futures::channel::oneshot::channel::<()>();
+ let fresh_gate = Arc::new(std::sync::Mutex::new(Some(fresh_gate)));
+ fake_server.set_request_handler::({
+ move |params, _| {
+ let fresh_gate = fresh_gate.clone();
+ async move {
+ if params.abbreviation == "span" {
+ let gate = fresh_gate.lock().unwrap().take();
+ if let Some(gate) = gate {
+ gate.await.ok();
+ }
+ Ok(Some("hello ".to_string()))
+ } else {
+ assert_eq!(params.abbreviation, "div");
+ Ok(Some("hello
".to_string()))
+ }
+ }
+ }
+ });
+
+ confirm_abbreviation(&editor, "div", cx);
+ confirm_abbreviation(&editor, "span", cx);
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "hello
",
+ "the stale expansion should be dropped while the fresh one is in flight"
+ );
+ assert!(editor.pending_inline_input.is_some());
+ });
+
+ release_fresh.send(()).ok();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "hello
");
+ assert!(editor.pending_inline_input.is_none());
+ });
+ }
+
+ #[gpui::test]
+ async fn test_preview_updates_as_you_type(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div.wrap");
+ assert_eq!(params.options.text, Some(vec!["hello".to_string()]));
+ Ok(Some("${1:hello}
$0".to_string()))
+ },
+ );
+
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| input.set_text("div.wrap", window, cx));
+ });
+ cx.executor().advance_clock(PREVIEW_DEBOUNCE * 2);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor
+ .pending_inline_input
+ .as_ref()
+ .and_then(|state| state.preview.clone()),
+ Some(InlineInputPreview::Text(
+ "hello
".to_string()
+ )),
+ "the pending expansion should be previewed under the input"
+ );
+ assert_eq!(
+ editor.text(cx),
+ "hello
",
+ "previewing should not edit the buffer"
+ );
+ });
+
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| input.set_text("", window, cx));
+ });
+ cx.executor().run_until_parked();
+ editor.update(cx, |editor, _| {
+ assert_eq!(
+ editor
+ .pending_inline_input
+ .as_ref()
+ .and_then(|state| state.preview.clone()),
+ None,
+ "clearing the input should clear the preview"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_preview_does_not_embed_base_indentation(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) =
+ setup("", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(1, 2)..Point::new(2, 10)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div");
+ assert_eq!(
+ params.options.options.base_indent, "",
+ "previews render at the anchor column already, \
+ so they must request no base indent"
+ );
+ Ok(Some(format_expansion(
+ "",
+ ¶ms.options.options,
+ )))
+ },
+ );
+
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| input.set_text("div", window, cx));
+ });
+ cx.executor().advance_clock(PREVIEW_DEBOUNCE * 2);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, _| {
+ assert_eq!(
+ editor
+ .pending_inline_input
+ .as_ref()
+ .and_then(|state| state.preview.clone()),
+ Some(InlineInputPreview::Text(
+ "".to_string()
+ )),
+ "the preview block already renders at the anchor column, \
+ so its lines must not repeat the wrapped line's base indentation"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_last_abbreviation_is_prefilled(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let mut requests =
+ fake_server.set_request_handler::(|_, _| async move {
+ Ok(Some("hello
".to_string()))
+ });
+
+ confirm_abbreviation(&editor, "div.wrap", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "
hello
");
+ });
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+ editor.update(cx, |editor, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| {
+ assert_eq!(
+ input.text(cx),
+ "div.wrap",
+ "the last used abbreviation should be prefilled"
+ );
+ let snapshot = input.display_snapshot(cx);
+ let selected = input.selections.newest::(&snapshot);
+ assert_eq!(
+ selected.range(),
+ Point::new(0, 0)..Point::new(0, "div.wrap".len() as u32),
+ "the prefilled abbreviation should be selected so typing replaces it"
+ );
+ });
+ });
+ }
+
+ #[gpui::test]
+ async fn test_up_and_down_cycle_abbreviation_history(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+ cx.update(|_, cx| {
+ cx.bind_keys([
+ gpui::KeyBinding::new("up", crate::MoveUp, Some("Editor")),
+ gpui::KeyBinding::new("down", crate::MoveDown, Some("Editor")),
+ ]);
+ });
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move { Ok(Some(format!("<{0}>hello{0}>", params.abbreviation))) },
+ );
+
+ for abbreviation in ["em", "strong", "span"] {
+ let hello = editor.update(cx, |editor, cx| {
+ let text = editor.text(cx);
+ let start = text.find("hello").expect("hello should still be there") as u32;
+ Point::new(0, start)..Point::new(0, start + "hello".len() as u32)
+ });
+ select_and_wrap(&editor, [hello], cx);
+ confirm_abbreviation(&editor, abbreviation, cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+ }
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "hello
"
+ );
+ });
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+ assert_eq!(input_text(&editor, cx), "span", "newest entry is prefilled");
+
+ cx.simulate_keystrokes("up");
+ assert_eq!(
+ input_text(&editor, cx),
+ "strong",
+ "up moves to the older entry"
+ );
+ cx.simulate_keystrokes("up");
+ assert_eq!(input_text(&editor, cx), "em");
+ cx.simulate_keystrokes("up");
+ assert_eq!(
+ input_text(&editor, cx),
+ "em",
+ "up stops at the oldest entry"
+ );
+
+ cx.simulate_keystrokes("down down down");
+ assert_eq!(
+ input_text(&editor, cx),
+ "",
+ "down past the newest entry returns to the empty draft"
+ );
+ cx.simulate_keystrokes("down");
+ assert_eq!(input_text(&editor, cx), "", "down stops at the draft");
+
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| input.set_text("div.wip", window, cx));
+ });
+ cx.executor().run_until_parked();
+ cx.simulate_keystrokes("up");
+ assert_eq!(
+ input_text(&editor, cx),
+ "span",
+ "up from a draft starts at the newest entry"
+ );
+ cx.simulate_keystrokes("down");
+ assert_eq!(
+ input_text(&editor, cx),
+ "div.wip",
+ "down restores the draft that was being typed"
+ );
+
+ editor.update(cx, |editor, cx| {
+ assert!(
+ editor.pending_inline_input.is_some(),
+ "cycling history must not dismiss the input"
+ );
+ assert_eq!(
+ editor.text(cx),
+ "hello
"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_wrap_individual_lines(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) =
+ setup("\n About\n News\n Products\n
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(1, 4)..Point::new(3, 12)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "ul>li*");
+ assert_eq!(
+ params.options.text,
+ Some(vec![
+ "About".to_string(),
+ "News".to_string(),
+ "Products".to_string(),
+ ]),
+ "each selected line should be sent separately so `*` repeats per line"
+ );
+ assert_eq!(
+ params.options.options,
+ EmmetOutputOptions {
+ indent: " ".to_string(),
+ base_indent: " ".to_string(),
+ inline_break: Some(1),
+ ..EmmetOutputOptions::default()
+ },
+ "multi-line wraps should break inline elements per line \
+ and carry the buffer's indentation to the server"
+ );
+ Ok(Some(format_expansion(
+ "\n\tAbout \n\tNews \n\tProducts \n ",
+ ¶ms.options.options,
+ )))
+ },
+ );
+
+ confirm_abbreviation(&editor, "ul>li*", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "\n
\n About \n News \n Products \n \n
"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_trim_filter_strips_list_markers(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup(" * one\n 2. two\n", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 0)..Point::new(1, 8)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(
+ params.abbreviation, "ul>li*",
+ "the `|t` filter should be stripped before querying the server"
+ );
+ assert_eq!(
+ params.options.text,
+ Some(vec!["one".to_string(), "two".to_string()]),
+ "list markers should be removed from the wrapped lines"
+ );
+ Ok(Some(format_expansion(
+ "",
+ ¶ms.options.options,
+ )))
+ },
+ );
+
+ confirm_abbreviation(&editor, "ul>li*|t", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ " \n"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_partial_tag_selection_expands_to_element(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 1)..Point::new(0, 6)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div");
+ assert_eq!(
+ params.options.text,
+ Some(vec!["hello
".to_string()]),
+ "a selection endpoint inside a tag should expand to the whole element"
+ );
+ Ok(Some("".to_string()))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "");
+ });
+ }
+
+ #[gpui::test]
+ async fn test_dollar_signs_in_wrapped_text_are_escaped(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("costs $100
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 13)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(
+ params.options.text,
+ Some(vec!["costs \\$100".to_string()]),
+ "dollar signs should be escaped so snippet insertion cannot eat them"
+ );
+ Ok(Some("costs \\$100
".to_string()))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "
costs $100
",
+ "the escaped dollar sign should round-trip back to a literal"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_comment_and_bem_filters_are_forwarded(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(
+ params.abbreviation, "div#page",
+ "filters should be stripped from the abbreviation"
+ );
+ assert_eq!(
+ params.options.options,
+ EmmetOutputOptions {
+ indent: " ".to_string(),
+ base_indent: String::new(),
+ comment_enabled: Some(true),
+ bem_enabled: Some(true),
+ inline_break: None,
+ },
+ "the |c and |bem filters should map to emmet output options"
+ );
+ Ok(Some(
+ "hello
\n".to_string(),
+ ))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div#page|c|bem", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "
hello
\n"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_wrap_is_unavailable_in_stylesheets(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_file(path!("/file.css"), ".a { color: red; }".into())
+ .await;
+
+ let project = Project::test(fs, [path!("/file.css").as_ref()], cx).await;
+ let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+ language_registry.add(Arc::new(Language::new(
+ LanguageConfig {
+ name: "CSS".into(),
+ matcher: LanguageMatcher {
+ path_suffixes: vec!["css".into()],
+ ..LanguageMatcher::default()
+ }
+ .into(),
+ ..LanguageConfig::default()
+ },
+ None,
+ )));
+ let mut fake_servers = language_registry.register_fake_lsp(
+ "CSS",
+ FakeLspAdapter {
+ name: "emmet-language-server",
+ ..FakeLspAdapter::default()
+ },
+ );
+
+ let buffer = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/file.css"), cx)
+ })
+ .await
+ .unwrap();
+ let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+ let (editor, cx) = cx.add_window_view(|window, cx| {
+ build_editor_with_project(project, multi_buffer, window, cx)
+ });
+ fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 0)..Point::new(0, 18)], cx);
+
+ editor.update(cx, |editor, cx| {
+ assert!(
+ editor.pending_inline_input.is_none(),
+ "wrapping is a markup action and should refuse stylesheets"
+ );
+ assert_eq!(editor.text(cx), ".a { color: red; }");
+ });
+ }
+
+ #[gpui::test]
+ async fn test_interleaved_overlapping_selections_are_merged(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(
+ &editor,
+ [
+ Point::new(0, 8)..Point::new(0, 9),
+ Point::new(0, 16)..Point::new(0, 17),
+ Point::new(0, 22)..Point::new(0, 22),
+ ],
+ cx,
+ );
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "section");
+ assert_eq!(
+ params.options.text,
+ Some(vec!["".to_string()]),
+ "the element expanded from the trailing caret should be merged \
+ with the earlier selections into a single wrap target"
+ );
+ Ok(Some(
+ "".to_string(),
+ ))
+ },
+ );
+
+ confirm_abbreviation(&editor, "section", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ ""
+ );
+ assert!(editor.pending_inline_input.is_none());
+ });
+ }
+
+ #[gpui::test]
+ async fn test_enter_confirms_when_parent_editor_is_focused(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div.wrap");
+ Ok(Some("hello
".to_string()))
+ },
+ );
+
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| input.set_text("div.wrap", window, cx));
+ });
+ cx.update(|_, cx| {
+ cx.bind_keys([
+ gpui::KeyBinding::new(
+ "enter",
+ crate::actions::Newline,
+ Some("Editor && mode == full"),
+ ),
+ gpui::KeyBinding::new("enter", menu::Confirm, Some("Editor && inline_input")),
+ ]);
+ });
+ editor.update_in(cx, |editor, window, cx| {
+ let focus_handle = editor.focus_handle(cx);
+ window.focus(&focus_handle, cx);
+ });
+ cx.simulate_keystrokes("enter");
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "
hello
",
+ "enter should confirm the wrap instead of inserting a newline"
+ );
+ assert!(editor.pending_inline_input.is_none());
+ });
+ }
+
+ #[gpui::test]
+ async fn test_wrap_in_multibuffer_excerpts(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ serde_json::json!({
+ "a.html": "one
",
+ "b.html": "two
",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+ let mut fake_servers = register_html_with_emmet(&project, cx);
+
+ let buffer_a = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/root/a.html"), cx)
+ })
+ .await
+ .unwrap();
+ let buffer_b = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/root/b.html"), cx)
+ })
+ .await
+ .unwrap();
+ let multi_buffer = cx.new(|cx| {
+ let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite);
+ multi_buffer.set_excerpts_for_path(
+ PathKey::sorted(0),
+ buffer_a.clone(),
+ [Point::new(0, 0)..Point::new(0, 10)],
+ 0,
+ cx,
+ );
+ multi_buffer.set_excerpts_for_path(
+ PathKey::sorted(1),
+ buffer_b.clone(),
+ [Point::new(0, 0)..Point::new(0, 10)],
+ 0,
+ cx,
+ );
+ multi_buffer
+ });
+ let (editor, cx) = cx.add_window_view(|window, cx| {
+ build_editor_with_project(project, multi_buffer, window, cx)
+ });
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(
+ &editor,
+ [
+ Point::new(0, 3)..Point::new(0, 6),
+ Point::new(1, 3)..Point::new(1, 6),
+ ],
+ cx,
+ );
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div");
+ let wrapped = match params.options.text.as_deref() {
+ Some([line]) if line == "one" => "one
",
+ Some([line]) if line == "two" => "two
",
+ other => panic!("unexpected wrap text: {other:?}"),
+ };
+ Ok(Some(wrapped.to_string()))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div", cx);
+ requests.next().await.unwrap();
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "
one
\n
two
",
+ "each excerpt should be wrapped in its own buffer"
+ );
+ assert!(editor.pending_inline_input.is_none());
+ });
+ cx.update(|_, cx| {
+ assert_eq!(buffer_a.read(cx).text(), "
one
");
+ assert_eq!(buffer_b.read(cx).text(), "
two
");
+ });
+ }
+
+ fn select_and_wrap(
+ editor: &Entity,
+ ranges: impl IntoIterator- >,
+ cx: &mut VisualTestContext,
+ ) {
+ editor.update_in(cx, |editor, window, cx| {
+ editor.change_selections(SelectionEffects::default(), window, cx, |s| {
+ s.select_ranges(ranges)
+ });
+ wrap_with_abbreviation(editor, &WrapWithAbbreviation, window, cx);
+ });
+ }
+
+ fn confirm_abbreviation(
+ editor: &Entity
,
+ abbreviation: &str,
+ cx: &mut VisualTestContext,
+ ) {
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| input.set_text(abbreviation, window, cx));
+ editor.confirm_inline_input(window, cx);
+ });
+ }
+
+ fn format_expansion(tab_indented: &str, options: &EmmetOutputOptions) -> String {
+ tab_indented
+ .split('\n')
+ .enumerate()
+ .map(|(ix, line)| {
+ let levels = line.chars().take_while(|ch| *ch == '\t').count();
+ let base = if ix == 0 { "" } else { &options.base_indent };
+ format!("{base}{}{}", options.indent.repeat(levels), &line[levels..])
+ })
+ .collect::>()
+ .join("\n")
+ }
+
+ fn input_text(editor: &Entity, cx: &mut VisualTestContext) -> String {
+ editor.update(cx, |editor, cx| pending_input(editor).read(cx).text(cx))
+ }
+
+ fn pending_input(editor: &Editor) -> Entity {
+ editor
+ .pending_inline_input
+ .as_ref()
+ .expect("emmet wrap input should be pending")
+ .editor
+ .clone()
+ }
+
+ async fn setup<'a>(
+ text: &str,
+ cx: &'a mut TestAppContext,
+ ) -> (
+ Entity,
+ futures::channel::mpsc::UnboundedReceiver,
+ &'a mut VisualTestContext,
+ ) {
+ init_test(cx, |_| {});
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_file(path!("/file.html"), text.into()).await;
+
+ let project = Project::test(fs, [path!("/file.html").as_ref()], cx).await;
+ let fake_servers = register_html_with_emmet(&project, cx);
+
+ let buffer = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/file.html"), cx)
+ })
+ .await
+ .unwrap();
+ let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+ let (editor, cx) = cx.add_window_view(|window, cx| {
+ build_editor_with_project(project, multi_buffer, window, cx)
+ });
+ (editor, fake_servers, cx)
+ }
+
+ fn register_html_with_emmet(
+ project: &Entity,
+ cx: &mut TestAppContext,
+ ) -> futures::channel::mpsc::UnboundedReceiver {
+ let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+ language_registry.add(Arc::new(Language::new(
+ LanguageConfig {
+ name: "HTML".into(),
+ matcher: LanguageMatcher {
+ path_suffixes: vec!["html".into()],
+ ..LanguageMatcher::default()
+ }
+ .into(),
+ ..LanguageConfig::default()
+ },
+ Some(tree_sitter_html::LANGUAGE.into()),
+ )));
+ language_registry.register_fake_lsp(
+ "HTML",
+ FakeLspAdapter {
+ name: "emmet-language-server",
+ ..FakeLspAdapter::default()
+ },
+ )
+ }
+}
diff --git a/crates/editor/src/inline_input.rs b/crates/editor/src/inline_input.rs
new file mode 100644
index 0000000000..5cce637c25
--- /dev/null
+++ b/crates/editor/src/inline_input.rs
@@ -0,0 +1,370 @@
+use gpui::StyledText;
+use language::HighlightId;
+
+use super::*;
+
+pub struct InlineInputState {
+ pub editor: Entity,
+ block_id: CustomBlockId,
+ position: Anchor,
+ confirm_task: Option>,
+ preview_task: Option>,
+ pub(crate) preview: Option,
+ preview_language: Option>,
+ block_height: u32,
+ history: Vec,
+ history_ix: Option,
+ draft: String,
+ _subscription: Subscription,
+ on_confirm:
+ Rc) -> Option>>,
+}
+
+impl InlineInputState {
+ pub(crate) fn take_confirm_task(&mut self) -> Option> {
+ self.confirm_task.take()
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum InlineInputPreview {
+ Text(String),
+ Error(String),
+}
+
+#[derive(Clone, Copy)]
+pub(crate) enum InlineInputHistoryDirection {
+ Older,
+ Newer,
+}
+
+const INLINE_INPUT_PREVIEW_MIN_LINES: usize = 8;
+
+impl InlineInputPreview {
+ fn display_text(&self, max_lines: usize) -> (String, u32) {
+ let text = match self {
+ InlineInputPreview::Text(text) => text,
+ InlineInputPreview::Error(message) => message,
+ };
+ let line_count = text.lines().count().max(1);
+ if line_count <= max_lines {
+ return (text.to_string(), line_count as u32);
+ }
+ let hidden_lines = line_count - max_lines;
+ let noun = if hidden_lines == 1 { "line" } else { "lines" };
+ let shown = text.lines().take(max_lines).collect::>().join("\n");
+ (
+ format!("{shown}\n… +{hidden_lines} more {noun}"),
+ max_lines as u32 + 1,
+ )
+ }
+
+ fn is_error(&self) -> bool {
+ matches!(self, InlineInputPreview::Error(_))
+ }
+}
+
+struct RenderedPreview {
+ text: SharedString,
+ highlights: Vec<(Range, HighlightId)>,
+ height_in_lines: u32,
+ is_error: bool,
+}
+
+impl RenderedPreview {
+ fn new(
+ preview: &InlineInputPreview,
+ language: Option<&Arc>,
+ max_lines: usize,
+ ) -> Self {
+ let (text, height_in_lines) = preview.display_text(max_lines);
+ let highlights = match (preview, language) {
+ (InlineInputPreview::Text(_), Some(language)) => {
+ language.highlight_text(&Rope::from(text.as_str()), 0..text.len())
+ }
+ _ => Vec::new(),
+ };
+ Self {
+ text: SharedString::from(text),
+ highlights,
+ height_in_lines,
+ is_error: preview.is_error(),
+ }
+ }
+}
+
+impl Editor {
+ pub fn pending_inline_input(&self) -> Option<&InlineInputState> {
+ self.pending_inline_input.as_ref()
+ }
+
+ pub fn confirm_inline_input(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(state) = self.pending_inline_input.as_ref() else {
+ return;
+ };
+ let text = state.editor.read(cx).text(cx);
+ let on_confirm = state.on_confirm.clone();
+ if let Some(task) = on_confirm(self, text, window, cx)
+ && let Some(state) = self.pending_inline_input.as_mut()
+ {
+ state.confirm_task = Some(task);
+ }
+ }
+
+ pub(crate) fn show_inline_input(
+ &mut self,
+ placeholder: &str,
+ position: Anchor,
+ preview_language: Option>,
+ history: Vec,
+ on_confirm: impl Fn(&mut Editor, String, &mut Window, &mut Context) -> Option>
+ + 'static,
+ on_change: impl Fn(&mut Editor, String, &mut Window, &mut Context) -> Option>
+ + 'static,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.take_inline_input(window, cx);
+
+ let input = cx.new(|cx| {
+ let mut input = Editor::single_line(window, cx);
+ input.set_placeholder_text(placeholder, window, cx);
+ if let Some(newest) = history.first() {
+ input.set_text(newest.clone(), window, cx);
+ input.select_all(&SelectAll, window, cx);
+ }
+ input
+ });
+ let subscription = cx.subscribe_in(
+ &input,
+ window,
+ move |editor, input, event: &EditorEvent, window, cx| match event {
+ EditorEvent::Focused => cx.emit(EditorEvent::FocusedIn),
+ EditorEvent::BufferEdited => {
+ let Some(state) = editor.pending_inline_input.as_mut() else {
+ return;
+ };
+ let text = input.read(cx).text(cx);
+ if state.history_ix.is_some_and(|ix| {
+ state.history.get(ix).map(SharedString::as_ref) != Some(text.as_str())
+ }) {
+ state.history_ix = None;
+ }
+ let task = on_change(editor, text, window, cx);
+ if let Some(state) = editor.pending_inline_input.as_mut() {
+ state.preview_task = task;
+ }
+ }
+ _ => {}
+ },
+ );
+ let block_ids = self.insert_blocks(
+ [BlockProperties {
+ style: BlockStyle::Flex,
+ placement: BlockPlacement::Below(position),
+ height: Some(1),
+ render: render_inline_input_block(input.clone(), None),
+ priority: 0,
+ }],
+ Some(reveal_block_below(
+ position,
+ &self.buffer().read(cx).snapshot(cx),
+ )),
+ cx,
+ );
+ let Some(&block_id) = block_ids.first() else {
+ return;
+ };
+
+ let input_focus_handle = input.focus_handle(cx);
+ window.focus(&input_focus_handle, cx);
+ self.pending_inline_input = Some(InlineInputState {
+ editor: input,
+ block_id,
+ position,
+ confirm_task: None,
+ preview_task: None,
+ preview: None,
+ preview_language,
+ block_height: 1,
+ history_ix: (!history.is_empty()).then_some(0),
+ history,
+ draft: String::new(),
+ _subscription: subscription,
+ on_confirm: Rc::new(on_confirm),
+ });
+ }
+
+ pub(crate) fn set_inline_input_preview(
+ &mut self,
+ preview: Option,
+ cx: &mut Context,
+ ) {
+ let max_lines = self.inline_input_preview_max_lines();
+ let Some(state) = self.pending_inline_input.as_mut() else {
+ return;
+ };
+ let block_id = state.block_id;
+ let position = state.position;
+ let input = state.editor.clone();
+ let rendered = preview.as_ref().map(|preview| {
+ RenderedPreview::new(preview, state.preview_language.as_ref(), max_lines)
+ });
+ let height = 1 + rendered
+ .as_ref()
+ .map_or(0, |rendered| rendered.height_in_lines);
+ state.preview = preview;
+ let resized = height != state.block_height;
+ state.block_height = height;
+ let autoscroll =
+ resized.then(|| reveal_block_below(position, &self.buffer().read(cx).snapshot(cx)));
+ self.replace_blocks(
+ HashMap::from_iter([(block_id, render_inline_input_block(input, rendered))]),
+ None,
+ cx,
+ );
+ self.resize_blocks(HashMap::from_iter([(block_id, height)]), autoscroll, cx);
+ }
+
+ pub(crate) fn cycle_inline_input_history(
+ &mut self,
+ direction: InlineInputHistoryDirection,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> bool {
+ let Some(state) = self.pending_inline_input.as_mut() else {
+ return false;
+ };
+ let next_ix = match (direction, state.history_ix) {
+ (InlineInputHistoryDirection::Older, None) => {
+ if state.history.is_empty() {
+ return true;
+ }
+ state.draft = state.editor.read(cx).text(cx);
+ Some(0)
+ }
+ (InlineInputHistoryDirection::Older, Some(ix)) => {
+ if ix + 1 >= state.history.len() {
+ return true;
+ }
+ Some(ix + 1)
+ }
+ (InlineInputHistoryDirection::Newer, None) => return true,
+ (InlineInputHistoryDirection::Newer, Some(0)) => None,
+ (InlineInputHistoryDirection::Newer, Some(ix)) => Some(ix - 1),
+ };
+ state.history_ix = next_ix;
+ let text = match next_ix {
+ Some(ix) => state.history.get(ix).cloned().unwrap_or_default(),
+ None => SharedString::from(mem::take(&mut state.draft)),
+ };
+ let input = state.editor.clone();
+ input.update(cx, |input, cx| {
+ input.set_text(text, window, cx);
+ input.select_all(&SelectAll, window, cx);
+ });
+ true
+ }
+
+ pub(crate) fn take_inline_input(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Option {
+ let state = self.pending_inline_input.take()?;
+ if state.editor.focus_handle(cx).is_focused(window) {
+ window.focus(&self.focus_handle, cx);
+ }
+ self.remove_blocks(
+ HashSet::from_iter([state.block_id]),
+ Some(Autoscroll::fit()),
+ cx,
+ );
+ Some(state)
+ }
+
+ fn inline_input_preview_max_lines(&self) -> usize {
+ let half_viewport = (self.visible_line_count().unwrap_or(0.) / 2.).floor() as usize;
+ half_viewport.max(INLINE_INPUT_PREVIEW_MIN_LINES)
+ }
+}
+
+fn reveal_block_below(position: Anchor, snapshot: &MultiBufferSnapshot) -> Autoscroll {
+ let row_below = position.to_point(snapshot).row + 1;
+ if row_below > snapshot.max_point().row {
+ return Autoscroll::center().for_anchor(position);
+ }
+ Autoscroll::fit().for_anchor(snapshot.anchor_before(Point::new(row_below, 0)))
+}
+
+fn render_inline_input_block(
+ input: Entity,
+ preview: Option,
+) -> RenderBlock {
+ Arc::new(move |cx: &mut BlockContext| {
+ v_flex()
+ .block_mouse_except_scroll()
+ .w_full()
+ .bg(cx.theme().colors().elevated_surface_background)
+ .pl(cx.anchor_x)
+ .child(EditorElement::new(
+ &input,
+ EditorStyle {
+ background: cx.theme().system().transparent,
+ local_player: cx.editor_style.local_player,
+ text: cx.editor_style.text.clone(),
+ scrollbar_width: cx.editor_style.scrollbar_width,
+ syntax: cx.editor_style.syntax.clone(),
+ status: cx.editor_style.status.clone(),
+ ..EditorStyle::default()
+ },
+ ))
+ .when_some(preview.as_ref(), |this, preview| {
+ let mut text_style = cx.editor_style.text.clone();
+ text_style.color = if preview.is_error {
+ cx.theme().status().error
+ } else {
+ cx.theme().colors().text_muted
+ };
+ let syntax = cx.theme().syntax();
+ let highlights = preview
+ .highlights
+ .iter()
+ .filter_map(|(range, highlight_id)| {
+ Some((range.clone(), *syntax.get(*highlight_id)?))
+ });
+ this.child(
+ StyledText::new(preview.text.clone())
+ .with_default_highlights(&text_style, highlights),
+ )
+ })
+ .into_any_element()
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_preview_truncation_reports_hidden_lines() {
+ let text = (1..=12)
+ .map(|ix| format!("line{ix}"))
+ .collect::>()
+ .join("\n");
+ let preview = InlineInputPreview::Text(text.clone());
+ let (shown, height) = preview.display_text(8);
+ assert_eq!(height, 9);
+ assert_eq!(
+ shown,
+ format!(
+ "{}\n… +4 more lines",
+ text.lines().take(8).collect::>().join("\n")
+ )
+ );
+ let (shown, height) = preview.display_text(11);
+ assert_eq!(height, 12);
+ assert_eq!(shown.lines().last(), Some("… +1 more line"));
+ assert_eq!(preview.display_text(12), (text, 12));
+ }
+}
diff --git a/crates/editor/src/lsp_ext.rs b/crates/editor/src/lsp_ext.rs
index e70f1b1202..8831728674 100644
--- a/crates/editor/src/lsp_ext.rs
+++ b/crates/editor/src/lsp_ext.rs
@@ -151,6 +151,7 @@ pub fn lsp_tasks(
GetLspRunnables {
buffer_id,
position: for_position,
+ server_id,
},
cx,
)
diff --git a/crates/editor/src/navigation.rs b/crates/editor/src/navigation.rs
index 3766811b76..1fc78b3198 100644
--- a/crates/editor/src/navigation.rs
+++ b/crates/editor/src/navigation.rs
@@ -45,6 +45,9 @@ impl Editor {
if self.take_rename(true, window, cx).is_some() {
return;
}
+ if self.cycle_inline_input_history(InlineInputHistoryDirection::Older, window, cx) {
+ return;
+ }
if self.mode.is_single_line() {
cx.propagate();
@@ -262,6 +265,9 @@ impl Editor {
if self.take_rename(true, window, cx).is_some() {
return;
}
+ if self.cycle_inline_input_history(InlineInputHistoryDirection::Newer, window, cx) {
+ return;
+ }
if self.mode.is_single_line() {
cx.propagate();
diff --git a/crates/editor/src/rust_analyzer_ext.rs b/crates/editor/src/rust_analyzer_ext.rs
index ab59586c3a..b65a5082e9 100644
--- a/crates/editor/src/rust_analyzer_ext.rs
+++ b/crates/editor/src/rust_analyzer_ext.rs
@@ -89,6 +89,7 @@ pub fn go_to_parent_module(
project_id,
buffer_id: buffer_id.to_proto(),
position: Some(serialize_anchor(&trigger_anchor)),
+ server_id: server_to_query.to_proto(),
};
let response = client
.request(request)
@@ -112,7 +113,10 @@ pub fn go_to_parent_module(
project.request_lsp(
buffer,
project::LanguageServerToQuery::Other(server_to_query),
- project::lsp_store::lsp_ext_command::GoToParentModule { position },
+ project::lsp_store::lsp_ext_command::GoToParentModule {
+ position,
+ server_id: server_to_query,
+ },
cx,
)
})
@@ -169,6 +173,7 @@ pub fn expand_macro_recursively(
project_id,
buffer_id: buffer_id.to_proto(),
position: Some(serialize_anchor(&trigger_anchor)),
+ server_id: server_to_query.to_proto(),
};
let response = client
.request(request)
@@ -186,7 +191,10 @@ pub fn expand_macro_recursively(
project.request_lsp(
buffer,
project::LanguageServerToQuery::Other(server_to_query),
- ExpandMacro { position },
+ ExpandMacro {
+ position,
+ server_id: server_to_query,
+ },
cx,
)
})
@@ -258,6 +266,7 @@ pub fn open_docs(editor: &mut Editor, _: &OpenDocs, window: &mut Window, cx: &mu
project_id,
buffer_id: buffer_id.to_proto(),
position: Some(serialize_anchor(&trigger_anchor)),
+ server_id: server_to_query.to_proto(),
};
let response = client
.request(request)
@@ -275,7 +284,10 @@ pub fn open_docs(editor: &mut Editor, _: &OpenDocs, window: &mut Window, cx: &mu
project.request_lsp(
buffer,
project::LanguageServerToQuery::Other(server_to_query),
- project::lsp_store::lsp_ext_command::OpenDocs { position },
+ project::lsp_store::lsp_ext_command::OpenDocs {
+ position,
+ server_id: server_to_query,
+ },
cx,
)
})
diff --git a/crates/editor/src/selection.rs b/crates/editor/src/selection.rs
index 11cd7e3826..4c1908a639 100644
--- a/crates/editor/src/selection.rs
+++ b/crates/editor/src/selection.rs
@@ -1600,6 +1600,7 @@ impl Editor {
self.invalidate_autoclose_regions(&selection_anchors, buffer);
self.snippet_stack.invalidate(&selection_anchors, buffer);
self.take_rename(false, window, cx);
+ self.take_inline_input(window, cx);
let newest_selection = self.selections.newest_anchor();
let new_cursor_position = newest_selection.head();
diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs
index e217398faa..cda783fbfb 100644
--- a/crates/editor/src/test.rs
+++ b/crates/editor/src/test.rs
@@ -3,6 +3,7 @@ pub mod editor_test_context;
use std::{rc::Rc, sync::LazyLock};
+pub use crate::emmet_ext::wrap_with_abbreviation;
pub use crate::rust_analyzer_ext::expand_macro_recursively;
use crate::{
DisplayPoint, Editor, EditorMode, FoldPlaceholder, MultiBuffer, SelectionEffects, Size,
diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs
index d734876003..22561ee834 100644
--- a/crates/language/src/language_registry.rs
+++ b/crates/language/src/language_registry.rs
@@ -894,6 +894,20 @@ impl LanguageRegistry {
.unwrap_or_default()
}
+ pub fn lsp_adapter(
+ &self,
+ language_name: &LanguageName,
+ server_name: &LanguageServerName,
+ ) -> Option> {
+ self.state
+ .read()
+ .lsp_adapters
+ .get(language_name)?
+ .iter()
+ .find(|adapter| adapter.name() == *server_name)
+ .cloned()
+ }
+
pub fn all_lsp_adapters(&self) -> Vec> {
self.state
.read()
diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs
index ddc74a4a32..a386162413 100644
--- a/crates/project/src/lsp_command.rs
+++ b/crates/project/src/lsp_command.rs
@@ -6,7 +6,7 @@ use crate::{
InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location,
LocationLink, LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse, ProjectPath,
ProjectTransaction, PulledDiagnostics, ResolveState,
- lsp_store::{LocalLspStore, LspDocumentLink, LspFoldingRange, LspStore},
+ lsp_store::{LanguageServerToQuery, LocalLspStore, LspDocumentLink, LspFoldingRange, LspStore},
};
use anyhow::{Context as _, Result};
use async_trait::async_trait;
@@ -99,6 +99,10 @@ pub trait LspCommand: 'static + Sized + Send + std::fmt::Debug {
None
}
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::FirstCapable
+ }
+
fn to_lsp_params_or_response(
&self,
path: &Path,
diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs
index bd4d53ab54..266d398fd5 100644
--- a/crates/project/src/lsp_store.rs
+++ b/crates/project/src/lsp_store.rs
@@ -15,6 +15,7 @@ mod document_colors;
mod document_links;
mod document_symbols;
mod dynamic_registration;
+pub mod emmet_ext;
mod folding_ranges;
mod inlay_hints;
pub mod json_language_server_ext;
@@ -4605,6 +4606,8 @@ impl LspStore {
client.add_entity_request_handler(Self::handle_lsp_ext_run_flycheck);
client.add_entity_request_handler(Self::handle_lsp_ext_clear_flycheck);
client.add_entity_request_handler(Self::handle_lsp_command::);
+ client
+ .add_entity_request_handler(Self::handle_lsp_command::);
client.add_entity_request_handler(Self::handle_lsp_command::);
client.add_entity_request_handler(
Self::handle_lsp_command::,
@@ -5714,16 +5717,16 @@ impl LspStore {
Ok(LspParamsOrResponse::Params(lsp_params)) => lsp_params,
Ok(LspParamsOrResponse::Response(response)) => return Task::ready(Ok(response)),
Err(err) => {
- let message = format!(
- "{} via {} failed: {}",
+ let err = err.context(format!(
+ "{} via {} failed",
request.display_name(),
language_server.name(),
- err
- );
+ ));
+ let message = format!("{err:#}");
if should_log_lsp_request_failure(&message) {
log::warn!("{message}");
}
- return Task::ready(Err(anyhow!(message)));
+ return Task::ready(Err(err));
}
};
@@ -5775,16 +5778,16 @@ impl LspStore {
let result = lsp_request.await.into_response();
let response = result.map_err(|err| {
- let message = format!(
- "{} via {} failed: {}",
+ let err = err.context(format!(
+ "{} via {} failed",
request.display_name(),
language_server.name(),
- err
- );
+ ));
+ let message = format!("{err:#}");
if should_log_lsp_request_failure(&message) {
log::warn!("{message}");
}
- anyhow::anyhow!(message)
+ err
})?;
request
@@ -9168,6 +9171,13 @@ impl LspStore {
.unwrap_or_default()
}
+ pub fn language_server_ids_for_opened_buffer(
+ &self,
+ buffer_id: BufferId,
+ ) -> Option<&HashSet> {
+ self.as_local()?.buffers_opened_in_servers.get(&buffer_id)
+ }
+
pub fn language_server_for_local_buffer<'a>(
&'a self,
buffer: &'a Buffer,
@@ -9969,14 +9979,10 @@ impl LspStore {
cx.clone(),
)
.await?;
+ let server = request.language_server_to_query();
let response = this
.update(&mut cx, |this, cx| {
- this.request_lsp(
- buffer_handle.clone(),
- LanguageServerToQuery::FirstCapable,
- request,
- cx,
- )
+ this.request_lsp(buffer_handle.clone(), server, request, cx)
})
.await?;
this.update(&mut cx, |this, cx| {
diff --git a/crates/project/src/lsp_store/emmet_ext.rs b/crates/project/src/lsp_store/emmet_ext.rs
new file mode 100644
index 0000000000..93feeeb98c
--- /dev/null
+++ b/crates/project/src/lsp_store/emmet_ext.rs
@@ -0,0 +1,186 @@
+use std::{path::Path, sync::Arc};
+
+use anyhow::Result;
+use async_trait::async_trait;
+use gpui::{App, AsyncApp, Entity};
+use language::Buffer;
+use lsp::{AdapterServerCapabilities, LanguageServer, LanguageServerId};
+use rpc::proto::{self, PeerId};
+use serde::{Deserialize, Serialize};
+use text::BufferId;
+
+use crate::{
+ lsp_command::LspCommand,
+ lsp_store::{LanguageServerToQuery, LspStore},
+};
+
+pub const EMMET_SERVER_NAME: lsp::LanguageServerName =
+ lsp::LanguageServerName::new_static("emmet-language-server");
+
+pub enum LspExpandAbbreviation {}
+
+impl lsp::request::Request for LspExpandAbbreviation {
+ type Params = ExpandAbbreviationParams;
+ type Result = Option;
+ const METHOD: &'static str = "emmet/expandAbbreviation";
+}
+
+#[derive(Deserialize, Serialize, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct ExpandAbbreviationParams {
+ pub abbreviation: String,
+ pub language: String,
+ pub options: ExpandAbbreviationOptions,
+}
+
+#[derive(Deserialize, Serialize, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct ExpandAbbreviationOptions {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub text: Option>,
+ pub options: EmmetOutputOptions,
+}
+
+#[derive(Deserialize, Serialize, Debug, Default, PartialEq, Eq)]
+pub struct EmmetOutputOptions {
+ #[serde(rename = "output.indent")]
+ pub indent: String,
+ #[serde(rename = "output.baseIndent")]
+ pub base_indent: String,
+ #[serde(rename = "comment.enabled", skip_serializing_if = "Option::is_none")]
+ pub comment_enabled: Option,
+ #[serde(rename = "bem.enabled", skip_serializing_if = "Option::is_none")]
+ pub bem_enabled: Option,
+ #[serde(rename = "output.inlineBreak", skip_serializing_if = "Option::is_none")]
+ pub inline_break: Option,
+}
+
+#[derive(Debug)]
+pub struct ExpandAbbreviation {
+ pub abbreviation: String,
+ pub text: Option>,
+ pub language: String,
+ pub server_id: LanguageServerId,
+ pub indent: String,
+ pub base_indent: String,
+ pub comment_filter: bool,
+ pub bem_filter: bool,
+}
+
+#[async_trait(?Send)]
+impl LspCommand for ExpandAbbreviation {
+ type Response = Option;
+ type LspRequest = LspExpandAbbreviation;
+ type ProtoRequest = proto::LspExtExpandAbbreviation;
+
+ fn display_name(&self) -> &str {
+ "Expand Emmet abbreviation"
+ }
+
+ fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool {
+ true
+ }
+
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::Other(self.server_id)
+ }
+
+ fn to_lsp(
+ &self,
+ _: &Path,
+ _: &Buffer,
+ server: &Arc,
+ _: &App,
+ ) -> Result {
+ anyhow::ensure!(
+ server.name() == EMMET_SERVER_NAME,
+ "cannot expand Emmet abbreviations with the {} server",
+ server.name()
+ );
+ let multiline = self.text.as_ref().is_some_and(|text| text.len() > 1);
+ Ok(ExpandAbbreviationParams {
+ abbreviation: self.abbreviation.clone(),
+ language: self.language.clone(),
+ options: ExpandAbbreviationOptions {
+ text: self.text.clone(),
+ options: EmmetOutputOptions {
+ indent: self.indent.clone(),
+ base_indent: self.base_indent.clone(),
+ comment_enabled: self.comment_filter.then_some(true),
+ bem_enabled: self.bem_filter.then_some(true),
+ inline_break: multiline.then_some(1),
+ },
+ },
+ })
+ }
+
+ async fn response_from_lsp(
+ self,
+ message: Option,
+ _: Entity,
+ _: Entity,
+ _: LanguageServerId,
+ _: AsyncApp,
+ ) -> anyhow::Result> {
+ Ok(message.filter(|expansion| !expansion.is_empty()))
+ }
+
+ fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::LspExtExpandAbbreviation {
+ proto::LspExtExpandAbbreviation {
+ project_id,
+ buffer_id: buffer.remote_id().into(),
+ abbreviation: self.abbreviation.clone(),
+ text: self.text.clone().unwrap_or_default(),
+ language: self.language.clone(),
+ server_id: self.server_id.to_proto(),
+ indent: self.indent.clone(),
+ base_indent: self.base_indent.clone(),
+ comment_filter: self.comment_filter,
+ bem_filter: self.bem_filter,
+ }
+ }
+
+ async fn from_proto(
+ message: Self::ProtoRequest,
+ _: Entity,
+ _: Entity,
+ _: AsyncApp,
+ ) -> anyhow::Result {
+ Ok(Self {
+ abbreviation: message.abbreviation,
+ text: (!message.text.is_empty()).then_some(message.text),
+ language: message.language,
+ server_id: LanguageServerId::from_proto(message.server_id),
+ indent: message.indent,
+ base_indent: message.base_indent,
+ comment_filter: message.comment_filter,
+ bem_filter: message.bem_filter,
+ })
+ }
+
+ fn response_to_proto(
+ response: Option,
+ _: &mut LspStore,
+ _: PeerId,
+ _: &clock::Global,
+ _: &mut App,
+ ) -> proto::LspExtExpandAbbreviationResponse {
+ proto::LspExtExpandAbbreviationResponse {
+ expansion: response,
+ }
+ }
+
+ async fn response_from_proto(
+ self,
+ message: proto::LspExtExpandAbbreviationResponse,
+ _: Entity,
+ _: Entity,
+ _: AsyncApp,
+ ) -> anyhow::Result> {
+ Ok(message.expansion.filter(|expansion| !expansion.is_empty()))
+ }
+
+ fn buffer_id_from_proto(message: &proto::LspExtExpandAbbreviation) -> Result {
+ BufferId::new(message.buffer_id)
+ }
+}
diff --git a/crates/project/src/lsp_store/lsp_ext_command.rs b/crates/project/src/lsp_store/lsp_ext_command.rs
index bb994492d0..6e6fd59399 100644
--- a/crates/project/src/lsp_store/lsp_ext_command.rs
+++ b/crates/project/src/lsp_store/lsp_ext_command.rs
@@ -5,7 +5,7 @@ use crate::{
location_link_to_proto, location_links_from_lsp, location_links_from_proto,
location_links_to_proto,
},
- lsp_store::LspStore,
+ lsp_store::{LanguageServerToQuery, LspStore},
make_lsp_text_document_position, make_text_document_identifier,
};
use anyhow::{Context as _, Result};
@@ -56,6 +56,7 @@ impl ExpandedMacro {
#[derive(Debug)]
pub struct ExpandMacro {
pub position: PointUtf16,
+ pub server_id: LanguageServerId,
}
#[async_trait(?Send)]
@@ -72,6 +73,10 @@ impl LspCommand for ExpandMacro {
true
}
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::Other(self.server_id)
+ }
+
fn to_lsp(
&self,
path: &Path,
@@ -108,6 +113,7 @@ impl LspCommand for ExpandMacro {
position: Some(language::proto::serialize_anchor(
&buffer.anchor_before(self.position),
)),
+ server_id: self.server_id.to_proto(),
}
}
@@ -123,6 +129,7 @@ impl LspCommand for ExpandMacro {
.context("invalid position")?;
Ok(Self {
position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)),
+ server_id: LanguageServerId::from_proto(message.server_id),
})
}
@@ -188,6 +195,7 @@ impl DocsUrls {
#[derive(Debug)]
pub struct OpenDocs {
pub position: PointUtf16,
+ pub server_id: LanguageServerId,
}
#[async_trait(?Send)]
@@ -204,6 +212,10 @@ impl LspCommand for OpenDocs {
true
}
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::Other(self.server_id)
+ }
+
fn to_lsp(
&self,
path: &Path,
@@ -242,6 +254,7 @@ impl LspCommand for OpenDocs {
position: Some(language::proto::serialize_anchor(
&buffer.anchor_before(self.position),
)),
+ server_id: self.server_id.to_proto(),
}
}
@@ -257,6 +270,7 @@ impl LspCommand for OpenDocs {
.context("invalid position")?;
Ok(Self {
position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)),
+ server_id: LanguageServerId::from_proto(message.server_id),
})
}
@@ -307,13 +321,15 @@ pub struct SwitchSourceHeaderParams(lsp::TextDocumentIdentifier);
#[serde(rename_all = "camelCase")]
pub struct SwitchSourceHeaderResult(pub String);
-#[derive(Default, Deserialize, Serialize, Debug)]
-#[serde(rename_all = "camelCase")]
-pub struct SwitchSourceHeader;
+#[derive(Debug)]
+pub struct SwitchSourceHeader {
+ pub server_id: LanguageServerId,
+}
#[derive(Debug)]
pub struct GoToParentModule {
pub position: PointUtf16,
+ pub server_id: LanguageServerId,
}
pub struct LspGoToParentModule {}
@@ -338,6 +354,10 @@ impl LspCommand for SwitchSourceHeader {
true
}
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::Other(self.server_id)
+ }
+
fn to_lsp(
&self,
path: &Path,
@@ -367,16 +387,19 @@ impl LspCommand for SwitchSourceHeader {
proto::LspExtSwitchSourceHeader {
project_id,
buffer_id: buffer.remote_id().into(),
+ server_id: self.server_id.to_proto(),
}
}
async fn from_proto(
- _: Self::ProtoRequest,
+ message: Self::ProtoRequest,
_: Entity,
_: Entity,
_: AsyncApp,
) -> anyhow::Result {
- Ok(Self {})
+ Ok(Self {
+ server_id: LanguageServerId::from_proto(message.server_id),
+ })
}
fn response_to_proto(
@@ -420,6 +443,10 @@ impl LspCommand for GoToParentModule {
true
}
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::Other(self.server_id)
+ }
+
fn to_lsp(
&self,
path: &Path,
@@ -455,6 +482,7 @@ impl LspCommand for GoToParentModule {
position: Some(language::proto::serialize_anchor(
&buffer.anchor_before(self.position),
)),
+ server_id: self.server_id.to_proto(),
}
}
@@ -470,6 +498,7 @@ impl LspCommand for GoToParentModule {
.context("bad request with bad position")?;
Ok(Self {
position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)),
+ server_id: LanguageServerId::from_proto(request.server_id),
})
}
@@ -606,6 +635,7 @@ pub struct ShellRunnableArgs {
pub struct GetLspRunnables {
pub buffer_id: BufferId,
pub position: Option,
+ pub server_id: LanguageServerId,
}
#[derive(Debug, Default)]
@@ -677,6 +707,10 @@ impl LspCommand for GetLspRunnables {
true
}
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::Other(self.server_id)
+ }
+
fn to_lsp(
&self,
path: &Path,
@@ -723,6 +757,7 @@ impl LspCommand for GetLspRunnables {
project_id,
buffer_id: buffer.remote_id().to_proto(),
position: self.position.as_ref().map(serialize_anchor),
+ server_id: self.server_id.to_proto(),
}
}
@@ -737,6 +772,7 @@ impl LspCommand for GetLspRunnables {
Ok(Self {
buffer_id,
position,
+ server_id: LanguageServerId::from_proto(message.server_id),
})
}
diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs
index ed6627f66c..186af48708 100644
--- a/crates/project/src/project.rs
+++ b/crates/project/src/project.rs
@@ -6332,14 +6332,16 @@ impl Project {
if !relevant_language_servers.contains(name) {
return None;
}
+ let opened_in_servers = self
+ .lsp_store
+ .read(cx)
+ .language_server_ids_for_opened_buffer(buffer.remote_id());
self.language_server_statuses(cx)
.filter(|(_, server_status)| relevant_language_servers.contains(&server_status.name))
.find_map(|(server_id, server_status)| {
- if &server_status.name == name {
- Some(server_id)
- } else {
- None
- }
+ (&server_status.name == name
+ && opened_in_servers.is_none_or(|server_ids| server_ids.contains(&server_id)))
+ .then_some(server_id)
})
}
diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto
index c07d33857b..cf7080a719 100644
--- a/crates/proto/proto/lsp.proto
+++ b/crates/proto/proto/lsp.proto
@@ -299,6 +299,7 @@ message LspExtExpandMacro {
uint64 project_id = 1;
uint64 buffer_id = 2;
Anchor position = 3;
+ uint64 server_id = 4;
}
message LspExtExpandMacroResponse {
@@ -306,10 +307,28 @@ message LspExtExpandMacroResponse {
string expansion = 2;
}
+message LspExtExpandAbbreviation {
+ uint64 project_id = 1;
+ uint64 buffer_id = 2;
+ string abbreviation = 3;
+ repeated string text = 4;
+ string language = 5;
+ uint64 server_id = 6;
+ bool comment_filter = 7;
+ bool bem_filter = 8;
+ string indent = 9;
+ string base_indent = 10;
+}
+
+message LspExtExpandAbbreviationResponse {
+ optional string expansion = 1;
+}
+
message LspExtOpenDocs {
uint64 project_id = 1;
uint64 buffer_id = 2;
Anchor position = 3;
+ uint64 server_id = 4;
}
message LspExtOpenDocsResponse {
@@ -320,6 +339,7 @@ message LspExtOpenDocsResponse {
message LspExtSwitchSourceHeader {
uint64 project_id = 1;
uint64 buffer_id = 2;
+ uint64 server_id = 3;
}
message LspExtSwitchSourceHeaderResponse {
@@ -330,6 +350,7 @@ message LspExtGoToParentModule {
uint64 project_id = 1;
uint64 buffer_id = 2;
Anchor position = 3;
+ uint64 server_id = 4;
}
message LspExtGoToParentModuleResponse {
@@ -1068,6 +1089,7 @@ message LspExtRunnables {
uint64 project_id = 1;
uint64 buffer_id = 2;
optional Anchor position = 3;
+ uint64 server_id = 4;
}
message LspExtRunnablesResponse {
diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto
index c9cf63b4ba..e89edf3cde 100644
--- a/crates/proto/proto/zed.proto
+++ b/crates/proto/proto/zed.proto
@@ -516,7 +516,9 @@ message Envelope {
GetIncomingCalls get_incoming_calls = 484;
GetIncomingCallsResponse get_incoming_calls_response = 485;
GetOutgoingCalls get_outgoing_calls = 486;
- GetOutgoingCallsResponse get_outgoing_calls_response = 487; // current max
+ GetOutgoingCallsResponse get_outgoing_calls_response = 487;
+ LspExtExpandAbbreviation lsp_ext_expand_abbreviation = 488;
+ LspExtExpandAbbreviationResponse lsp_ext_expand_abbreviation_response = 489; // current max
}
reserved 87 to 88;
diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs
index e851c933cd..103ec4f1f9 100644
--- a/crates/proto/src/proto.rs
+++ b/crates/proto/src/proto.rs
@@ -176,6 +176,8 @@ messages!(
(LoadCommitDiffResponse, Foreground),
(LspExtExpandMacro, Background),
(LspExtExpandMacroResponse, Background),
+ (LspExtExpandAbbreviation, Background),
+ (LspExtExpandAbbreviationResponse, Background),
(LspExtOpenDocs, Background),
(LspExtOpenDocsResponse, Background),
(LspExtRunnables, Background),
@@ -547,6 +549,7 @@ request_messages!(
(UpdateRepository, Ack),
(RemoveRepository, Ack),
(LspExtExpandMacro, LspExtExpandMacroResponse),
+ (LspExtExpandAbbreviation, LspExtExpandAbbreviationResponse),
(LspExtOpenDocs, LspExtOpenDocsResponse),
(LspExtRunnables, LspExtRunnablesResponse),
(SetRoomParticipantRole, Ack),
@@ -784,6 +787,7 @@ entity_messages!(
UpdateWorktreeSettings,
UpdateUserSettings,
LspExtExpandMacro,
+ LspExtExpandAbbreviation,
LspExtOpenDocs,
LspExtRunnables,
LspExtSwitchSourceHeader,
diff --git a/docs/src/languages/emmet.md b/docs/src/languages/emmet.md
index 1ef6a1dea9..b78ca96aa9 100644
--- a/docs/src/languages/emmet.md
+++ b/docs/src/languages/emmet.md
@@ -11,6 +11,39 @@ Emmet support is available through the [Emmet extension](https://github.com/zed-
- Language Server: [olrtg/emmet-language-server](https://github.com/olrtg/emmet-language-server)
+## Wrap with abbreviation
+
+With the Emmet extension installed, you can wrap the current selections in an expanded [Emmet abbreviation](https://docs.emmet.io/abbreviations/):
+
+1. Select the text to wrap. With an empty selection, the enclosing HTML element is wrapped; if no enclosing element can be determined, the current line is wrapped instead.
+2. Run `editor: wrap with abbreviation` from the command palette. The last used abbreviation is prefilled and selected, so typing replaces it; `up` and `down` cycle through previously used abbreviations.
+3. Type an abbreviation, for example `div.wrapper>ul>li*3`, into the input that appears below the selection. A preview of the expansion is shown under the input as you type.
+4. Press `enter` to apply, or `escape` to cancel. If the abbreviation produces no expansion, the error is shown next to the input and the input stays open so you can correct it.
+
+For a single selection, the expansion is inserted as a snippet and the cursor is placed at the end of the expansion. With multiple selections, each selection is wrapped separately; overlapping selections are merged and wrapped once.
+
+The [documented wrap behaviors](https://docs.emmet.io/actions/wrap-with-abbreviation/) are supported:
+
+- Wrapping individual lines: mark the repeating element with `*` (without a number), for example `ul>li*`, and each selected line is wrapped in its own element. `$` numbering works too: `ul>li.item$*>a`.
+- Removing list markers: append the trim filter `|t`, for example `ul>li*|t`, to strip list markers such as `*`, `-`, or `1.` from the wrapped lines.
+- Controlling output position: use the `$#` placeholder inside attribute values or text nodes, for example `ul>li[title=$#]*>{$#}`.
+- The [comment filter](https://docs.emmet.io/filters/comment/) `|c` and the [BEM filter](https://docs.emmet.io/filters/bem/) `|bem` are also supported and can be combined, for example `div#page>p.intro|c|bem`.
+
+Selections that partially cover an opening or closing tag are expanded to the whole element before wrapping. Wrapping is a markup action: it is not offered in stylesheets, where Emmet provides completions instead.
+
+The action has no default keybinding. To add one, bind it in your keymap:
+
+```json [keymap]
+[
+ {
+ "context": "Editor && mode == full",
+ "bindings": {
+ "ctrl-alt-w": "editor::WrapWithAbbreviation"
+ }
+ }
+]
+```
+
From 6b5e15ed463cdcf28b0d1d49c1a681ad044e308b Mon Sep 17 00:00:00 2001
From: Kirill Bulatov
Date: Wed, 2 Sep 2026 18:21:25 +0000
Subject: [PATCH 015/283] Bump more dependencies (#63379)
* 1st commit fixes bumps `quinn-proto` and `serde_with` to fix the
dependabot alerts
* 2nd commit deduplicates more dependencies
`cargo tree -d` listing: 194 -> 187 top-level entries on the host
target, 298 -> 287 unique duplicated crate-versions across all targets.
Deduplicated list
```
accesskit_consumer (v0.35.0, v0.37.0 -> v0.38.0)
bindgen (v0.71.1, v0.72.1 -> v0.72.1)
nix (v0.28.0, v0.29.0, v0.30.1 -> v0.28.0, v0.30.1)
ordered-float (v2.10.1, v4.6.0 -> v5.5.0)
quick-xml (v0.30.0, v0.37.5, v0.38.3, v0.39.3, v0.41.0 -> v0.30.0, v0.37.5, v0.39.3, v0.41.0)
sysinfo (v0.31.4, v0.37.2 -> v0.31.4, v0.39.6)
wasm-encoder (v0.252.0, v0.254.0 -> v0.254.0)
wasmparser (v0.252.0, v0.254.0 -> v0.254.0)
windows-registry (v0.4.0, v0.5.3, v0.6.1 -> v0.4.0, v0.6.1)
```
`ordered-float v4.6.0` remains in the lockfile only as an inactive
optional dependency of `sea-query`; the compiled graph is fully
deduplicated to v5.5.0.
Release Notes:
- N/A
---
Cargo.lock | 338 ++++++++----------
Cargo.toml | 22 +-
crates/etw_tracing/Cargo.toml | 7 +-
crates/explorer_command_injector/Cargo.toml | 2 +-
crates/gpui/Cargo.toml | 2 +-
crates/gpui_windows/Cargo.toml | 6 +-
crates/media/Cargo.toml | 2 +-
crates/mermaid_render/src/postprocess.rs | 4 +-
.../src/postprocess/accent_colors.rs | 15 +-
.../accent_colors/class_diagram.rs | 3 +-
.../src/postprocess/accent_colors/mindmap.rs | 3 +-
.../accent_colors/sequence_diagram.rs | 5 +-
.../src/postprocess/element_fixup.rs | 9 +-
.../src/postprocess/strip_foreignobject.rs | 4 +-
.../tests/check_invalid_attrs.rs | 17 +-
crates/sandbox/src/linux_bubblewrap.rs | 6 +-
crates/sandbox/src/sandbox.rs | 8 +-
crates/sandbox/src/util/canonical_path.rs | 8 +-
18 files changed, 221 insertions(+), 240 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 92fda85101..729a0aecac 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -14,12 +14,12 @@ dependencies = [
[[package]]
name = "accesskit_atspi_common"
-version = "0.18.0"
+version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "842fd8203e6dfcf531d24f5bac792088edfba7d6b35844fead191603fb32a260"
+checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636"
dependencies = [
"accesskit",
- "accesskit_consumer 0.35.0",
+ "accesskit_consumer",
"atspi-common",
"phf 0.13.1",
"serde",
@@ -28,19 +28,9 @@ dependencies = [
[[package]]
name = "accesskit_consumer"
-version = "0.35.0"
+version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
-dependencies = [
- "accesskit",
- "hashbrown 0.16.1",
-]
-
-[[package]]
-name = "accesskit_consumer"
-version = "0.37.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb"
+checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
@@ -48,12 +38,12 @@ dependencies = [
[[package]]
name = "accesskit_macos"
-version = "0.26.0"
+version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "534bc3fdc89a64a1db3c46b33c198fde2b7c3c7d094e5809c8c8bf2970c18243"
+checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
dependencies = [
"accesskit",
- "accesskit_consumer 0.35.0",
+ "accesskit_consumer",
"hashbrown 0.16.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
@@ -62,9 +52,9 @@ dependencies = [
[[package]]
name = "accesskit_unix"
-version = "0.21.0"
+version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90e549dd7c6562b6a2ea807b44726e6241707db054a817dc4c7e2b8d3b39bfac"
+checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f"
dependencies = [
"accesskit",
"accesskit_atspi_common",
@@ -80,12 +70,12 @@ dependencies = [
[[package]]
name = "accesskit_windows"
-version = "0.33.1"
+version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2"
+checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1"
dependencies = [
"accesskit",
- "accesskit_consumer 0.37.0",
+ "accesskit_consumer",
"hashbrown 0.16.1",
"static_assertions",
"windows 0.62.2",
@@ -293,7 +283,7 @@ dependencies = [
"project",
"prompt_store",
"proptest",
- "quick-xml 0.38.3",
+ "quick-xml 0.41.0",
"rand 0.9.4",
"regex",
"release_channel",
@@ -516,7 +506,7 @@ dependencies = [
"menu",
"multi_buffer",
"notifications",
- "ordered-float 2.10.1",
+ "ordered-float 5.5.0",
"parking_lot",
"paths",
"picker",
@@ -935,7 +925,7 @@ dependencies = [
"tempfile",
"util",
"which",
- "windows 0.61.3",
+ "windows 0.62.2",
"zeroize",
]
@@ -1386,7 +1376,7 @@ dependencies = [
"ctor",
"db",
"futures 0.3.32",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"gpui",
"http_client",
"log",
@@ -1414,7 +1404,7 @@ dependencies = [
"scopeguard",
"simplelog",
"tempfile",
- "windows 0.61.3",
+ "windows 0.62.2",
"windows_resources",
]
@@ -2187,26 +2177,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "bindgen"
-version = "0.71.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
-dependencies = [
- "bitflags 2.13.1",
- "cexpr",
- "clang-sys",
- "itertools 0.11.0",
- "log",
- "prettyplease",
- "proc-macro2",
- "quote",
- "regex",
- "rustc-hash 2.1.1",
- "shlex 1.3.0",
- "syn 2.0.117",
-]
-
[[package]]
name = "bindgen"
version = "0.72.1"
@@ -2217,6 +2187,8 @@ dependencies = [
"cexpr",
"clang-sys",
"itertools 0.11.0",
+ "log",
+ "prettyplease",
"proc-macro2",
"quote",
"regex",
@@ -3139,7 +3111,7 @@ dependencies = [
"tempfile",
"util",
"walkdir",
- "windows 0.61.3",
+ "windows 0.62.2",
"windows_resources",
]
@@ -3194,7 +3166,7 @@ dependencies = [
"tokio-rustls 0.26.4",
"url",
"util",
- "windows 0.61.3",
+ "windows 0.62.2",
"worktree",
"zed_credentials_provider",
]
@@ -3754,7 +3726,7 @@ dependencies = [
"base64 0.22.1",
"collections",
"futures 0.3.32",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"gpui",
"http_client",
"log",
@@ -4050,7 +4022,7 @@ version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
dependencies = [
- "bindgen 0.72.1",
+ "bindgen",
]
[[package]]
@@ -4318,7 +4290,7 @@ dependencies = [
"serde",
"serde_json",
"system_specs",
- "windows 0.61.3",
+ "windows 0.62.2",
"zstd",
]
@@ -4923,7 +4895,7 @@ dependencies = [
"serde_json",
"serde_json_lenient",
"settings",
- "sysinfo 0.37.2",
+ "sysinfo 0.39.6",
"task",
"tasks_ui",
"terminal",
@@ -5085,7 +5057,7 @@ dependencies = [
"collections",
"ctor",
"editor",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"gpui",
"indoc",
"itertools 0.14.0",
@@ -5606,7 +5578,7 @@ dependencies = [
"file_icons",
"fs",
"futures 0.3.32",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"fuzzy",
"git",
"gpui",
@@ -5621,7 +5593,7 @@ dependencies = [
"markdown",
"menu",
"multi_buffer",
- "ordered-float 2.10.1",
+ "ordered-float 5.5.0",
"parking_lot",
"pretty_assertions",
"project",
@@ -6111,9 +6083,9 @@ dependencies = [
name = "explorer_command_injector"
version = "0.1.0"
dependencies = [
- "windows 0.61.3",
- "windows-core 0.61.2",
- "windows-registry 0.5.3",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
+ "windows-registry 0.6.1",
]
[[package]]
@@ -6168,8 +6140,8 @@ dependencies = [
"tracing",
"url",
"util",
- "wasm-encoder 0.252.0",
- "wasmparser 0.252.0",
+ "wasm-encoder 0.254.0",
+ "wasmparser 0.254.0",
"which",
"ztracing",
]
@@ -6250,7 +6222,7 @@ dependencies = [
"tracing",
"url",
"util",
- "wasmparser 0.252.0",
+ "wasmparser 0.254.0",
"wasmtime",
"wasmtime-wasi",
"zlog",
@@ -6732,7 +6704,7 @@ dependencies = [
"trash",
"unicode-normalization",
"util",
- "windows 0.61.3",
+ "windows 0.62.2",
]
[[package]]
@@ -6975,7 +6947,7 @@ dependencies = [
"itertools 0.10.5",
"num-traits",
"rand 0.8.6",
- "rand_pcg",
+ "rand_pcg 0.3.1",
"random_choice",
"rayon",
"seahash",
@@ -6988,16 +6960,17 @@ dependencies = [
[[package]]
name = "generator"
-version = "0.8.7"
+version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2"
+checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
dependencies = [
"cc",
"cfg-if",
"libc",
"log",
"rustversion",
- "windows 0.61.3",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
]
[[package]]
@@ -7054,11 +7027,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasip2",
"wasip3",
+ "wasm-bindgen",
]
[[package]]
@@ -7207,7 +7182,7 @@ dependencies = [
"file_icons",
"fs",
"futures 0.3.32",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"fuzzy",
"fuzzy_nucleo",
"git",
@@ -7237,7 +7212,7 @@ dependencies = [
"settings",
"smallvec",
"strum 0.28.0",
- "sysinfo 0.37.2",
+ "sysinfo 0.39.6",
"task",
"telemetry",
"terminal",
@@ -7508,7 +7483,7 @@ dependencies = [
"async-channel 2.5.0",
"async-task",
"backtrace",
- "bindgen 0.71.1",
+ "bindgen",
"bitflags 2.13.1",
"chrono",
"collections",
@@ -7574,7 +7549,7 @@ dependencies = [
"wasm-bindgen",
"web-sys",
"web-time",
- "windows 0.61.3",
+ "windows 0.62.2",
"zed-font-kit",
"zed-scap",
"ztracing",
@@ -7814,10 +7789,10 @@ dependencies = [
"raw-window-handle",
"smallvec",
"uuid",
- "windows 0.61.3",
- "windows-core 0.61.2",
- "windows-numerics 0.2.0",
- "windows-registry 0.5.3",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
+ "windows-numerics 0.3.1",
+ "windows-registry 0.6.1",
"zed-scap",
]
@@ -8485,7 +8460,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.6.3",
+ "socket2 0.6.5",
"tokio",
"tower-service",
"tracing",
@@ -8503,7 +8478,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.62.2",
+ "windows-core 0.57.0",
]
[[package]]
@@ -9107,10 +9082,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49"
dependencies = [
"jiff-static",
+ "jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -9124,6 +9101,21 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
[[package]]
name = "jni"
version = "0.21.1"
@@ -9497,7 +9489,7 @@ dependencies = [
"encoding_rs",
"fs",
"futures 0.3.32",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"fuzzy_nucleo",
"globset",
"gpui",
@@ -9785,7 +9777,7 @@ dependencies = [
"semver",
"serde_json",
"settings",
- "sysinfo 0.37.2",
+ "sysinfo 0.39.6",
"telemetry",
"theme",
"theme_settings",
@@ -10396,7 +10388,7 @@ dependencies = [
"collections",
"ctor",
"futures 0.3.32",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"gpui",
"gpui_util",
"log",
@@ -10730,7 +10722,7 @@ name = "media"
version = "0.1.0"
dependencies = [
"anyhow",
- "bindgen 0.71.1",
+ "bindgen",
"core-foundation 0.10.0",
"core-video",
"foreign-types 0.5.0",
@@ -10808,7 +10800,7 @@ dependencies = [
"gpui",
"mermaid_render",
"merman",
- "quick-xml 0.38.3",
+ "quick-xml 0.41.0",
"serde_json",
"tracing",
"usvg",
@@ -11152,7 +11144,7 @@ dependencies = [
"clock",
"collections",
"ctor",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"gpui",
"indoc",
"itertools 0.14.0",
@@ -11287,7 +11279,7 @@ dependencies = [
"async-io",
"smol",
"tempfile",
- "windows 0.61.3",
+ "windows 0.62.2",
]
[[package]]
@@ -11308,19 +11300,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "nix"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
-dependencies = [
- "bitflags 2.13.1",
- "cfg-if",
- "cfg_aliases 0.2.1",
- "libc",
- "memoffset",
-]
-
[[package]]
name = "nix"
version = "0.30.1"
@@ -11331,6 +11310,7 @@ dependencies = [
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
+ "memoffset",
]
[[package]]
@@ -11398,7 +11378,7 @@ dependencies = [
"channel",
"client",
"component",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"gpui",
"rpc",
"sum_tree",
@@ -11972,6 +11952,17 @@ dependencies = [
"objc2-foundation 0.3.2",
]
+[[package]]
+name = "objc2-open-directory"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d"
+dependencies = [
+ "objc2 0.6.3",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+]
+
[[package]]
name = "objc2-quartz-core"
version = "0.2.2"
@@ -12370,18 +12361,18 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-float"
-version = "2.10.1"
+version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
+checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
dependencies = [
"num-traits",
]
[[package]]
name = "ordered-float"
-version = "4.6.0"
+version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
+checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
dependencies = [
"num-traits",
]
@@ -13506,20 +13497,20 @@ dependencies = [
"theme",
"theme_settings",
"ui",
- "windows 0.61.3",
+ "windows 0.62.2",
"workspace",
"zed_actions",
]
[[package]]
name = "plist"
-version = "1.8.0"
+version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
- "quick-xml 0.38.3",
+ "quick-xml 0.41.0",
"serde",
"time",
]
@@ -14029,7 +14020,7 @@ dependencies = [
"gpui",
"language",
"lsp",
- "ordered-float 2.10.1",
+ "ordered-float 5.5.0",
"picker",
"picker_preview",
"project",
@@ -14403,15 +14394,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "quick-xml"
-version = "0.38.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89"
-dependencies = [
- "memchr",
-]
-
[[package]]
name = "quick-xml"
version = "0.39.3"
@@ -14444,7 +14426,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.40",
- "socket2 0.6.3",
+ "socket2 0.6.5",
"thiserror 2.0.17",
"tokio",
"tracing",
@@ -14453,14 +14435,15 @@ dependencies = [
[[package]]
name = "quinn-proto"
-version = "0.11.14"
+version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"bytes 1.11.1",
- "getrandom 0.3.4",
+ "getrandom 0.4.1",
"lru-slab",
- "rand 0.9.4",
+ "rand 0.10.2",
+ "rand_pcg 0.10.2",
"ring",
"rustc-hash 2.1.1",
"rustls 0.23.40",
@@ -14481,7 +14464,7 @@ dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
- "socket2 0.6.3",
+ "socket2 0.6.5",
"tracing",
"windows-sys 0.60.2",
]
@@ -14640,6 +14623,15 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "rand_xorshift"
version = "0.4.0"
@@ -14820,7 +14812,7 @@ dependencies = [
"menu",
"node_runtime",
"open_path_prompt",
- "ordered-float 2.10.1",
+ "ordered-float 5.5.0",
"paths",
"picker",
"project",
@@ -15083,7 +15075,7 @@ dependencies = [
"settings",
"shellexpand",
"smol",
- "sysinfo 0.37.2",
+ "sysinfo 0.39.6",
"task",
"telemetry",
"tempfile",
@@ -15095,7 +15087,7 @@ dependencies = [
"util",
"uuid",
"watch",
- "windows 0.61.3",
+ "windows 0.62.2",
"workspace",
"worktree",
"zlog",
@@ -15830,7 +15822,7 @@ dependencies = [
"http_proxy",
"libc",
"log",
- "nix 0.29.0",
+ "nix 0.30.1",
"seccompiler",
"serde",
"serde_json",
@@ -16366,9 +16358,9 @@ dependencies = [
[[package]]
name = "serde_with"
-version = "3.20.0"
+version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
+checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
dependencies = [
"base64 0.22.1",
"bs58",
@@ -16376,6 +16368,7 @@ dependencies = [
"hex",
"indexmap 1.9.3",
"indexmap 2.14.0",
+ "jiff",
"schemars 0.9.0",
"schemars 1.0.4",
"serde_core",
@@ -16386,9 +16379,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
-version = "3.20.0"
+version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
+checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
dependencies = [
"darling 0.23.0",
"proc-macro2",
@@ -17045,9 +17038,9 @@ dependencies = [
[[package]]
name = "socket2"
-version = "0.6.3"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
@@ -17412,7 +17405,7 @@ name = "streaming_diff"
version = "0.1.0"
dependencies = [
"criterion",
- "ordered-float 2.10.1",
+ "ordered-float 5.5.0",
"rand 0.9.4",
"rope",
"util",
@@ -17896,16 +17889,17 @@ dependencies = [
[[package]]
name = "sysinfo"
-version = "0.37.2"
+version = "0.39.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f"
+checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
- "windows 0.61.3",
+ "objc2-open-directory",
+ "windows 0.62.2",
]
[[package]]
@@ -17974,7 +17968,7 @@ dependencies = [
"release_channel",
"semver",
"serde",
- "sysinfo 0.37.2",
+ "sysinfo 0.39.6",
]
[[package]]
@@ -18149,12 +18143,12 @@ dependencies = [
[[package]]
name = "tempfile"
-version = "3.23.0"
+version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand 2.3.0",
- "getrandom 0.3.4",
+ "getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -18187,7 +18181,7 @@ dependencies = [
"async-channel 2.5.0",
"collections",
"futures 0.3.32",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"gpui",
"itertools 0.14.0",
"libc",
@@ -18200,7 +18194,7 @@ dependencies = [
"schemars 1.0.4",
"serde",
"settings",
- "sysinfo 0.37.2",
+ "sysinfo 0.39.6",
"task",
"theme",
"theme_settings",
@@ -18210,7 +18204,7 @@ dependencies = [
"util",
"util_macros",
"vte",
- "windows 0.61.3",
+ "windows 0.62.2",
]
[[package]]
@@ -18483,7 +18477,7 @@ dependencies = [
"core-foundation-sys",
"sys-locale",
"time",
- "windows 0.61.3",
+ "windows 0.62.2",
]
[[package]]
@@ -18620,7 +18614,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
- "socket2 0.6.3",
+ "socket2 0.6.5",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -19068,7 +19062,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]]
@@ -19717,14 +19711,14 @@ dependencies = [
"dirs",
"dunce",
"futures 0.3.32",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"globset",
"gpui_util",
"itertools 0.14.0",
"libc",
"log",
"mach2 0.5.0",
- "nix 0.29.0",
+ "nix 0.30.1",
"path",
"percent-encoding",
"rand 0.9.4",
@@ -19744,7 +19738,7 @@ dependencies = [
"util_macros",
"walkdir",
"which",
- "windows 0.61.3",
+ "windows 0.62.2",
]
[[package]]
@@ -20137,16 +20131,6 @@ dependencies = [
"wasmparser 0.244.0",
]
-[[package]]
-name = "wasm-encoder"
-version = "0.252.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f"
-dependencies = [
- "leb128fmt",
- "wasmparser 0.252.0",
-]
-
[[package]]
name = "wasm-encoder"
version = "0.254.0"
@@ -20275,19 +20259,6 @@ dependencies = [
"semver",
]
-[[package]]
-name = "wasmparser"
-version = "0.252.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c"
-dependencies = [
- "bitflags 2.13.1",
- "hashbrown 0.17.1",
- "indexmap 2.14.0",
- "semver",
- "serde",
-]
-
[[package]]
name = "wasmparser"
version = "0.254.0"
@@ -20963,7 +20934,7 @@ dependencies = [
"objc2-metal 0.3.2",
"objc2-quartz-core 0.3.2",
"once_cell",
- "ordered-float 4.6.0",
+ "ordered-float 5.5.0",
"parking_lot",
"portable-atomic",
"portable-atomic-util",
@@ -21425,17 +21396,6 @@ dependencies = [
"windows-targets 0.53.5",
]
-[[package]]
-name = "windows-registry"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
-dependencies = [
- "windows-link 0.1.3",
- "windows-result 0.3.4",
- "windows-strings 0.4.2",
-]
-
[[package]]
name = "windows-registry"
version = "0.6.1"
@@ -22280,7 +22240,7 @@ dependencies = [
"dirs",
"fs",
"futures 0.3.32",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"git",
"gpui",
"http_client",
@@ -22331,7 +22291,7 @@ dependencies = [
"encoding_rs",
"fs",
"futures 0.3.32",
- "futures-lite 1.13.0",
+ "futures-lite 2.6.1",
"fuzzy",
"git",
"gpui",
@@ -22894,7 +22854,7 @@ dependencies = [
"snippets_ui",
"spin 0.10.0",
"svg_preview",
- "sysinfo 0.37.2",
+ "sysinfo 0.39.6",
"system_specs",
"tab_switcher",
"tabular_data_preview",
@@ -22924,7 +22884,7 @@ dependencies = [
"web_search",
"web_search_providers",
"which_key",
- "windows 0.61.3",
+ "windows 0.62.2",
"windows_resources",
"workspace",
"zed-reqwest",
diff --git a/Cargo.toml b/Cargo.toml
index 6e4907333e..204959209c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -516,8 +516,8 @@ ztracing_macro = { path = "crates/ztracing_macro" }
accesskit = { version = "0.24.0", features = ["enumn"] }
accesskit_macos = "0.26.0"
-accesskit_unix = "0.21.0"
-accesskit_windows = "0.33.1"
+accesskit_unix = "0.22"
+accesskit_windows = "0.34"
agent-client-protocol = { version = "=2.0.0", features = ["unstable"] }
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" }
@@ -624,7 +624,7 @@ foreign-types = "0.5"
fork = "0.4.0"
futures = "0.3.32"
futures-concurrency = "7.7.1"
-futures-lite = "1.13"
+futures-lite = "2"
gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" }
globset = "0.4"
handlebars = "4.3"
@@ -686,7 +686,7 @@ minidumper = "0.11"
moka = { version = "0.12.10", features = ["sync"] }
nanoid = "0.4"
nbformat = "1.2.0"
-nix = "0.29"
+nix = "0.30"
notify-rust = "4"
nucleo = "0.5"
num-format = "0.4.4"
@@ -732,7 +732,7 @@ objc2-foundation = { version = "=0.3.2", default-features = false, features = [
] }
objc2-user-notifications = "0.3"
open = "5.0.0"
-ordered-float = "2.1.1"
+ordered-float = "5"
palette = { version = "0.7.5", default-features = false, features = ["std"] }
parking_lot = "0.12.1"
parse_int = "0.9"
@@ -763,7 +763,7 @@ prost-types = "0.14"
protox = "0.9"
proxyvars = "0.2"
pulldown-cmark = { version = "0.13.0", default-features = false }
-quick-xml = "0.38"
+quick-xml = "0.41"
quote = "1.0.9"
rand = "0.9"
raw-window-handle = "0.6"
@@ -824,7 +824,7 @@ strum = { version = "0.28", features = ["derive"] }
swash = "0.2.6"
syn = { version = "2.0.101", features = ["full", "extra-traits", "visit-mut"] }
sys-locale = "0.3.1"
-sysinfo = "0.37.0"
+sysinfo = "0.39"
take-until = "0.2.0"
tempfile = "3.20.0"
thiserror = "2.0.12"
@@ -883,8 +883,8 @@ usvg = { version = "0.46.0", default-features = false }
uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] }
vte = { version = "0.15.0", features = ["ansi"] }
walkdir = "2.5"
-wasm-encoder = "0.252"
-wasmparser = "0.252"
+wasm-encoder = "0.254"
+wasmparser = "0.254"
wasmtime = { version = "48", default-features = false, features = [
"anyhow",
"async",
@@ -904,7 +904,7 @@ webpki-roots = "1.0.8"
webrtc-sys = "0.3.23"
wgpu = "29.0.4"
which = "8"
-windows-core = "0.61"
+windows-core = "0.62"
windows-registry = "0.6.0"
yaml-rust2 = "0.8"
yawc = { git = "https://github.com/zed-industries/yawc", rev = "71a452f551cac178367eaac5d7418a09afa1f3a2", version = "0.3.3" }
@@ -912,7 +912,7 @@ zeroize = "1.8"
zstd = "0.11"
[workspace.dependencies.windows]
-version = "0.61"
+version = "0.62"
features = [
"Data_Xml_Dom",
"Foundation_Numerics",
diff --git a/crates/etw_tracing/Cargo.toml b/crates/etw_tracing/Cargo.toml
index ce57b9e467..639b1d6c4b 100644
--- a/crates/etw_tracing/Cargo.toml
+++ b/crates/etw_tracing/Cargo.toml
@@ -20,4 +20,9 @@ workspace.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
wprcontrol = { git = "https://github.com/zed-industries/wprcontrol", rev = "cd811f7" }
windows-core = "0.61"
-windows.workspace = true
+windows = { version = "0.61", features = [
+ "Win32_Foundation",
+ "Win32_System_Com",
+ "Win32_UI_Shell",
+ "Win32_UI_WindowsAndMessaging",
+] }
diff --git a/crates/explorer_command_injector/Cargo.toml b/crates/explorer_command_injector/Cargo.toml
index 8530329358..44471b7f37 100644
--- a/crates/explorer_command_injector/Cargo.toml
+++ b/crates/explorer_command_injector/Cargo.toml
@@ -22,6 +22,6 @@ nightly = []
[target.'cfg(target_os = "windows")'.dependencies]
windows.workspace = true
windows-core.workspace = true
-windows-registry = "0.5"
+windows-registry.workspace = true
[dependencies]
diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml
index 025107fe53..336e3c0035 100644
--- a/crates/gpui/Cargo.toml
+++ b/crates/gpui/Cargo.toml
@@ -155,7 +155,7 @@ web-sys = { version = "0.3", features = ["console"] }
embed-resource = { version = "3.0", optional = true }
[target.'cfg(target_os = "macos")'.build-dependencies]
-bindgen = "0.71"
+bindgen = "0.72"
[package.metadata.cargo-shear]
ignored = [
diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml
index 823e4d1d62..76f13ad7a4 100644
--- a/crates/gpui_windows/Cargo.toml
+++ b/crates/gpui_windows/Cargo.toml
@@ -38,15 +38,15 @@ smallvec.workspace = true
uuid.workspace = true
windows.workspace = true
windows-core.workspace = true
-windows-numerics = "0.2"
-windows-registry = "0.5"
+windows-numerics = "0.3"
+windows-registry.workspace = true
[target.'cfg(target_os = "windows")'.dependencies.scap]
workspace = true
optional = true
[target.'cfg(target_os = "windows")'.build-dependencies]
-windows-registry = "0.5"
+windows-registry.workspace = true
[package.metadata.cargo-shear]
ignored = ["scap"]
diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml
index 330fc26a06..908dd86368 100644
--- a/crates/media/Cargo.toml
+++ b/crates/media/Cargo.toml
@@ -24,4 +24,4 @@ core-video.workspace = true
objc.workspace = true
[build-dependencies]
-bindgen = "0.71"
+bindgen = "0.72"
diff --git a/crates/mermaid_render/src/postprocess.rs b/crates/mermaid_render/src/postprocess.rs
index 1f3b818c47..bc24fd70e3 100644
--- a/crates/mermaid_render/src/postprocess.rs
+++ b/crates/mermaid_render/src/postprocess.rs
@@ -18,8 +18,8 @@ mod strip_foreignobject;
pub(crate) mod util;
use anyhow::{Context as _, Result};
-use quick_xml::Reader;
use quick_xml::events::Event;
+use quick_xml::{Reader, XmlVersion};
use crate::MermaidTheme;
@@ -60,7 +60,7 @@ fn extract_svg_id(svg: &str) -> String {
.try_get_attribute("id")
.ok()
.flatten()
- .and_then(|a| a.unescape_value().ok())
+ .and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok())
.map(|v| v.into_owned())
.unwrap_or_default();
}
diff --git a/crates/mermaid_render/src/postprocess/accent_colors.rs b/crates/mermaid_render/src/postprocess/accent_colors.rs
index 2145f447c9..6cd72acea8 100644
--- a/crates/mermaid_render/src/postprocess/accent_colors.rs
+++ b/crates/mermaid_render/src/postprocess/accent_colors.rs
@@ -10,6 +10,7 @@ mod mindmap;
mod sequence_diagram;
use anyhow::Result;
+use quick_xml::XmlVersion;
use quick_xml::events::{BytesStart, Event};
use crate::MermaidTheme;
@@ -59,7 +60,7 @@ impl NodeTracker {
pub(crate) fn parse_translate(e: &BytesStart<'_>) -> Option<(f64, f64)> {
let attr = e.try_get_attribute("transform").ok()??;
- let val = attr.unescape_value().ok()?;
+ let val = attr.normalized_value(XmlVersion::Implicit1_0).ok()?;
let inner = val.strip_prefix("translate(")?.strip_suffix(')')?;
let (x_str, y_str) = inner.split_once(',')?;
Some((x_str.trim().parse().ok()?, y_str.trim().parse().ok()?))
@@ -67,7 +68,7 @@ pub(crate) fn parse_translate(e: &BytesStart<'_>) -> Option<(f64, f64)> {
pub(crate) fn parse_path_half_height(e: &BytesStart<'_>) -> Option {
let attr = e.try_get_attribute("d").ok()??;
- let d = attr.unescape_value().ok()?;
+ let d = attr.normalized_value(XmlVersion::Implicit1_0).ok()?;
let rest = d.strip_prefix('M')?.trim_start();
// The path data starts with `M x,y ...`; the y coordinate is the second
// whitespace/comma-separated token.
@@ -129,7 +130,7 @@ pub(crate) fn add_class<'a>(e: &BytesStart<'_>, class_to_add: &str) -> Result
let parse_attr = |name| -> Option {
e.try_get_attribute(name)
.ok()??
- .unescape_value()
+ .normalized_value(XmlVersion::Implicit1_0)
.ok()?
.parse()
.ok()
@@ -221,7 +222,7 @@ fn detect_diagram_type(e: &BytesStart<'_>) -> DiagramType {
.try_get_attribute("class")
.ok()
.flatten()
- .and_then(|a| a.unescape_value().ok())
+ .and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok())
{
Some(c) => c,
None => return DiagramType::SequenceDiagram,
@@ -279,7 +280,7 @@ impl<'a, 'theme, I: Iterator- >>> AccentColors<'theme, I>
self.plot_depth += 1;
}
if let Some(class_attr) = e.try_get_attribute("class")? {
- let class = class_attr.unescape_value()?;
+ let class = class_attr.normalized_value(XmlVersion::Implicit1_0)?;
if class.as_ref() == "plot" {
if is_start && !self.in_plot {
self.in_plot = true;
@@ -326,7 +327,7 @@ impl<'a, 'theme, I: Iterator
- >>> AccentColors<'theme, I>
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"path" => {
let class_val = e
.try_get_attribute("class")?
- .map(|a| a.unescape_value())
+ .map(|a| a.normalized_value(XmlVersion::Implicit1_0))
.transpose()?;
if class_val.as_deref() == Some("pieCircle") {
diff --git a/crates/mermaid_render/src/postprocess/accent_colors/class_diagram.rs b/crates/mermaid_render/src/postprocess/accent_colors/class_diagram.rs
index 6c7d019c38..1893e3aee2 100644
--- a/crates/mermaid_render/src/postprocess/accent_colors/class_diagram.rs
+++ b/crates/mermaid_render/src/postprocess/accent_colors/class_diagram.rs
@@ -1,4 +1,5 @@
use anyhow::Result;
+use quick_xml::XmlVersion;
use quick_xml::events::Event;
use super::{
@@ -38,7 +39,7 @@ impl ClassDiagramAccents {
}
let is_node = if let Some(class_attr) = e.try_get_attribute("class")? {
- let class = class_attr.unescape_value()?;
+ let class = class_attr.normalized_value(XmlVersion::Implicit1_0)?;
class
.split_whitespace()
.any(|token| token == "node" || token == "stateGroup")
diff --git a/crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs b/crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs
index f77329c689..b978c8afd0 100644
--- a/crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs
+++ b/crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs
@@ -1,4 +1,5 @@
use anyhow::Result;
+use quick_xml::XmlVersion;
use quick_xml::events::{BytesStart, Event};
use super::{AccentStackEntry, NodeTracker};
@@ -105,7 +106,7 @@ impl MindmapAccents {
Some(attr) => attr,
None => return Ok(None),
};
- let class = class_attr.unescape_value()?;
+ let class = class_attr.normalized_value(XmlVersion::Implicit1_0)?;
let is_root = class.split_whitespace().any(|t| t == "section-root");
for token in class.split_whitespace() {
diff --git a/crates/mermaid_render/src/postprocess/accent_colors/sequence_diagram.rs b/crates/mermaid_render/src/postprocess/accent_colors/sequence_diagram.rs
index 8489e93b76..005ff7c479 100644
--- a/crates/mermaid_render/src/postprocess/accent_colors/sequence_diagram.rs
+++ b/crates/mermaid_render/src/postprocess/accent_colors/sequence_diagram.rs
@@ -1,4 +1,5 @@
use anyhow::Result;
+use quick_xml::XmlVersion;
use quick_xml::events::{BytesStart, Event};
use super::{accent_class_name, add_to_event};
@@ -67,7 +68,7 @@ impl SequenceDiagramAccents {
Some(a) => a,
None => return Ok(None),
};
- let class_val = class_attr.unescape_value()?;
+ let class_val = class_attr.normalized_value(XmlVersion::Implicit1_0)?;
if class_val.contains("actor-bottom") {
let idx = self.actor_bottom_counter % self.accent_count;
self.actor_bottom_counter += 1;
@@ -88,7 +89,7 @@ impl SequenceDiagramAccents {
Some(a) => a,
None => return Ok(None),
};
- let class_val = class_attr.unescape_value()?;
+ let class_val = class_attr.normalized_value(XmlVersion::Implicit1_0)?;
if class_val.contains("actor") && class_val.contains("actor-box") {
Ok(self.last_actor_accent.take())
} else {
diff --git a/crates/mermaid_render/src/postprocess/element_fixup.rs b/crates/mermaid_render/src/postprocess/element_fixup.rs
index fabdf21e8c..275022c4a1 100644
--- a/crates/mermaid_render/src/postprocess/element_fixup.rs
+++ b/crates/mermaid_render/src/postprocess/element_fixup.rs
@@ -22,6 +22,7 @@ use std::borrow::Cow;
use std::fmt::Write as _;
use anyhow::Result;
+use quick_xml::XmlVersion;
use quick_xml::events::{BytesStart, Event};
use crate::MermaidTheme;
@@ -68,7 +69,7 @@ fn is_bad_rect(e: &BytesStart) -> Result
{
match e.try_get_attribute(attr_name)? {
None => return Ok(true),
Some(attr) => {
- let val = attr.unescape_value()?;
+ let val = attr.normalized_value(XmlVersion::Implicit1_0)?;
let trimmed = val.trim();
if trimmed.is_empty() {
return Ok(true);
@@ -183,7 +184,7 @@ impl<'a, I: Iterator- >>> ElementFixup
{
fn rewrite_svg_style(&self, e: &BytesStart<'_>) -> Result>> {
let Some(style) = e
.try_get_attribute("style")?
- .map(|a| a.unescape_value())
+ .map(|a| a.normalized_value(XmlVersion::Implicit1_0))
.transpose()?
else {
return Ok(None);
@@ -207,7 +208,7 @@ impl<'a, I: Iterator- >>> ElementFixup
{
let attr = attr?;
match attr.key.local_name().as_ref() {
b"fill" if fix_fill => {
- let val = attr.unescape_value()?;
+ let val = attr.normalized_value(XmlVersion::Implicit1_0)?;
if is_hardcoded_text_fill(&val) {
new_elem.push_attribute(("fill", self.text_color_css.as_str()));
} else {
@@ -220,7 +221,7 @@ impl<'a, I: Iterator- >>> ElementFixup
{
}
b"style" => {
has_style = true;
- let style = attr.unescape_value()?;
+ let style = attr.normalized_value(XmlVersion::Implicit1_0)?;
let style = rewrite_font_style(&style, &self.font_family_css);
new_elem.push_attribute(("style", style.as_ref()));
}
diff --git a/crates/mermaid_render/src/postprocess/strip_foreignobject.rs b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs
index 1163c0a621..c0f23e306f 100644
--- a/crates/mermaid_render/src/postprocess/strip_foreignobject.rs
+++ b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs
@@ -26,8 +26,8 @@
use std::collections::{HashSet, VecDeque};
use anyhow::Result;
-use quick_xml::Reader;
use quick_xml::events::Event;
+use quick_xml::{Reader, XmlVersion};
use super::ReaderIter;
@@ -72,7 +72,7 @@ fn is_fallback_text(e: &quick_xml::events::BytesStart<'_>) -> bool {
e.try_get_attribute("class")
.ok()
.flatten()
- .and_then(|a| a.unescape_value().ok())
+ .and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok())
.is_some_and(|v| v.split_whitespace().any(|c| c == FALLBACK_TEXT_CLASS))
}
diff --git a/crates/mermaid_render/tests/check_invalid_attrs.rs b/crates/mermaid_render/tests/check_invalid_attrs.rs
index 93093d899d..3c51ac69a9 100644
--- a/crates/mermaid_render/tests/check_invalid_attrs.rs
+++ b/crates/mermaid_render/tests/check_invalid_attrs.rs
@@ -174,6 +174,7 @@ fn check_svg_issues(name: &str, svg: &str) -> Vec {
}
// Parse with quick-xml to find ANY empty attribute values on visual elements
+ use quick_xml::XmlVersion;
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_str(svg);
loop {
@@ -183,7 +184,9 @@ fn check_svg_issues(name: &str, svg: &str) -> Vec {
let tag = String::from_utf8_lossy(e.name().local_name().as_ref()).to_string();
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(attr.key.local_name().as_ref()).to_string();
- let val = attr.unescape_value().unwrap_or_default();
+ let val = attr
+ .normalized_value(XmlVersion::Implicit1_0)
+ .unwrap_or_default();
let visual_attr = matches!(
key.as_str(),
"fill"
@@ -283,6 +286,7 @@ fn class_diagram_label_text_uses_accent_classes() {
let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed");
+ use quick_xml::XmlVersion;
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_str(&svg);
let mut accent_classes: Vec = Vec::new();
@@ -291,7 +295,10 @@ fn class_diagram_label_text_uses_accent_classes() {
Ok(Event::Eof) => break,
Ok(Event::Start(e)) if e.name().as_ref() == b"text" => {
if let Ok(Some(class_attr)) = e.try_get_attribute("class") {
- let class = class_attr.unescape_value().unwrap_or_default().to_string();
+ let class = class_attr
+ .normalized_value(XmlVersion::Implicit1_0)
+ .unwrap_or_default()
+ .to_string();
for token in class.split_whitespace() {
if token.starts_with("zed-accent-") {
accent_classes.push(token.to_string());
@@ -315,6 +322,7 @@ fn sequence_diagram_tspan_uses_accent_classes() {
let source = "sequenceDiagram\n participant Database";
let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed");
+ use quick_xml::XmlVersion;
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_str(&svg);
let mut accent_classes: Vec = Vec::new();
@@ -323,7 +331,10 @@ fn sequence_diagram_tspan_uses_accent_classes() {
Ok(Event::Eof) => break,
Ok(Event::Start(e)) if e.name().as_ref() == b"tspan" => {
if let Ok(Some(class_attr)) = e.try_get_attribute("class") {
- let class = class_attr.unescape_value().unwrap_or_default().to_string();
+ let class = class_attr
+ .normalized_value(XmlVersion::Implicit1_0)
+ .unwrap_or_default()
+ .to_string();
for token in class.split_whitespace() {
if token.starts_with("zed-accent-") {
accent_classes.push(token.to_string());
diff --git a/crates/sandbox/src/linux_bubblewrap.rs b/crates/sandbox/src/linux_bubblewrap.rs
index abac281d9a..b0a137697c 100644
--- a/crates/sandbox/src/linux_bubblewrap.rs
+++ b/crates/sandbox/src/linux_bubblewrap.rs
@@ -26,7 +26,7 @@ use anyhow::{Context as _, Result, anyhow, bail};
use std::ffi::{OsStr, OsString};
use std::io::{Read, Write};
use std::net::{Ipv4Addr, Shutdown, TcpListener, TcpStream};
-use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd};
+use std::os::fd::{AsFd as _, AsRawFd as _, BorrowedFd, FromRawFd as _, OwnedFd, RawFd};
use std::os::unix::fs::MetadataExt as _;
use std::os::unix::net::{UnixListener, UnixStream};
use std::os::unix::process::{CommandExt as _, ExitStatusExt as _};
@@ -848,7 +848,7 @@ fn validate_binds(socket_path: &Path, paths: &[PathBuf]) -> Result<()> {
);
}
for (fd, path) in fds.iter().zip(paths) {
- let expected = fd_dev_ino(fd.as_raw_fd())
+ let expected = fd_dev_ino(fd.as_fd())
.with_context(|| format!("fstat of captured descriptor for {}", path.display()))?;
let mounted = lstat_dev_ino(path)
.with_context(|| format!("lstat of mounted bind {}", path.display()))?;
@@ -1121,7 +1121,7 @@ fn recv_fds(stream: &UnixStream) -> std::io::Result> {
}
/// `(device, inode)` of the object an already-open descriptor refers to.
-fn fd_dev_ino(fd: RawFd) -> std::io::Result<(u64, u64)> {
+fn fd_dev_ino(fd: BorrowedFd<'_>) -> std::io::Result<(u64, u64)> {
let stat = nix::sys::stat::fstat(fd).map_err(std::io::Error::from)?;
Ok((stat.st_dev as u64, stat.st_ino as u64))
}
diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs
index 866e16a104..9b19321dba 100644
--- a/crates/sandbox/src/sandbox.rs
+++ b/crates/sandbox/src/sandbox.rs
@@ -246,7 +246,7 @@ fn linux_location_is_equal_or_descendant(
location: &HostFilesystemLocation,
ancestor: &HostFilesystemLocation,
) -> bool {
- use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd};
+ use std::os::fd::{AsFd as _, AsRawFd as _, FromRawFd as _, OwnedFd};
if location == ancestor {
return true;
@@ -270,15 +270,15 @@ fn linux_location_is_equal_or_descendant(
OwnedFd::from_raw_fd(fd)
};
- let Some(parent_identity) = linux_fd_identity(parent.as_raw_fd()) else {
+ let Some(parent_identity) = linux_fd_identity(parent.as_fd()) else {
log::warn!("failed to fstat parent fd while checking protected path overlap");
return false;
};
- let Some(current_identity) = linux_fd_identity(current.as_raw_fd()) else {
+ let Some(current_identity) = linux_fd_identity(current.as_fd()) else {
log::warn!("failed to fstat current fd while checking protected path overlap");
return false;
};
- let Some(ancestor_identity) = linux_fd_identity(ancestor.linux_fd().as_raw_fd()) else {
+ let Some(ancestor_identity) = linux_fd_identity(ancestor.linux_fd().as_fd()) else {
log::warn!("failed to fstat protected fd while checking protected path overlap");
return false;
};
diff --git a/crates/sandbox/src/util/canonical_path.rs b/crates/sandbox/src/util/canonical_path.rs
index 2b820bae16..3c8c88dd50 100644
--- a/crates/sandbox/src/util/canonical_path.rs
+++ b/crates/sandbox/src/util/canonical_path.rs
@@ -145,7 +145,7 @@ impl CanonicalPathBuf {
// and `readlink` of an `O_PATH|O_NOFOLLOW` fd on a symlink returns
// the symlink's *own* path (equal to `path`), so the comparison
// below wouldn't catch it.
- let stat = nix::sys::stat::fstat(fd.as_raw_fd()).map_err(io::Error::from)?;
+ let stat = nix::sys::stat::fstat(&fd).map_err(io::Error::from)?;
if stat.st_mode & libc::S_IFMT == libc::S_IFLNK {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
@@ -266,8 +266,8 @@ impl PartialEq for CanonicalPathBuf {
#[cfg(target_os = "linux")]
{
match (
- linux_fd_identity(self.fd.as_raw_fd()),
- linux_fd_identity(other.fd.as_raw_fd()),
+ linux_fd_identity(self.fd.as_fd()),
+ linux_fd_identity(other.fd.as_fd()),
) {
(Some(a), Some(b)) => a == b,
// An `fstat` on an `O_PATH` fd we own should never fail; if it
@@ -296,7 +296,7 @@ impl Eq for CanonicalPathBuf {}
/// whether two [`CanonicalPathBuf`]s (or their parents) refer to the same
/// filesystem object.
#[cfg(target_os = "linux")]
-pub(crate) fn linux_fd_identity(fd: std::os::fd::RawFd) -> Option<(u64, u64)> {
+pub(crate) fn linux_fd_identity(fd: BorrowedFd<'_>) -> Option<(u64, u64)> {
let stat = nix::sys::stat::fstat(fd).ok()?;
Some((stat.st_dev as u64, stat.st_ino as u64))
}
From 9decdcc1f22d324b5d5b186a3da1418ec0ea4c37 Mon Sep 17 00:00:00 2001
From: Cameron Mcloughlin
Date: Wed, 2 Sep 2026 18:23:07 +0000
Subject: [PATCH 016/283] gpui_web: Drive IME mirror focus state from GPUI
(#63629)
Previously, we manually synchronized the keyboard state in response to
tap events, but this meant that several common cases were unreliable.
Now, we use GPUI's focused element as the source of truth, and use
`sync_virtual_keyboard` in cases where they have gotten out of sync
(i.e. a user manually dismisses a keyboard while the input is still
focused).
---
Release Notes:
- N/A or Added/Fixed/Improved ...
---
crates/gpui/src/input.rs | 19 +++++++++++++++--
crates/gpui/src/platform/test/window.rs | 15 ++++++++++++--
crates/gpui/src/window.rs | 27 +++++++++++++++++++------
crates/gpui_web/src/events.rs | 2 +-
crates/gpui_web/src/window.rs | 13 ++++++++++--
5 files changed, 63 insertions(+), 13 deletions(-)
diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs
index cbf6e405cf..4167aa0990 100644
--- a/crates/gpui/src/input.rs
+++ b/crates/gpui/src/input.rs
@@ -288,11 +288,12 @@ mod tests {
use super::*;
use crate::{
AnyWindowHandle, AppContext as _, FocusHandle, InteractiveElement as _, IntoElement,
- ParentElement as _, Render, Styled as _, TestAppContext, TextInputAction, canvas, div,
+ ParentElement as _, Render, Styled as _, TestAppContext, TextInputAction,
+ TextInputStateChange, canvas, div,
};
#[gpui::test]
- fn text_input_configuration_forwarded_only_on_change(cx: &mut TestAppContext) {
+ fn text_input_configuration_and_focus_state_are_forwarded_on_change(cx: &mut TestAppContext) {
let custom = TextInputConfiguration {
autocorrect: true,
input_action: TextInputAction::Send,
@@ -319,6 +320,7 @@ mod tests {
test_window.text_input_configurations(),
vec![TextInputConfiguration::default()]
);
+ assert!(test_window.text_input_state_changes().is_empty());
// Focusing the view routes its configuration to the platform.
cx.update_window(window, |_, window, cx| {
@@ -331,10 +333,15 @@ mod tests {
test_window.text_input_configurations(),
vec![TextInputConfiguration::default(), custom.clone()]
);
+ assert_eq!(
+ test_window.text_input_state_changes(),
+ vec![TextInputStateChange::FocusGained]
+ );
// Redrawing without a change forwards nothing.
draw(cx);
assert_eq!(test_window.text_input_configurations().len(), 2);
+ assert_eq!(test_window.text_input_state_changes().len(), 1);
// Changing the configuration forwards the new value.
let updated = TextInputConfiguration {
@@ -354,6 +361,7 @@ mod tests {
Some(&updated)
);
assert_eq!(test_window.text_input_configurations().len(), 3);
+ assert_eq!(test_window.text_input_state_changes().len(), 1);
// Losing focus reverts the platform to the default configuration.
cx.update_window(window, |_, window, cx| window.blur(cx))
@@ -364,6 +372,13 @@ mod tests {
Some(&TextInputConfiguration::default())
);
assert_eq!(test_window.text_input_configurations().len(), 4);
+ assert_eq!(
+ test_window.text_input_state_changes(),
+ vec![
+ TextInputStateChange::FocusGained,
+ TextInputStateChange::FocusLost
+ ]
+ );
}
struct ConfigurationTestView {
diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs
index 223a2370e4..38926b649c 100644
--- a/crates/gpui/src/platform/test/window.rs
+++ b/crates/gpui/src/platform/test/window.rs
@@ -2,8 +2,9 @@ use crate::{
AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels,
DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay,
PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point,
- PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration, TileId,
- WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams,
+ PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration,
+ TextInputStateChange, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds,
+ WindowControlArea, WindowParams,
};
use collections::HashMap;
use gpui_util::ResultExt as _;
@@ -43,6 +44,7 @@ pub(crate) struct TestWindowState {
frame_callback_pending: bool,
input_handler: Option,
text_input_configurations: Vec,
+ text_input_state_changes: Vec,
is_fullscreen: bool,
appearance: WindowAppearance,
external_drag_files: Vec<(PathBuf, bool)>,
@@ -106,6 +108,7 @@ impl TestWindow {
frame_callback_pending: false,
input_handler: None,
text_input_configurations: Vec::new(),
+ text_input_state_changes: Vec::new(),
is_fullscreen: false,
appearance: WindowAppearance::Light,
external_drag_files: Vec::new(),
@@ -140,6 +143,10 @@ impl TestWindow {
self.0.lock().text_input_configurations.clone()
}
+ pub fn text_input_state_changes(&self) -> Vec {
+ self.0.lock().text_input_state_changes.clone()
+ }
+
pub fn simulate_resize(&mut self, size: Size) {
let scale_factor = self.scale_factor();
let mut lock = self.0.lock();
@@ -269,6 +276,10 @@ impl PlatformWindow for TestWindow {
self.0.lock().text_input_configurations.push(configuration);
}
+ fn text_input_state_changed(&self, change: TextInputStateChange) {
+ self.0.lock().text_input_state_changes.push(change);
+ }
+
fn prompt(
&self,
_level: crate::PromptLevel,
diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs
index 63b11d95b5..0950071c3f 100644
--- a/crates/gpui/src/window.rs
+++ b/crates/gpui/src/window.rs
@@ -19,10 +19,10 @@ use crate::{
SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, SharedString, Size,
StrikethroughStyle, Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab,
SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextInputConfiguration,
- TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix,
- Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, WindowBounds,
- WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, point,
- prelude::*, px, rems, size, transparent_black,
+ TextInputStateChange, TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState,
+ TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance,
+ WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem,
+ point, prelude::*, px, rems, size, transparent_black,
};
use crate::gestures::{GestureTuning, RecognizedTouchGesture, TouchGestureRecognizer};
@@ -1162,6 +1162,7 @@ pub struct Window {
/// window, so that only actual changes are forwarded (reconfiguring a live
/// input session can restart the IME connection).
last_text_input_configuration: Option,
+ focused_text_input_active: bool,
pub(crate) image_cache_stack: Vec,
pub(crate) rendered_frame: Frame,
pub(crate) next_frame: Frame,
@@ -1859,6 +1860,7 @@ impl Window {
element_opacity: 1.0,
requested_autoscroll: None,
last_text_input_configuration: None,
+ focused_text_input_active: false,
rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())),
next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())),
next_frame_callbacks,
@@ -2935,16 +2937,29 @@ impl Window {
// paint_range indices remain valid for reuse_paint on the next frame.
// Search backwards to find the last Some entry, since reuse_paint may
// have copied None slots from the previous frame. (Fixes #50456)
- if let Some(input_handler) = self
+ let focused_text_input_active = if let Some(mut input_handler) = self
.next_frame
.input_handlers
.iter_mut()
.rev()
.find_map(|h| h.take())
{
+ let accepts_text_input = input_handler.accepts_text_input(self, cx);
self.platform_window.set_input_handler(input_handler);
- }
+ accepts_text_input
+ } else {
+ false
+ };
self.apply_text_input_configuration(cx);
+ if focused_text_input_active != self.focused_text_input_active {
+ self.focused_text_input_active = focused_text_input_active;
+ self.platform_window
+ .text_input_state_changed(if focused_text_input_active {
+ TextInputStateChange::FocusGained
+ } else {
+ TextInputStateChange::FocusLost
+ });
+ }
self.layout_engine.as_mut().unwrap().clear();
self.text_system().finish_frame();
diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs
index 7afedeb4e8..a6a1b2f4be 100644
--- a/crates/gpui_web/src/events.rs
+++ b/crates/gpui_web/src/events.rs
@@ -480,7 +480,7 @@ impl WebWindowInner {
///
/// We don't use `navigator.virtualKeyboard` here because it's
/// Chromium-only.
- fn sync_virtual_keyboard(self: &Rc, editable: bool) {
+ pub(crate) fn sync_virtual_keyboard(self: &Rc, editable: bool) {
let was_editable = !self.ime_mirror.read_only();
self.ime_mirror.set_read_only(!editable);
// Trigger a focus event only when the keyboard actually needs
diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs
index 799c3fadcf..8d6b0bd688 100644
--- a/crates/gpui_web/src/window.rs
+++ b/crates/gpui_web/src/window.rs
@@ -11,8 +11,9 @@ use gpui::{
AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, DispatchEventResult, GpuSpecs,
Modifiers, MouseButton, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput,
PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions,
- ResizeEdge, Scene, Size, TextInputConfiguration, WindowAppearance, WindowBackgroundAppearance,
- WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, px,
+ ResizeEdge, Scene, Size, TextInputConfiguration, TextInputStateChange, WindowAppearance,
+ WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations,
+ WindowParams, px,
};
use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig, wgpu};
use wasm_bindgen::prelude::*;
@@ -691,6 +692,14 @@ impl PlatformWindow for WebWindow {
self.inner.ime_mirror.apply_configuration(&configuration);
}
+ fn text_input_state_changed(&self, change: TextInputStateChange) {
+ match change {
+ TextInputStateChange::FocusGained => self.inner.sync_virtual_keyboard(true),
+ TextInputStateChange::FocusLost => self.inner.sync_virtual_keyboard(false),
+ TextInputStateChange::SelectionChanged | TextInputStateChange::ContentChanged => {}
+ }
+ }
+
fn prompt(
&self,
_level: PromptLevel,
From 60bf47b9f2ffb425905c4a3feaca89be67f1d90e Mon Sep 17 00:00:00 2001
From: Shuhei Kadowaki <40514306+aviatesk@users.noreply.github.com>
Date: Wed, 2 Sep 2026 18:23:27 +0000
Subject: [PATCH 017/283] lsp: Scope logs and status updates to their projects
(#61221)
The language registry and LSP log store are shared by every workspace in
a Zed process. The LSP Logs selector enumerated the shared log store
without filtering by project, while each activity indicator consumed
global binary status updates. This exposed servers from other windows as
"Unknown worktree" entries and surfaced their startup failures or
timeouts in unrelated status bars.
Key each logged server by its owning project or LSP store together with
its process-local language server ID. Use that identity consistently
when adding, removing, streaming, selecting, opening, and reselecting
log entries so local, remote, SSH, and supplementary servers cannot
collide across ownership domains.
Model Copilot and other servers outside the project LSP store as
project-scoped supplementary servers, while matching SSH servers by
their owning LSP store.
Tag project-originated binary status updates with the owning LSP store
entity ID. Carry that source through extension worktree delegates and
every worktree-based WASM language-server call, then let each local LSP
store forward only statuses for its project. Activity indicators rely
solely on their project LSP store, and remote projects continue to
receive host statuses through the existing protocol path instead of
subscribing to local registry events.
Own each binary-status subscription task in its local LSP store and
unregister its broadcaster sender when the store is dropped, so closing
windows cannot leave detached status tasks or stale senders behind.
Add regression coverage for duplicate process-local server IDs across
ownership domains, filtering remote, supplementary, and SSH servers from
other projects, retaining servers owned by the current project or LSP
store, preserving extension status sources, and unregistering dropped
status subscriptions.
Release Notes:
- Fixed language servers from other projects appearing in the LSP Logs
view and their statuses showing in unrelated windows' status bars
---
.../src/activity_indicator.rs | 20 +-
.../remote_editing_collaboration_tests.rs | 16 +-
crates/extension/src/extension.rs | 9 +-
crates/extension/src/extension_host_proxy.rs | 6 +-
.../src/extension_store_test.rs | 22 +-
crates/extension_host/src/wasm_host.rs | 53 +-
.../src/wasm_host/wit/since_v0_0_1.rs | 8 +-
.../src/wasm_host/wit/since_v0_1_0.rs | 8 +-
.../src/wasm_host/wit/since_v0_8_0.rs | 8 +-
crates/language/src/language.rs | 3 +-
crates/language/src/language_registry.rs | 68 ++-
.../src/extension_lsp_adapter.rs | 32 +-
crates/language_tools/src/lsp_button.rs | 10 +-
crates/language_tools/src/lsp_log_view.rs | 500 ++++++++++--------
.../language_tools/src/lsp_log_view_tests.rs | 311 ++++++++++-
crates/project/src/lsp_store.rs | 111 ++--
crates/project/src/lsp_store/log_store.rs | 415 ++++++++++-----
crates/project/src/project.rs | 15 +-
crates/remote_server/src/headless_project.rs | 41 +-
.../remote_server/src/remote_editing_tests.rs | 76 ++-
crates/zed/src/zed.rs | 10 +-
21 files changed, 1266 insertions(+), 476 deletions(-)
diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs
index dccc0bcafc..d3a3458876 100644
--- a/crates/activity_indicator/src/activity_indicator.rs
+++ b/crates/activity_indicator/src/activity_indicator.rs
@@ -7,8 +7,7 @@ use gpui::{
SharedString, Styled, Task, Window, actions,
};
use language::{
- BinaryStatus, LanguageRegistry, LanguageServerId, LanguageServerName,
- LanguageServerStatusUpdate, ServerHealth,
+ BinaryStatus, LanguageServerId, LanguageServerName, LanguageServerStatusUpdate, ServerHealth,
};
use project::{
LanguageServerProgress, LspStoreEvent, ProgressToken, Project, ProjectEnvironmentEvent,
@@ -91,28 +90,11 @@ struct Content {
impl ActivityIndicator {
pub fn new(
workspace: &mut Workspace,
- languages: Arc,
window: &mut Window,
cx: &mut Context,
) -> Entity {
let project = workspace.project().clone();
let this = cx.new(|cx| {
- let mut status_events = languages.language_server_binary_statuses();
- cx.spawn(async move |this, cx| {
- while let Some((name, binary_status)) = status_events.next().await {
- this.update(cx, |this: &mut ActivityIndicator, cx| {
- this.statuses.retain(|s| s.name != name);
- this.statuses.push(ServerStatus {
- name,
- status: LanguageServerStatusUpdate::Binary(binary_status),
- });
- cx.notify();
- })?;
- }
- anyhow::Ok(())
- })
- .detach();
-
let fs = project.read(cx).fs().clone();
let mut job_events = fs.subscribe_to_jobs();
cx.spawn(async move |this, cx| {
diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
index d039bce3b3..06989f8136 100644
--- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
+++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
@@ -927,12 +927,12 @@ async fn test_ssh_restarting_language_server_replaces_remote_status(
let matching_server_ids = log_store
.language_servers
.iter()
- .filter_map(|(server_id, state)| {
+ .filter_map(|(key, state)| {
state
.name
.as_ref()
.is_some_and(|name| name.0 == "the-language-server")
- .then_some(*server_id)
+ .then_some(key.server_id)
})
.collect::>();
assert_eq!(matching_server_ids, vec![first_server_id]);
@@ -958,10 +958,6 @@ async fn test_ssh_restarting_language_server_replaces_remote_status(
statuses[0].0, restarted_server_id,
"restarting a remote language server should publish the replacement server id"
);
- assert_ne!(
- statuses[0].0, first_server_id,
- "restarting a remote language server should remove the previous server id"
- );
assert_eq!(statuses[0].1.name.0, "the-language-server");
});
cx_a.read_global::(|global, cx| {
@@ -969,12 +965,12 @@ async fn test_ssh_restarting_language_server_replaces_remote_status(
let matching_server_ids = log_store
.language_servers
.iter()
- .filter_map(|(server_id, state)| {
+ .filter_map(|(key, state)| {
state
.name
.as_ref()
.is_some_and(|name| name.0 == "the-language-server")
- .then_some(*server_id)
+ .then_some(key.server_id)
})
.collect::>();
assert_eq!(
@@ -982,10 +978,6 @@ async fn test_ssh_restarting_language_server_replaces_remote_status(
vec![restarted_server_id],
"restarting a remote language server should replace the old log store entry"
);
- assert!(
- !log_store.language_servers.contains_key(&first_server_id),
- "restarting a remote language server should remove the previous log store entry"
- );
});
}
diff --git a/crates/extension/src/extension.rs b/crates/extension/src/extension.rs
index 850da7a69e..ceffef8efe 100644
--- a/crates/extension/src/extension.rs
+++ b/crates/extension/src/extension.rs
@@ -11,7 +11,7 @@ use std::sync::Arc;
use ::lsp::LanguageServerName;
use anyhow::{Context as _, Result, bail};
use async_trait::async_trait;
-use gpui::{App, Task};
+use gpui::{App, EntityId, Task};
use language::LanguageName;
use semver::Version;
use task::{SpawnInTerminal, ZedDebugConfig};
@@ -64,6 +64,7 @@ pub trait Extension: Send + Sync + 'static {
language_server_id: LanguageServerName,
language_name: LanguageName,
worktree: Arc,
+ language_server_status_source: EntityId,
) -> Result;
async fn language_server_initialization_options(
@@ -71,24 +72,28 @@ pub trait Extension: Send + Sync + 'static {
language_server_id: LanguageServerName,
language_name: LanguageName,
worktree: Arc,
+ language_server_status_source: EntityId,
) -> Result>;
async fn language_server_workspace_configuration(
&self,
language_server_id: LanguageServerName,
worktree: Arc,
+ language_server_status_source: EntityId,
) -> Result>;
async fn language_server_initialization_options_schema(
&self,
language_server_id: LanguageServerName,
worktree: Arc,
+ language_server_status_source: EntityId,
) -> Result>;
async fn language_server_workspace_configuration_schema(
&self,
language_server_id: LanguageServerName,
worktree: Arc,
+ language_server_status_source: EntityId,
) -> Result>;
async fn language_server_additional_initialization_options(
@@ -96,6 +101,7 @@ pub trait Extension: Send + Sync + 'static {
language_server_id: LanguageServerName,
target_language_server_id: LanguageServerName,
worktree: Arc,
+ language_server_status_source: EntityId,
) -> Result>;
async fn language_server_additional_workspace_configuration(
@@ -103,6 +109,7 @@ pub trait Extension: Send + Sync + 'static {
language_server_id: LanguageServerName,
target_language_server_id: LanguageServerName,
worktree: Arc,
+ language_server_status_source: EntityId,
) -> Result>;
async fn labels_for_completions(
diff --git a/crates/extension/src/extension_host_proxy.rs b/crates/extension/src/extension_host_proxy.rs
index 6190e86294..9050017091 100644
--- a/crates/extension/src/extension_host_proxy.rs
+++ b/crates/extension/src/extension_host_proxy.rs
@@ -3,7 +3,7 @@ use std::sync::Arc;
use anyhow::Result;
use fs::Fs;
-use gpui::{App, Global, ReadGlobal, SharedString, Task};
+use gpui::{App, EntityId, Global, ReadGlobal, SharedString, Task};
use language::{BinaryStatus, LanguageLoader, LanguageMatcher, LanguageName};
use lsp::LanguageServerName;
use parking_lot::RwLock;
@@ -301,6 +301,7 @@ pub trait ExtensionLanguageServerProxy: Send + Sync + 'static {
fn update_language_server_status(
&self,
+ source: Option,
language_server_id: LanguageServerName,
status: BinaryStatus,
);
@@ -335,6 +336,7 @@ impl ExtensionLanguageServerProxy for ExtensionHostProxy {
fn update_language_server_status(
&self,
+ source: Option,
language_server_id: LanguageServerName,
status: BinaryStatus,
) {
@@ -342,7 +344,7 @@ impl ExtensionLanguageServerProxy for ExtensionHostProxy {
return;
};
- proxy.update_language_server_status(language_server_id, status)
+ proxy.update_language_server_status(source, language_server_id, status)
}
}
diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs
index e39a57fb1a..ad6679e8fe 100644
--- a/crates/extension_host/src/extension_store_test.rs
+++ b/crates/extension_host/src/extension_store_test.rs
@@ -22,7 +22,7 @@ use extension::{
};
use fs::{FakeFs, Fs, RealFs, RemoveOptions};
use futures::{AsyncReadExt, FutureExt, StreamExt, io::BufReader};
-use gpui::{AppContext as _, BackgroundExecutor, Entity, TaskExt, TestAppContext};
+use gpui::{AppContext as _, BackgroundExecutor, Entity, EntityId, TaskExt, TestAppContext};
use http_client::{FakeHttpClient, Response};
use language::{
BinaryStatus, LanguageConfig, LanguageMatcher, LanguageName, LanguageRegistry, QueryFiles,
@@ -826,6 +826,7 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
theme_extension::init(proxy.clone(), theme_registry.clone(), cx.executor());
let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
+ let lsp_store_id = project.read_with(cx, |project, _| project.lsp_store().entity_id());
language_extension::init(
LspAccess::ViaLspStore(
project
@@ -837,7 +838,8 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
);
let node_runtime = NodeRuntime::unavailable();
- let mut status_updates = language_registry.language_server_binary_statuses();
+ let (mut status_updates, _status_updates_subscription) =
+ language_registry.language_server_binary_statuses();
struct FakeLanguageServerVersion {
version: String,
@@ -1093,18 +1095,25 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
],
[
(
+ Some(lsp_store_id),
LanguageServerName::new_static("gleam"),
BinaryStatus::Starting
),
(
+ Some(lsp_store_id),
LanguageServerName::new_static("gleam"),
BinaryStatus::CheckingForUpdate
),
(
+ Some(lsp_store_id),
LanguageServerName::new_static("gleam"),
BinaryStatus::Downloading
),
- (LanguageServerName::new_static("gleam"), BinaryStatus::None)
+ (
+ Some(lsp_store_id),
+ LanguageServerName::new_static("gleam"),
+ BinaryStatus::None
+ )
]
);
@@ -4107,6 +4116,7 @@ impl Extension for FakeExtension {
_language_server_id: LanguageServerName,
_language_name: LanguageName,
_worktree: Arc,
+ _language_server_status_source: EntityId,
) -> anyhow::Result {
anyhow::bail!("not supported by FakeExtension")
}
@@ -4116,6 +4126,7 @@ impl Extension for FakeExtension {
_language_server_id: LanguageServerName,
_language_name: LanguageName,
_worktree: Arc,
+ _language_server_status_source: EntityId,
) -> anyhow::Result> {
anyhow::bail!("not supported by FakeExtension")
}
@@ -4124,6 +4135,7 @@ impl Extension for FakeExtension {
&self,
_language_server_id: LanguageServerName,
_worktree: Arc,
+ _language_server_status_source: EntityId,
) -> anyhow::Result> {
anyhow::bail!("not supported by FakeExtension")
}
@@ -4132,6 +4144,7 @@ impl Extension for FakeExtension {
&self,
_language_server_id: LanguageServerName,
_worktree: Arc,
+ _language_server_status_source: EntityId,
) -> anyhow::Result> {
anyhow::bail!("not supported by FakeExtension")
}
@@ -4140,6 +4153,7 @@ impl Extension for FakeExtension {
&self,
_language_server_id: LanguageServerName,
_worktree: Arc,
+ _language_server_status_source: EntityId,
) -> anyhow::Result> {
anyhow::bail!("not supported by FakeExtension")
}
@@ -4149,6 +4163,7 @@ impl Extension for FakeExtension {
_language_server_id: LanguageServerName,
_target_language_server_id: LanguageServerName,
_worktree: Arc,
+ _language_server_status_source: EntityId,
) -> anyhow::Result> {
anyhow::bail!("not supported by FakeExtension")
}
@@ -4158,6 +4173,7 @@ impl Extension for FakeExtension {
_language_server_id: LanguageServerName,
_target_language_server_id: LanguageServerName,
_worktree: Arc,
+ _language_server_status_source: EntityId,
) -> anyhow::Result> {
anyhow::bail!("not supported by FakeExtension")
}
diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs
index c04445f4dc..857d33e0de 100644
--- a/crates/extension_host/src/wasm_host.rs
+++ b/crates/extension_host/src/wasm_host.rs
@@ -21,7 +21,7 @@ use futures::{
},
future::BoxFuture,
};
-use gpui::{App, AsyncApp, BackgroundExecutor, Task};
+use gpui::{App, AsyncApp, BackgroundExecutor, EntityId, Task};
use http_client::HttpClient;
use language::LanguageName;
use lsp::LanguageServerName;
@@ -90,8 +90,9 @@ impl extension::Extension for WasmExtension {
language_server_id: LanguageServerName,
language_name: LanguageName,
worktree: Arc,
+ status_source: EntityId,
) -> Result {
- self.call(|extension, store| {
+ self.call_with_language_server_status_source(status_source, move |extension, store| {
async move {
let resource = store.data_mut().table.push(worktree)?;
let command = extension
@@ -116,8 +117,9 @@ impl extension::Extension for WasmExtension {
language_server_id: LanguageServerName,
language_name: LanguageName,
worktree: Arc,
+ status_source: EntityId,
) -> Result> {
- self.call(|extension, store| {
+ self.call_with_language_server_status_source(status_source, move |extension, store| {
async move {
let resource = store.data_mut().table.push(worktree)?;
let options = extension
@@ -140,8 +142,9 @@ impl extension::Extension for WasmExtension {
&self,
language_server_id: LanguageServerName,
worktree: Arc,
+ status_source: EntityId,
) -> Result> {
- self.call(|extension, store| {
+ self.call_with_language_server_status_source(status_source, move |extension, store| {
async move {
let resource = store.data_mut().table.push(worktree)?;
let options = extension
@@ -163,8 +166,9 @@ impl extension::Extension for WasmExtension {
&self,
language_server_id: LanguageServerName,
worktree: Arc,
+ status_source: EntityId,
) -> Result> {
- self.call(|extension, store| {
+ self.call_with_language_server_status_source(status_source, move |extension, store| {
async move {
let resource = store.data_mut().table.push(worktree)?;
extension
@@ -185,8 +189,9 @@ impl extension::Extension for WasmExtension {
&self,
language_server_id: LanguageServerName,
worktree: Arc,
+ status_source: EntityId,
) -> Result> {
- self.call(|extension, store| {
+ self.call_with_language_server_status_source(status_source, move |extension, store| {
async move {
let resource = store.data_mut().table.push(worktree)?;
extension
@@ -208,8 +213,9 @@ impl extension::Extension for WasmExtension {
language_server_id: LanguageServerName,
target_language_server_id: LanguageServerName,
worktree: Arc,
+ status_source: EntityId,
) -> Result> {
- self.call(|extension, store| {
+ self.call_with_language_server_status_source(status_source, move |extension, store| {
async move {
let resource = store.data_mut().table.push(worktree)?;
let options = extension
@@ -233,8 +239,9 @@ impl extension::Extension for WasmExtension {
language_server_id: LanguageServerName,
target_language_server_id: LanguageServerName,
worktree: Arc,
+ status_source: EntityId,
) -> Result> {
- self.call(|extension, store| {
+ self.call_with_language_server_status_source(status_source, move |extension, store| {
async move {
let resource = store.data_mut().table.push(worktree)?;
let options = extension
@@ -538,6 +545,7 @@ pub struct WasmState {
ctx: WasiCtx,
pub host: Arc,
pub(crate) capability_granter: CapabilityGranter,
+ pub(crate) language_server_status_source: Option,
}
type MainThreadCall = Box FnOnce(&'a mut AsyncApp) -> LocalBoxFuture<'a, ()>>;
@@ -671,6 +679,7 @@ impl WasmHost {
this.granted_capabilities.clone(),
manifest.clone(),
),
+ language_server_status_source: None,
},
);
// Store will yield after 1 tick, and get a new deadline of 1 tick after each yield.
@@ -872,6 +881,34 @@ impl WasmExtension {
.with_context(|| format!("loading wasm extension: {}", manifest.id))
}
+ async fn call_with_language_server_status_source(
+ &self,
+ source: EntityId,
+ f: Fn,
+ ) -> Result
+ where
+ T: 'static + Send,
+ Fn: 'static
+ + Send
+ + for<'a> FnOnce(&'a mut Extension, &'a mut Store) -> BoxFuture<'a, T>,
+ {
+ self.call(move |extension, store| {
+ async move {
+ debug_assert!(store.data().language_server_status_source.is_none());
+
+ // The installation-status WIT methods do not receive a worktree, so expose the
+ // source through WasmState for the duration of this serialized extension call.
+ // Clear it before returning so failed calls cannot affect the next invocation.
+ store.data_mut().language_server_status_source = Some(source);
+ let result = f(extension, store).await;
+ store.data_mut().language_server_status_source = None;
+ result
+ }
+ .boxed()
+ })
+ .await
+ }
+
pub async fn call(&self, f: Fn) -> Result
where
T: 'static + Send,
diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs
index 57dfa5b5af..582e78f813 100644
--- a/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs
+++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs
@@ -148,9 +148,11 @@ impl ExtensionImports for WasmState {
LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error },
};
- self.host
- .proxy
- .update_language_server_status(lsp::LanguageServerName(server_name.into()), status);
+ self.host.proxy.update_language_server_status(
+ self.language_server_status_source,
+ lsp::LanguageServerName(server_name.into()),
+ status,
+ );
Ok(())
}
diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs
index f2b9adf5cf..d7df044773 100644
--- a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs
+++ b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs
@@ -507,9 +507,11 @@ impl ExtensionImports for WasmState {
LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error },
};
- self.host
- .proxy
- .update_language_server_status(::lsp::LanguageServerName(server_name.into()), status);
+ self.host.proxy.update_language_server_status(
+ self.language_server_status_source,
+ ::lsp::LanguageServerName(server_name.into()),
+ status,
+ );
Ok(())
}
diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs
index 4fed1b313a..57d5e808f1 100644
--- a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs
+++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs
@@ -1064,9 +1064,11 @@ impl ExtensionImports for WasmState {
LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error },
};
- self.host
- .proxy
- .update_language_server_status(::lsp::LanguageServerName(server_name.into()), status);
+ self.host.proxy.update_language_server_status(
+ self.language_server_status_source,
+ ::lsp::LanguageServerName(server_name.into()),
+ status,
+ );
Ok(())
}
diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs
index 8258034447..a7791c51e7 100644
--- a/crates/language/src/language.rs
+++ b/crates/language/src/language.rs
@@ -39,7 +39,7 @@ use collections::{HashMap, HashSet};
use futures::Future;
use futures::future::LocalBoxFuture;
use futures::lock::OwnedMutexGuard;
-use gpui::{App, AsyncApp, Entity};
+use gpui::{App, AsyncApp, Entity, EntityId};
use http_client::HttpClient;
pub use language_core::{
@@ -492,6 +492,7 @@ pub trait LspAdapterDelegate: Send + Sync {
fn worktree_id(&self) -> WorktreeId;
fn worktree_root_path(&self) -> &Path;
fn resolve_relative_path(&self, path: PathBuf) -> PathBuf;
+ fn status_source_id(&self) -> EntityId;
fn update_status(&self, language: LanguageServerName, status: BinaryStatus);
fn registered_lsp_adapters(&self) -> Vec>;
async fn language_server_download_dir(&self, name: &LanguageServerName) -> Option>;
diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs
index 22561ee834..4a3accb9d1 100644
--- a/crates/language/src/language_registry.rs
+++ b/crates/language/src/language_registry.rs
@@ -19,7 +19,7 @@ use futures::{
future::{BoxFuture, FutureExt as _},
};
use globset::GlobSet;
-use gpui::{App, BackgroundExecutor};
+use gpui::{App, BackgroundExecutor, EntityId, Subscription};
use lsp::LanguageServerId;
use parking_lot::{Mutex, RwLock};
use postage::watch;
@@ -89,9 +89,17 @@ impl std::fmt::Display for LanguageNotFound {
}
}
+type ServerStatus = (Option, LanguageServerName, BinaryStatus);
+
#[derive(Clone, Default)]
struct ServerStatusSender {
- txs: Arc>>>,
+ state: Arc>,
+}
+
+#[derive(Default)]
+struct ServerStatusSenderState {
+ next_subscription_id: usize,
+ txs: HashMap>,
}
pub struct LoadedLanguage {
@@ -922,7 +930,17 @@ impl LanguageRegistry {
}
pub fn update_lsp_binary_status(&self, server_name: LanguageServerName, status: BinaryStatus) {
- self.lsp_binary_status_tx.send(server_name, status);
+ self.lsp_binary_status_tx.send(None, server_name, status);
+ }
+
+ pub fn update_lsp_binary_status_for_entity(
+ &self,
+ source: EntityId,
+ server_name: LanguageServerName,
+ status: BinaryStatus,
+ ) {
+ self.lsp_binary_status_tx
+ .send(Some(source), server_name, status);
}
pub fn next_language_server_id(&self) -> LanguageServerId {
@@ -968,7 +986,10 @@ impl LanguageRegistry {
pub fn language_server_binary_statuses(
&self,
- ) -> mpsc::UnboundedReceiver<(LanguageServerName, BinaryStatus)> {
+ ) -> (
+ mpsc::UnboundedReceiver<(Option, LanguageServerName, BinaryStatus)>,
+ Subscription,
+ ) {
self.lsp_binary_status_tx.subscribe()
}
}
@@ -1065,14 +1086,41 @@ impl LanguageRegistryState {
}
impl ServerStatusSender {
- fn subscribe(&self) -> mpsc::UnboundedReceiver<(LanguageServerName, BinaryStatus)> {
+ fn subscribe(&self) -> (mpsc::UnboundedReceiver, Subscription) {
let (tx, rx) = mpsc::unbounded();
- self.txs.lock().push(tx);
- rx
+ let subscription_id = {
+ let mut state = self.state.lock();
+ let subscription_id = post_inc(&mut state.next_subscription_id);
+ state.txs.insert(subscription_id, tx);
+ subscription_id
+ };
+ let state = self.state.clone();
+ let subscription = Subscription::new(move || {
+ state.lock().txs.remove(&subscription_id);
+ });
+ (rx, subscription)
}
- fn send(&self, name: LanguageServerName, status: BinaryStatus) {
- let mut txs = self.txs.lock();
- txs.retain(|tx| tx.unbounded_send((name.clone(), status.clone())).is_ok());
+ fn send(&self, source: Option, name: LanguageServerName, status: BinaryStatus) {
+ let mut state = self.state.lock();
+ state.txs.retain(|_, tx| {
+ tx.unbounded_send((source, name.clone(), status.clone()))
+ .is_ok()
+ });
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn dropping_server_status_subscription_unregisters_sender() {
+ let sender = ServerStatusSender::default();
+ let (_receiver, subscription) = sender.subscribe();
+ assert_eq!(sender.state.lock().txs.len(), 1);
+
+ drop(subscription);
+ assert!(sender.state.lock().txs.is_empty());
}
}
diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs
index d9f28100d8..378e82a160 100644
--- a/crates/language_extension/src/extension_lsp_adapter.rs
+++ b/crates/language_extension/src/extension_lsp_adapter.rs
@@ -122,6 +122,7 @@ impl ExtensionLanguageServerProxy for LanguageServerRegistryProxy {
fn update_language_server_status(
&self,
+ source: Option,
language_server_id: LanguageServerName,
status: BinaryStatus,
) {
@@ -130,8 +131,16 @@ impl ExtensionLanguageServerProxy for LanguageServerRegistryProxy {
language_server_id,
status
);
- self.language_registry
- .update_lsp_binary_status(language_server_id, status);
+ if let Some(source) = source {
+ self.language_registry.update_lsp_binary_status_for_entity(
+ source,
+ language_server_id,
+ status,
+ );
+ } else {
+ self.language_registry
+ .update_lsp_binary_status(language_server_id, status);
+ }
}
}
@@ -167,6 +176,7 @@ impl DynLspInstaller for ExtensionLspAdapter {
) -> LanguageServerBinaryLocations {
async move {
let ret = maybe!(async move {
+ let language_server_status_source = delegate.status_source_id();
let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
let command = self
.extension
@@ -174,6 +184,7 @@ impl DynLspInstaller for ExtensionLspAdapter {
self.language_server_id.clone(),
self.language_name.clone(),
delegate,
+ language_server_status_source,
)
.await?;
@@ -314,6 +325,7 @@ impl LspAdapter for ExtensionLspAdapter {
delegate: &Arc,
_: &mut AsyncApp,
) -> Result> {
+ let language_server_status_source = delegate.status_source_id();
let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
let json_options = self
.extension
@@ -321,6 +333,7 @@ impl LspAdapter for ExtensionLspAdapter {
self.language_server_id.clone(),
self.language_name.clone(),
delegate,
+ language_server_status_source,
)
.await?;
Ok(if let Some(json_options) = json_options {
@@ -339,10 +352,15 @@ impl LspAdapter for ExtensionLspAdapter {
_: Option,
_cx: &mut AsyncApp,
) -> Result {
+ let language_server_status_source = delegate.status_source_id();
let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
let json_options: Option = self
.extension
- .language_server_workspace_configuration(self.language_server_id.clone(), delegate)
+ .language_server_workspace_configuration(
+ self.language_server_id.clone(),
+ delegate,
+ language_server_status_source,
+ )
.await?;
Ok(if let Some(json_options) = json_options {
serde_json::from_str(&json_options).with_context(|| {
@@ -359,12 +377,14 @@ impl LspAdapter for ExtensionLspAdapter {
_cached_binary: OwnedMutexGuard>,
_cx: &mut AsyncApp,
) -> Option {
+ let language_server_status_source = delegate.status_source_id();
let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
let json_schema: Option = self
.extension
.language_server_initialization_options_schema(
self.language_server_id.clone(),
delegate,
+ language_server_status_source,
)
.await
.ok()
@@ -378,12 +398,14 @@ impl LspAdapter for ExtensionLspAdapter {
_cached_binary: OwnedMutexGuard>,
_cx: &mut AsyncApp,
) -> Option {
+ let language_server_status_source = delegate.status_source_id();
let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
let json_schema: Option = self
.extension
.language_server_workspace_configuration_schema(
self.language_server_id.clone(),
delegate,
+ language_server_status_source,
)
.await
.ok()
@@ -396,6 +418,7 @@ impl LspAdapter for ExtensionLspAdapter {
target_language_server_id: LanguageServerName,
delegate: &Arc,
) -> Result> {
+ let language_server_status_source = delegate.status_source_id();
let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
let json_options: Option = self
.extension
@@ -403,6 +426,7 @@ impl LspAdapter for ExtensionLspAdapter {
self.language_server_id.clone(),
target_language_server_id.clone(),
delegate,
+ language_server_status_source,
)
.await?;
Ok(if let Some(json_options) = json_options {
@@ -424,6 +448,7 @@ impl LspAdapter for ExtensionLspAdapter {
_cx: &mut AsyncApp,
) -> Result> {
+ let language_server_status_source = delegate.status_source_id();
let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
let json_options: Option = self
.extension
@@ -431,6 +456,7 @@ impl LspAdapter for ExtensionLspAdapter {
self.language_server_id.clone(),
target_language_server_id.clone(),
delegate,
+ language_server_status_source,
)
.await?;
Ok(if let Some(json_options) = json_options {
diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs
index f5c3c2e28f..49bd5053dd 100644
--- a/crates/language_tools/src/lsp_button.rs
+++ b/crates/language_tools/src/lsp_button.rs
@@ -346,7 +346,15 @@ impl LanguageServerState {
.lsp_store
.update(cx, |lsp_store, _| lsp_store.as_remote().is_some())
.unwrap_or(false);
- let has_logs = is_remote || lsp_logs.read(cx).has_server_logs(&server_selector);
+ let has_logs = is_remote
+ || self.workspace.upgrade().is_some_and(|workspace| {
+ let project = workspace.read(cx).project();
+ lsp_logs.read(cx).has_server_logs(
+ &server_selector,
+ &project.downgrade(),
+ &self.lsp_store,
+ )
+ });
let (status_color, status_label) = server_info
.binary_status
diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs
index ace4a0da96..338efb35e3 100644
--- a/crates/language_tools/src/lsp_log_view.rs
+++ b/crates/language_tools/src/lsp_log_view.rs
@@ -13,7 +13,9 @@ use lsp::{
};
use project::{
LanguageServerStatus, Project,
- lsp_store::log_store::{self, Event, LanguageServerKind, LogKind, LogStore, Message},
+ lsp_store::log_store::{
+ self, Event, LanguageServerKind, LanguageServerLogKey, LogKind, LogStore, Message,
+ },
search::SearchQuery,
};
use proto::toggle_lsp_logs::LogType;
@@ -44,6 +46,9 @@ pub fn open(
workspace
.update_in(cx, |workspace, window, cx| {
let project = workspace.project().clone();
+ let weak_project = project.downgrade();
+ let project_is_local = project.read(cx).is_local();
+ let weak_lsp_store = project.read(cx).lsp_store().downgrade();
let tool_log_store = log_store.clone();
let log_view = get_or_create_tool(
workspace,
@@ -53,22 +58,35 @@ pub fn open(
move |window, cx| LspLogView::new(project, tool_log_store, window, cx),
);
log_view.update(cx, |log_view, cx| {
- let server_id = match server {
- LanguageServerSelector::Id(id) => Some(id),
- LanguageServerSelector::Name(name) => {
- log_store.read(cx).language_servers.iter().find_map(
- |(id, state)| {
- if state.name.as_ref() == Some(&name) {
- Some(*id)
- } else {
- None
- }
- },
- )
- }
+ let key_priority = |key: &LanguageServerLogKey| match &key.kind {
+ LanguageServerKind::Local { .. } if project_is_local => 0,
+ LanguageServerKind::Remote { .. } if !project_is_local => 0,
+ _ => 1,
};
- if let Some(server_id) = server_id {
- log_view.show_logs_for_server(server_id, window, cx);
+ let server_key = match server {
+ LanguageServerSelector::Id(id) => log_store
+ .read(cx)
+ .language_servers
+ .keys()
+ .filter(|key| {
+ key.server_id == id
+ && key.is_for_project(&weak_project, &weak_lsp_store)
+ })
+ .min_by_key(|key| key_priority(key))
+ .cloned(),
+ LanguageServerSelector::Name(name) => log_store
+ .read(cx)
+ .language_servers
+ .iter()
+ .filter(|(key, state)| {
+ key.is_for_project(&weak_project, &weak_lsp_store)
+ && state.name.as_ref() == Some(&name)
+ })
+ .min_by_key(|(key, _)| key_priority(key))
+ .map(|(key, _)| key.clone()),
+ };
+ if let Some(server_key) = server_key {
+ log_view.show_logs_for_server(server_key, window, cx);
}
});
})
@@ -82,7 +100,7 @@ pub struct LspLogView {
pub(crate) editor: Entity,
editor_subscriptions: Vec,
log_store: Entity,
- current_server_id: Option,
+ current_server_key: Option,
active_entry_kind: LogKind,
project: Entity